Skip to content
Open
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-drain-bash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

In `kimi -p`, wait for background bash commands to finish and respond to their results before the turn ends, instead of tearing them down with the process.
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ async function resolvePromptSession(
permission: 'auto',
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
drainAgentTasksOnStop: true,
drainProcessTasksOnStop: true,

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 Enable process draining for resumed print sessions

When kimi -p -r <id> or kimi -p -c resumes an existing session, resolvePromptSession returns from the two harness.resumeSession(...) branches above and never reaches this fresh-session createSession option; ResumeSessionOptions also has no equivalent drain flag. In those resumed print runs, a background Bash started by the prompt turn still is not held/drained before exit, so the new behavior only works for brand-new kimi -p sessions despite the feature/docs describing print mode generally.

Useful? React with 👍 / 👎.

});
installHeadlessHandlers(session);
return {
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ describe('runPrompt', () => {
permission: 'auto',
additionalDirs: undefined,
drainAgentTasksOnStop: true,
drainProcessTasksOnStop: true,
});
expect(mocks.session.setPermission).not.toHaveBeenCalled();
expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function));
Expand Down Expand Up @@ -336,6 +337,7 @@ describe('runPrompt', () => {
permission: 'auto',
additionalDirs: undefined,
drainAgentTasksOnStop: true,
drainProcessTasksOnStop: true,
});
expect(mocks.initializeTelemetry).toHaveBeenCalledWith(
expect.objectContaining({ model: 'kimi-code/k2.5' }),
Expand All @@ -354,6 +356,7 @@ describe('runPrompt', () => {
permission: 'auto',
additionalDirs: ['../shared', '/tmp/extra'],
drainAgentTasksOnStop: true,
drainProcessTasksOnStop: true,
});
});

Expand Down
6 changes: 3 additions & 3 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,12 @@ 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`), setting this to `true` also makes the process wait for all background tasks to finish before exiting, so background subagents and bash can complete their work |
| `print_wait_ceiling_s` | `integer` | `3600` | In print mode (`kimi -p`), the maximum number of seconds Kimi Code waits for background tasks to finish. It bounds both the end-of-turn drain of background subagents and bash (so the agent can react to their results) and, when `keep_alive_on_exit = true`, the additional wait before the process exits. Has no effect outside print mode |

`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 runs a single non-interactive turn. Before the turn ends, it holds the turn open to drain any still-running background subagents and bash, delivering their completions so the agent can react to the results; this drain is bounded by `print_wait_ceiling_s`. If you also need tasks to outlive the turn (for example, long-running subagents launched via `Agent(run_in_background=true)`), set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, again bounded by `print_wait_ceiling_s`. Otherwise, any background task still running when the run exits is torn down with the process.

<!--
## `experimental`
Expand Down
6 changes: 3 additions & 3 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,12 @@ 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`)下,设为 `true` 还会让进程在退出前等待所有后台任务跑完,使后台子代理与 bash 得以完成工作 |
| `print_wait_ceiling_s` | `integer` | `3600` | 在 print 模式(`kimi -p`)下,Kimi Code 等待后台任务完成的最长秒数。它同时限制 turn 结束前对后台子代理与 bash 的排空(让 agent 能对结果作出反应),以及在 `keep_alive_on_exit = true` 时进程退出前的额外等待。在非 print 模式下无效 |

`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。turn 结束前,Kimi Code 会先挂起 turn 以排空仍在运行的后台子代理与 bash,把它们的完成结果回传给 agent 以便其作出反应;这一排空受 `print_wait_ceiling_s` 限制。如果你还需要任务在 turn 结束之后继续运行(例如通过 `Agent(run_in_background=true)` 启动的长耗时子代理),请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,同样不超过 `print_wait_ceiling_s`。否则,运行退出时仍在运行的后台任务会随进程一起被清理

