diff --git a/.changeset/subagent-timeout-resume.md b/.changeset/subagent-timeout-resume.md new file mode 100644 index 000000000..ec3ca5cf6 --- /dev/null +++ b/.changeset/subagent-timeout-resume.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Use a fixed 30-minute timeout for subagents and show concise resume instructions when they time out. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bbe79d5aa..a83524c27 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -50,7 +50,6 @@ reserved_context_size = 50000 [background] max_running_tasks = 4 keep_alive_on_exit = false -agent_task_timeout_s = 900 [experimental] goal_command = false @@ -167,13 +166,12 @@ You can also switch models temporarily without touching the config file — by s ## `background` -`background` controls the concurrency and timeout behavior of background tasks (launched via the `Bash` tool or the `Agent` tool's `run_in_background=true` parameter). +`background` controls the concurrency behavior of background tasks (launched via the `Bash` tool or the `Agent` tool's `run_in_background=true` parameter). | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | Maximum number of background tasks running concurrently | | `keep_alive_on_exit` | `boolean` | `true` | Whether to keep still-running background tasks when the session closes. Set to `false` to request that all background tasks stop before the process exits | -| `agent_task_timeout_s` | `integer` | — | Maximum runtime in seconds for background Agent tasks | `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`. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index d7874abdb..6131dcdfe 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -88,7 +88,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `timeout` (30–3600 seconds). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. See [Agent & Sub-Agents](../customization/agents.md) for details. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index e660940ad..adcc06271 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -50,7 +50,6 @@ reserved_context_size = 50000 [background] max_running_tasks = 4 keep_alive_on_exit = false -agent_task_timeout_s = 900 [experimental] goal_command = false @@ -167,13 +166,12 @@ max_context_size = 1047576 ## `background` -`background` 控制后台任务(通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动)的并发数和超时行为。 +`background` 控制后台任务(通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动)的并发数。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | 同时运行的最大后台任务数 | | `keep_alive_on_exit` | `boolean` | `true` | 会话关闭时是否保留仍在运行的后台任务。设为 `false` 时,进程退出前会请求停止所有后台任务 | -| `agent_task_timeout_s` | `integer` | — | 后台 Agent 任务的最长运行时间(秒) | `keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,优先级高于配置文件。 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 2a3118d68..61920019b 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -88,7 +88,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `timeout`(30–3600 秒)。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 6c898b63a..11c6500c7 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -100,7 +100,6 @@ export const BackgroundConfigSchema = z.object({ maxRunningTasks: z.number().int().min(1).optional(), keepAliveOnExit: z.boolean().optional(), killGracePeriodMs: z.number().int().min(0).optional(), - agentTaskTimeoutS: z.number().int().min(1).optional(), printWaitCeilingS: z.number().int().min(1).optional(), }); diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 64c71ce05..6974d3f05 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -456,11 +456,6 @@ export class Session { }); } - private backgroundTaskTimeoutMs(): number | undefined { - const timeoutS = this.options.background?.agentTaskTimeoutS; - return timeoutS === undefined ? undefined : timeoutS * 1000; - } - private refreshAgentBuiltinTools(): void { for (const agent of this.readyAgents()) { if (!agent.config.hasProvider) continue; @@ -488,8 +483,7 @@ export class Session { rpc: proxyWithExtraPayload(this.rpc, { agentId: id }), modelProvider: this.options.providerManager, hookEngine: config.hookEngine ?? this.hookEngine, - subagentHost: - config.subagentHost ?? new SessionSubagentHost(this, id, this.backgroundTaskTimeoutMs()), + subagentHost: config.subagentHost ?? new SessionSubagentHost(this, id), mcp: this.mcp, goals: this.goals, permission: this.permissionOptions(parentAgentId, config.permission), diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 88b2be4fd..17430d3dd 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -75,7 +75,6 @@ export class SessionSubagentHost { constructor( private readonly session: Session, private readonly ownerAgentId: string, - readonly backgroundTaskTimeoutMs?: number | undefined, ) {} async spawn(profileName: string, options: RunSubagentOptions): Promise { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md b/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md index 0732a8cc3..126b3389b 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md @@ -1,3 +1 @@ When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say. - -For a background task, when `timeout` is omitted it falls back to the operator-configured background timeout, if one is set. If the operator has not configured a background timeout, an omitted `timeout` means the task runs with no time limit. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.md b/packages/agent-core/src/tools/builtin/collaboration/agent.md index 0c6bae3bf..6ff3f26a4 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.md @@ -9,7 +9,8 @@ Writing the prompt: Usage notes: - When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context. - A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. +- Subagents use a fixed 30-minute timeout. If one times out, resume the same agent instead of starting over. When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. -Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy. \ No newline at end of file +Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index cada92bfd..9e8af9be3 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -76,15 +76,6 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', ), - timeout: z - .number() - .int() - .min(30) - .max(3600) - .optional() - .describe( - 'Timeout in seconds for the agent task (min 30s, max 3600s / 1hr). When omitted, a foreground task runs until completion with no timeout. The agent is stopped if it exceeds this limit.', - ), }), ); @@ -108,6 +99,9 @@ export type AgentToolOutput = z.infer; const BACKGROUND_AGENT_UNAVAILABLE = 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.'; +const AGENT_TIMEOUT_SECONDS = 30 * 60; +const AGENT_TIMEOUT_MS = AGENT_TIMEOUT_SECONDS * 1000; +const AGENT_TIMEOUT_DESCRIPTION = '30 minutes'; // ── AgentTool class ────────────────────────────────────────────────── @@ -191,11 +185,8 @@ export class AgentTool implements BuiltinTool { } } const backgroundController = runInBackground ? new AbortController() : undefined; - const timeoutMs = args.timeout === undefined ? undefined : args.timeout * 1000; foregroundDeadline = - !runInBackground && timeoutMs !== undefined - ? createDeadlineAbortSignal(signal, timeoutMs) - : undefined; + !runInBackground ? createDeadlineAbortSignal(signal, AGENT_TIMEOUT_MS) : undefined; const options = { parentToolCallId: toolCallId, @@ -231,7 +222,7 @@ export class AgentTool implements BuiltinTool { try { taskId = this.backgroundManager!.registerTask( new AgentBackgroundTask(handle.completion, args.description, { - timeoutMs: timeoutMs ?? this.subagentHost.backgroundTaskTimeoutMs, + timeoutMs: AGENT_TIMEOUT_MS, agentId: handle.agentId, subagentType: handle.profileName, abort: () => { @@ -281,8 +272,9 @@ export class AgentTool implements BuiltinTool { return { output: lines.join('\n') }; } catch (error) { let message: string; - if (foregroundDeadline?.timedOut() === true && args.timeout !== undefined) { - message = `Agent timed out after ${args.timeout}s.`; + const timedOut = foregroundDeadline?.timedOut() === true; + if (timedOut) { + message = `Agent timed out after ${AGENT_TIMEOUT_DESCRIPTION}.`; } else if (isUserCancellation(signal.reason)) { message = 'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.'; @@ -298,12 +290,17 @@ export class AgentTool implements BuiltinTool { '', `subagent error: ${message}`, ]; + if (timedOut) { + lines.push( + `resume_hint: Continue with Agent(resume="${handle.agentId}", prompt="continue"). Use agent_id only; do not set subagent_type. The subagent retains its prior context; redo any unfinished tool call if its result was lost.`, + ); + } return { output: lines.join('\n'), isError: true }; } } catch (error) { let message: string; - if (foregroundDeadline?.timedOut() === true && args.timeout !== undefined) { - message = `Agent timed out after ${args.timeout}s.`; + if (foregroundDeadline?.timedOut() === true) { + message = `Agent timed out after ${AGENT_TIMEOUT_DESCRIPTION}.`; } else if (isUserCancellation(signal.reason)) { message = 'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.'; diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 3af483488..745aa93f0 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -99,7 +99,6 @@ compaction_trigger_ratio = 0.85 max_running_tasks = 4 keep_alive_on_exit = false kill_grace_period_ms = 2000 -agent_task_timeout_s = 900 print_wait_ceiling_s = 3600 [[hooks]] @@ -173,7 +172,12 @@ describe('harness config TOML loader', () => { reservedContextSize: 50000, compactionTriggerRatio: 0.85, }); - expect(config.background?.agentTaskTimeoutS).toBe(900); + expect(config.background).toMatchObject({ + maxRunningTasks: 4, + keepAliveOnExit: false, + killGracePeriodMs: 2000, + printWaitCeilingS: 3600, + }); expect(config.hooks).toEqual([ { event: 'PreToolUse', diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index 4532de28d..de43d5bd5 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -92,33 +92,21 @@ describe('AgentTool', () => { expect(properties['run_in_background']?.description).toContain('false'); }); - it('does not mention background-only timeout details in the timeout description', () => { + it('does not expose a timeout parameter in the JSON schema', () => { const host = mockSubagentHost({ spawn: vi.fn() }); const tool = new AgentTool(host); - const properties = ( - tool.parameters as { - properties: Record; - } - ).properties; + const properties = (tool.parameters as { properties: Record }).properties; - const timeoutDescription = properties['timeout']?.description ?? ''; - // #5: the background default-timeout note is kept out of the static - // describe — it would mislead in the background-disabled variant - expect(timeoutDescription).not.toContain('Background'); - expect(timeoutDescription).not.toContain('15min'); + expect(properties).not.toHaveProperty('timeout'); }); - it('explains background timeout fallback in the background-enabled description without claiming a 15min default', () => { + it('explains the fixed background subagent timeout', () => { const host = mockSubagentHost({ spawn: vi.fn() }); const tool = new AgentTool(host, createBackgroundManager().manager); - // #5: the background-enabled variant describes the real timeout fallback — - // an omitted timeout falls back to the operator-configured background - // timeout, or no time limit when the operator configured none — and must - // never claim an incorrect "15min default". - expect(tool.description).toContain('operator-configured background timeout'); - expect(tool.description).toContain('no time limit'); - expect(tool.description).not.toContain('15min'); + expect(tool.description).toContain('fixed 30-minute timeout'); + expect(tool.description).not.toContain('operator-configured background timeout'); + expect(tool.description).not.toContain('no time limit'); }); it('does not expose a model parameter in the JSON schema', () => { @@ -219,13 +207,16 @@ describe('AgentTool', () => { }), ); - expect(host.spawn).toHaveBeenCalledWith('explore', { - parentToolCallId: 'call_agent', - prompt: 'Investigate', - description: 'Find cause', - runInBackground: false, - signal, - }); + expect(host.spawn).toHaveBeenCalledWith( + 'explore', + expect.objectContaining({ + parentToolCallId: 'call_agent', + prompt: 'Investigate', + description: 'Find cause', + runInBackground: false, + signal: expect.any(AbortSignal), + }), + ); expect(result.output).toContain('agent_id: agent-child'); expect(result.output).toContain('actual_subagent_type: explore'); expect(result.output).toContain('child result'); @@ -279,13 +270,16 @@ describe('AgentTool', () => { ); expect(host.spawn).not.toHaveBeenCalled(); - expect(host.resume).toHaveBeenCalledWith('agent-existing', { - parentToolCallId: 'call_agent', - prompt: 'Continue', - description: 'Continue work', - runInBackground: false, - signal, - }); + expect(host.resume).toHaveBeenCalledWith( + 'agent-existing', + expect.objectContaining({ + parentToolCallId: 'call_agent', + prompt: 'Continue', + description: 'Continue work', + runInBackground: false, + signal: expect.any(AbortSignal), + }), + ); expect(result.output).toContain('agent_id: agent-existing'); expect(result.output).toContain('actual_subagent_type: explore'); expect(result.output).toContain('resumed result'); @@ -375,13 +369,16 @@ describe('AgentTool', () => { ); expect(host.spawn).not.toHaveBeenCalled(); - expect(host.resume).toHaveBeenCalledWith('agent-existing', { - parentToolCallId: 'call_agent', - prompt: 'Continue', - description: 'Continue work', - runInBackground: false, - signal, - }); + expect(host.resume).toHaveBeenCalledWith( + 'agent-existing', + expect.objectContaining({ + parentToolCallId: 'call_agent', + prompt: 'Continue', + description: 'Continue work', + runInBackground: false, + signal: expect.any(AbortSignal), + }), + ); expect(result.output).toContain('actual_subagent_type: explore'); }); @@ -445,6 +442,7 @@ describe('AgentTool', () => { expect(background.getTask(taskId!)).toMatchObject({ status: 'running', description: 'Find cause', + timeoutMs: 30 * 60 * 1000, }); }); @@ -736,17 +734,20 @@ describe('AgentTool', () => { context({ prompt: 'Investigate', description: 'Find cause', - timeout: 30, }), ); - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); const result = await resultPromise; expect(result).toMatchObject({ isError: true }); expect(result.output).toContain('agent_id: agent-child'); expect(result.output).toContain('actual_subagent_type: coder'); expect(result.output).toContain('status: failed'); - expect(result.output).toContain('subagent error: Agent timed out after 30s.'); + expect(result.output).toContain('subagent error: Agent timed out after 30 minutes.'); + expect(result.output).toContain('resume_hint:'); + expect(result.output).toContain('Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('do not set subagent_type'); + expect(result.output).toContain('retains its prior context'); } finally { vi.useRealTimers(); } diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 8de282573..8e5306920 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -277,13 +277,16 @@ describe('current builtin collaboration tools', () => { }); const result = await executeTool(tool, context(input, 'call_agent')); - expect(host.spawn).toHaveBeenCalledWith('coder', { - parentToolCallId: 'call_agent', - prompt: 'Investigate', - description: 'Find cause', - runInBackground: false, - signal, - }); + expect(host.spawn).toHaveBeenCalledWith( + 'coder', + expect.objectContaining({ + parentToolCallId: 'call_agent', + prompt: 'Investigate', + description: 'Find cause', + runInBackground: false, + signal: expect.any(AbortSignal), + }), + ); expect(result.output).toContain('child result'); }); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 20129f171..5b4f790d4 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -66,7 +66,6 @@ compaction_trigger_ratio = 0.85 max_running_tasks = 4 keep_alive_on_exit = false kill_grace_period_ms = 2000 -agent_task_timeout_s = 900 print_wait_ceiling_s = 3600 [services.moonshot_search] @@ -154,7 +153,6 @@ max_context_size = "large" maxRunningTasks: 4, keepAliveOnExit: false, killGracePeriodMs: 2000, - agentTaskTimeoutS: 900, printWaitCeilingS: 3600, }); expect(config.services?.moonshotSearch?.customHeaders).toEqual({ 'X-Search': '1' });