Skip to content
Merged
7 changes: 3 additions & 4 deletions .agents/skills/agent-core-dev/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk.
- `src/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types.
- `src/config/configService.ts` — `ConfigRegistry` + `ConfigService` impl; self-registers at App scope.
- `src/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics.
- `src/profile/thinking.ts` (owner domain, not `config`) — `resolveThinkingEffort` / `resolveThinkingLevel` helpers; uses the authoritative `ThinkingConfig` from `configSection.ts`.
- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`.
- `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`.

A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`.
Expand Down Expand Up @@ -126,8 +126,7 @@ type-checked against the schema (no magic strings), and nested schemas recurse:
```ts
registerSection('thinking', ThinkingConfigSchema, {
env: envBindings(ThinkingConfigSchema, {
mode: 'KIMI_MODEL_THINKING_MODE',
effort:'KIMI_MODEL_THINKING_EFFORT',
effort: 'KIMI_MODEL_THINKING_EFFORT',
}),
});

Expand Down Expand Up @@ -238,7 +237,7 @@ When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`s
|---|---|---|---|
| `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) |
| `experimental` | `flag` | L3 | owner-owned |
| `thinking` / `defaultThinking` | `profile` | L4 | owner-owned |
| `thinking` | `profile` | L4 | owner-owned |
| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) |
| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) |
| `session` | `config` | L2 | in config |
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/v2/create-v2-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class V2PromptHarness implements PromptHarness {
const session = await this.core.accessor.get(ISessionLifecycleService).create({
sessionId: randomUUID(),
workDir: options.workDir,
additionalDirs: options.additionalDirs,
});
const agent = await ensureMainAgent(session);
if (options.model !== undefined) {
Expand Down
27 changes: 15 additions & 12 deletions packages/agent-core-v2/src/agent/contextMemory/contextOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* `contextMemory` domain (L4) — wire Model (`ContextModel`) and the wire-protocol
* 1.4 Ops `context.append_message` (`contextAppendMessage`) / `context.clear`
* (`contextClear`) / `context.apply_compaction` (`contextApplyCompaction`) /
* `context.undo` (`contextUndo`) for the per-agent conversation history, plus the
* `context.undo` (`contextUndo`) / `context.append_loop_event`
* (`contextAppendLoopEvent`) for the per-agent conversation history, plus the
* legacy `context.splice` (`contextSplice`) Op.
*
* Declares the history as `ContextMessage[]` (initial `[]`); every Op's `apply`
Expand All @@ -11,13 +12,13 @@
* carries no non-determinism — message ids are stamped at the dispatch call site
* (`AgentContextMemoryService.append`), never inside `apply`.
*
* The live write path emits the 1.4 Ops (`append_message` / `clear` /
* `apply_compaction` / `undo`); assistant and tool messages are persisted already
* folded (the loop appends whole messages, not raw loop events), so on-disk
* records use the 1.4 type names. Sessions written by the v1 loop stream a turn
* as `context.append_loop_event` records instead; `contextAppendLoopEvent` folds
* them back into assistant / tool messages at restore time (see
* `loopEventFold.ts`) so those sessions replay identically. `context.splice` (the
* The live write path emits the 1.4 Ops: non-loop appends (user prompts,
* injections, hook/task notices) go on the wire as `append_message`, while the
* agent loop streams each turn as `context.append_loop_event` records — the
* same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds
* them into assistant / tool messages (see `loopEventFold.ts`) both at live
* dispatch time and on replay, so v1- and v2-written sessions reduce
* identically. `context.splice` (the
* pre-1.4 primitive) stays registered so sessions written at wire protocol 1.5
* still replay (newer-version passthrough, no migration) and for the few internal
* single-delete mutations that have no 1.4 spelling.
Expand Down Expand Up @@ -145,10 +146,12 @@ export interface ContextLoopEventPayload {
}

/**
* Restore-only Op: folds a v1 `context.append_loop_event` record into the
* history (see `loopEventFold.ts`). Never dispatched by the v2 live loop, so it
* is never persisted by v2 — registering it lets `WireService.replay` reduce
* v1-loop sessions instead of skipping the record.
* Folds a `context.append_loop_event` record into the history (see
* `loopEventFold.ts`). Since the v1.4 wire-parity alignment the v2 live loop
* dispatches (and persists) these records itself — one per streamed step
* fragment, byte-compatible with the v1 loop — so the same fold runs both on
* live dispatch and when `WireService.replay` reduces v1- or v2-written
* sessions.
*/
export const contextAppendLoopEvent = defineOp(ContextModel, 'context.append_loop_event', {
apply: (state, p: ContextLoopEventPayload): ContextMessage[] =>
Expand Down
24 changes: 13 additions & 11 deletions packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
/**
* `contextMemory` loop-event fold — restore-time reduction of v1
* `context.append_loop_event` records into folded `ContextMessage`s.
* `contextMemory` loop-event fold — reduction of `context.append_loop_event`
* records into folded `ContextMessage`s.
*
* v2's agent loop persists assistant / tool messages already folded
* (`context.append_message`); it never emits loop events. Sessions written by
* the v1 loop (`packages/agent-core`), however, stream a turn as
* `context.append_loop_event` records (`step.begin` / `content.part` /
* `tool.call` / `tool.result` / `step.end`) and never write a folded assistant
* message. Without this fold, `WireService.replay` skips those records (no Op
* is registered for the type) and the restored `ContextModel` — and every
* consumer built on it (`/messages`, `/snapshot`, live resume) — shows only the
* user prompts.
* Both loops stream a turn as `context.append_loop_event` records
* (`step.begin` / `content.part` / `tool.call` / `tool.result` / `step.end`)
* and never write a folded assistant message: the v1 loop
* (`packages/agent-core`) always has, and since the v1.4 wire-parity alignment
* the v2 live loop emits the same records (`LoopService` →
* `ContextMemory.appendLoopEvent`), keeping the on-disk shape byte-compatible.
* This fold turns them into assistant / tool messages — at live dispatch time
* and again when `WireService.replay` restores a session. Without it, replay
* would skip those records (no Op is registered for the type) and the restored
* `ContextModel` — and every consumer built on it (`/messages`, `/snapshot`,
* live resume) — would show only the user prompts.
*
* Semantics mirror v1's `ContextMemory.appendLoopEvent`
* (`packages/agent-core/src/agent/context/index.ts`) and the transcript
Expand Down
33 changes: 25 additions & 8 deletions packages/agent-core-v2/src/agent/externalHooks/runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import {
spawn,
type ChildProcessWithoutNullStreams,
type SpawnOptionsWithoutStdio,
} from 'node:child_process';

import { z } from 'zod';

Expand All @@ -11,6 +15,25 @@ export interface RunHookOptions {
readonly signal?: AbortSignal;
}

export function buildHookSpawnOptions(options: {
cwd?: string;
env?: Record<string, string>;
}): SpawnOptionsWithoutStdio {
return {
shell: true,
cwd: options.cwd,
stdio: 'pipe',
detached: process.platform !== 'win32',
// Hide the console Windows would otherwise allocate for the shell child.
// Without `windowsHide:true`, each hook flashes a visible console window —
// the same regression the node-local process host already guards against
// (see `buildSpawnOptions` in os/backends/node-local/hostProcessService.ts)
// and the runner's own taskkill spawn. Unconditional: it is a no-op on POSIX.
windowsHide: true,
env: options.env === undefined ? undefined : { ...process.env, ...options.env },
};
}

const DEFAULT_TIMEOUT_SECONDS = 30;
const KILL_GRACE_MS = 100;
const OptionalStringSchema = z.preprocess(
Expand Down Expand Up @@ -46,13 +69,7 @@ export async function runHook(
): Promise<HookResult> {
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(command, {
shell: true,
cwd: options.cwd,
env: options.env === undefined ? undefined : { ...process.env, ...options.env },
stdio: 'pipe',
detached: process.platform !== 'win32',
});
child = spawn(command, buildHookSpawnOptions({ cwd: options.cwd, env: options.env }));
} catch (error) {
return allowResult({ stderr: errorMessage(error) });
}
Expand Down
30 changes: 4 additions & 26 deletions packages/agent-core-v2/src/agent/profile/configSection.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,29 @@
/**
* `profile` domain (L4) — `thinking` / `defaultThinking` config-section env bindings.
* `profile` domain (L4) — `thinking` config-section env bindings.
*
* Declares the `KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_THINKING_EFFORT` /
* `KIMI_MODEL_DEFAULT_THINKING` environment bindings (gated on
* `KIMI_MODEL_NAME`). Applied to the effective `thinking` / `defaultThinking`
* values by `config`.
* Declares the `KIMI_MODEL_THINKING_EFFORT` environment binding (gated on
* `KIMI_MODEL_NAME`). Applied to the effective `thinking` value by `config`.
*/

import { z } from 'zod';

import { parseBooleanEnv } from '#/_base/utils/env';
import { type EnvBindings, envBindings } from '#/app/config/config';
import { envBindings } from '#/app/config/config';
import { registerConfigSection } from '#/app/config/configSectionContributions';

export const THINKING_SECTION = 'thinking';
export const DEFAULT_THINKING_SECTION = 'defaultThinking';

export const ThinkingConfigSchema = z.object({
enabled: z.boolean().optional(),
mode: z.enum(['auto', 'on', 'off']).optional(),
effort: z.string().optional(),
keep: z.string().optional(),
});

export type ThinkingConfig = z.infer<typeof ThinkingConfigSchema>;

function parseBooleanVar(raw: string): boolean {
const parsed = parseBooleanEnv(raw);
if (parsed === undefined) {
throw new Error(`KIMI_MODEL_DEFAULT_THINKING must be a boolean, got "${raw}".`);
}
return parsed;
}

export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, {
mode: 'KIMI_MODEL_THINKING_MODE',
effort: 'KIMI_MODEL_THINKING_EFFORT',
});

export const defaultThinkingEnvBindings: EnvBindings<boolean> = {
env: 'KIMI_MODEL_DEFAULT_THINKING',
parse: parseBooleanVar,
};

registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, {
env: thinkingEnvBindings,
});
registerConfigSection(DEFAULT_THINKING_SECTION, { parse: (v) => v as boolean }, {
env: defaultThinkingEnvBindings,
});
15 changes: 8 additions & 7 deletions packages/agent-core-v2/src/agent/profile/profileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import picomatch from 'picomatch';
import { ErrorCodes, KimiError } from "#/errors";
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { resolveThinkingEffort, resolveThinkingKeep, resolveThinkingLevel } from './thinking';
import { resolveThinkingEffort, resolveThinkingKeep } from './thinking';
import type { LoopControl } from '#/agent/loop/configSection';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
Expand Down Expand Up @@ -63,7 +63,6 @@ import type {
} from './profile';
import { IAgentProfileService } from './profile';
import {
DEFAULT_THINKING_SECTION,
THINKING_SECTION,
type ThinkingConfig,
} from './configSection';
Expand Down Expand Up @@ -161,11 +160,11 @@ export class AgentProfileService implements IAgentProfileService {
const { agentsMdWarning } = context;
this.agentsMdWarning = agentsMdWarning;

const thinkingLevel = resolveThinkingLevel(input.thinking, {
defaultThinking: this.config.get<boolean | undefined>(DEFAULT_THINKING_SECTION),
thinking: this.config.get<ThinkingConfig>(THINKING_SECTION),
const thinkingLevel = resolveThinkingEffort(
input.thinking,
this.config.get<ThinkingConfig>(THINKING_SECTION),
model,
});
);

this.update({
cwd: input.cwd,
Expand Down Expand Up @@ -462,8 +461,10 @@ export class AgentProfileService implements IAgentProfileService {
private get thinkingLevel(): ThinkingEffort {
const stored = this.profileState.thinkingLevel;
if (stored === 'off' && this.alwaysThinkingModel) {
// Re-run the resolver so the always_thinking clamp restores the
// configured effort (or the model default) instead of a stale 'off'.
return resolveThinkingEffort(
'on',
stored,
this.config.get<ThinkingConfig>(THINKING_SECTION),
this.tryResolveRawModel(),
);
Expand Down
33 changes: 5 additions & 28 deletions packages/agent-core-v2/src/agent/profile/thinking.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,16 @@
/**
* `profile` domain — thinking-level resolution helpers.
* `profile` domain — thinking-effort resolution helpers.
*
* Resolves the effective `ThinkingEffort` from a requested level, the
* `thinking` config section (`ThinkingConfig`, owned here in `profile`), and
* the `defaultThinking` toggle. Pure functions; own no scoped state.
* Resolves the effective `ThinkingEffort` from a requested effort and the
* `thinking` config section (`ThinkingConfig`, owned here in `profile`).
* Pure functions; own no scoped state.
*/

import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort';
import {
type ModelThinkingMetadata,
resolveThinkingEffortForModel,
} from '#/app/model/thinking';
import { type ModelThinkingMetadata, resolveThinkingEffortForModel } from '#/app/model/thinking';

import type { ThinkingConfig } from './configSection';

export interface ResolveThinkingLevelOptions {
readonly defaultThinking?: boolean;
readonly thinking?: ThinkingConfig;
readonly model?: ModelThinkingMetadata;
}

export function resolveThinkingLevel(
requestedThinking: string | undefined,
options: ResolveThinkingLevelOptions,
): ThinkingEffort {
const resolvedRequest =
requestedThinking !== undefined && requestedThinking.trim().length > 0
? requestedThinking
: options.defaultThinking === false
? 'off'
: undefined;

return resolveThinkingEffort(resolvedRequest, options.thinking, options.model);
}

export function resolveThinkingEffort(
requested: string | undefined,
defaults: ThinkingConfig | undefined,
Expand Down
Loading
Loading