From c0b9cd8e006e88f189860bfc14ac01bffebdda11 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 8 Jul 2026 17:04:17 +0800 Subject: [PATCH] feat(background): add print_background_mode with steer for multi-turn -p runs - add `[background].print_background_mode` (`exit`/`drain`/`steer`) and `print_max_turns`; when unset, falls back to `keep_alive_on_exit = true` mapping to `drain`, preserving existing behavior - core: `Session.handlePrintMainTurnCompleted()` returns `finish`/`continue`; in `steer` mode the run stays alive so a background-task completion `turn.steer`s the main agent into a new turn (matching background subagents), bounded by `print_wait_ceiling_s` and `print_max_turns` - cli: print driver follows every main turn instead of only the first and defers `finish()` until the run quiesces or a limit is hit - plumb `handlePrintMainTurnCompleted` through node-sdk RPC; docs + tests --- apps/kimi-code/src/cli/run-prompt.ts | 21 ++-- apps/kimi-code/test/cli/run-prompt.test.ts | 51 ++++++++ docs/en/configuration/config-files.md | 8 +- docs/en/release-notes/changelog.md | 1 + docs/zh/configuration/config-files.md | 8 +- docs/zh/release-notes/changelog.md | 1 + packages/agent-core/src/config/schema.ts | 2 + packages/agent-core/src/rpc/core-api.ts | 1 + packages/agent-core/src/rpc/core-impl.ts | 4 + packages/agent-core/src/session/index.ts | 93 ++++++++++++-- packages/agent-core/src/session/rpc.ts | 4 + .../test/session/lifecycle-hooks.test.ts | 119 ++++++++++++++++++ packages/node-sdk/src/rpc.ts | 5 + packages/node-sdk/src/session.ts | 20 ++- 14 files changed, 308 insertions(+), 30 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 100368203..56e5a15de 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -463,7 +463,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; } @@ -516,15 +516,21 @@ function runPromptTurn( case 'turn.ended': if (event.reason === 'completed') { void (async () => { - // 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. + return; + } } catch (error) { - log.warn('waitForBackgroundTasksOnPrint failed', { error }); + log.warn('handlePrintMainTurnCompleted failed', { error }); } finish(); })(); @@ -550,7 +556,6 @@ function runPromptTurn( case 'subagent.started': case 'subagent.suspended': case 'tool.list.updated': - case 'turn.started': case 'turn.step.completed': case 'warning': return; diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index aafb99af9..bc5e16ece 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -40,6 +40,7 @@ const mocks = vi.hoisted(() => { } }), waitForBackgroundTasksOnPrint: vi.fn(async () => {}), + handlePrintMainTurnCompleted: vi.fn(async (): Promise<'finish' | 'continue'> => 'finish'), }; return { @@ -697,6 +698,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 f7e893555..234083b82 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -195,12 +195,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`.