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
5 changes: 5 additions & 0 deletions .changeset/print-mode-steer-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

In print mode (`kimi -p`), keep the run alive by default while background tasks are pending and feed each completion back to the main agent as a new turn, with an effectively unbounded wait ceiling and turn cap and a 72-hour subagent timeout. Set `print_background_mode = "exit"` (or `"drain"`) to restore the previous exit-after-one-turn behavior.
1 change: 1 addition & 0 deletions packages/agent-core/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './merge';
export * from './model';
export * from './path';
export * from './print-defaults';
export * from './resolve';
export * from './schema';
export * from './toml';
Expand Down
42 changes: 42 additions & 0 deletions packages/agent-core/src/config/print-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { KimiConfig } from './schema';

/**
* Print-mode (`kimi -p`) defaults for the v1 engine. A headless run should not
* be cut short by limits meant for interactive use, so every value here is
* "effectively unbounded". Explicit user config always wins over these.
*/

/**
* Wall-clock ceiling (seconds) for the drain/steer wait once the main turn
* ends: 10 years ≈ unbounded.
*/
export const PRINT_WAIT_CEILING_S_DEFAULT = 315_360_000;

/** Cap on extra turns steered by background-task completions: ≈ unbounded. */
export const PRINT_MAX_TURNS_DEFAULT = 100_000;

/**
* Per-subagent (`Agent` / `AgentSwarm`, foreground and background) timeout:
* 72 hours ≈ none (the interactive default is 2 hours).
*/
export const PRINT_SUBAGENT_TIMEOUT_MS_DEFAULT = 259_200_000;

/**
* Merge print-mode defaults into the config bound to a new session. Only
* values the user left unset are filled (per-key spread order).
*
* `background` is deliberately untouched: its print defaults live next to the
* consuming code in `Session` (`resolvePrintBackgroundMode`,
* `waitForBackgroundTasksOnPrint`, `handlePrintMainTurnCompleted`), because
* `printBackgroundMode`'s fallback must keep honoring the legacy
* `keep_alive_on_exit` → `'drain'` mapping.
*/
export function applyPrintModeConfigDefaults(config: KimiConfig): KimiConfig {
return {
...config,
// `0` is already what an unset maxStepsPerTurn means (unlimited); the
// explicit value just pins the print-mode contract.
loopControl: { maxStepsPerTurn: 0, ...config.loopControl },
subagent: { timeoutMs: PRINT_SUBAGENT_TIMEOUT_MS_DEFAULT, ...config.subagent },
};
}
30 changes: 26 additions & 4 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getCoreVersion } from '#/version';
import { resolveThinkingEffort } from '../agent/config/thinking';
import { Agent } from '../agent';
import {
applyPrintModeConfigDefaults,
ensureKimiHome,
loadRuntimeConfigSafe,
mergeConfigPatch,
Expand Down Expand Up @@ -147,6 +148,12 @@ export interface KimiCoreOptions {
readonly skillDirs?: readonly string[];
readonly telemetry?: TelemetryClient | undefined;
readonly appVersion?: string;
/**
* Host UI mode (`'print'` for `kimi -p`, `'cli'` for the TUI, ...). When
* `'print'`, sessions are created with the print-mode config defaults from
* `applyPrintModeConfigDefaults` (user-set values still win).
*/
readonly uiMode?: string | undefined;
}

export class KimiCore implements PromisableMethods<CoreAPI> {
Expand All @@ -171,6 +178,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
private pluginsLoadError: Error | undefined;
private readonly appVersion: string | undefined;
private readonly experimentalFlags: FlagResolver;
/** `true` when the host runs `kimi -p` (v1 print mode); see `withPrintModeDefaults`. */
private readonly printMode: boolean;
/** Owner-scoped [image] limits; reload pushes the new config via setConfig. */
readonly imageLimits: ImageLimits;

Expand All @@ -191,6 +200,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
this.skillDirs = options.skillDirs ?? [];
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.appVersion = options.appVersion;
this.printMode = options.uiMode === 'print';
ensureKimiHome(this.homeDir);
// Schema errors degrade (invalid sections are dropped with warnings) so a
// typo cannot prevent startup, but a file that cannot be used at all —
Expand Down Expand Up @@ -236,6 +246,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const options = input;
const workDir = requiredWorkDir('createSession', options.workDir);
const config = this.reloadProviderManager();
const sessionConfig = this.withPrintModeDefaults(config);
const id = options.id ?? createSessionId();
const modelAlias = options.model ?? config.defaultModel;
const model = modelAlias !== undefined ? config.models?.[modelAlias] : undefined;
Expand Down Expand Up @@ -298,13 +309,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
kaos: parentKaos.withCwd(workDir),
persistenceKaos,
toolServices: runtime,
config,
config: sessionConfig,
id,
homedir: summary.sessionDir,
kimiHomeDir: this.homeDir,
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
providerManager: this.resolveProviderManager(summary.id),
background: config.background,
background: sessionConfig.background,
hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()],
permissionRules: config.permission?.rules,
skills: this.resolveSessionSkillConfig(config),
Expand Down Expand Up @@ -418,6 +429,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
}

