feat(agent-core): cap AgentSwarm concurrency via env var (#888)

* feat(agent-core): cap AgentSwarm concurrency via env var

Add KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY to limit how many subagents run concurrently during the initial ramp, so large swarms do not trip provider rate limits as easily. Leave it unset to keep the previous uncapped ramp behavior.

* chore: drop ignored agent-core from changeset

* fix(agent-core): fail fast on invalid AgentSwarm concurrency cap
This commit is contained in:
7Sageer 2026-06-18 16:45:42 +08:00 committed by GitHub
parent 7bc3d99933
commit 58898de020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 156 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Add an environment variable to cap AgentSwarm concurrency during the initial ramp, so large swarms do not trip provider rate limits as easily.

View file

@ -125,6 +125,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins` | URL or local path |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` |
| `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy |
| `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path |

View file

@ -91,7 +91,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and 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`), 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.
**`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.
**`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. 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 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

@ -125,6 +125,7 @@ kimi
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1``true``yes``y`(不区分大小写) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 替换 `/plugins` 加载的 marketplace JSON | URL 或本地路径 |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能;`micro_compaction` 已默认开启 | `1``true``yes``on` |
| `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | 覆盖当前进程的 [`[experimental].micro_compaction`](./config-files.md#experimental) | 真值或假值 |
| `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 |

View file

@ -91,7 +91,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只
**`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` 调用在同一步运行时TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。
**`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)` 这类参数模式。
**`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)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。
**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 14 道题,每道题需提供 `question`(以 `?` 结尾)、`options`24 个选项,每项含 `label``description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示Agent 应改为在文本回复中直接提问。

View file

@ -12,7 +12,7 @@ import { isUserCancellation } from '../utils/abort';
Subagent batch scheduling contract:
Normal phase:
- Return results in input order; empty input returns an empty list.
- Start up to 5 tasks immediately, then 1 more every 700 ms while queued work remains; active tasks do not cap this ramp.
- Start up to 5 tasks immediately, then 1 more every 700 ms while queued work remains. By default active tasks do not cap this ramp; when KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY is set to a positive integer, the ramp additionally stops while active tasks reach that cap, and resumes as tasks complete.
- Launch priority: previous agent id saved after a rate limit, explicit resume, then new spawn.
- Readiness can be reported while the attempt is active. Ready normal launches seed the first rate-limit capacity.
- The first provider rate limit stops the ramp and enters rate-limit phase.
@ -37,6 +37,8 @@ const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2000;
const RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS = 3 * 60 * 1000;
const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for retry.';
const AGENT_SWARM_MAX_CONCURRENCY_ENV = 'KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY';
type BaseQueuedSubagentTask<T> = {
readonly data: T;
readonly profileName: string;
@ -114,6 +116,15 @@ type ActiveAttempt<T> = {
timedOut: boolean;
};
export type SubagentBatchOptions = {
/**
* Optional cap on how many subagents may run concurrently during the normal
* phase. `undefined` means no cap (legacy ramp behavior). The rate-limit
* phase is governed by its own capacity logic and is not affected.
*/
readonly maxConcurrency?: number;
};
export class SubagentBatch<T> {
private readonly states: Array<TaskState<T>>;
private readonly pending: Array<TaskState<T>>;
@ -122,6 +133,7 @@ export class SubagentBatch<T> {
private readonly controller = new AbortController();
private readonly batchSignal: AbortSignal | undefined;
private readonly batchAbortListener: () => void;
private readonly maxConcurrency: number | undefined;
private normalLaunchCount = 0;
private normalLaunchTimer: ReturnType<typeof setTimeout> | undefined;
private rateLimitLaunchTimer: ReturnType<typeof setTimeout> | undefined;
@ -141,7 +153,9 @@ export class SubagentBatch<T> {
constructor(
private readonly launcher: SubagentBatchLauncher,
tasks: readonly QueuedSubagentTask<T>[],
options: SubagentBatchOptions = {},
) {
this.maxConcurrency = options.maxConcurrency;
this.states = tasks.map((task, index) => ({
index,
task,
@ -203,7 +217,8 @@ export class SubagentBatch<T> {
while (
this.normalLaunchCount < INITIAL_LAUNCH_LIMIT &&
this.pending.length > 0 &&
!this.rateLimitMode
!this.rateLimitMode &&
!this.isAtConcurrencyLimit()
) {
this.startAttempt(this.pending.shift()!);
this.normalLaunchCount += 1;
@ -212,7 +227,8 @@ export class SubagentBatch<T> {
if (
this.pending.length === 0 ||
this.rateLimitMode ||
this.normalLaunchTimer !== undefined
this.normalLaunchTimer !== undefined ||
this.isAtConcurrencyLimit()
) {
return;
}
@ -220,12 +236,17 @@ export class SubagentBatch<T> {
this.normalLaunchTimer = setTimeout(() => {
this.normalLaunchTimer = undefined;
if (this.finished || this.rateLimitMode || this.pending.length === 0) return;
if (this.isAtConcurrencyLimit()) return;
this.startAttempt(this.pending.shift()!);
this.normalLaunchCount += 1;
this.schedule();
}, INITIAL_LAUNCH_INTERVAL_MS);
}
private isAtConcurrencyLimit(): boolean {
return this.maxConcurrency !== undefined && this.active.size >= this.maxConcurrency;
}
private scheduleRateLimitLaunch(): void {
this.clearRateLimitTimer();
if (this.pending.length === 0) return;
@ -636,3 +657,24 @@ export class SubagentBatch<T> {
return error instanceof Error ? error.message : String(error);
}
}
/**
* Resolve the optional AgentSwarm normal-phase concurrency cap from the environment.
*
* Returns `undefined` when the variable is unset/empty. A present value must be a
* positive integer; invalid input fails fast so a misconfigured cap never silently
* reverts to the uncapped ramp.
*/
export function resolveSwarmMaxConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): number | undefined {
const raw = env[AGENT_SWARM_MAX_CONCURRENCY_ENV];
if (raw === undefined || raw.trim() === '') return undefined;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(
`${AGENT_SWARM_MAX_CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`,
);
}
return value;
}

View file

@ -23,6 +23,7 @@ import { collectGitContext } from './git-context';
import type { Session } from './index';
import {
SubagentBatch,
resolveSwarmMaxConcurrency,
type SubagentResult,
type SubagentSuspendedEvent,
type QueuedSubagentTask,
@ -196,7 +197,8 @@ export class SessionSubagentHost {
}
async runQueued<T>(tasks: readonly QueuedSubagentTask<T>[]): Promise<Array<SubagentResult<T>>> {
return new SubagentBatch(this, tasks).run();
const maxConcurrency = resolveSwarmMaxConcurrency();
return new SubagentBatch(this, tasks, { maxConcurrency }).run();
}
suspended(event: SubagentSuspendedEvent): void {

View file

@ -10,6 +10,7 @@ import {
} from '../../src/session/subagent-host';
import {
SubagentBatch,
resolveSwarmMaxConcurrency,
type SubagentBatchLauncher,
type SubagentResult,
type SubagentSuspendedEvent,
@ -642,6 +643,100 @@ describe('SubagentBatch scheduling contract', () => {
});
});
describe('resolveSwarmMaxConcurrency', () => {
it('returns undefined when the variable is unset', () => {
expect(resolveSwarmMaxConcurrency({})).toBeUndefined();
});
it('returns undefined for empty or whitespace-only values', () => {
expect(
resolveSwarmMaxConcurrency({ KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY: '' }),
).toBeUndefined();
expect(
resolveSwarmMaxConcurrency({ KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY: ' ' }),
).toBeUndefined();
});
it('throws for non-positive, non-integer, or non-numeric values', () => {
for (const raw of ['0', '-1', '2.5', 'abc']) {
expect(() =>
resolveSwarmMaxConcurrency({ KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY: raw }),
).toThrow(/KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY.*positive integer/);
}
});
it('returns the integer for a positive integer value', () => {
expect(resolveSwarmMaxConcurrency({ KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY: '3' })).toBe(3);
expect(resolveSwarmMaxConcurrency({ KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY: ' 8 ' })).toBe(8);
});
});
describe('SubagentBatch max concurrency cap', () => {
it('caps in-flight tasks at maxConcurrency during the normal phase', async () => {
vi.useFakeTimers();
try {
const { runBatch, attempts } = createMockBatchRunner({ maxConcurrency: 3 });
const running = runBatch(Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), {
signal,
});
const resolved = new Set<number>();
const resolveOne = (index: number) => {
const attempt = attempts[index]!;
resolved.add(index);
attempt.outcome.resolve({
task: attempt.task,
agentId: `agent-${String(index + 1)}`,
status: 'completed',
result: `result ${String(index + 1)}`,
});
};
const inFlight = () => attempts.length - resolved.size;
// Initial burst is capped at 3 instead of the default 5.
await vi.advanceTimersByTimeAsync(0);
expect(attempts).toHaveLength(3);
expect(inFlight()).toBe(3);
// The 700ms ramp tick does not exceed the cap while all slots are occupied.
await vi.advanceTimersByTimeAsync(700);
expect(attempts).toHaveLength(3);
// Freeing one slot refills it without exceeding the cap.
resolveOne(0);
await vi.advanceTimersByTimeAsync(0);
expect(attempts).toHaveLength(4);
expect(inFlight()).toBeLessThanOrEqual(3);
resolveOne(1);
await vi.advanceTimersByTimeAsync(0);
expect(attempts).toHaveLength(5);
expect(inFlight()).toBeLessThanOrEqual(3);
// Once the initial burst budget (5) is exhausted, further launches wait for
// the 700ms ramp tick, but the in-flight count still never exceeds the cap.
resolveOne(2);
await vi.advanceTimersByTimeAsync(0);
expect(attempts).toHaveLength(5);
await vi.advanceTimersByTimeAsync(700);
expect(attempts).toHaveLength(6);
expect(inFlight()).toBeLessThanOrEqual(3);
// Drain the remaining attempts.
for (let index = 3; index < 9; index += 1) {
resolveOne(index);
await vi.advanceTimersByTimeAsync(700);
expect(inFlight()).toBeLessThanOrEqual(3);
}
const results = await running;
expect(results).toHaveLength(9);
expect(results.every((result) => result.status === 'completed')).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
type MockAttemptOutcome<T> =
| SubagentResult<T>
| {
@ -659,6 +754,7 @@ type MockAttemptRecord = {
type MockBatchRunnerOptions = {
readonly onSuspended?: (event: SubagentSuspendedEvent) => void;
readonly readyDelay?: (attemptIndex: number) => number | undefined;
readonly maxConcurrency?: number;
};
function createMockBatchRunner(
@ -732,7 +828,9 @@ function createMockBatchRunner(
...task,
signal: task.signal ?? runOptions?.signal,
}));
return new SubagentBatch(host, activeTasks as readonly QueuedSubagentTask<T>[]).run();
return new SubagentBatch(host, activeTasks as readonly QueuedSubagentTask<T>[], {
maxConcurrency: options.maxConcurrency,
}).run();
},
attempts,
};