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/background-question-inline-answer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Deliver background question answers to the agent directly instead of via a saved output file.
5 changes: 5 additions & 0 deletions .changeset/background-question-survives-turn-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix background questions being cancelled as soon as the agent finishes its turn.
4 changes: 2 additions & 2 deletions docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill

**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when a [subagent model pool](../configuration/config-files.md#subagent-model-pool) is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. Each subagent times out after 2 hours by default; the limit is configurable via [`[swarm] timeout_ms`](../configuration/config-files.md#swarm) in `config.toml` (`0` = no timeout, or the `KIMI_CODE_SWARM_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). A timed-out subagent is aborted and marked as failed in the aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast.

**`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.
**`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; the question stays open after the turn ends, and the answer is delivered to the Agent as a notification once the user responds. 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.

**`Skill`** allows the Agent to actively invoke a registered inline-type Skill. Accepts `skill` (the Skill name) and optional `args` (additional argument text). Only `type = "inline"` Skills can be called via this tool; Skills with `disableModelInvocation: true` are rejected. Maximum nesting depth is 3 levels. See [Agent Skills](../customization/skills.md) for details.

## Background Tasks

Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path are automatically delivered back to the Agent; use `TaskOutput` to check progress early, or `WaitFor` to wait for a result inside the current turn.
Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path (or, for questions, the answer itself) are automatically delivered back to the Agent; use `TaskOutput` to check progress early, or `WaitFor` to wait for a result inside the current turn.

| Tool | Default Approval | Description |
| --- | --- | --- |
Expand Down
4 changes: 2 additions & 2 deletions docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只

**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动 subagent,也可以通过 `resume_agent_ids` 恢复已有 subagent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的 subagent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的 subagent 使用的 profile;省略时默认使用 `coder`。传入 `model`(在配置 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 后可用——`[secondary_model.models]` 表或仅一行 `default_model`)可以让新启动的 subagent 运行在池中别名指定的模型或调用方自己的模型(`"primary"`)上。未传入时新启动的 subagent 绑定池的 `default_model`;未配置模型池时则继承调用方模型。恢复的 subagent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有 subagent。本工具最多支持 128 个 subagent,会等待全部 subagent 完成,并返回聚合报告。每个 subagent 默认 2 小时超时,可通过 `config.toml` 的 [`[swarm] timeout_ms`](../configuration/config-files.md#swarm)(`0` = 无超时,或 `KIMI_CODE_SWARM_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时;超时的 subagent 会被中止,并在聚合报告中标记为失败。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个 subagent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的 subagent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。

**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。
**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID;问题在本轮结束后仍保持待答,用户作答后答案会以通知形式直接送回 Agent。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。

**`Skill`** 允许 Agent 主动调用已注册的 inline 类型 Skill。接受 `skill`(Skill 名称)和可选的 `args`(附加参数文本)。只有 `type = "inline"` 的 Skill 能通过此工具调用;`disableModelInvocation: true` 的 Skill 会被拒绝。嵌套调用深度上限 3 层。Skill 体系细节见 [Agent Skills](../customization/skills.md)。

## 后台任务

后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径送回 Agent;如需提前检查进度,使用 `TaskOutput`;如果下一步必须等待某个任务的结果,使用 `WaitFor` 在当前轮次内等待。
后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径(问题任务则直接送回答案)送回 Agent;如需提前检查进度,使用 `TaskOutput`;如果下一步必须等待某个任务的结果,使用 `WaitFor` 在当前轮次内等待。

| 工具 | 默认审批 | 说明 |
| --- | --- | --- |
Expand Down
82 changes: 68 additions & 14 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
userCancellationReason,
} from '#/_base/utils/abort';
import { setClampedTimeout } from '#/_base/utils/timer';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape';
import { IEventBus } from '#/app/event/eventBus';
import { Error2, ErrorCodes } from '#/errors';
import { z } from 'zod';
Expand Down Expand Up @@ -163,6 +163,7 @@ const SIGTERM_GRACE_MS = 5_000;
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
const SESSION_CLOSED_REASON = 'Session closed';
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
const QUESTION_ANSWER_INLINE_BYTES = 16_000;
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
const TASK_RESUME_TERMINATION_VARIANT = 'task_resume_termination';
const ACTIVE_BACKGROUND_TASK_GUIDANCE = [
Expand Down Expand Up @@ -1260,10 +1261,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
try {
let output = emptyOutputSnapshot();
try {
output = await this.getOutputSnapshot(info.taskId, 0);
if (!output.fullOutputAvailable) {
output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
}
output = await this.notificationOutputSnapshot(info);
} catch (error) {
this.log.error('task notification output read failed; delivering without output', {
taskId: info.taskId,
Expand All @@ -1275,10 +1273,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (this.deliveredNotificationKeys.has(key)) return undefined;
if (this.hasDeliveredNotification(key)) return undefined;
this.scheduledNotificationKeys.add(key);
const notification = buildAgentTaskNotification(
info,
agentTaskNotificationChildren(output),
);
const notification = buildAgentTaskNotification(info, output);
const content = [
{
type: 'text',
Expand All @@ -1291,6 +1286,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
}
}

private async notificationOutputSnapshot(info: AgentTaskInfo): Promise<AgentTaskOutputSnapshot> {
if (info.kind === 'question') {
return this.getOutputSnapshot(info.taskId, QUESTION_ANSWER_INLINE_BYTES);
}
const persisted = await this.getOutputSnapshot(info.taskId, 0);
if (persisted.fullOutputAvailable) return persisted;
return this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
}

private fireNotificationHook(notification: AgentTaskNotification): void {
void this.dispatcher.dispatch(
new TaskNotified({
Expand Down Expand Up @@ -1389,15 +1393,64 @@ function emptyOutputSnapshot(): AgentTaskOutputSnapshot {
}

function agentTaskNotificationChildren(
output: AgentTaskOutputSnapshot,
info: AgentTaskInfo,
output: AgentTaskOutputSnapshot | undefined,
): readonly string[] | undefined {
if (output === undefined) return undefined;
if (inlinesQuestionAnswer(info, output)) {
return output.preview.length === 0 ? undefined : [renderAnswerBlock(output.preview)];
}
if (output.fullOutputAvailable && output.outputPath !== undefined) {
return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)];
}
if (output.preview.length === 0) return undefined;
return [renderOutputPreviewBlock(output)];
}

function inlinesQuestionAnswer(info: AgentTaskInfo, output: AgentTaskOutputSnapshot): boolean {
return info.kind === 'question' && !output.truncated;
}

function renderAnswerBlock(answer: string): string {
return ['<answer>', escapeXmlTags(answer), '</answer>'].join('\n');
}

function questionNotificationText(
info: AgentTaskInfo,
output: AgentTaskOutputSnapshot | undefined,
): { readonly title: string; readonly body: string } | undefined {
if (info.status !== 'completed' || output === undefined || !inlinesQuestionAnswer(info, output)) {
return undefined;
}
const outcome = questionOutcome(output.preview);
if (outcome === 'answered') {
return {
title: 'Background question answered',
body: `The user answered "${info.description}".`,
};
}
if (outcome === 'dismissed') {
return {
title: 'Background question dismissed',
body: `The user dismissed "${info.description}" without answering.`,
};
}
return undefined;
}

function questionOutcome(output: string): 'answered' | 'dismissed' | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(output);
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const answers = (parsed as { readonly answers?: unknown }).answers;
if (typeof answers !== 'object' || answers === null || Array.isArray(answers)) return undefined;
return Object.keys(answers).length > 0 ? 'answered' : 'dismissed';
}

function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string {
return [
`<output-file path="${escapeXmlAttr(outputPath)}" bytes="${String(outputSizeBytes)}">`,
Expand Down Expand Up @@ -1504,19 +1557,20 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {

function buildAgentTaskNotification(
info: AgentTaskInfo,
children?: readonly string[],
output?: AgentTaskOutputSnapshot,
): AgentTaskNotification {
const question = questionNotificationText(info, output);
return {
id: taskNotificationId(info.taskId, info.status),
category: 'task',
type: `task.${info.status}`,
source_kind: 'background_task',
source_id: info.taskId,
agent_id: info.kind === 'agent' ? info.agentId : undefined,
title: `Background ${info.kind} ${info.status}`,
title: question?.title ?? `Background ${info.kind} ${info.status}`,
severity: info.status === 'completed' ? 'info' : 'warning',
body: buildAgentTaskNotificationBody(info),
children,
body: question?.body ?? buildAgentTaskNotificationBody(info),
children: agentTaskNotificationChildren(info, output),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,8 @@ export class AskUserQuestionTool implements IAskUserQuestionTool {
isError: false,
output:
`task_id: ${taskId}\n` +
`description: ${description}\n` +
`status: ${status}\n` +
`automatic_notification: true\n` +
'next_step: Continue your current work; the answer will arrive automatically when the user responds.\n' +
'next_step: Use TaskOutput with this task_id for a non-blocking status/answer snapshot.\n' +
'next_step: Use TaskStop only if the question should be cancelled.\n' +
'human_shell_hint: The pending question is also visible in the client UI.',
'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.',
};
}

Expand Down Expand Up @@ -174,7 +169,7 @@ export class AskUserQuestionTool implements IAskUserQuestionTool {
multiSelect: q.multi_select,
})),
},
{ signal, agentId: this.scopeContext.agentId },
{ signal, agentId: this.scopeContext.agentId, detached: args.background === true },
);

const normalized = normalizeQuestionResult(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class QuestionBackgroundTask implements AgentTask {
const result = await this.run(sink.signal);
const output =
typeof result.output === 'string' ? result.output : JSON.stringify(result.output);
if (result.isError === true) {
await sink.settle({ status: 'failed', stopReason: output });
return;
}
sink.appendOutput(output);
await sink.settle({ status: 'completed' });
} catch (error: unknown) {
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core-v2/src/features/interaction/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export interface Interaction<TPayload = unknown> {
readonly createdAt: number;
}

export type InteractionCancellationReason = 'turn_ended' | 'agent_closed';

export interface InteractionCancellation {
readonly cancelled: true;
readonly reason: InteractionCancellationReason;
}

export function isInteractionCancellation(response: unknown): response is InteractionCancellation {
if (typeof response !== 'object' || response === null) return false;
const value = response as { readonly cancelled?: unknown; readonly reason?: unknown };
return value.cancelled === true && (value.reason === 'turn_ended' || value.reason === 'agent_closed');
}

export interface InteractionResolution {
readonly id: string;
readonly response: unknown;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { IEventDispatcher } from '#/state/eventDispatcher';

import {
type Interaction,
type InteractionCancellation,
type InteractionKind,
type InteractionPendingChangedEvent,
type InteractionRequest,
Expand Down Expand Up @@ -90,7 +91,7 @@ function cancelTurnPending(
if (entry.interaction.origin?.turnId !== turnId) continue;
effects.pending.delete(id);
rememberResolved(effects, id);
const response = { cancelled: true, reason: 'turn_ended' };
const response: InteractionCancellation = { cancelled: true, reason: 'turn_ended' };
entry.resolve(response);
recordResolved(runtime, id, response);
effects.resolveEmitter.fire({ id, response });
Expand Down Expand Up @@ -119,7 +120,7 @@ const interactionEffects = fromCallback(({
return () => {
subscription.dispose();
for (const entry of input.effects.pending.values()) {
entry.resolve({ cancelled: true, reason: 'agent_closed' });
entry.resolve({ cancelled: true, reason: 'agent_closed' } satisfies InteractionCancellation);
}
input.effects.pending.clear();
input.effects.changeEmitter.dispose();
Expand Down
Loading
Loading