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
This commit is contained in:
haozhe.yang 2026-07-08 17:04:17 +08:00
parent 2394d013bc
commit c0b9cd8e00
14 changed files with 308 additions and 30 deletions

View file

@ -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;

View file

@ -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' });

View file

@ -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 "<prompt>"`), 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 "<prompt>"`), 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`.
<!--
## `experimental`

View file

@ -131,6 +131,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

View file

@ -195,12 +195,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 "<prompt>"`Kimi Code 只跑一个非交互的单轮 turn主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理
在 print 模式(`kimi -p "<prompt>"`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` 限制
<!--
## `experimental`

View file

@ -131,6 +131,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` 控制。
### 重构

View file

@ -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<typeof BackgroundConfigSchema>;

View file

@ -437,6 +437,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;
}

View file

@ -835,6 +835,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).waitForBackgroundTasksOnPrint(payload);
}
handlePrintMainTurnCompleted({ sessionId, ...payload }: SessionScopedPayload<EmptyPayload>): Promise<'finish' | 'continue'> {
return this.sessionApi(sessionId).handlePrintMainTurnCompleted(payload);
}
addAdditionalDir({
sessionId,
...payload

View file

@ -185,6 +185,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
@ -440,24 +442,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<void> {
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;
@ -511,6 +507,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<AgentOptions>,
options: CreateAgentOptions = {},

View file

@ -111,6 +111,10 @@ export class SessionAPIImpl implements PromisableMethods<SessionAPI> {
return this.session.waitForBackgroundTasksOnPrint();
}
handlePrintMainTurnCompleted(_payload: EmptyPayload): Promise<'finish' | 'continue'> {
return this.session.handlePrintMainTurnCompleted();
}
addAdditionalDir(payload: AddAdditionalDirPayload): Promise<AddAdditionalDirResult> {
return this.session.addAdditionalDir(payload.path, payload.persist);
}

View file

@ -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({

View file

@ -547,6 +547,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<GoalSnapshot> {
const rpc = await this.getRpc();
return rpc.createGoal({

View file

@ -380,15 +380,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<void> {
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