Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions .agents/skills/agent-core-dev/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,18 @@ registerSection('providers', ProvidersSectionSchema, {
```

Each field is an `EnvBinding` — a string (env var name) or
`{ env, parse?, default? }`. IConfig resolves every field by
`{ env, deprecatedEnv?, parse?, default? }`. IConfig resolves every field by
`env > config.toml > default`, sets it on the effective value, and validates the
section. Empty nested entries (no field resolved) are omitted, so a synthetic
entry like `__kimi_env__` only appears when at least one of its env vars is set.
When `deprecatedEnv` is set and `env` itself is absent or fails `parse`, the
deprecated var still supplies the value and a warning diagnostic is reported —
use it to rename an env var without breaking existing setups.

`stripEnv(value, raw?, getEnv?)` removes env-derived fields before `set`/`replace`
persists, so env overrides never leak into `config.toml`. `raw` is the section's
env-free camelCase base (already `fromToml`-normalized, so legacy key renames
are honored), and `getEnv` reads the live env bag. For fields that are **both
env-free camelCase base (already `fromToml`-normalized), and `getEnv` reads the
live env bag. For fields that are **both
user-persistable and env-overridable**, register
`stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`)
— it derives the guard from the same bindings the read path uses: while a
Expand Down Expand Up @@ -231,7 +234,7 @@ This means registration order is never a correctness concern — you do not need

`config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform:

- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `loop_control.max_steps_per_run` → `maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern.
- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern.
- **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip).

`ConfigService` keeps four views:
Expand All @@ -241,6 +244,23 @@ This means registration order is never a correctness concern — you do not need
- `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay.
- `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it.

### Renaming config keys and env vars (deprecations)

Renames are declared once on the section, never hand-rolled in `fromToml`:

```ts
registerSection(MY_SECTION, MySectionSchema, {
deprecations: [{ key: 'old_key', replacement: 'new_key' }], // snake_case, on-disk
env: envBindings(MySectionSchema, {
newKey: { env: 'KIMI_NEW_KEY', deprecatedEnv: 'KIMI_OLD_KEY', parse },
}),
});
```

- A deprecated TOML key is **ignored** (its value no longer applies — the schema only knows the new key) and reports a warning `ConfigDiagnostic` while present; the file is never rewritten, so the warning is the migration guide. Diagnostics are recomputed on every load/reload and surface to clients via `IConfigService.diagnostics()` and `onDidChangeDiagnostics` (kap-server republishes them as the global `event.config.warning` WS event).
- A deprecated env var still **resolves** as a fallback (new var first), with the same warning treatment, and `stripEnvBoundFields` treats it as env-owned for writes.
- See `src/agent/loop/configSection.ts` for a worked example (`max_retries_per_step` → `max_attempts_per_step`).

### `KIMI_MODEL_*` env overlay

When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics.
Expand Down
5 changes: 5 additions & 0 deletions .changeset/kap-server-config-warning-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kap-server": patch
---

Add the global `event.config.warning` WebSocket event that pushes the current set of config warnings (deprecated config keys or environment variables in use) to every connection whenever it changes.
5 changes: 5 additions & 0 deletions .changeset/loop-control-attempt-limit-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Rename the `[loop_control] max_retries_per_step` config key to `max_attempts_per_step` and `max_steps_per_run` to `max_steps_per_turn`: on the v2 engine the old keys no longer take effect and a startup warning prompts the rename in `config.toml`. The `KIMI_LOOP_MAX_RETRIES_PER_STEP` env var is likewise deprecated in favor of `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` but keeps working with a warning.
9 changes: 5 additions & 4 deletions apps/kimi-code/src/cli/experimental-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
*
* When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, `kimi -p`
* (print mode) routes to the native agent-core-v2 runner (see
* `run-prompt.ts`) and the interactive TUI builds its harness through the
* SDK's v2-backed client (see `run-shell.ts`), both instead of the default
* v1 engine. The master switch also enables every experimental feature flag
* in the engine. Read directly from the env (matching
* `run-prompt.ts`), the interactive TUI builds its harness through the
* SDK's v2-backed client (see `run-shell.ts`), and `kimi doctor` validates
* config.toml against the v2 section registry (see `sub/doctor.ts` /
* `v2/validate-config.ts`), all instead of the default v1 engine. The
* master switch also enables every experimental feature flag in the engine. Read directly from the env (matching
* `cli/update/rollout.ts`) because the CLI must not depend on the core flag
* registry. Unset / any non-truthy value keeps the v1 path.
*
Expand Down
7 changes: 3 additions & 4 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
import { CHROME_GUTTER } from '#/tui/constant/rendering';
import { KimiTUI } from '#/tui/index';
import { currentTheme, getColorPalette } from '#/tui/theme';
import { combineStartupNotice } from '#/tui/utils/startup';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';

Expand Down Expand Up @@ -108,9 +107,9 @@ export async function runShell(
return;
}
const config = await harness.getConfig();
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
configWarning = combineStartupNotice(configWarning, warning);
}
// Config diagnostics (deprecated keys, invalid sections, ...) are surfaced
// by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` —
// folded into the dim startup notice they were too easy to miss.
const configMs = Date.now() - configStartedAt;
// Resolve --agent/--agent-file once for the startup session; validateOptions
// has already rejected them alongside --session/--continue.
Expand Down
24 changes: 18 additions & 6 deletions apps/kimi-code/src/cli/sub/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type { Command } from 'commander';
import { z } from 'zod';

import { isKimiV2Enabled } from '#/cli/experimental-v2';
import { getTuiConfigPath, parseTuiConfig } from '#/tui/config';

interface WritableLike {
Expand All @@ -28,7 +29,7 @@ export interface DoctorDeps {
readonly configRpc?: KimiConfigRpc;
readonly fileExists?: (path: string) => boolean;
readonly readTextFile?: (path: string) => Promise<string>;
readonly validateConfigToml?: (text: string, path: string) => MaybePromise<void>;
readonly validateConfigToml?: (text: string, path: string) => MaybePromise<string | void>;
}

export interface DoctorOptions {
Expand All @@ -40,7 +41,8 @@ interface CheckSpec {
readonly label: 'config.toml' | 'tui.toml';
readonly path: string;
readonly explicit: boolean;
readonly parse: (text: string, path: string) => MaybePromise<void>;
/** Throws on invalid content; may return a non-fatal warning message. */
readonly parse: (text: string, path: string) => MaybePromise<string | void>;
}

interface CheckResult {
Expand All @@ -59,7 +61,7 @@ interface ResolvedDoctorDeps {
readonly exit: (code: number) => never;
readonly fileExists: (path: string) => boolean;
readonly readTextFile: (path: string) => Promise<string>;
readonly validateConfigToml: (text: string, path: string) => MaybePromise<void>;
readonly validateConfigToml: (text: string, path: string) => MaybePromise<string | void>;
}

export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise<number> {
Expand Down Expand Up @@ -130,7 +132,17 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')),
validateConfigToml:
deps?.validateConfigToml ??
((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })),
(async (text, filePath) => {
if (isKimiV2Enabled()) {
// Experimental v2 route (same master switch as `kimi -p`): validate
// with the agent-core-v2 section registry instead of the v1 schema.
// Loaded lazily so the v2 module graph stays off the default path.
const { validateConfigTomlV2 } = await import('../v2/validate-config');
return validateConfigTomlV2(text, filePath);
}
await getConfigRpc().validateConfigToml({ text, filePath });
return undefined;
}),
};
}

Expand Down Expand Up @@ -204,8 +216,8 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise

try {
const text = await deps.readTextFile(spec.path);
await spec.parse(text, spec.path);
return { label: spec.label, path: spec.path, status: 'OK' };
const warning = await spec.parse(text, spec.path);
return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined };
} catch (error) {
return {
label: spec.label,
Expand Down
187 changes: 187 additions & 0 deletions apps/kimi-code/src/cli/v2/validate-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Experimental v2 config.toml validation for `kimi doctor`.
*
* Loaded lazily (dynamic import) by the doctor command only when the
* agent-core-v2 master switch (`KIMI_CODE_EXPERIMENTAL_FLAG`) is on, so the
* v2 module graph stays off the default (v1) doctor path. Validation uses the
* engine's own section registry instead of v1's whole-document strict schema:
* importing the package root runs every built-in section's side-effect
* registration ("import = register"), and `ConfigRegistry` is then
* constructed directly — no DI container, no `ConfigService`, no file IO.
*
* Semantics deliberately mirror the v2 engine rather than v1:
* - a registered section that fails schema validation is an error (the
* engine would silently ignore that section at runtime; surfacing it is
* doctor's job);
* - a top-level key with no registered section passes through the engine
* untouched, so it is reported as a non-fatal warning — except the known
* schema-less domains the engine consumes directly (`default_model`, …);
* - section-declared key renames (`deprecations`) and renamed env vars
* (`deprecatedEnv` bindings actually supplying a value) surface as
* non-fatal warnings, reusing the engine's own detection
* (`collectKeyDeprecations`) and mirroring `ConfigService`'s env-fallback
* warning rule.
*/

import { parse as parseToml } from 'smol-toml';
import { z } from 'zod';

import {
ConfigRegistry,
type AnyEnvBindings,
type EnvBinding,
} from '@moonshot-ai/agent-core-v2';
import { collectKeyDeprecations } from '@moonshot-ai/agent-core-v2/app/config/deprecations';
import {
camelToSnake,
describeTomlSyntaxError,
isPlainObject,
transformTomlData,
} from '@moonshot-ai/agent-core-v2/app/config/toml';

/**
* Top-level domains the v2 engine reads via `IConfigService.get` / `inspect`
* without registering a schema (free-form values, structurally validated
* nowhere): `defaultModel` / `defaultProvider` (`kosongConfig` default
* pointers), `modelOverrides` (`llmRequester` / `profile`), and `telemetry`
* (read by the CLI itself).
*/
const SCHEMALESS_DOMAINS: ReadonlySet<string> = new Set([
'defaultModel',
'defaultProvider',
'modelOverrides',
'telemetry',
]);

interface V2ConfigValidationIssue {
readonly path: readonly (string | number)[];
readonly message: string;
}

/**
* Matches the shape `handleDoctor` extracts from `error.details` (the SDK's
* `KimiConfigValidationIssue` list), so the doctor formatter renders v2
* issues exactly like v1 ones.
*/
class V2ConfigValidationError extends Error {
readonly details: { readonly validationIssues: readonly V2ConfigValidationIssue[] };

constructor(issues: readonly V2ConfigValidationIssue[]) {
super('v2 config validation failed');
this.details = { validationIssues: issues };
}
}

/**
* Validate `text` as config.toml against the v2 engine's section registry.
* Throws on TOML syntax errors and on any registered section failing its
* schema; returns non-fatal warnings (one per line) for unknown top-level
* keys, deprecated config keys, and deprecated env vars in use.
*/
export function validateConfigTomlV2(
text: string,
filePath: string,
getEnv: (name: string) => string | undefined = (name) => process.env[name],
): string | undefined {
let data: Record<string, unknown> = {};
if (text.trim().length > 0) {
try {
data = parseToml(text) as Record<string, unknown>;
} catch (error) {
throw new Error(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}`, {
cause: error,
});
}
}

const registry = new ConfigRegistry();
const transformed = transformTomlData(data, registry);

const issues: V2ConfigValidationIssue[] = [];
const unknownKeys: string[] = [];
for (const [domain, value] of Object.entries(transformed)) {
if (registry.getSection(domain) === undefined) {
if (!SCHEMALESS_DOMAINS.has(domain)) unknownKeys.push(camelToSnake(domain));
continue;
}
try {
registry.validate(domain, value);
} catch (error) {
if (!(error instanceof z.ZodError)) throw error;
for (const issue of error.issues) {
issues.push({
path: [
domain,
...issue.path.map((segment) =>
typeof segment === 'number' ? segment : String(segment),
),
],
message: issue.message,
});
}
}
}

if (issues.length > 0) throw new V2ConfigValidationError(issues);

const warnings: string[] = [];
for (const diagnostic of collectKeyDeprecations(data, registry.listSections())) {
warnings.push(diagnostic.message);
}
warnings.push(...collectEnvDeprecations(registry, getEnv));
if (unknownKeys.length > 0) {
warnings.push(
`Unknown top-level ${unknownKeys.length === 1 ? 'key' : 'keys'} ignored by the v2 engine: ${unknownKeys.join(', ')}.`,
);
}
return warnings.length > 0 ? warnings.join('\n') : undefined;
}

/**
* Warn about renamed env vars that actually supply a value, mirroring
* `ConfigService`'s `resolveBinding`: the deprecated name only resolves (and
* thus only warns) when the primary var is absent or fails to parse.
*/
function collectEnvDeprecations(
registry: ConfigRegistry,
getEnv: (name: string) => string | undefined,
): string[] {
const warnings = new Set<string>();
for (const section of registry.listSections()) {
if (section.env === undefined) continue;
walkEnvBindings(section.env, (binding) => {
if (typeof binding === 'string' || binding.deprecatedEnv === undefined) return;
const primary = getEnv(binding.env);
if (
primary !== undefined &&
(binding.parse === undefined || binding.parse(primary) !== undefined)
) {
return;
}
const deprecated = getEnv(binding.deprecatedEnv);
if (deprecated === undefined) return;
if (binding.parse !== undefined && binding.parse(deprecated) === undefined) return;
warnings.add(
`Environment variable ${binding.deprecatedEnv} is deprecated; use ${binding.env} instead.`,
);
});
}
return [...warnings];
}

function isEnvBinding(value: AnyEnvBindings): value is EnvBinding {
return typeof value === 'string' || (isPlainObject(value) && 'env' in value);
}

function walkEnvBindings(
bindings: AnyEnvBindings,
visit: (binding: EnvBinding) => void,
): void {
if (isEnvBinding(bindings)) {
visit(bindings);
return;
}
for (const value of Object.values(bindings)) {
if (value !== undefined) walkEnvBindings(value, visit);
}
}
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,10 @@ export class KimiTUI {
this.startupNotice = undefined;
}
void this.showTmuxKeyboardWarningIfNeeded();
// Config diagnostics (deprecated keys/env vars, invalid sections) in
// warning yellow at boot; `run-prompt`/`run-v2-print` print them to
// stderr for non-interactive runs.
void this.showConfigWarningsIfAny();
if (this.state.startupState === 'picker') {
void this.bootstrapFromPicker();
return;
Expand Down
Loading
Loading