<!--
## `experimental`
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ export class Agent {
* by the session for print runs; defaults to false everywhere else.
*/
printDrainAgentTasksOnStop = false;
/**
* Print-mode (`kimi -p`) only: when true and the agent ends a turn while
* background bash (`kind === 'process'`) tasks are still running, the turn
* loop holds the turn open and idle-waits until they finish, flushing their
* completions into the turn so the model can react before the run exits.
* Shares `printDrainDeadlineMs` and the wait path with
* `printDrainAgentTasksOnStop`. Set by the session for print runs; defaults
* to false everywhere else.
*/
printDrainProcessTasksOnStop = false;
/**
* Absolute deadline (ms epoch) bounding all print-mode drain waits for this
* agent, derived from `background.printWaitCeilingS`. `Infinity` when the
Expand Down
38 changes: 23 additions & 15 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { AgentEvent, TurnEndedEvent, TurnEndReason } from '../../rpc';
import type { TelemetryPropertyValue } from '../../telemetry';
import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort';
import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
import type { BackgroundTaskInfo } from '../background';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
import { ToolCallDeduplicator } from './tool-dedup';
Expand Down Expand Up @@ -730,22 +731,29 @@ export class TurnFlow {
signal.throwIfAborted();

// Print-mode drain: when `kimi -p` ends a turn while background
// subagents are still running, hold the turn open and idle-wait
// until they finish (or the drain deadline is reached). Their
// completions steer into the buffer during the wait and are
// flushed afterward, so the model gets one wrap-up step to react
// (nominate, backfill, ...) before the turn ends. Gated on a
// session flag so interactive / goal modes are unaffected.
if (this.agent.printDrainAgentTasksOnStop) {
// tasks enabled for draining (subagents via `kind === 'agent'`,
// bash via `kind === 'process'`) are still running, hold the turn
// open and idle-wait until they finish (or the shared drain
// deadline is reached). Their completions steer into the buffer
// during the wait and are flushed afterward, so the model gets
// one wrap-up step to react (nominate, backfill, ...) before the
// turn ends. Gated on per-kind session flags so interactive /
// goal modes are unaffected, while agent and process draining
// stay independently controlled yet share one wait path and one
// aggregate deadline.
const drainKinds = new Set<BackgroundTaskInfo['kind']>();
if (this.agent.printDrainAgentTasksOnStop) drainKinds.add('agent');
if (this.agent.printDrainProcessTasksOnStop) drainKinds.add('process');
if (drainKinds.size > 0) {
const remaining = this.agent.printDrainDeadlineMs - Date.now();
const hasActiveAgentTask = this.agent.background
.list(true)
.some((task) => task.kind === 'agent');
if (hasActiveAgentTask && remaining > 0) {
await this.agent.background.waitForActiveTasks(
(task) => task.kind === 'agent',
{ timeoutMs: remaining, signal },
);
const matchesDrain = (task: BackgroundTaskInfo): boolean =>
drainKinds.has(task.kind);
const hasActiveTask = this.agent.background.list(true).some(matchesDrain);
if (hasActiveTask && remaining > 0) {
await this.agent.background.waitForActiveTasks(matchesDrain, {
timeoutMs: remaining,
signal,
});
this.flushSteerBuffer();
return { continue: true };
}
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface CreateSessionPayload {
readonly additionalDirs?: readonly string[];
readonly client?: ClientTelemetryInfo | undefined;
readonly drainAgentTasksOnStop?: boolean;
readonly drainProcessTasksOnStop?: boolean;
}

export interface CloseSessionPayload {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
appVersion: this.appVersion,
additionalDirs,
drainAgentTasksOnStop: options.drainAgentTasksOnStop,
drainProcessTasksOnStop: options.drainProcessTasksOnStop,
});
try {
session.metadata = {
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export interface SessionOptions {
* finish before the run exits. Set via the SDK `createSession` option.
*/
readonly drainAgentTasksOnStop?: boolean;
/**
* Print-mode (`kimi -p`) only: hold the main turn open while background bash
* (`kind === 'process'`) tasks are still running, idle-waiting until they
* finish before the run exits. Shares the deadline and wait path with
* `drainAgentTasksOnStop`. Set via the SDK `createSession` option.
*/
readonly drainProcessTasksOnStop?: boolean;
}

export interface SessionSkillConfig {
Expand Down Expand Up @@ -300,8 +307,13 @@ export class Session {
profile: DEFAULT_AGENT_PROFILES['agent'],
});
if (this.options.drainAgentTasksOnStop) {
const ceilingS = this.options.background?.printWaitCeilingS ?? 3600;
agent.printDrainAgentTasksOnStop = true;
}
if (this.options.drainProcessTasksOnStop) {
agent.printDrainProcessTasksOnStop = true;
}
if (agent.printDrainAgentTasksOnStop || agent.printDrainProcessTasksOnStop) {
const ceilingS = this.options.background?.printWaitCeilingS ?? 3600;
agent.printDrainDeadlineMs = Date.now() + ceilingS * 1000;
}
await this.triggerSessionStart('startup');
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-core/test/agent/background/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,4 +899,22 @@ describe('waitForActiveTasks', () => {
await expect(wait).rejects.toThrow('stop');
done.resolve({ result: 'late' });
});

