diff --git a/.changeset/print-background-steer.md b/.changeset/print-background-steer.md new file mode 100644 index 0000000000..90c2bda639 --- /dev/null +++ b/.changeset/print-background-steer.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a print-mode background policy that lets `kimi -p` stay alive across background-task completions so the main agent can be steered into follow-up turns. Set `[background].print_background_mode = "steer"` to enable it. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index cb86adb2c6..f772862948 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -510,7 +510,7 @@ function runPromptTurn( finish(new Error(`${event.code}: ${event.message}`)); return; } - if (event.type === 'turn.started' && activeTurnId === undefined) { + if (event.type === 'turn.started') { if (event.agentId !== PROMPT_MAIN_AGENT_ID) { return; } @@ -599,7 +599,6 @@ function runPromptTurn( case 'subagent.started': case 'subagent.suspended': case 'tool.list.updated': - case 'turn.started': case 'turn.step.completed': case 'warning': return; @@ -611,15 +610,22 @@ function runPromptTurn( }); async function finishCompletedTurn(): Promise { - // Flush the buffered assistant message before draining background tasks: - // in stream-json mode the final message is only emitted by finish(), so a - // long background wait would otherwise withhold the main turn's result - // until the drain settles. + // Flush the buffered assistant message before the end-of-turn policy + // runs: in stream-json mode the final message is only emitted by + // finish(), so a long drain/steer wait would otherwise withhold the main + // turn's result until the run exits. outputWriter.flushAssistant(); try { - await session.waitForBackgroundTasksOnPrint(); + const action = await session.handlePrintMainTurnCompleted(); + if (action === 'continue') { + // Stay alive: a still-pending background task will, on completion, + // steer the main agent into a new turn whose events we keep mapping. + // Do not finish yet. + holdEventLoop(); + return; + } } catch (error) { - log.warn('waitForBackgroundTasksOnPrint failed', { error }); + log.warn('handlePrintMainTurnCompleted failed', { error }); } finish(); } diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index c7856954e9..bfebd12e27 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -42,6 +42,7 @@ const mocks = vi.hoisted(() => { waitForBackgroundTasksOnPrint: vi.fn(async () => {}), getGoal: vi.fn(async () => ({ goal: null })), getCronTasks: vi.fn(async () => ({ tasks: [] })), + handlePrintMainTurnCompleted: vi.fn(async (): Promise<'finish' | 'continue'> => 'finish'), }; return { @@ -752,6 +753,56 @@ describe('runPrompt', () => { await runPromise; }); + it('follows a background-steered second main turn before finishing in steer mode', async () => { + // First end-of-turn: stay alive (a background task is still pending). + // Second end-of-turn: finish. + mocks.session.handlePrintMainTurnCompleted + .mockResolvedValueOnce('continue') + .mockResolvedValueOnce('finish'); + + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'first' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); + } + }); + + const stdout = writer(); + const stderr = writer(); + const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); + + // The first turn's assistant message must be flushed and the end-of-turn + // policy consulted, while the run stays alive (action === 'continue'). + await waitForAssertion(() => { + expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(1); + expect(stdout.text()).toContain('{"role":"assistant","content":"first"}'); + }); + + // Simulate a background-task completion steering the main agent into a new + // turn (the runtime does this via turn.steer; here we drive the events + // directly to verify the driver follows and finishes only after it). + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 11, + origin: { kind: 'background_task' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 11, delta: 'second' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 11, reason: 'completed' })); + } + + await runPromise; + + expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(2); + expect(stdout.text()).toContain('{"role":"assistant","content":"second"}'); + }); + it('resumes a concrete session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 2e093baf29..6d4bdfe99e 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -208,12 +208,14 @@ You can also switch models temporarily without touching the config file — by s | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | Maximum number of background tasks running concurrently | -| `keep_alive_on_exit` | `boolean` | `false` | Whether to keep still-running background tasks when the session closes. By default, Kimi Code requests that all background tasks stop before the process exits; set this to `true` only when you want tasks to outlive the session. In print mode (`kimi -p`), setting this to `true` also makes the process wait for all background tasks to finish before exiting, so background subagents can complete their work | -| `print_wait_ceiling_s` | `integer` | `3600` | In print mode (`kimi -p`) with `keep_alive_on_exit = true`, the maximum number of seconds the process waits for background tasks to finish after the main agent's turn ends. Has no effect outside print mode or when `keep_alive_on_exit` is `false` | +| `keep_alive_on_exit` | `boolean` | `false` | Whether to keep still-running background tasks when the session closes. By default, Kimi Code requests that all background tasks stop before the process exits; set this to `true` only when you want tasks to outlive the session. In print mode (`kimi -p`), this is only a legacy fallback used when `print_background_mode` is unset: `true` is equivalent to `print_background_mode = "drain"` | +| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"exit"` | Print mode (`kimi -p`) only. Governs how pending background tasks are handled once the main agent's turn ends: `"exit"` exits immediately; `"drain"` waits for every background task to reach a terminal state before exiting (results are not fed back to the main agent); `"steer"` stays alive so a completing background task — like a background subagent — injects a synthetic user message that steers the main agent into a new turn, looping until a turn ends with no pending background tasks or a limit is hit. Takes precedence over the `keep_alive_on_exit` print fallback | +| `print_wait_ceiling_s` | `integer` | `3600` | In print mode (`kimi -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"`. Has no effect outside print mode or when it is `"exit"` | +| `print_max_turns` | `integer` | `50` | 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 | `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`. -In print mode (`kimi -p ""`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process. +In print mode (`kimi -p ""`), Kimi Code by default runs a single non-interactive turn and exits as soon as the main agent finishes (`print_background_mode = "exit"`). If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`, or a long command via `Bash(run_in_background=true)`) and need them to run to completion, set `print_background_mode` to `"drain"` (wait for them to finish, without feeding results back) or `"steer"` (feed each completion back to the main agent, starting a new turn so it can act on the result). `"steer"` is useful when the main agent should keep working based on the outcome of a long background task (e.g. training or evaluation); its total wall-clock is bounded by `print_wait_ceiling_s` and the number of extra turns by `print_max_turns`. ## `image` diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index ab6868f1b8..d4bfec149b 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -169,6 +169,7 @@ This page documents the changes in each Kimi Code CLI release. - Add a TUI preference to keep rapid multi-line pastes from submitting line by line when bracketed paste is unavailable. Set `disable_paste_burst = true` in `tui.toml` to turn it off. - Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window. - In `kimi -p` runs, wait for background subagents to finish before exiting when `background.keep_alive_on_exit` is enabled. Set `keep_alive_on_exit = true` to let concurrent background subagents complete. +- Add `background.print_background_mode` (`exit`/`drain`/`steer`) for `kimi -p`: in `steer` mode, a completing background task (including `Bash(run_in_background=true)`) behaves like a background subagent — it injects a synthetic user message that steers the main agent into a new turn so it can act on the result. Bounded by `print_wait_ceiling_s` and the new `print_max_turns`. ### Refactors diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 23545d7f26..e25b41203e 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -208,12 +208,14 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | 同时运行的最大后台任务数 | -| `keep_alive_on_exit` | `boolean` | `false` | 会话关闭时是否保留仍在运行的后台任务。默认情况下,Kimi Code 会在进程退出前请求停止所有后台任务;只有希望任务在会话结束后继续运行时才设为 `true`。在 print 模式(`kimi -p`)下,设为 `true` 还会让进程在退出前等待所有后台任务跑完,使后台子代理得以完成工作 | -| `print_wait_ceiling_s` | `integer` | `3600` | 在 print 模式(`kimi -p`)且 `keep_alive_on_exit = true` 时,主 agent 的 turn 结束后进程等待后台任务完成的最长秒数。在非 print 模式或 `keep_alive_on_exit` 为 `false` 时无效 | +| `keep_alive_on_exit` | `boolean` | `false` | 会话关闭时是否保留仍在运行的后台任务。默认情况下,Kimi Code 会在进程退出前请求停止所有后台任务;只有希望任务在会话结束后继续运行时才设为 `true`。在 print 模式(`kimi -p`)下,本字段仅作为 `print_background_mode` 未设置时的兼容回退:`true` 等价于 `print_background_mode = "drain"` | +| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"exit"` | 仅 print 模式(`kimi -p`)生效,决定主 agent 的 turn 结束后如何处理未返回的后台任务:`"exit"` 立即退出;`"drain"` 退出前等待所有后台任务进入终态(结果不回馈给主 agent);`"steer"` 不退出,让后台任务完成时像后台子代理一样以合成 user 消息 steer 主 agent 进入新 turn,直到某 turn 结束时无未决后台任务或触及上限。设置后优先级高于 `keep_alive_on_exit` 的 print 回退 | +| `print_wait_ceiling_s` | `integer` | `3600` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒)。在非 print 模式或 `"exit"` 时无效 | +| `print_max_turns` | `integer` | `50` | print 模式(`kimi -p`)且 `print_background_mode = "steer"` 时,允许由后台任务完成触发的新 turn 的最大数量,防止 steer 循环失控 | `keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,优先级高于配置文件。 -在 print 模式(`kimi -p ""`)下,Kimi Code 只跑一个非交互的单轮 turn,主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。 +在 print 模式(`kimi -p ""`)下,Kimi Code 默认只跑一个非交互的单轮 turn,主 agent 一结束就退出(`print_background_mode = "exit"`)。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理,或 `Bash(run_in_background=true)` 的长命令)并希望它们跑完,可将 `print_background_mode` 设为 `"drain"`(等任务结束再退出,结果不回馈)或 `"steer"`(任务结束后把结果 steer 给主 agent,触发新 turn 继续处理)。`"steer"` 适合让主 agent 依据后台长任务(如训练、评测)的结果继续做后续步骤;其总耗时受 `print_wait_ceiling_s` 限制、额外 turn 数受 `print_max_turns` 限制。 ## `image` diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index eef2673ed9..d6498bd60d 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -169,6 +169,7 @@ outline: 2 - TUI 新增一项偏好设置:当 bracketed paste 不可用时,避免快速多行粘贴被逐行提交。可在 `tui.toml` 中设置 `disable_paste_burst = true` 关闭该行为。 - 优化子 Agent 卡片,使其保持固定高度,并在紧凑的双行活动窗口内显示实时状态 spinner。 - `kimi -p` 运行时,若启用了 `background.keep_alive_on_exit`,退出前会等待后台子 Agent 完成。设置 `keep_alive_on_exit = true` 可让并发的后台子 Agent 执行完毕。 +- `kimi -p` 新增 `background.print_background_mode`(`exit`/`drain`/`steer`):`steer` 模式下,后台任务(含 `Bash(run_in_background=true)`)完成时会像后台子 Agent 一样,以合成 user 消息 steer 主 Agent 进入新 turn,使其能依据后台结果继续工作;上限由 `print_wait_ceiling_s` 与新增的 `print_max_turns` 控制。 ### 重构 diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 190da81b76..1f9f9e3b2c 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -130,6 +130,8 @@ export const BackgroundConfigSchema = z.object({ keepAliveOnExit: z.boolean().optional(), killGracePeriodMs: z.number().int().min(0).optional(), printWaitCeilingS: z.number().int().min(1).optional(), + printBackgroundMode: z.enum(['exit', 'drain', 'steer']).optional(), + printMaxTurns: z.number().int().min(1).optional(), }); export type BackgroundConfig = z.infer; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 53e067b7f4..8bef899e09 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -443,6 +443,7 @@ export interface SessionAPI extends AgentAPIWithId { generateAgentsMd: (payload: EmptyPayload) => void; getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; waitForBackgroundTasksOnPrint: (payload: EmptyPayload) => void; + handlePrintMainTurnCompleted: (payload: EmptyPayload) => 'finish' | 'continue'; addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; } diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 0583fcb40d..2b12ad013d 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -842,6 +842,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).waitForBackgroundTasksOnPrint(payload); } + handlePrintMainTurnCompleted({ sessionId, ...payload }: SessionScopedPayload): Promise<'finish' | 'continue'> { + return this.sessionApi(sessionId).handlePrintMainTurnCompleted(payload); + } + addAdditionalDir({ sessionId, ...payload diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index a5086e76ea..f8f40de5f2 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -189,6 +189,8 @@ export class Session { }; private writeMetadataPromise = Promise.resolve(); private agentsMdWarning: string | undefined; + private printSteerDeadline: number | undefined; + private printSteerTurns = 0; constructor(public readonly options: SessionOptions) { // Attach the per-session log sink up front so the constructor's @@ -445,24 +447,18 @@ export class Session { * Wait for all still-running background tasks (across every agent) to reach a * terminal state before a `kimi -p` (print) run exits. * - * Gated by `background.keep_alive_on_exit`: when it is not `true`, this returns - * immediately so print mode keeps its default single-turn semantics. The wait is - * bounded by `background.print_wait_ceiling_s` (default 3600s) so a wedged task - * cannot keep the process alive forever. + * Only runs when the resolved print background mode is `'drain'` (see + * `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. * * Terminal notifications are suppressed for each task while we wait, so a task * completing cannot `turn.steer` the (already finished) main agent into launching - * a new turn. + * a new turn. (This is exactly what `'steer'` mode avoids by never calling here.) */ async waitForBackgroundTasksOnPrint(): Promise { - const keepAliveOnExit = resolveConfigValue({ - env: process.env, - envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV, - configValue: this.options.background?.keepAliveOnExit, - defaultValue: false, - parseEnv: parseBooleanEnv, - }); - if (!keepAliveOnExit) return; + if (this.resolvePrintBackgroundMode() !== 'drain') return; const ceilingS = this.options.background?.printWaitCeilingS ?? 3600; const timeoutMs = ceilingS * 1000; @@ -516,6 +512,77 @@ export class Session { } } + /** + * Resolve the effective print-mode (`kimi -p`) background-task policy. + * + * `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'`. + */ + private resolvePrintBackgroundMode(): 'exit' | 'drain' | 'steer' { + const configured = this.options.background?.printBackgroundMode; + if (configured !== undefined) return configured; + const keepAliveOnExit = resolveConfigValue({ + env: process.env, + envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV, + configValue: this.options.background?.keepAliveOnExit, + defaultValue: false, + parseEnv: parseBooleanEnv, + }); + return keepAliveOnExit ? 'drain' : 'exit'; + } + + private countActiveBackgroundTasks(): number { + let count = 0; + for (const agent of this.readyAgents()) { + count += agent.background.list(true).length; + } + return count; + } + + /** + * Decide what the `kimi -p` driver should do after the main agent's turn ends + * with `reason === 'completed'`. Returns `'finish'` when the run may exit, or + * `'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). + * - '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. + */ + async handlePrintMainTurnCompleted(): Promise<'finish' | 'continue'> { + const mode = this.resolvePrintBackgroundMode(); + if (mode === 'exit') return 'finish'; + if (mode === 'drain') { + await this.waitForBackgroundTasksOnPrint(); + return 'finish'; + } + + // 'steer' + const ceilingS = this.options.background?.printWaitCeilingS ?? 3600; + const maxTurns = this.options.background?.printMaxTurns ?? 50; + const now = Date.now(); + this.printSteerDeadline ??= now + ceilingS * 1000; + this.printSteerTurns += 1; + if (now >= this.printSteerDeadline) { + this.log.warn('print steer ceiling reached, finishing', { ceilingS }); + return 'finish'; + } + if (this.printSteerTurns > maxTurns) { + this.log.warn('print steer max turns reached, finishing', { maxTurns }); + return 'finish'; + } + if (this.countActiveBackgroundTasks() > 0) { + return 'continue'; + } + return 'finish'; + } + async createAgent( config: Partial, options: CreateAgentOptions = {}, diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d15edef256..bbc85c7763 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -111,6 +111,10 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.waitForBackgroundTasksOnPrint(); } + handlePrintMainTurnCompleted(_payload: EmptyPayload): Promise<'finish' | 'continue'> { + return this.session.handlePrintMainTurnCompleted(); + } + addAdditionalDir(payload: AddAdditionalDirPayload): Promise { return this.session.addAdditionalDir(payload.path, payload.persist); } diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index d064f8cde9..cc45993382 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -295,6 +295,125 @@ describe('Session lifecycle hooks', () => { await session.close(); }); + it('handlePrintMainTurnCompleted returns finish by default (exit mode)', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-print-mode-exit', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + await session.createMain(); + + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('finish'); + await session.close(); + }); + + it('handlePrintMainTurnCompleted drains when printBackgroundMode is drain without keepAliveOnExit', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-print-mode-drain', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { printBackgroundMode: 'drain' }, + }); + const agent = await session.createMain(); + const { proc } = pendingProcess(0); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'drain me'), + ); + + let settled = false; + const promise = session.handlePrintMainTurnCompleted().then((action) => { + settled = true; + return action; + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + + await proc.kill('SIGTERM'); + await expect(promise).resolves.toBe('finish'); + expect(agent.background.getTask(taskId)?.status).toBe('completed'); + await session.close(); + }); + + it('explicit printBackgroundMode exit overrides keepAliveOnExit (no drain)', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-print-mode-exit-override', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { keepAliveOnExit: true, printBackgroundMode: 'exit' }, + }); + const agent = await session.createMain(); + const { proc, killSpy } = pendingProcess(); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'no drain'), + ); + + await session.waitForBackgroundTasksOnPrint(); + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('finish'); + + expect(killSpy).not.toHaveBeenCalled(); + expect(agent.background.getTask(taskId)?.status).toBe('running'); + await proc.kill('SIGTERM').catch(() => undefined); + await session.close(); + }); + + it('handlePrintMainTurnCompleted returns continue in steer mode while a task is pending, then finish once quiescent', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-print-mode-steer', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { printBackgroundMode: 'steer' }, + }); + const agent = await session.createMain(); + const { proc } = pendingProcess(); + agent.background.registerTask(new ProcessBackgroundTask(proc, 'sleep 60', 'steer me')); + + 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(); + }); + + it('handlePrintMainTurnCompleted finishes in steer mode once printMaxTurns is reached', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-print-mode-steer-cap', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { printBackgroundMode: 'steer', printMaxTurns: 1 }, + }); + const agent = await session.createMain(); + const { proc } = pendingProcess(); + agent.background.registerTask(new ProcessBackgroundTask(proc, 'sleep 60', 'cap me')); + + // First call: printSteerTurns becomes 1 (not over cap), task pending ⇒ continue. + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('continue'); + // Second call: printSteerTurns becomes 2 (> printMaxTurns=1) ⇒ finish even though + // the task is still running. + await expect(session.handlePrintMainTurnCompleted()).resolves.toBe('finish'); + + await proc.kill('SIGTERM').catch(() => undefined); + await session.close(); + }); + it('waitForBackgroundTasksOnPrint waits for tasks spawned after the first enumeration', async () => { const { sessionDir, workDir } = await hookFixture(); const session = new Session({ diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index b51960e5c1..9b1b83554c 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -548,6 +548,11 @@ export abstract class SDKRpcClientBase { return rpc.waitForBackgroundTasksOnPrint({ sessionId: input.sessionId }); } + async handlePrintMainTurnCompleted(input: SessionIdRpcInput): Promise<'finish' | 'continue'> { + const rpc = await this.getRpc(); + return rpc.handlePrintMainTurnCompleted({ sessionId: input.sessionId }); + } + async createGoal(input: SessionIdRpcInput & CreateGoalInput): Promise { const rpc = await this.getRpc(); return rpc.createGoal({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 687b0d275b..2c0ec3e9f5 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -381,15 +381,29 @@ export class Session { /** * Block until every still-running background task (across all agents in this * session) reaches a terminal state. Used by `kimi -p` after the main agent's - * turn finishes when `background.keep_alive_on_exit` is `true`, so background - * subagents get a chance to complete before the process exits. No-op when - * `keep_alive_on_exit` is not enabled. Bounded by `background.print_wait_ceiling_s`. + * turn finishes when the resolved print background mode is `'drain'` + * (`print_background_mode = "drain"`, or the legacy `keep_alive_on_exit = true` + * fallback), so background subagents get a chance to complete before the process + * exits. No-op in other modes. Bounded by `background.print_wait_ceiling_s`. */ async waitForBackgroundTasksOnPrint(): Promise { this.ensureOpen(); await this.rpc.waitForBackgroundTasksOnPrint({ sessionId: this.id }); } + /** + * Used by `kimi -p` after the main agent's turn ends with `reason === + * 'completed'`. Returns `'finish'` when the run may exit, or `'continue'` when + * the caller must keep the session alive so a background-task completion can + * steer the main agent into a new turn. Policy is selected by + * `background.print_background_mode` (`'exit' | 'drain' | 'steer'`); when unset + * it falls back to the legacy `keep_alive_on_exit` mapping (`true ⇒ 'drain'`). + */ + async handlePrintMainTurnCompleted(): Promise<'finish' | 'continue'> { + this.ensureOpen(); + return this.rpc.handlePrintMainTurnCompleted({ sessionId: this.id }); + } + // --- Goal lifecycle --------------------------------------------------- // Deterministic user/host control surface. There is intentionally no // `updateGoal`: the goal's terminal status is decided by the model via the