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

Align the print-mode run lifecycle across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine, and `kimi -p "/goal ..."` now stays alive until the goal reaches a terminal state instead of exiting after the first turn.
5 changes: 5 additions & 0 deletions .changeset/align-subagent-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the KIMI_SUBAGENT_TIMEOUT_MS environment variable.
5 changes: 5 additions & 0 deletions .changeset/fix-session-format-backward-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open.
5 changes: 5 additions & 0 deletions .changeset/server-version-from-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix `kimi server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version.
5 changes: 5 additions & 0 deletions .changeset/web-message-history-real-times.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time.
3 changes: 3 additions & 0 deletions apps/kimi-code/src/cli/sub/server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ async function runServerInProcess(
const v2 = await startServer({
host: options.host,
port: options.port,
// Report the CLI's product version as `server_version` (/meta, web UI)
// rather than kap-server's private package version.
version,
logLevel: options.logLevel,
logger,
debugEndpoints: options.debugEndpoints,
Expand Down
239 changes: 219 additions & 20 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* native `DomainEvent` stream (payloads are already v1-protocol-shaped),
* - drives a turn through `IAgentPromptService.enqueue()` and awaits
* `Turn.result` for authoritative completion,
* - drains background tasks (config-driven) before exiting.
* - applies the print-mode background policy (config-driven, v1-aligned:
* `exit` / `drain` / `steer`) before exiting.
*
* Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set.
*/
Expand All @@ -34,13 +35,16 @@ import {
ensureMainAgent,
hostRequestHeadersSeed,
logSeed,
resolveAgentTaskConfig,
resolveKimiHome,
resolveLoggingConfig,
resolvePrintBackgroundMode,
skillCatalogRuntimeOptionsSeed,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
type LoopRunResult,
type PrintBackgroundMode,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
Expand Down Expand Up @@ -81,12 +85,7 @@ import {

const PROMPT_UI_MODE = 'print';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const TASK_CONFIG_SECTION = 'task';
const LEGACY_BACKGROUND_CONFIG_SECTION = 'background';

interface TaskPrintWaitConfig {
readonly printWaitCeilingS?: number;
}
const DEFAULT_PRINT_MAX_TURNS = 50;

export async function runV2Print(
opts: CLIOptions,
Expand Down Expand Up @@ -336,8 +335,13 @@ async function runNativeTurn(

await agent.accessor.get(IAuthSummaryService).ensureReady();

const turnEndings = createPrintTurnEndings();
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
dispatchNativeEvent(writer, event, stderr);
// Arm the turn-endings collector before `turn.result` settles so a
// background-task completion that steers a new turn right after the main
// turn ends cannot have its `turn.ended` slip past the policy loop.
if (event.type === 'turn.ended') turnEndings.push(event);
});
try {
const handle = await agent.accessor.get(IAgentPromptService).enqueue({
Expand All @@ -361,16 +365,41 @@ async function runNativeTurn(
}
const result = await turn.result;

// Turn settled, but `-p` is not done until any background work the turn
// spawned has drained (config-bounded). Flush the buffered assistant
// message first so a long drain does not withhold the final message.
// Turn settled, but `-p` is not done until the print-mode background
// policy says so (config-driven: exit / drain / steer). Flush the buffered
// assistant message first so a long drain/steer wait does not withhold the
// final message.
writer.flushAssistant();
if (result.type === 'completed') {
const configService = app.accessor.get(IConfigService);
const taskConfig = resolveAgentTaskConfig(configService);
const goalService = agent.accessor.get(IAgentGoalService);
try {
await drainBackgroundTasks(app, session);
} catch {
// Draining is best-effort; a wedged background task must not fail the
// (already completed) turn. Swallow and proceed to finish.
await applyPrintBackgroundPolicy({
mode: resolvePrintBackgroundMode(configService),
ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S,
maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS,
countPending: () => countPendingBackgroundTasks(session),
drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS),
turnEndings,
skipTurnId: turn.id,
warn: (message) => stderr.write(`Warning: ${message}\n`),
now: () => Date.now(),
goalActive: () => goalService.getGoal().goal?.status === 'active',
});
} catch (error) {
// A steered turn that fails fails the run (v1 parity). Anything else
// is best-effort: a wedged background task must not fail the (already
// completed) main turn.
if (error instanceof PrintSteeredTurnFailedError) {
writer.finish();
throw error;
}
stderr.write(
`Warning: print background policy failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
writer.finish();
return;
Expand Down Expand Up @@ -466,12 +495,182 @@ function dispatchNativeEvent(
}
}