const config = this.reloadProviderManager();
const sessionConfig = this.withPrintModeDefaults(config);
const baseMcpConfig = await resolveSessionMcpConfig({
cwd: summary.workDir,
homeDir: this.homeDir,
Expand All @@ -434,13 +446,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
kaos: parentKaos.withCwd(summary.workDir),
persistenceKaos,
toolServices: runtime,
config,
config: sessionConfig,
id: summary.id,
homedir: summary.sessionDir,
kimiHomeDir: this.homeDir,
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
providerManager: this.resolveProviderManager(summary.id),
background: config.background,
background: sessionConfig.background,
hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()],
permissionRules: config.permission?.rules,
skills: this.resolveSessionSkillConfig(config),
Expand Down Expand Up @@ -1108,6 +1120,16 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.config;
}

/**
* Config bound to a newly created/resumed session. In print mode (`kimi -p`,
* v1) the print-mode defaults are merged in; explicit user config wins. The
* raw `this.config` is left untouched so `getKimiConfig` and config writes
* still round-trip the user's file values.
*/
private withPrintModeDefaults(config: KimiConfig): KimiConfig {
return this.printMode ? applyPrintModeConfigDefaults(config) : config;
}

private clearRuntimeCache(): void {
if (this.runtimeOverride !== undefined) return;
this.runtime = undefined;
Expand Down
21 changes: 13 additions & 8 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
appendWorkspaceAdditionalDir,
normalizeAdditionalDirs,
parseBooleanEnv,
PRINT_MAX_TURNS_DEFAULT,
PRINT_WAIT_CEILING_S_DEFAULT,
readWorkspaceAdditionalDirs,
resolveWorkspaceAdditionalDirs,
resolveConfigValue,
Expand Down Expand Up @@ -451,7 +453,8 @@ export class Session {
* `resolvePrintBackgroundMode`): `print_background_mode = "drain"`, or the
* legacy `keep_alive_on_exit = true` fallback. In every other mode it returns
* immediately. The wait is bounded by `background.print_wait_ceiling_s`
* (default 3600s) so a wedged task cannot keep the process alive forever.
* (default `PRINT_WAIT_CEILING_S_DEFAULT`, effectively unbounded) so a wedged
* task can still be given up on eventually.
*
* Terminal notifications are suppressed for each task while we wait, so a task
* completing cannot `turn.steer` the (already finished) main agent into launching
Expand All @@ -460,7 +463,7 @@ export class Session {
async waitForBackgroundTasksOnPrint(): Promise<void> {
if (this.resolvePrintBackgroundMode() !== 'drain') return;

const ceilingS = this.options.background?.printWaitCeilingS ?? 3600;
const ceilingS = this.options.background?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT;
const timeoutMs = ceilingS * 1000;
const deadline = Date.now() + timeoutMs;

Expand Down Expand Up @@ -518,7 +521,9 @@ export class Session {
* `background.print_background_mode` is authoritative when set. Otherwise we
* fall back to the legacy `background.keep_alive_on_exit` mapping so existing
* configs keep their behavior: `keep_alive_on_exit = true` ⇒ `'drain'`
* (suppress + drain background tasks before exit), otherwise `'exit'`.
* (suppress + drain background tasks before exit). When neither is set the
* mode defaults to `'steer'`: a headless run stays alive while background
* tasks are pending so their completions can steer new main turns.
*/
private resolvePrintBackgroundMode(): 'exit' | 'drain' | 'steer' {
const configured = this.options.background?.printBackgroundMode;
Expand All @@ -530,7 +535,7 @@ export class Session {
defaultValue: false,
parseEnv: parseBooleanEnv,
});
return keepAliveOnExit ? 'drain' : 'exit';
return keepAliveOnExit ? 'drain' : 'steer';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit keep-alive false as exit

When an existing print-mode config explicitly sets [background] keep_alive_on_exit = false (or the env override to false), this now resolves to steer because the false result is treated the same as the unset default. The new behavior is documented here as defaulting to steer only “when neither is set,” while the legacy mapping made explicit false exit immediately; those users will unexpectedly keep kimi -p alive for background completions unless they add a new print_background_mode = "exit". Check whether the legacy option/env was present before falling through to the new default.

Useful? React with 👍 / 👎.

}

private countActiveBackgroundTasks(): number {
Expand All @@ -547,13 +552,13 @@ export class Session {
* `'continue'` when the driver must stay alive so a background-task completion
* can `turn.steer` the main agent into a new turn.
*
* - 'exit' : finish immediately (default).
* - 'exit' : finish immediately.
* - 'drain' : suppress + drain background tasks, then finish (legacy
* `keep_alive_on_exit = true` behavior).
* - 'steer' : while background tasks are still pending, return 'continue' so
* completions steer new main turns; finish once quiescent, or when
* the wall-clock ceiling (`print_wait_ceiling_s`) or the turn cap
* (`print_max_turns`) is reached.
* (`print_max_turns`) is reached. This is the default mode.
*/
async handlePrintMainTurnCompleted(): Promise<'finish' | 'continue'> {
const mode = this.resolvePrintBackgroundMode();
Expand All @@ -564,8 +569,8 @@ export class Session {
}

// 'steer'
const ceilingS = this.options.background?.printWaitCeilingS ?? 3600;
const maxTurns = this.options.background?.printMaxTurns ?? 50;
const ceilingS = this.options.background?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT;
const maxTurns = this.options.background?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT;
const now = Date.now();
this.printSteerDeadline ??= now + ceilingS * 1000;
this.printSteerTurns += 1;
Expand Down
29 changes: 21 additions & 8 deletions packages/agent-core/src/utils/promise.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
const NEVER = new Promise<never>(() => {});

/**
* Largest delay `setTimeout` accepts: beyond this Node clamps the delay to 1ms,
* which would fire immediately. Clamp instead so huge ("effectively unbounded")
* timeouts still mean a long wait (~24.8 days).
*/
const MAX_TIMER_DELAY_MS = 0x7fffffff;

export type TimeoutOutcomePromise<Outcome> = Promise<Outcome> & {
clear(): void;
};
Expand All @@ -13,10 +20,13 @@ export function timeoutOutcome<Outcome>(
timeoutMs === undefined || timeoutMs <= 0
? NEVER
: new Promise((resolve) => {
timeout = setTimeout(() => {
timeout = undefined;
resolve(outcome);
}, timeoutMs);
timeout = setTimeout(
() => {
timeout = undefined;
resolve(outcome);
},
Math.min(timeoutMs, MAX_TIMER_DELAY_MS),
);
});

return Object.assign(promise, {
Expand Down Expand Up @@ -57,10 +67,13 @@ export function resettableTimeoutOutcome<Outcome>(
const reset = (timeoutMs: number | undefined): void => {
clear();
if (timeoutMs === undefined || timeoutMs <= 0) return;
timer = setTimeout(() => {
timer = undefined;
resolvePromise(outcome);
}, timeoutMs);
timer = setTimeout(
() => {
timer = undefined;
resolvePromise(outcome);
},
Math.min(timeoutMs, MAX_TIMER_DELAY_MS),
);
};
reset(initialMs);
return Object.assign(promise, { reset, clear });
Expand Down
Loading
Loading