From 20fc0ce128599a6e7b8c8455af492fa6726e47a8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 21 Jul 2026 17:56:34 +0800 Subject: [PATCH 1/2] feat(v2-print): align kimi -p run lifecycle with the default engine (13 files) - add applyPrintModeConfigDefaults in agent-core-v2: fill print-mode config defaults (bash task timeout / max steps per turn / subagent timeout all unbounded) into the memory layer, respecting user-set keys - applyPrintBackgroundPolicy: keep the run alive while cron tasks have future fires so their steered turns can run, with an anti-spin guard for wedged ticks; goal/cron waiting shares the steer ceiling budget - default print background mode changed from exit to steer - add task.bashTaskTimeoutS config key and wire it into BashTool's detachTimeoutMs - v2 print runner now forwards turn.step.retrying events to writeRetrying - allow subagent timeoutMs = 0 (no timeout) --- .changeset/align-v2-print-run-lifecycle.md | 5 + apps/kimi-code/src/cli/prompt-render.ts | 7 +- apps/kimi-code/src/cli/v2/run-v2-print.ts | 137 ++++++++++---- apps/kimi-code/test/cli/run-v2-print.test.ts | 167 ++++++++++++++++++ apps/kimi-code/test/cli/v2-run-print.test.ts | 7 + .../scripts/check-domain-layers.mjs | 4 + .../src/agent/task/configSection.ts | 11 +- .../src/agent/task/printDefaults.ts | 87 +++++++++ packages/agent-core-v2/src/index.ts | 1 + .../src/os/backends/node-local/tools/bash.ts | 8 +- .../src/session/subagent/configSection.ts | 3 +- .../test/app/config/config.test.ts | 106 ++++++++++- .../os/backends/node-local/tools/bash.test.ts | 33 ++++ 13 files changed, 524 insertions(+), 52 deletions(-) create mode 100644 .changeset/align-v2-print-run-lifecycle.md create mode 100644 packages/agent-core-v2/src/agent/task/printDefaults.ts diff --git a/.changeset/align-v2-print-run-lifecycle.md b/.changeset/align-v2-print-run-lifecycle.md new file mode 100644 index 0000000000..647a4ff2a5 --- /dev/null +++ b/.changeset/align-v2-print-run-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align `kimi -p` on the experimental engine with the default engine's run lifecycle: the print background policy now defaults to steer with no practical turn or time cap, background task and per-turn step limits are lifted unless configured, and the run stays alive while cron tasks still have future fires so their steered turns can run. diff --git a/apps/kimi-code/src/cli/prompt-render.ts b/apps/kimi-code/src/cli/prompt-render.ts index 0ef5058105..0e2f35238d 100644 --- a/apps/kimi-code/src/cli/prompt-render.ts +++ b/apps/kimi-code/src/cli/prompt-render.ts @@ -25,9 +25,10 @@ interface HookResultEventLike { /** * Structural retry shape the renderer reads. Mirrors the v1 SDK - * `turn.step.retrying` event fields the stream-json meta line surfaces. Only - * the v1 driver forwards retries to `writeRetrying`; the v2 runner currently - * just discards the failed attempt's partial output and stays silent. + * `turn.step.retrying` event fields the stream-json meta line surfaces. Both + * drivers forward retries to `writeRetrying`: v1 from its SDK event stream, + * v2 from the native `turn.step.retrying` `DomainEvent` (same field names), + * after discarding the failed attempt's partial output. */ interface RetryingEventLike { readonly failedAttempt: number; diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index f345093c53..ff0c96c590 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -30,10 +30,14 @@ import { IConfigService, IEventBus, IOAuthToolkit, + ISessionCronService, ISessionIndex, ISessionLifecycleService, ITelemetryService, + PRINT_MAX_TURNS_DEFAULT, + PRINT_WAIT_CEILING_S_DEFAULT, agentCatalogRuntimeOptionsSeed, + applyPrintModeConfigDefaults, bootstrap, createCloudAppender, ensureMainAgent, @@ -90,10 +94,14 @@ import { } from '../prompt-render'; const PROMPT_UI_MODE = 'print'; -const DEFAULT_PRINT_WAIT_CEILING_S = 3600; -const DEFAULT_PRINT_MAX_TURNS = 50; /** Re-check `goalActive` at least this often while waiting for goal turns. */ const GOAL_WAIT_POLL_MS = 250; +/** + * Slack on top of a scheduled cron fire time while waiting for the steered + * turn: covers the 1s tick poll interval plus fire → inject → turn-launch + * latency. + */ +const CRON_FIRE_GRACE_MS = 5_000; export async function runV2Print( opts: CLIOptions, @@ -136,6 +144,10 @@ export async function runV2Print( const configService = app.accessor.get(IConfigService); await configService.ready; + // Print-mode config defaults (task timeouts / loop step cap / subagent + // timeout → unbounded) before anything resolves a session; only keys the + // user left unset are filled, in the memory layer. + await applyPrintModeConfigDefaults(configService); const defaultModel = configService.get('defaultModel') ?? undefined; let telemetryEnabled = true; try { @@ -449,11 +461,12 @@ async function runNativeTurn( const configService = app.accessor.get(IConfigService); const taskConfig = resolveAgentTaskConfig(configService); const goalService = agent.accessor.get(IAgentGoalService); + const cronService = session.accessor.get(ISessionCronService); try { await applyPrintBackgroundPolicy({ mode: resolvePrintBackgroundMode(configService), - ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S, - maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS, + ceilingS: taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT, + maxTurns: taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT, countPending: () => countPendingBackgroundTasks(session), drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), turnEndings, @@ -461,6 +474,7 @@ async function runNativeTurn( warn: (message) => stderr.write(`Warning: ${message}\n`), now: () => Date.now(), goalActive: () => goalService.getGoal().goal?.status === 'active', + cronNextFireAt: () => cronService.getNextFireTime(), }); } catch (error) { // A steered turn that fails fails the run (v1 parity). Anything else @@ -543,6 +557,7 @@ function dispatchNativeEvent( return; case 'turn.step.retrying': writer.discardAssistant(); + writer.writeRetrying(event); return; case 'assistant.delta': writer.writeAssistantDelta(event.delta); @@ -657,54 +672,106 @@ export interface PrintBackgroundPolicyInput { * background policy. */ readonly goalActive?: () => boolean; + /** + * Reports the next scheduled cron fire time (epoch ms), or `null` when no + * cron task has a future fire. While it returns non-null the policy keeps + * the process alive — the cron tick timer itself is unref'd — waiting for + * the fire to steer a new turn, then re-evaluating (a fired one-shot task + * disappears; a recurring one reports its advanced next fire). Cron + * liveness is independent of the background mode: it applies under + * `exit`/`drain` too (v1 parity). Omitted = no cron waiting. + */ + readonly cronNextFireAt?: () => number | null; } /** - * Apply the print-mode (`kimi -p`) background-task policy after the main turn - * completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`: + * Apply the print-mode (`kimi -p`) background-resource policy after the main + * turn completes. A single loop re-evaluates the Session's live resources in + * order on every round and stays alive while any of them is pending: * - 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. + * - cron : while `cronNextFireAt` reports a future fire, keep waiting — + * the cron tick timer is unref'd, so the process must hold the + * event loop itself (v1 parity, independent of the mode). The + * fire steers a new turn; a steered turn that does not complete + * fails the run. Each round re-reads the next fire time, so a + * fired one-shot task ends the wait while a recurring one keeps + * it. A fire time that stays unchanged and in the past across + * two consecutive rounds means the tick is wedged: warn once and + * stop cron waiting instead of spinning. + * - mode : 'exit' → return immediately; + * '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. + * The steer ceiling deadline is set once on entry, so goal/cron waiting + * consumes the same budget. */ export async function applyPrintBackgroundPolicy( input: PrintBackgroundPolicyInput, ): Promise { - if (input.goalActive !== undefined) { - const goalDeadline = input.now() + input.ceilingS * 1000; - while (input.goalActive()) { - // Also wake on a short poll: a goal can leave `active` without any - // further turn.ended (budget block at a turn boundary, or a pause after - // a continuation-launch failure), which would otherwise hang the run - // until the ceiling. + const deadline = input.now() + input.ceilingS * 1000; + let turns = 0; + // Cron anti-spin guard: the last fire time seen already in the past. Two + // consecutive rounds with the same past fire time mean the tick never ran. + let lastPastFireAt: number | undefined; + let cronWedged = false; + for (;;) { + // (a) goal: while a goal is `active`, keep waiting for its continuation + // turns. Also wake on a short poll: a goal can leave `active` without any + // further turn.ended (budget block at a turn boundary, or a pause after a + // continuation-launch failure), which would otherwise hang the run until + // the ceiling. A continuation turn that does not complete pauses/blocks + // the goal, so the condition exits on the next check. + while (input.goalActive?.() === true) { const ended = await input.turnEndings.next( - Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS), + Math.min(deadline - input.now(), GOAL_WAIT_POLL_MS), input.skipTurnId, ); - if (ended === null && input.now() >= goalDeadline) { + if (ended === null && input.now() >= deadline) { input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`); return; } - // 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 (;;) { + // (b) cron: keep the process alive until the pending fire steered a turn + // (one-shot tasks vanish after firing; recurring ones advance their next + // fire), then re-evaluate from the top. + if (!cronWedged && input.cronNextFireAt !== undefined) { + const fireAt = input.cronNextFireAt(); + if (fireAt !== null) { + if (fireAt <= input.now() && lastPastFireAt === fireAt) { + cronWedged = true; + input.warn( + 'print cron wait: next fire time stuck in the past; cron tick appears wedged, giving up on cron', + ); + } else { + if (fireAt <= input.now()) lastPastFireAt = fireAt; + const ended = await input.turnEndings.next( + Math.max(fireAt - input.now(), 0) + CRON_FIRE_GRACE_MS, + input.skipTurnId, + ); + if (ended !== null && ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + // Fire observed (or its grace elapsed without a turn): re-read the + // next fire time from the top. + continue; + } + } + } + + // (c) background-task mode. + if (input.mode === 'exit') return; + if (input.mode === 'drain') { + await input.drain(); + return; + } + + // 'steer' turns += 1; if (input.now() >= deadline) { input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`); @@ -749,7 +816,7 @@ async function drainBackgroundTasks( const ceilingMs = typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0 ? ceilingS * 1000 - : DEFAULT_PRINT_WAIT_CEILING_S * 1000; + : PRINT_WAIT_CEILING_S_DEFAULT * 1000; const deadline = Date.now() + ceilingMs; const seen = new Set(); diff --git a/apps/kimi-code/test/cli/run-v2-print.test.ts b/apps/kimi-code/test/cli/run-v2-print.test.ts index 2e93c2137a..492d20ed8e 100644 --- a/apps/kimi-code/test/cli/run-v2-print.test.ts +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -256,6 +256,173 @@ describe('applyPrintBackgroundPolicy', () => { }); expect(warn).not.toHaveBeenCalled(); }); + + it('keeps an exit-mode run alive until a pending cron fire steered a turn', async () => { + let nextFire: number | null = 60_000; + let fireTurnEnded = false; + const cronNextFireAt = vi.fn(() => nextFire); + const countPending = vi.fn(() => 0); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + // The cron fire steered this turn; once it ends the one-shot task + // is gone. + event: ending(2), + apply: () => { + fireTurnEnded = true; + nextFire = null; + }, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + cronNextFireAt, + }); + // The policy waited for the fire turn's ending instead of returning + // immediately, then re-read the (now empty) cron schedule. + expect(fireTurnEnded).toBe(true); + expect(cronNextFireAt).toHaveBeenCalledTimes(2); + // 'exit' never consults background tasks. + expect(countPending).not.toHaveBeenCalled(); + }); + + it('throws when a cron-fire steered turn does not complete', async () => { + await expect( + applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + event: { + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.overloaded', message: 'try later' }, + } as PrintTurnEnding, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + cronNextFireAt: () => 60_000, + }), + ).rejects.toThrow(PrintSteeredTurnFailedError); + }); + + it('keeps waiting while a recurring cron advances its next fire time', async () => { + let nextFire: number | null = 10_000; + const cronNextFireAt = vi.fn(() => nextFire); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + // First fire: the recurring task advances to its next slot. + { event: ending(2), apply: () => { nextFire = 20_000; } }, + // Second fire: the task is deleted, no future fire remains. + { event: ending(3), apply: () => { nextFire = null; } }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + cronNextFireAt, + }); + expect(cronNextFireAt).toHaveBeenCalledTimes(3); + }); + + it('re-reads the cron schedule when the fire wait times out without a turn', async () => { + let nextFire: number | null = 10_000; + const cronNextFireAt = vi.fn(() => { + const value = nextFire; + // The task was removed between the first query and the re-check. + nextFire = null; + return value; + }); + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // Empty script: the fire produced no turn before the grace elapsed. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => 0, + cronNextFireAt, + }); + expect(cronNextFireAt).toHaveBeenCalledTimes(2); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns and stops cron waiting when the next fire time is stuck in the past', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // No turn ever ends: the tick is wedged and never fires the overdue task. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => 1_000, + cronNextFireAt: () => 500, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('cron'); + }); + + it('finishes goal waiting before consulting the cron schedule', async () => { + let active = true; + let consumed = 0; + let nextFire: number | null = 60_000; + let cronFirstCallAtConsumed = -1; + const cronNextFireAt = vi.fn(() => { + if (cronFirstCallAtConsumed === -1) cronFirstCallAtConsumed = consumed; + return nextFire; + }); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { consumed += 1; } }, + { + event: ending(3), + apply: () => { + consumed += 1; + active = false; + }, + }, + // The pending cron fire steered this turn. + { event: ending(4), apply: () => { nextFire = null; } }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + goalActive: () => active, + cronNextFireAt, + }); + // Both goal continuation turns ended before the cron schedule was read. + expect(cronFirstCallAtConsumed).toBe(2); + expect(cronNextFireAt).toHaveBeenCalled(); + }); }); describe('createPrintTurnEndings', () => { diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 7f284100d3..e6a5e5ce3e 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -18,6 +18,7 @@ import { IEventBus, IFileSystemStorageService, IOAuthToolkit, + ISessionCronService, ISessionIndex, ISessionLifecycleService, ISkillCatalogRuntimeOptions, @@ -173,6 +174,8 @@ function makeFakeHarness() { const sessionServices = new Map([ // drain enumerates agents; empty → no background work to wait on. [IAgentLifecycleService, { list: vi.fn(() => []) }], + // No scheduled cron tasks → no future fire time to wait on. + [ISessionCronService, { getNextFireTime: vi.fn(() => null) }], ]); const session = fakeScope('ses_v2', sessionServices); @@ -182,6 +185,10 @@ function makeFakeHarness() { { ready: Promise.resolve(), get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)), + // `applyPrintModeConfigDefaults` inspects each section and fills unset + // keys via the memory layer; an empty section means everything is unset. + inspect: vi.fn(() => ({ value: {} })), + set: vi.fn(async () => {}), diagnostics: vi.fn(() => []), }, ], diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index d02848869c..ed2f9ceaa5 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -422,6 +422,10 @@ const ALLOWED_EXCEPTIONS = new Set([ // `swarm` (L4) drives sub-agent runs through the `subagent` domain (L6) — // same shape as the `swarm>agentLifecycle` spawn exception above. 'swarm>subagent', + // `agentTask` (L5) owns the print-mode (`kimi -p`) policy; filling its + // config defaults reaches the `subagent` section (L6) for the subagent + // timeout — same cross-scope config-fill shape as `swarm>subagent`. + 'agentTask>subagent', 'cron>agentLifecycle', 'cron>sessionContext', 'todo>agentLifecycle', diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index e0e518f406..d9bc0d29a7 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -40,6 +40,7 @@ export const AgentTaskConfigSchema = z.object({ maxRunningTasks: z.number().int().min(1).optional(), keepAliveOnExit: z.boolean().optional(), bashAutoBackgroundOnTimeout: z.boolean().optional(), + bashTaskTimeoutS: z.number().int().min(0).optional(), killGracePeriodMs: z.number().int().min(0).optional(), printWaitCeilingS: z.number().int().min(1).optional(), printBackgroundMode: PrintBackgroundModeSchema.optional(), @@ -56,18 +57,10 @@ export function resolveAgentTaskConfig(config: IConfigService): AgentTaskConfig return { ...legacy, ...current }; } -/** - * Resolve the effective print-mode (`kimi -p`) background-task policy, mirroring - * v1's `Session.resolvePrintBackgroundMode`: `printBackgroundMode` is - * authoritative when set; otherwise fall back to the legacy `keepAliveOnExit` - * mapping (`true` ⇒ `'drain'`, otherwise `'exit'`). The - * `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` env override is applied by the - * config env overlay (see `taskEnvBindings`), so it is covered here. - */ export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgroundMode { const section = resolveAgentTaskConfig(config); if (section?.printBackgroundMode !== undefined) return section.printBackgroundMode; - return section?.keepAliveOnExit === true ? 'drain' : 'exit'; + return section?.keepAliveOnExit === true ? 'drain' : 'steer'; } export const KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; diff --git a/packages/agent-core-v2/src/agent/task/printDefaults.ts b/packages/agent-core-v2/src/agent/task/printDefaults.ts new file mode 100644 index 0000000000..74ad6a9002 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/printDefaults.ts @@ -0,0 +1,87 @@ +/** + * `task` domain (L5) — print-mode (`kimi -p`) config-section defaults. + * + * Ports v1's `applyPrintModeConfigDefaults` + * (`packages/agent-core/src/config/print-defaults.ts`): a headless run should + * not be cut short by limits meant for interactive use, so every filled value + * is "effectively unbounded". Fills land in the config memory layer via + * `IConfigService.set(…, ConfigTarget.Memory)`, never on disk. + * + * Only keys the user left unset are filled. A key counts as set when it has a + * user-config value (for `bashTaskTimeoutS`, in either `[task]` or the legacy + * `[background]` section), a memory-layer value, or an env-overlay value (an + * effective value with no user/memory source and different from the section + * default). Because the memory layer shadows a whole section on read, each + * patch spreads the section's current effective value so sibling user keys + * stay visible. Explicit user config always wins over these defaults. + */ + +import { ConfigTarget, type ConfigInspectValue, type IConfigService } from '#/app/config/config'; +import { LOOP_CONTROL_SECTION } from '#/agent/loop/configSection'; +import { SUBAGENT_SECTION } from '#/session/subagent/configSection'; + +import { LEGACY_BACKGROUND_SECTION, TASK_SECTION } from './configSection'; + +/** + * Wall-clock ceiling (seconds) for the drain/steer wait once the main turn + * ends: 10 years ≈ unbounded. + */ +export const PRINT_WAIT_CEILING_S_DEFAULT = 315_360_000; + +/** Cap on extra turns steered by background-task completions: ≈ unbounded. */ +export const PRINT_MAX_TURNS_DEFAULT = 100_000; + +/** + * Background Bash task timeout: `0` = no timeout (the interactive default is + * 600s). Also covers foreground commands re-armed after being moved to the + * background on timeout, so a headless run never kills a command it detached. + */ +export const PRINT_BASH_TASK_TIMEOUT_S_DEFAULT = 0; + +/** + * Per-subagent (`Agent` / `AgentSwarm`, foreground and background) timeout: + * `0` = no timeout (the interactive default is 2 hours). A headless run must + * never have a subagent killed by a wall-clock cap; only the model itself may + * stop one. + */ +export const PRINT_SUBAGENT_TIMEOUT_MS_DEFAULT = 0; + +type SectionValue = Record; + +function isUnset(inspected: ConfigInspectValue, key: string): boolean { + if (inspected.userValue?.[key] !== undefined) return false; + if (inspected.memoryValue?.[key] !== undefined) return false; + const effective = inspected.value?.[key]; + if (effective === undefined) return true; + return inspected.defaultValue?.[key] === effective; +} + +async function fillSectionDefault( + config: IConfigService, + domain: string, + key: string, + value: number, + legacyUserValue?: SectionValue, +): Promise { + const inspected = config.inspect(domain); + if (!isUnset(inspected, key)) return; + if (legacyUserValue?.[key] !== undefined) return; + await config.set(domain, { ...inspected.value, [key]: value }, ConfigTarget.Memory); +} + +export async function applyPrintModeConfigDefaults(config: IConfigService): Promise { + await fillSectionDefault( + config, + TASK_SECTION, + 'bashTaskTimeoutS', + PRINT_BASH_TASK_TIMEOUT_S_DEFAULT, + config.inspect(LEGACY_BACKGROUND_SECTION).userValue, + ); + await fillSectionDefault(config, LOOP_CONTROL_SECTION, 'maxStepsPerTurn', 0); + await fillSectionDefault( + config, + SUBAGENT_SECTION, + 'timeoutMs', + PRINT_SUBAGENT_TIMEOUT_MS_DEFAULT, + ); +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 219c55a51c..179e8aa5fd 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -237,6 +237,7 @@ export { type AgentTaskConfig, type PrintBackgroundMode, } from '#/agent/task/configSection'; +export * from '#/agent/task/printDefaults'; import '#/agent/task/tools/task-list'; import '#/agent/task/tools/task-output'; import '#/agent/task/tools/task-stop'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index 95adde0484..d28c7bebd6 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts @@ -204,6 +204,12 @@ export class BashTool implements BuiltinTool { return resolveAgentTaskConfig(this.config)?.bashAutoBackgroundOnTimeout ?? true; } + private detachTimeoutMs(): number { + const configuredS = resolveAgentTaskConfig(this.config)?.bashTaskTimeoutS; + if (configuredS === undefined) return DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND; + return configuredS * MS_PER_SECOND; + } + get description(): string { if (!this.allowBackground()) return withoutBackgroundDescription(this.renderedDescription); if (!this.autoBackgroundOnTimeout()) { @@ -304,7 +310,7 @@ export class BashTool implements BuiltinTool { { detached: startsInBackground, timeoutMs, - detachTimeoutMs: DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND, + detachTimeoutMs: this.detachTimeoutMs(), autoBackgroundOnTimeout: this.allowBackground() && this.autoBackgroundOnTimeout(), signal: startsInBackground ? undefined : signal, }, diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index af1b0ab376..2178197edf 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -27,8 +27,7 @@ import { registerConfigSection } from '#/app/config/configSectionContributions'; export const SUBAGENT_SECTION = 'subagent'; export const SubagentConfigSchema = z.object({ - /** Per-run subagent timeout in milliseconds; set a large value to effectively disable the cap. */ - timeoutMs: z.number().int().min(1).optional(), + timeoutMs: z.number().int().min(0).optional(), }); export type SubagentConfig = z.infer; diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 76c66ae566..d9f12bcd1b 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -47,6 +47,8 @@ import { THINKING_SECTION, type ThinkingConfig, } from '#/kosong/model/thinking'; +import '#/agent/loop/configSection'; +import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { KEEP_ALIVE_ON_EXIT_ENV, MAX_RUNNING_TASKS_ENV, @@ -54,6 +56,7 @@ import { resolvePrintBackgroundMode, type AgentTaskConfig, } from '#/agent/task/configSection'; +import { applyPrintModeConfigDefaults } from '#/agent/task/printDefaults'; import '#/session/subagent/configSection'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, @@ -1044,11 +1047,11 @@ describe('task config section', () => { disposables.dispose(); }); - it('resolvePrintBackgroundMode falls back to keepAliveOnExit then exit', async () => { + it('resolvePrintBackgroundMode falls back to keepAliveOnExit then steer', async () => { const env: Record = {}; const { config, disposables } = await createTaskConfig(env); - expect(resolvePrintBackgroundMode(config)).toBe('exit'); + expect(resolvePrintBackgroundMode(config)).toBe('steer'); env[KEEP_ALIVE_ON_EXIT_ENV] = 'true'; expect(resolvePrintBackgroundMode(config)).toBe('drain'); @@ -1057,6 +1060,105 @@ describe('task config section', () => { }); }); +describe('applyPrintModeConfigDefaults', () => { + async function createConfig(env: Record, toml?: string) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + if (toml !== undefined) { + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + } + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + return { config, disposables }; + } + + it('fills unset keys into the memory layer with effectively unbounded values', async () => { + const { config, disposables } = await createConfig({}); + + await applyPrintModeConfigDefaults(config); + + expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(0); + expect(config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn).toBe(0); + expect(resolveSubagentTimeoutMs(config)).toBe(0); + expect(config.inspect('task').memoryValue).toMatchObject({ bashTaskTimeoutS: 0 }); + expect(config.inspect(LOOP_CONTROL_SECTION).memoryValue).toMatchObject({ + maxStepsPerTurn: 0, + }); + expect(config.inspect('subagent').memoryValue).toMatchObject({ timeoutMs: 0 }); + + disposables.dispose(); + }); + + it('does not override keys the user set explicitly', async () => { + const { config, disposables } = await createConfig( + {}, + '[task]\nbash_task_timeout_s = 30\n\n' + + '[loop_control]\nmax_steps_per_turn = 7\n\n' + + '[subagent]\ntimeout_ms = 5000\n', + ); + + await applyPrintModeConfigDefaults(config); + + expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(30); + expect(config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn).toBe(7); + expect(resolveSubagentTimeoutMs(config)).toBe(5000); + expect(config.inspect('task').memoryValue).toBeUndefined(); + expect(config.inspect(LOOP_CONTROL_SECTION).memoryValue).toBeUndefined(); + expect(config.inspect('subagent').memoryValue).toBeUndefined(); + + disposables.dispose(); + }); + + it('treats a legacy [background] bash_task_timeout_s as user-set', async () => { + const { config, disposables } = await createConfig( + {}, + '[background]\nbash_task_timeout_s = 15\n', + ); + + await applyPrintModeConfigDefaults(config); + + expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(15); + + disposables.dispose(); + }); + + it('keeps sibling user keys of a filled section visible', async () => { + const { config, disposables } = await createConfig( + {}, + '[task]\nprint_background_mode = "drain"\n\n[loop_control]\nmax_retries_per_step = 5\n', + ); + + await applyPrintModeConfigDefaults(config); + + expect(resolvePrintBackgroundMode(config)).toBe('drain'); + expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(0); + expect(config.get(LOOP_CONTROL_SECTION)).toMatchObject({ + maxRetriesPerStep: 5, + maxStepsPerTurn: 0, + }); + + disposables.dispose(); + }); + + it('does not override the subagent timeout env override', async () => { + const env: Record = { [SUBAGENT_TIMEOUT_ENV]: '3000' }; + const { config, disposables } = await createConfig(env); + + await applyPrintModeConfigDefaults(config); + + expect(resolveSubagentTimeoutMs(config)).toBe(3000); + + disposables.dispose(); + }); +}); + describe('subagent config section', () => { async function createConfig(env: Record, toml?: string) { const disposables = new DisposableStore(); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 49d4409bd2..a4ca4eea3e 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -1292,6 +1292,39 @@ describe('BashTool', () => { expect(noBackground.description).not.toContain('moved to the background instead of being killed'); expect(noBackground.description).toContain('hits its timeout is killed'); }); + + it('resolves the detach timeout from the bashTaskTimeoutS config', async () => { + async function detachTimeoutMsFor( + configValues: Record, + ): Promise { + const { runner } = createTestRunner(processWithOutput()); + const { service, tasks } = createFakeTaskService(); + const tool = bashTool( + runner, + createTestEnv(), + createTestCtx(), + service, + stubToolPolicy(), + stubConfig(configValues), + ); + + const result = await executeTool( + tool, + context({ command: 'watch', run_in_background: true, description: 'watch files' }), + ); + expect(result).toMatchObject({ isError: false }); + + const taskId = service.list(false)[0]!.taskId; + return tasks.get(taskId)?.options.detachTimeoutMs; + } + + await expect(detachTimeoutMsFor({})).resolves.toBe(600_000); + await expect(detachTimeoutMsFor({ task: { bashTaskTimeoutS: 30 } })).resolves.toBe(30_000); + await expect(detachTimeoutMsFor({ background: { bashTaskTimeoutS: 45 } })).resolves.toBe( + 45_000, + ); + await expect(detachTimeoutMsFor({ task: { bashTaskTimeoutS: 0 } })).resolves.toBe(0); + }); }); describe('BashTool background mode', () => { From c7a2216a76e4a42130e54deb004d6feb4c9fdc45 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 22 Jul 2026 19:12:31 +0800 Subject: [PATCH 2/2] test(agent-core-v2): remove duplicate loop config imports in config test --- packages/agent-core-v2/test/app/config/config.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index d9f12bcd1b..5bf59b6cfc 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -47,8 +47,6 @@ import { THINKING_SECTION, type ThinkingConfig, } from '#/kosong/model/thinking'; -import '#/agent/loop/configSection'; -import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { KEEP_ALIVE_ON_EXIT_ENV, MAX_RUNNING_TASKS_ENV,