it('waits until a matching process task reaches a terminal state', async () => {
const { manager } = createBackgroundManager();
const { proc, resolve } = manuallyResolvedProcess();
registerProcess(manager, proc, 'sleep 60', 'proc');
const isProcess = (info: BackgroundTaskInfo): boolean => info.kind === 'process';

let settled = false;
const wait = manager.waitForActiveTasks(isProcess).then(() => {
settled = true;
});
await new Promise((r) => setImmediate(r));
expect(settled).toBe(false);

resolve(0);
await wait;
expect(settled).toBe(true);
});
});
48 changes: 45 additions & 3 deletions packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe('Agent turn flow', () => {
expect(ctx.llmCalls.length).toBe(2);
});

it('does not hold the turn for a non-agent (process) background task', async () => {
it('does not hold the turn for a process task when process drain is disabled', async () => {
const ctx = testAgent();
ctx.agent.printDrainAgentTasksOnStop = true;
ctx.agent.printDrainDeadlineMs = Date.now() + 60_000;
Expand All @@ -132,11 +132,53 @@ describe('Agent turn flow', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'go' }] });
await ctx.untilTurnEnd();

// Process tasks do not trigger the subagent-only drain hold, so the turn
// ends after the single step.
// Process tasks do not trigger the agent-only drain hold, so the turn ends
// after the single step.
expect(ctx.llmCalls.length).toBe(1);
});

it('holds the turn until a background bash task finishes, then runs a wrap-up step', async () => {
const ctx = testAgent();
ctx.agent.printDrainProcessTasksOnStop = true;
ctx.agent.printDrainDeadlineMs = Date.now() + 60_000;

const procDone = createControlledPromise<number>();
const proc: KaosProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
pid: 4242,
exitCode: null,
wait: vi.fn().mockReturnValue(procDone) as unknown as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as unknown as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as unknown as KaosProcess['dispose'],
};
ctx.agent.background.registerTask(new ProcessBackgroundTask(proc, 'npm test', 'proc'));

ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'first' });
ctx.mockNextResponse({ type: 'text', text: 'wrap-up' });

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'go' }] });

let turnEnded = false;
const turnEnd = ctx.untilTurnEnd().then(() => {
turnEnded = true;
});

// Let the first model step finish and the drain hold engage.
for (let i = 0; i < 100 && ctx.llmCalls.length < 1; i++) await delay(5);
await delay(20);
expect(turnEnded).toBe(false);

// Completing the bash releases the hold; the model takes a wrap-up step.
procDone.resolve(0);
await turnEnd;

expect(turnEnded).toBe(true);
expect(ctx.llmCalls.length).toBe(2);
});

it('tracks turn_ended telemetry with protocol props', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
Expand Down
21 changes: 21 additions & 0 deletions packages/agent-core/test/session/lifecycle-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,27 @@ describe('Session lifecycle hooks', () => {
await session.close();
});

it('createMain enables process print drain and shares the deadline when drainProcessTasksOnStop is true', async () => {
const { sessionDir, workDir } = await hookFixture();
const before = Date.now();
const session = new Session({
kaos: testKaos.withCwd(workDir),
id: 'session-print-drain-proc',
homedir: sessionDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
background: { keepAliveOnExit: true, printWaitCeilingS: 42 },
drainProcessTasksOnStop: true,
});
const agent = await session.createMain();

expect(agent.printDrainProcessTasksOnStop).toBe(true);
expect(agent.printDrainAgentTasksOnStop).toBe(false);
expect(agent.printDrainDeadlineMs).toBeGreaterThanOrEqual(before + 42 * 1000 - 50);
expect(agent.printDrainDeadlineMs).toBeLessThanOrEqual(Date.now() + 42 * 1000 + 50);
await session.close();
});

it('createMain leaves print drain disabled by default', async () => {
const { sessionDir, workDir } = await hookFixture();
const session = new Session({
Expand Down
9 changes: 9 additions & 0 deletions packages/node-sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ export interface CreateSessionOptions {
* interactive / SDK sessions.
*/
readonly drainAgentTasksOnStop?: boolean;
/**
* Print-mode (`kimi -p`) only: when the main agent ends a turn while
* background bash (`kind === 'process'`) tasks are still running, hold the
* turn open and idle-wait until they all finish, flushing their completions
* into the turn so the model can react before the run exits. Shares the
* deadline and wait path with `drainAgentTasksOnStop`. Ignored by
* interactive / SDK sessions.
*/
readonly drainProcessTasksOnStop?: boolean;
}

export interface RenameSessionInput {
Expand Down
Loading