Skip to content

Commit 4e5043b

Browse files
authored
fix: require AgentSwarm to run alone (#643)
1 parent 30459af commit 4e5043b

14 files changed

Lines changed: 303 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Require AgentSwarm tool calls to run alone in a model response.

docs/en/reference/tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill
9191

9292
**`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. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details.
9393

94-
**`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`. 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. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. 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.
94+
**`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`. 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. 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.
9595

9696
**`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.
9797

docs/zh/reference/tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只
9191

9292
**`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` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)
9393

94-
**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。
94+
**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。
9595

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
2+
3+
export class AgentSwarmExclusiveDenyPermissionPolicy implements PermissionPolicy {
4+
readonly name = 'agent-swarm-exclusive-deny';
5+
6+
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
7+
const toolCalls = context.toolCalls;
8+
const agentSwarmCount = toolCalls.filter(
9+
(toolCall) => toolCall.name === 'AgentSwarm',
10+
).length;
11+
12+
if (agentSwarmCount === 0) return;
13+
if (agentSwarmCount === 1 && toolCalls.length === 1) return;
14+
15+
return {
16+
kind: 'deny',
17+
message:
18+
agentSwarmCount > 1
19+
? multipleAgentSwarmDeniedMessage(toolCalls.length > agentSwarmCount)
20+
: mixedAgentSwarmDeniedMessage(),
21+
reason: {
22+
agent_swarm_tool_calls: agentSwarmCount,
23+
tool_calls: toolCalls.length,
24+
},
25+
};
26+
}
27+
}
28+
29+
function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string {
30+
const suffix = hasOtherToolCalls
31+
? ' AgentSwarm also must not be combined with other tools in the same response.'
32+
: '';
33+
return (
34+
'AgentSwarm must be called one swarm at a time. Multiple AgentSwarm calls are not forbidden, ' +
35+
'but issue them sequentially: call one AgentSwarm, wait for its result, then call the next; ' +
36+
`or merge the work into a single AgentSwarm when one swarm can cover it.${suffix}`
37+
);
38+
}
39+
40+
function mixedAgentSwarmDeniedMessage(): string {
41+
return (
42+
'AgentSwarm must be the only tool call in a model response. Retry with a single AgentSwarm ' +
43+
'call by itself, then call any other tools after it returns.'
44+
);
45+
}

