diff --git a/.changeset/print-mode-steer-defaults.md b/.changeset/print-mode-steer-defaults.md new file mode 100644 index 0000000000..df902ced4c --- /dev/null +++ b/.changeset/print-mode-steer-defaults.md @@ -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. diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index b4c9799d7e..b34ef5653f 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -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'; diff --git a/packages/agent-core/src/config/print-defaults.ts b/packages/agent-core/src/config/print-defaults.ts new file mode 100644 index 0000000000..3430313bed --- /dev/null +++ b/packages/agent-core/src/config/print-defaults.ts @@ -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 }, + }; +} diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index dc7a102a71..2fdc740cbf 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -13,6 +13,7 @@ import { getCoreVersion } from '#/version'; import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; import { + applyPrintModeConfigDefaults, ensureKimiHome, loadRuntimeConfigSafe, mergeConfigPatch, @@ -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 { @@ -171,6 +178,8 @@ export class KimiCore implements PromisableMethods { 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; @@ -191,6 +200,7 @@ export class KimiCore implements PromisableMethods { 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 — @@ -236,6 +246,7 @@ export class KimiCore implements PromisableMethods { 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; @@ -298,13 +309,13 @@ export class KimiCore implements PromisableMethods { 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), @@ -418,6 +429,7 @@ export class KimiCore implements PromisableMethods { } const config = this.reloadProviderManager(); + const sessionConfig = this.withPrintModeDefaults(config); const baseMcpConfig = await resolveSessionMcpConfig({ cwd: summary.workDir, homeDir: this.homeDir, @@ -434,13 +446,13 @@ export class KimiCore implements PromisableMethods { 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), @@ -1108,6 +1120,16 @@ export class KimiCore implements PromisableMethods { 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; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index edda4f21b0..2442b900b1 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -17,6 +17,8 @@ import { appendWorkspaceAdditionalDir, normalizeAdditionalDirs, parseBooleanEnv, + PRINT_MAX_TURNS_DEFAULT, + PRINT_WAIT_CEILING_S_DEFAULT, readWorkspaceAdditionalDirs, resolveWorkspaceAdditionalDirs, resolveConfigValue, @@ -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 @@ -460,7 +463,7 @@ export class Session { async waitForBackgroundTasksOnPrint(): Promise { 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; @@ -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; @@ -530,7 +535,7 @@ export class Session { defaultValue: false, parseEnv: parseBooleanEnv, }); - return keepAliveOnExit ? 'drain' : 'exit'; + return keepAliveOnExit ? 'drain' : 'steer'; } private countActiveBackgroundTasks(): number { @@ -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(); @@ -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; diff --git a/packages/agent-core/src/utils/promise.ts b/packages/agent-core/src/utils/promise.ts index e030e33c0c..bbeb0b9d14 100644 --- a/packages/agent-core/src/utils/promise.ts +++ b/packages/agent-core/src/utils/promise.ts @@ -1,5 +1,12 @@ const NEVER = new Promise(() => {}); +/** + * 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 = Promise & { clear(): void; }; @@ -13,10 +20,13 @@ export function timeoutOutcome( 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, { @@ -57,10 +67,13 @@ export function resettableTimeoutOutcome( 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 }); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 569c7d5fa0..e3f44373ce 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -1147,6 +1147,116 @@ base_url = "https://search.example.test/v1" }); }); +describe('KimiCore print-mode defaults', () => { + let tmp: string; + + afterEach(async () => { + if (tmp !== undefined) { + await rm(tmp, { recursive: true, force: true }); + } + await __resetRootLoggerForTest(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + async function createCorePair(homeDir: string, uiMode?: string) { + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir, uiMode }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + return { core, rpc }; + } + + async function setupDirs(configToml: string): Promise<{ homeDir: string; workDir: string }> { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-print-defaults-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), configToml); + return { homeDir, workDir }; + } + + it('applies print-mode subagent/loop defaults to sessions when uiMode is print', async () => { + const { homeDir, workDir } = await setupDirs(baseModelConfig()); + const { core, rpc } = await createCorePair(homeDir, 'print'); + + const created = await rpc.createSession({ + id: 'ses_print_defaults', + workDir, + model: 'default-mock', + }); + + const main = core.sessions.get(created.id)?.getReadyAgent('main'); + expect(main?.kimiConfig?.subagent?.timeoutMs).toBe(259_200_000); + expect(main?.kimiConfig?.loopControl?.maxStepsPerTurn).toBe(0); + + // The raw user config is left untouched so config reads/writes still + // round-trip the user's file values. + const raw = await core.getKimiConfig(); + expect(raw.subagent).toBeUndefined(); + expect(raw.loopControl).toBeUndefined(); + }); + + it('keeps explicit user config over the print-mode defaults', async () => { + const { homeDir, workDir } = await setupDirs(`${baseModelConfig()} +[loop_control] +max_steps_per_turn = 7 + +[subagent] +timeout_ms = 5000 +`); + const { core, rpc } = await createCorePair(homeDir, 'print'); + + const created = await rpc.createSession({ + id: 'ses_print_defaults_user_config', + workDir, + model: 'default-mock', + }); + + const main = core.sessions.get(created.id)?.getReadyAgent('main'); + expect(main?.kimiConfig?.subagent?.timeoutMs).toBe(5000); + expect(main?.kimiConfig?.loopControl?.maxStepsPerTurn).toBe(7); + }); + + it('does not apply print-mode defaults outside print mode', async () => { + const { homeDir, workDir } = await setupDirs(baseModelConfig()); + const { core, rpc } = await createCorePair(homeDir); + + const created = await rpc.createSession({ + id: 'ses_print_defaults_off', + workDir, + model: 'default-mock', + }); + + const main = core.sessions.get(created.id)?.getReadyAgent('main'); + expect(main?.kimiConfig?.subagent).toBeUndefined(); + expect(main?.kimiConfig?.loopControl).toBeUndefined(); + }); + + it('applies print-mode defaults when a session is reloaded', async () => { + const { homeDir, workDir } = await setupDirs(baseModelConfig()); + const { core, rpc } = await createCorePair(homeDir, 'print'); + + const created = await rpc.createSession({ + id: 'ses_print_defaults_reload', + workDir, + model: 'default-mock', + }); + await rpc.reloadSession({ sessionId: created.id }); + + // The reload path rebuilds the session through resumeSessionWithOverrides; + // the agent it constructs must carry the same print-mode defaults. + const main = core.sessions.get(created.id)?.getReadyAgent('main'); + expect(main?.kimiConfig?.subagent?.timeoutMs).toBe(259_200_000); + expect(main?.kimiConfig?.loopControl?.maxStepsPerTurn).toBe(0); + }); +}); + async function writeSessionStartPlugin(root: string, skillBody: string): Promise { await mkdir(join(root, 'skills', 'greeter'), { recursive: true }); await writeFile( diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index 71e3847fb2..2993fc90eb 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -297,17 +297,43 @@ describe('Session lifecycle hooks', () => { await session.close(); }); - it('handlePrintMainTurnCompleted returns finish by default (exit mode)', async () => { + it('handlePrintMainTurnCompleted finishes immediately by default once quiescent (steer mode)', async () => { const { sessionDir, workDir } = await hookFixture(); const session = new Session({ kaos: testKaos.withCwd(workDir), - id: 'session-print-mode-exit', + id: 'session-print-mode-default', homedir: sessionDir, rpc: createSessionRpc(), skills: { explicitDirs: [join(workDir, 'missing-skills')] }, }); await session.createMain(); + // Default mode is 'steer'; with no pending background tasks the run finishes. + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('finish'); + await session.close(); + }); + + it('handlePrintMainTurnCompleted defaults to steer: continue while a task is pending, then finish', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-print-mode-default-steer', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + const agent = await session.createMain(); + const { proc } = pendingProcess(); + agent.background.registerTask(new ProcessBackgroundTask(proc, 'sleep 60', 'steer by default')); + + // No background config at all: the print default is 'steer', so a pending + // task keeps the run alive. + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('continue'); + + await proc.kill('SIGTERM'); + // Let the background manager observe the terminal status. + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('finish'); await session.close(); }); diff --git a/packages/node-sdk/src/sdk-rpc-client.ts b/packages/node-sdk/src/sdk-rpc-client.ts index 16b73fb703..cbb93bf27f 100644 --- a/packages/node-sdk/src/sdk-rpc-client.ts +++ b/packages/node-sdk/src/sdk-rpc-client.ts @@ -37,6 +37,12 @@ export interface SDKRpcClientOptions { readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient; readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; + /** + * Host UI mode (`'print'` for `kimi -p`, `'cli'` for the TUI, ...). Forwarded + * to the v1 core, which applies print-mode config defaults when it is + * `'print'`. + */ + readonly uiMode?: string; } export class SDKRpcClient extends SDKRpcClientBase { @@ -78,6 +84,7 @@ export class SDKRpcClient extends SDKRpcClientBase { skillDirs: options.skillDirs, telemetry: this.telemetry, appVersion: this.identity?.version, + uiMode: options.uiMode, }); this.ready = sdkRpc(new ClientAPI(this)); }