feat: use fixed 30-minute subagent timeout (#470)

This commit is contained in:
_Kerman 2026-06-05 15:59:10 +08:00 committed by GitHub
parent bbcf6170a4
commit aa610e247d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 88 additions and 92 deletions

View file

@ -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.

View file

@ -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`.

View file

@ -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 35 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` (303600 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 35 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 14 questions; each question requires `question` (ending with `?`), `options` (24 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.

View file

@ -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` 覆盖,优先级高于配置文件。

View file

@ -88,7 +88,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只
| `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 |
| `Skill` | 自动放行 | 调用已注册的 inline Skill |
**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`35 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID`subagent_type` 互斥)`run_in_background`(默认 false`timeout`303600 秒)。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID完成时通过合成 User 消息自动回到主 Agent。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。
**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`35 个词的简短说明)。可选参数:`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` 参数接受 14 道题,每道题需提供 `question`(以 `?` 结尾)、`options`24 个选项,每项含 `label``description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示Agent 应改为在文本回复中直接提问。

View file

@ -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(),
});

View file

@ -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),

View file

@ -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<SubagentHandle> {

View file

@ -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.

View file

@ -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.
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.

View file

@ -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<typeof AgentToolOutputSchema>;
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<AgentToolInput> {
}
}
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<AgentToolInput> {
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<AgentToolInput> {
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<AgentToolInput> {
'',
`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.';

View file

@ -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',

View file

@ -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<string, { description?: string }>;
}
).properties;
const properties = (tool.parameters as { properties: Record<string, unknown> }).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();
}

View file

@ -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');
});

View file

@ -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' });