diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 477fd05ecd..84d66fa293 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -150,8 +150,18 @@ 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, 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-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`). Business domains read `config.get('section')`; they never read env directly, and never write their own env-merge logic. @@ -217,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/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/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index afadd5e458..dd2d4b0ba5 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -212,6 +212,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). @@ -227,7 +229,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 a7500c0eaa..0a71711ace 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -212,6 +212,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` 参数启动)的并发数。 @@ -227,7 +229,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..2e63973cf4 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`. + * + * 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`. */ import { z } from 'zod'; +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { plainObjectToToml, transformPlainObject } from '#/app/config/toml'; 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,20 @@ export const LoopControlSchema = z.object({ export type LoopControl = z.infer; +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 stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings); + export const loopControlFromToml = (rawSnake: unknown): unknown => { if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; const out = transformPlainObject(rawSnake as Record); @@ -43,4 +69,6 @@ export const loopControlToToml = (value: unknown, rawSnake: unknown): unknown => 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..2e432cd768 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 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 * 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,10 @@ export const imageEnvBindings: EnvBindings = envBindings(ImageConfi readByteBudget: { env: IMAGE_READ_BYTE_BUDGET_ENV, parse: parsePositiveInt }, }); +export const stripImageEnv = stripEnvBoundFields(imageEnvBindings); + 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 405f9c0f4b..e0e518f406 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -5,9 +5,12 @@ * 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` - * (applied live by the config env overlay, never persisted). Also owns the + * `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; while a field's env var is set, + * `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 * `resolvePrintBackgroundMode`. Self-registered @@ -18,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'; @@ -63,10 +71,27 @@ 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'; + +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 }); -registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings }); +export const stripTaskEnv = stripEnvBoundFields(taskEnvBindings); + +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..6c51b63a76 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -8,11 +8,26 @@ * 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 value from the + * env-free raw base (already `fromToml`-normalized, so legacy key renames are + * honored) — or drops it when absent there — instead of persisting an echoed + * env value; otherwise writes pass through untouched. When nothing + * 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'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { isPlainObject } from './configPure'; + export interface ConfigSchema { parse(value: unknown): T; } @@ -35,7 +50,40 @@ export function envBindings(_schema: ConfigSchema, bindings: EnvBindings = (value: T, rawSnake?: unknown) => T | undefined; +export type ConfigStripEnv = ( + value: T, + raw?: unknown, + getEnv?: (name: string) => string | undefined, +) => T | undefined; + +function isEnvBinding(value: unknown): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv { + return (value, raw, getEnv) => { + if (getEnv === undefined || value === null || typeof value !== 'object') return value; + if (!isPlainObject(bindings) || isEnvBinding(bindings)) return value; + const base = isPlainObject(raw) ? raw : {}; + let out: Record | undefined; + for (const [field, binding] of Object.entries(bindings)) { + if (binding === undefined || !isEnvBinding(binding)) continue; + const rawEnv = getEnv(typeof binding === 'string' ? binding : binding.env); + if (rawEnv === undefined) continue; + const parse = typeof binding === 'string' ? undefined : binding.parse; + if (parse !== undefined && parse(rawEnv) === undefined) continue; + out ??= { ...(value as Record) }; + if (base[field] !== undefined) { + out[field] = base[field]; + } else { + delete out[field]; + } + } + if (out === undefined) return value; + if (Object.keys(out).length > 0) return out as T; + return (Object.keys(base).length > 0 ? base : undefined) as T | undefined; + }; +} 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..b54bfd99cd 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -4,9 +4,16 @@ * 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 - * write base, kept for lossless round-trip), `raw` (camelCase, env-free), - * `effective` (validated, env overlay applied), and `memory` (per-run overrides) + * 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 + * 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 @@ -90,13 +97,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]; @@ -227,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 = {}; @@ -262,16 +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) { - 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.effective[domain] as T; + return this.freshEffective()[domain] as T; } inspect(domain: string): ConfigInspectValue { @@ -285,19 +281,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[] { @@ -329,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); @@ -367,7 +359,8 @@ 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); + result = section.stripEnv(result, this.raw[domain], getEnv); } if (result === undefined) return result; for (const overlay of this.registry.listEffectiveOverlays()) { @@ -420,7 +413,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; @@ -448,11 +443,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, @@ -462,10 +457,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; @@ -474,17 +473,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); @@ -492,17 +492,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)])]); @@ -521,11 +525,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 57aa44d287..af1b0ab376 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 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`, * 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,12 @@ export const subagentEnvBindings: EnvBindings = envBindings( }, ); +export const stripSubagentEnv = stripEnvBoundFields(subagentEnvBindings); + 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 596dbee428..76c66ae566 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 '#/kosong/model/thinking'; import { KEEP_ALIVE_ON_EXIT_ENV, + MAX_RUNNING_TASKS_ENV, resolveAgentTaskConfig, resolvePrintBackgroundMode, type AgentTaskConfig, @@ -50,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'; @@ -526,6 +536,318 @@ 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', () => { + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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()); + 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 }); + 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(); + }); + + 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', () => { @@ -585,6 +907,86 @@ 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(); + }); + + 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(); + }); + + 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()); @@ -699,6 +1101,114 @@ 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(); + }); + + 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; + + ix.get(IConfigRegistry).registerEffectiveOverlay({ + apply(effective, getEnv) { + if (getEnv('SMOKE_OVERLAY_FLAG') !== '1') return []; + effective['overlayDomain'] = { flag: true }; + return ['overlayDomain']; + }, + }); + + 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(); + }); +}); + +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[] { 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[] = []; 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; } });