packages/agent-core/src/agent/permission/policies/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Agent } from '../..';
22
import type { PermissionPolicy } from '../types';
3+
import { AgentSwarmExclusiveDenyPermissionPolicy } from './agent-swarm-exclusive-deny';
34
import { AutoModeApprovePermissionPolicy } from './auto-mode-approve';
45
import { AutoModeAskUserQuestionDenyPermissionPolicy } from './auto-mode-ask-user-question-deny';
56
import { DefaultToolApprovePermissionPolicy } from './default-tool-approve';
@@ -27,6 +28,8 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy
2728
return [
2829
// PreToolUse hook returned a block → deny.
2930
new PreToolCallHookPermissionPolicy(agent),
31+
// AgentSwarm is batch-exclusive and must run alone, regardless of permission mode.
32+
new AgentSwarmExclusiveDenyPermissionPolicy(),
3033
// auto mode + AskUserQuestion → deny.
3134
new AutoModeAskUserQuestionDenyPermissionPolicy(agent),
3235
// plan mode: Write/Edit outside the plan file, or TaskStop → deny.

packages/agent-core/src/loop/tool-call.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export interface ToolCallStepContext {
7272
readonly stepUuid: string;
7373
}
7474

75+
interface ToolCallBatchContext extends ToolCallStepContext {
76+
readonly toolCalls: readonly ToolCall[];
77+
}
78+
7579
type PreflightedToolCall = RunnableToolCall | RejectedToolCall;
7680

7781
interface RunnableToolCall {
@@ -120,6 +124,7 @@ export async function runToolCallBatch(
120124
response: LLMChatResponse,
121125
): Promise<ToolCallBatchResult> {
122126
if (response.toolCalls.length === 0) return { stopTurn: false };
127+
const batchStep: ToolCallBatchContext = { ...step, toolCalls: response.toolCalls };
123128
const calls = response.toolCalls.map((toolCall) => preflightToolCall(step.tools, toolCall));
124129
const scheduler = new ToolScheduler<PendingToolResult>();
125130
const pendingResults: Array<Promise<PendingToolResult>> = [];
@@ -128,13 +133,13 @@ export async function runToolCallBatch(
128133
try {
129134
for (let index = 0; index < calls.length; index += 1) {
130135
const call = calls[index]!;
131-
const prepared = await prepareToolCall(step, call);
136+
const prepared = await prepareToolCall(batchStep, call);
132137
pendingResults.push(scheduler.add(prepared.task));
133138

134139
if (prepared.stopBatchAfterThis === true) {
135140
stopTurn = true;
136141
for (const skippedCall of calls.slice(index + 1)) {
137-
const skippedTask = await prepareSkippedToolCall(step, skippedCall);
142+
const skippedTask = await prepareSkippedToolCall(batchStep, skippedCall);
138143
pendingResults.push(scheduler.add(skippedTask));
139144
}
140145
break;
@@ -145,7 +150,7 @@ export async function runToolCallBatch(
145150
// provider order. Await all tasks so each recorded `tool.call` gets a
146151
// paired `tool.result`; the caller checks abort before writing `step.end`.
147152
for (const pendingResult of pendingResults) {
148-
const result = await finalizePendingToolResult(step, await pendingResult);
153+
const result = await finalizePendingToolResult(batchStep, await pendingResult);
149154
if (result.stopTurn === true) stopTurn = true;
150155
await step.dispatchEvent({
151156
type: 'tool.result',
@@ -235,7 +240,7 @@ function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string
235240
}
236241

237242
async function prepareToolCall(
238-
step: ToolCallStepContext,
243+
step: ToolCallBatchContext,
239244
call: PreflightedToolCall,
240245
): Promise<PreparedToolCallTask> {
241246
const settleError = async (
@@ -336,7 +341,7 @@ async function prepareToolCall(
336341
}
337342

338343
async function prepareSkippedToolCall(
339-
step: ToolCallStepContext,
344+
step: ToolCallBatchContext,
340345
call: PreflightedToolCall,
341346
): Promise<ToolCallTask<PendingToolResult>> {
342347
const output = 'Tool skipped because a previous tool call stopped the turn.';
@@ -356,7 +361,7 @@ function makeResolvedToolCallTask(result: PendingToolResult): ToolCallTask<Pendi
356361
* Hook decisions can block a call or replace args before execution starts.
357362
*/
358363
async function runPrepareToolExecutionHook(
359-
step: ToolCallStepContext,
364+
step: ToolCallBatchContext,
360365
call: RunnableToolCall,
361366
): Promise<PrepareToolExecutionDecision> {
362367
const { hooks, signal, turnId, currentStep, llm } = step;
@@ -370,6 +375,7 @@ async function runPrepareToolExecutionHook(
370375
try {
371376
hookResult = await hooks.prepareToolExecution({
372377
toolCall,
378+
toolCalls: step.toolCalls,
373379
tool: call.tool,
374380
args,
375381
turnId,
@@ -411,7 +417,7 @@ async function runPrepareToolExecutionHook(
411417
}
412418

413419
async function runAuthorizeToolExecutionHook(
414-
step: ToolCallStepContext,
420+
step: ToolCallBatchContext,
415421
call: RunnableToolCall,
416422
args: unknown,
417423
execution: RunnableToolExecution,
@@ -422,6 +428,7 @@ async function runAuthorizeToolExecutionHook(
422428
try {
423429
return await hooks.authorizeToolExecution({
424430
toolCall: call.toolCall,
431+
toolCalls: step.toolCalls,
425432
tool: call.tool,
426433
args,
427434
execution,
@@ -493,7 +500,7 @@ async function runRunnableToolCall(
493500
}
494501

495502
async function finalizePendingToolResult(
496-
step: ToolCallStepContext,
503+
step: ToolCallBatchContext,
497504
pendingResult: PendingToolResult,
498505
): Promise<PendingToolResult> {
499506
const { hooks, signal, turnId, currentStep, llm } = step;
@@ -504,6 +511,7 @@ async function finalizePendingToolResult(
504511
try {
505512
const finalizedResult = await hooks.finalizeToolResult({
506513
toolCall: pendingResult.toolCall,
514+
toolCalls: step.toolCalls,
507515
args: pendingResult.args,
508516
result: pendingResult.result,
509517
turnId,

packages/agent-core/src/loop/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export interface LoopStepHookContext {
147147

148148
export interface ToolExecutionHookContext extends LoopStepHookContext {
149149
readonly toolCall: ToolCall;
150+
readonly toolCalls: readonly ToolCall[];
150151
readonly tool?: ExecutableTool | undefined;
151152
readonly args: unknown;
152153
}

packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ Use AgentSwarm when many subagents should run the same kind of task over differe
55
Use `resume_agent_ids` to continue subagents that already exist from earlier work, such as ones that failed or timed out: map each agent id to the prompt for that resumed subagent (usually `continue` if no extra information is needed). You may combine `resume_agent_ids` with `items` in the same call to resume existing subagents and launch new ones. Do not duplicate resumed work in `items`.
66

77
Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items.
8+
9+
If `AgentSwarm` is called, that call must be the only tool call in the response.

0 commit comments

Comments
 (0)