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

Wait for background subagents to finish and respond to their results before exiting in `kimi -p`, instead of ending the turn early.
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 @@ -338,6 +338,7 @@ async function resolvePromptSession(
model,
permission: 'auto',
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
drainAgentTasksOnStop: 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 print drain for resumed sessions

In kimi -p -r <id> or kimi -p --continue, the branches above call resumeSession and never set this new option (there is no resume-side equivalent), so resumed main agents keep printDrainAgentTasksOnStop false. A resumed print turn can still launch Agent(run_in_background=true) and then exit before those completion notifications are folded into the turn, leaving the original print-mode failure unfixed for resumed sessions. Please thread the drain flag through the resume/continue path or enable it on the resumed main agent for print mode.

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 @@ -209,6 +209,7 @@ describe('runPrompt', () => {
model: 'k2',
permission: 'auto',
additionalDirs: undefined,
drainAgentTasksOnStop: true,
});
expect(mocks.session.setPermission).not.toHaveBeenCalled();
expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function));
Expand Down Expand Up @@ -334,6 +335,7 @@ describe('runPrompt', () => {
model: 'kimi-code/k2.5',
permission: 'auto',
additionalDirs: undefined,
drainAgentTasksOnStop: true,
});
expect(mocks.initializeTelemetry).toHaveBeenCalledWith(
expect.objectContaining({ model: 'kimi-code/k2.5' }),
Expand All @@ -351,6 +353,7 @@ describe('runPrompt', () => {
model: 'k2',
permission: 'auto',
additionalDirs: ['../shared', '/tmp/extra'],
drainAgentTasksOnStop: true,
});
});

Expand Down
52 changes: 52 additions & 0 deletions packages/agent-core/src/agent/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,45 @@ export class BackgroundManager {
return this.toInfo(entry);
}

/**
* Wait until no active (non-terminal) task matching `predicate` remains.
*
* Used by print-mode (`kimi -p`) turn draining to hold a turn open while
* background subagents are still running. Re-enumerates after each batch
* settles so tasks registered during the wait (fan-out) are picked up.
* Resolves immediately when nothing matches. Bounded by `timeoutMs`; once
* the deadline passes the method returns without waiting for stragglers.
* Rejects with the abort reason when `signal` is aborted.
*/
async waitForActiveTasks(
predicate: (info: BackgroundTaskInfo) => boolean,
options: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<void> {
const deadline =
options.timeoutMs !== undefined && options.timeoutMs > 0
? Date.now() + options.timeoutMs
: undefined;
const signal = options.signal;
while (true) {
signal?.throwIfAborted();
const active = this.list(true).filter(predicate);
if (active.length === 0) return;
let perTaskTimeout: number | undefined;
if (deadline !== undefined) {
const remaining = deadline - Date.now();
if (remaining <= 0) return;
perTaskTimeout = remaining;
}
const batch = Promise.all(active.map((t) => this.wait(t.taskId, perTaskTimeout)));
if (signal === undefined) {
await batch;
} else {
await Promise.race([batch, abortRejecter(signal)]);
}
// Re-enumerate: settled tasks (and any fan-out) show up in the next list().
}
}

/**
* Wait until a foreground task either detaches from the current tool call or
* reaches a terminal state. Detached tasks return immediately.
Expand Down Expand Up @@ -995,3 +1034,16 @@ function buildBackgroundTaskNotificationBody(info: BackgroundTaskInfo): string {

return `${baseLine}${recovery}`;
}

function abortRejecter(signal: AbortSignal): Promise<never> {
if (signal.aborted) {
return Promise.reject(signal.reason ?? new Error('Aborted'));
}
return new Promise<never>((_, reject) => {
signal.addEventListener(
'abort',
() => reject(signal.reason ?? new Error('Aborted')),
{ once: true },
);
});
}
16 changes: 16 additions & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ export class Agent {
readonly goal: GoalMode;
readonly replayBuilder: ReplayBuilder;

/**
* Print-mode (`kimi -p`) only: when true and the agent ends a turn while
* background subagents (`kind === 'agent'`) 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. Set
* by the session for print runs; defaults to false everywhere else.
*/
printDrainAgentTasksOnStop = false;
/**
* Absolute deadline (ms epoch) bounding all print-mode drain waits for this
* agent, derived from `background.printWaitCeilingS`. `Infinity` when the
* drain is disabled. Shared across every drain hold within a single print
* run so backfill rounds cannot exceed the ceiling in aggregate.
*/
printDrainDeadlineMs = Number.POSITIVE_INFINITY;

private additionalDirs: readonly string[];
private activeProfile?: ResolvedAgentProfile;
private brandHome?: string;
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,28 @@ export class TurnFlow {
if (this.flushSteerBuffer()) return { continue: true };
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) {
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 },
);
this.flushSteerBuffer();
return { continue: 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 Respect the max-step cap before wrap-up

When loopControl.maxStepsPerTurn is already exhausted by the step that stops while background agents are still running, this unconditional continuation asks runTurn for another step; the loop checks maxSteps at the top of the next iteration and throws max_steps, so a bounded print run that should complete after waiting for subagents fails instead. The goal continuation below already guards with hasStepBudgetRemaining; the drain continuation needs the same check or should skip the wrap-up when no step budget remains.

Useful? React with 👍 / 👎.

}
}