async function drainBackgroundTasks(app: Scope, session: ISessionScopeHandle): Promise<void> {
const config = app.accessor.get(IConfigService);
const section =
config.get<TaskPrintWaitConfig>(TASK_CONFIG_SECTION) ??
config.get<TaskPrintWaitConfig>(LEGACY_BACKGROUND_CONFIG_SECTION);
const ceilingS = section?.printWaitCeilingS;
export type PrintTurnEnding = Extract<DomainEvent, { type: 'turn.ended' }>;

/**
* Source of `turn.ended` events for the print steer loop. `next` resolves with
* the next ending (skipping `skipTurnId`, the main turn's own buffered
* ending), or `null` when `remainingMs` elapses first.
*/
export interface PrintTurnEndings {
next(remainingMs: number, skipTurnId: number): Promise<PrintTurnEnding | null>;
}

/**
* Buffered `turn.ended` collector fed from the agent event bus. Events that
* arrive while no one is waiting are queued, so endings that fire between the
* main turn settling and the policy loop starting are not missed.
*/
export function createPrintTurnEndings(): PrintTurnEndings & {
push: (event: PrintTurnEnding) => void;
} {
const buffer: PrintTurnEnding[] = [];
let waiter: ((ending: PrintTurnEnding | null) => void) | undefined;
return {
push: (event) => {
const resolve = waiter;
if (resolve !== undefined) {
waiter = undefined;
resolve(event);
return;
}
buffer.push(event);
},
next: async (remainingMs, skipTurnId) => {
const deadlineAt = Date.now() + remainingMs;
const waitOnce = (ms: number): Promise<PrintTurnEnding | null> =>
new Promise((resolve) => {
let settled = false;
const settle = (value: PrintTurnEnding | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
waiter = undefined;
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
resolve(value);
};
const timer = Number.isFinite(ms)
? setTimeout(() => {
settle(null);
}, ms)
: undefined;
waiter = settle;
});
for (;;) {
while (buffer.length > 0) {
const ending = buffer.shift()!;
if (ending.turnId !== skipTurnId) return ending;
}
const ms = deadlineAt - Date.now();
if (ms <= 0) return null;
const ending = await waitOnce(ms);
if (ending === null) return null;
if (ending.turnId !== skipTurnId) return ending;
// The skipped turn's own ending: keep waiting within the same budget.
}
},
};
}

/** A background-task completion steered a new main turn that did not complete. */
export class PrintSteeredTurnFailedError extends Error {}

export interface PrintBackgroundPolicyInput {
readonly mode: PrintBackgroundMode;
readonly ceilingS: number;
readonly maxTurns: number;
readonly countPending: () => number;
readonly drain: () => Promise<void>;
readonly turnEndings: PrintTurnEndings;
readonly skipTurnId: number;
readonly warn: (message: string) => void;
readonly now: () => number;
/**
* Reports whether an agent goal is still `active`. v2 drives goal
* continuation as new turns (v1 keeps a single turn alive), so a `-p` goal
* run must stay alive until the goal leaves `active`, independent of the
* background policy.
*/
readonly goalActive?: () => boolean;
}

/**
* Apply the print-mode (`kimi -p`) background-task policy after the main turn
* completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`:
* - goal : while a goal is `active`, keep waiting for its continuation
* turns (bounded by `ceilingS` as a safety net), regardless of
* the background mode; the goal summary drives the exit code.
* - 'exit' : return immediately (default).
* - 'drain' : suppress + drain background tasks, then return.
* - 'steer' : while background tasks are still pending, stay alive so task
* completions steer new main turns; return once quiescent, or
* when the wall-clock ceiling (`ceilingS`) or the turn cap
* (`maxTurns`) is reached. A steered turn that does not complete
* fails the run.
*/
export async function applyPrintBackgroundPolicy(
input: PrintBackgroundPolicyInput,
): Promise<void> {
if (input.goalActive !== undefined) {
const goalDeadline = input.now() + input.ceilingS * 1000;
while (input.goalActive()) {
const ended = await input.turnEndings.next(
goalDeadline - input.now(),
input.skipTurnId,
);
if (ended === null) {
input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`);
return;
Comment on lines +612 to +613

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 Return non-zero when goal wait times out

When kimi -p "/goal ..." hits print_wait_ceiling_s while the goal is still active, this branch only warns and returns. runNativeGoal then summarizes that active snapshot and calls the existing goalExitCode('active'), which falls through to success, so long-running or low-ceiling goal runs can exit with code 0 even though the goal never reached a terminal success state. Treat this ceiling path as a failed/paused goal or otherwise force a non-zero exit before returning.

Useful? React with 👍 / 👎.

}
// A continuation turn that does not complete pauses/blocks the goal, so
// the loop condition exits on the next check.
}
}
if (input.mode === 'exit') return;
if (input.mode === 'drain') {
await input.drain();
return;
}

// 'steer'
const deadline = input.now() + input.ceilingS * 1000;
let turns = 0;
for (;;) {
turns += 1;
if (input.now() >= deadline) {
input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
return;
}
if (turns > input.maxTurns) {
input.warn(`print steer max turns reached (${input.maxTurns}), finishing`);
return;
}
if (input.countPending() === 0) return;
const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId);
if (ended === null) {
// The wait itself ran out: no further turn ended before the ceiling.
input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
return;
}
if (ended.reason !== 'completed') {
throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
}
}
}

function formatTurnEndingFailure(ending: PrintTurnEnding): string {
if (ending.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`;
if (ending.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${ending.reason}`;
}

function countPendingBackgroundTasks(session: ISessionScopeHandle): number {
let count = 0;
for (const handle of session.accessor.get(IAgentLifecycleService).list()) {
count += handle.accessor.get(IAgentTaskService).list(true).length;
}
return count;
}

async function drainBackgroundTasks(
session: ISessionScopeHandle,
ceilingS: number | undefined,
): Promise<void> {
const ceilingMs =
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
? ceilingS * 1000
Expand Down
Loading