// 2. After UpdateGoal marks a goal terminal, ask the model for one
// final user-facing outcome message before the turn ends.
if (
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 @@ -60,6 +60,7 @@ export interface CreateSessionPayload {
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly additionalDirs?: readonly string[];
readonly client?: ClientTelemetryInfo | undefined;
readonly drainAgentTasksOnStop?: 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 @@ -296,6 +296,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
pluginCommands,
appVersion: this.appVersion,
additionalDirs,
drainAgentTasksOnStop: options.drainAgentTasksOnStop,
});
try {
session.metadata = {
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export interface SessionOptions {
readonly appVersion?: string;
readonly experimentalFlags?: ExperimentalFlagResolver;
readonly additionalDirs?: readonly string[];
/**
* Print-mode (`kimi -p`) only: hold the main turn open while background
* subagents (`kind === 'agent'`) are still running, idle-waiting until they
* finish before the run exits. Set via the SDK `createSession` option.
*/
readonly drainAgentTasksOnStop?: boolean;
}

export interface SessionSkillConfig {
Expand Down Expand Up @@ -293,6 +299,11 @@ export class Session {
const { agent } = await this.createAgent({ type: 'main' }, {
profile: DEFAULT_AGENT_PROFILES['agent'],
});
if (this.options.drainAgentTasksOnStop) {
const ceilingS = this.options.background?.printWaitCeilingS ?? 3600;
agent.printDrainAgentTasksOnStop = true;
agent.printDrainDeadlineMs = Date.now() + ceilingS * 1000;
}
await this.triggerSessionStart('startup');
return agent;
}
Expand Down
94 changes: 94 additions & 0 deletions packages/agent-core/test/agent/background/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
BackgroundTaskPersistence,
ProcessBackgroundTask,
type BackgroundManager,
type BackgroundTaskInfo,
} from '../../../src/agent/background';
import {
agentTask,
Expand Down Expand Up @@ -806,3 +807,96 @@ describe('BackgroundManager', () => {
expect(await manager.readOutput(taskId)).toContain('bg-ok');
}, 15_000);
});


describe('waitForActiveTasks', () => {
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

const isAgent = (info: BackgroundTaskInfo): boolean => info.kind === 'agent';

it('resolves immediately when no task matches the predicate', async () => {
const { manager } = createBackgroundManager();
// A process task does not match the agent predicate.
registerProcess(manager, immediateProcess(0), 'noop', 'proc');
await expect(manager.waitForActiveTasks(isAgent)).resolves.toBeUndefined();
});

it('waits until a matching agent task reaches a terminal state', async () => {
const { manager } = createBackgroundManager();
const done = deferred<{ result: string }>();
manager.registerTask(agentTask(done.promise, 'agent'));

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

done.resolve({ result: 'ok' });
await wait;
expect(settled).toBe(true);
});

it('re-enumerates tasks registered during the wait (fan-out)', async () => {
const { manager } = createBackgroundManager();
const first = deferred<{ result: string }>();
manager.registerTask(agentTask(first.promise, 'first'));

let settled = false;
const wait = manager.waitForActiveTasks(isAgent).then(() => {
settled = true;
});
await new Promise((resolve) => setImmediate(resolve));

// Fan out a second agent task after the wait started.
const second = deferred<{ result: string }>();
manager.registerTask(agentTask(second.promise, 'second'));

// Completing only the first must not settle the wait.
first.resolve({ result: '1' });
await new Promise((resolve) => setImmediate(resolve));
expect(settled).toBe(false);

second.resolve({ result: '2' });
await wait;
expect(settled).toBe(true);
});

it('returns when the timeout elapses even if a matching task is still running', async () => {
const { manager } = createBackgroundManager();
const done = deferred<{ result: string }>();
const taskId = manager.registerTask(agentTask(done.promise, 'stuck'));

await manager.waitForActiveTasks(isAgent, { timeoutMs: 20 });

// Task is still running (never resolved), but the wait returned on the deadline.
expect(manager.getTask(taskId)?.status).toBe('running');
done.resolve({ result: 'late' });
});

it('rejects when the signal is aborted', async () => {
const { manager } = createBackgroundManager();
const done = deferred<{ result: string }>();
manager.registerTask(agentTask(done.promise, 'agent'));
const controller = new AbortController();

const wait = manager.waitForActiveTasks(isAgent, { signal: controller.signal });
controller.abort(new Error('stop'));

await expect(wait).rejects.toThrow('stop');
done.resolve({ result: 'late' });
});
});
66 changes: 65 additions & 1 deletion packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { existsSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { setTimeout as delay } from 'node:timers/promises';
import { Readable, type Writable } from 'node:stream';

import type { Kaos } from '@moonshot-ai/kaos';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import { createControlledPromise } from '@antfu/utils';
import {
APIConnectionError,
APIEmptyResponseError,
Expand All @@ -18,6 +20,7 @@ import { describe, expect, it, vi } from 'vitest';
import { HookEngine } from '../../src/session/hooks';
import { abortError } from '../../src/utils/abort';
import type { AgentOptions, AgentRecord, AgentRecordPersistence } from '../../src/agent';
import { ProcessBackgroundTask } from '../../src/agent/background';
import { InMemoryAgentRecordPersistence } from '../../src/agent/records';
import { ErrorCodes, KimiError } from '../../src/errors';
import type { Logger, LogPayload } from '../../src/logging';
Expand All @@ -30,6 +33,7 @@ import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createCommandKaos, testAgent, type TestAgentOptions } from './harness/agent';
import { executeTool } from '../tools/fixtures/execute-tool';
import { agentTask } from './background/helpers';

type GenerateFn = NonNullable<AgentOptions['generate']>;

Expand Down Expand Up @@ -73,6 +77,66 @@ describe('Agent turn flow', () => {
});
});

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

const subDone = createControlledPromise<{ result: string }>();
ctx.agent.background.registerTask(agentTask(subDone, 'subagent'));

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 subagent releases the hold; the model takes a wrap-up step.
subDone.resolve({ result: 'sub-result' });
await turnEnd;

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

it('does not hold the turn for a non-agent (process) background task', async () => {
const ctx = testAgent();
ctx.agent.printDrainAgentTasksOnStop = true;
ctx.agent.printDrainDeadlineMs = Date.now() + 60_000;

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(new Promise<number>(() => {})) 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, 'sleep 60', 'proc'));

ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'only step' });

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.
expect(ctx.llmCalls.length).toBe(1);
});

it('tracks turn_ended telemetry with protocol props', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
Expand Down
Loading
Loading