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

Fix unbounded recursive subagent spawning by capping default agent delegation at a single level; custom agent profiles can still declare their own delegation allowlists (including `*`).
4 changes: 2 additions & 2 deletions docs/en/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Kimi Code CLI includes three built-in sub-agents, ready to use out of the box, e
- **`explore`**: Dedicated to codebase exploration; performs read-only operations only and does not modify any files. Ideal for quickly searching, reading, and summarizing a repository without touching files.
- **`plan`**: Dedicated to implementation planning and architecture design; even shell commands are not available, keeping the focus on "figuring out how to do something" rather than "actually doing it."

A `coder` sub-agent shares most of the main Agent's tool set: it can run shell commands in the background, maintain todo lists, enter Plan mode, invoke Agent Skills, and dispatch its own nested sub-agents when a task decomposes naturally. If it finishes its turn while background tasks are still running, its run only reports completion after those tasks settle, so the parent receives the result after the underlying work has actually finished.
A `coder` sub-agent shares most of the main Agent's tool set: it can run shell commands in the background, maintain todo lists, enter Plan mode, and invoke Agent Skills. Built-in sub-agents cannot dispatch further sub-agents. By default a custom agent inherits the built-in delegation allowlist (`coder`, `explore`, `plan`), whose members cannot dispatch further either, so delegation chains always terminate — unbounded recursive spawning is impossible without an explicit opt-in. A custom agent can opt into deeper chains by declaring an explicit [`subagents`](#agent-file-format) allowlist. If a sub-agent finishes its turn while background tasks are still running, its run only reports completion after those tasks settle, so the parent receives the result after the underlying work has actually finished.

## How to Invoke

Expand Down Expand Up @@ -101,7 +101,7 @@ You are a strict code reviewer. Read the diff, then report findings grouped by s
| `override` | no | Whether this file may replace a same-name built-in Agent. Defaults to `false`; `--agent-file` is already explicit and does not require this field |
| `tools` | no | Allowlist of tool names such as `Read` or `Bash`; MCP tools are matched with globs such as `mcp__github__*`. Accepts a YAML list or a comma-separated string (`tools: Read, Grep`). Omit to allow all tools; a lone `*` also allows all tools; an empty list (`tools: []`) disables all tools |
| `disallowedTools` | no | Denylist with the same syntax and matching rules, applied after `tools` |
| `subagents` | no | Allowlist of sub-agent names this agent may delegate to, with the same syntax as `tools` (YAML list or comma-separated string). Omit to allow every type; a lone `*` also allows all types |
| `subagents` | no | Allowlist of sub-agent names this agent may delegate to, with the same syntax as `tools` (YAML list or comma-separated string). Omit to inherit the default agent's allowlist (built-in default: `coder`, `explore`, `plan`, whose members cannot delegate further, so inherited chains always terminate); a lone `*` allows every type. The main agent's effective allowlist additionally includes every discovered custom agent, so custom agents stay delegatable by default |

Built-in and user tools match by exact, case-sensitive name; entries starting with `mcp__` match MCP tools as globs. Three entry shapes never match anything and are reported with a warning when the profile takes effect: a wildcard outside an `mcp__` pattern (a bare `*` in `disallowedTools` disables nothing), an `mcp__` literal that is not a full `mcp__<server>__<tool>` name (`mcp__github` matches nothing — use `mcp__github__*` for the whole server), and a name no registered or built-in tool has (usually a typo, such as `read` instead of `Read`).

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Kimi Code CLI 内置三种 subagent,开箱即用,分别面向不同任务形
- **`explore`**:代码库探索专用,只做只读操作,不修改任何文件。适合在不改动文件的前提下快速搜索、阅读和总结仓库。
- **`plan`**:实现规划与架构设计专用,连 Shell 命令都不提供,专注于"想清楚怎么做"而不是"动手做"。

`coder` subagent 与 main agent 共享大部分工具集:可以在后台执行 Shell 命令、维护待办列表、进入 Plan 模式、调用 Agent Skills,也可以在任务自然拆解时继续派发自己的嵌套 subagent。如果它结束自己的轮次时仍有后台任务在运行,那么只有在这些后台任务全部落定后,这次运行才会回报完成——main agent 拿到结果时,背后的工作也已经真正完成。
`coder` subagent 与 main agent 共享大部分工具集:可以在后台执行 Shell 命令、维护待办列表、进入 Plan 模式、调用 Agent Skills。内置 subagent 都不能继续派发新的 subagent。自定义 Agent 缺省时继承内置委派列表(`coder`、`explore`、`plan`),而这些内置类型自身同样不能再派发,因此委派链默认必然终止——不存在不受限的递归派发。自定义 Agent 可以通过显式声明 [`subagents`](#agent-文件格式) 列表来获得更深的委派链。如果 subagent 结束自己的轮次时仍有后台任务在运行,那么只有在这些后台任务全部落定后,这次运行才会回报完成——main agent 拿到结果时,背后的工作也已经真正完成。

## 调用方式

Expand Down Expand Up @@ -101,7 +101,7 @@ disallowedTools:
| `override` | 否 | 是否允许覆盖同名内置 Agent,默认 `false`。`--agent-file` 属于显式启动意图,无需设置此字段 |
| `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。支持 YAML 列表或逗号分隔字符串(`tools: Read, Grep`)两种写法。缺省表示允许全部工具;单独的 `*` 同样表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 |
| `disallowedTools` | 否 | 禁止列表,写法与匹配规则相同,在 `tools` 之后应用 |
| `subagents` | 否 | 允许委派的 subagent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 |
| `subagents` | 否 | 允许委派的 subagent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示继承默认 Agent 的委派列表(内置默认为 `coder`、`explore`、`plan`,它们自身都不能再派发,因此继承得到的链路必然终止);单独的 `*` 表示可委派所有类型。main agent 的有效委派列表还会自动并入所有发现的自定义 Agent,因此自定义 Agent 默认即可被委派 |

内置工具与用户工具按名称精确匹配(区分大小写);以 `mcp__` 开头的条目按 glob 匹配 MCP 工具。有三种写法永远匹配不到任何工具,在 profile 生效时会给出警告:`mcp__` 模式之外使用通配符(`disallowedTools` 里单独的 `*` 什么也禁不掉);不是完整 `mcp__<服务器>__<工具>` 形式的 `mcp__` 字面量(`mcp__github` 匹配不到任何工具 —— 匹配整个服务器要用 `mcp__github__*`);以及任何已注册或内置工具都没有的名字(通常是笔误,如把 `Read` 写成 `read`)。

Expand Down
34 changes: 32 additions & 2 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ import { type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix';
import {
rootDelegationExtras,
subagentAllowlistFor,
subagentTypeNotAllowedMessage,
withoutDelegatingTargets,
} from '#/app/agentProfileCatalog/profile-shared';
import { ILogService } from '#/_base/log/log';
import { IConfigService } from '#/app/config/config';
Expand Down Expand Up @@ -132,8 +134,9 @@ export class SubagentTool implements ISubagentTool {
? AGENT_BACKGROUND_DESCRIPTION
: AGENT_BACKGROUND_DISABLED_DESCRIPTION;
let description = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`;
const allowlist = subagentAllowlistFor(this.catalog, this.profile.data());
const own = this.profile.data();
const catalogProfiles = this.catalogProfiles();
const allowlist = this.effectiveAllowlist(own, catalogProfiles);
const profiles =
allowlist === undefined
? catalogProfiles
Expand Down Expand Up @@ -165,6 +168,33 @@ export class SubagentTool implements ISubagentTool {
return profiles;
}

private delegationExtras(
own: {
readonly profileName?: string;
readonly subagents?: readonly string[];
},
profiles: readonly AgentProfile[],
): readonly string[] | undefined {
if (this.callerAgentId !== 'main') return undefined;
return rootDelegationExtras(this.catalog, own, profiles);
}

private effectiveAllowlist(
own: {
readonly profileName?: string;
readonly subagents?: readonly string[];
},
profiles: readonly AgentProfile[],
): readonly string[] | undefined {
const allowlist = subagentAllowlistFor(
this.catalog,
own,
this.delegationExtras(own, profiles),
);
if (allowlist === undefined || own.subagents !== undefined) return allowlist;
return withoutDelegatingTargets(this.catalog, allowlist);
Comment on lines +194 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the advertised one-hop cap for implicit profiles

When a discovered custom profile omits both tools and subagents, it receives the Agent tool, inherits coder/explore/plan, and this filter retains terminal targets such as explore; consequently the default main agent can launch that custom agent, which can then launch explore, contradicting the documented main → subagent cap and the requirement for an explicit allowlist to enable deeper chains. Unlike the earlier self-recursion report, the fresh evidence is this retained terminal-target path; inherited delegation should be disabled for subagents unless they explicitly declare subagents.

Useful? React with 👍 / 👎.

}

private knownToolReferences(): ToolReference[] {
const refs = new Map<string, ToolReference>();
for (const contribution of this.contributions.items) {
Expand Down Expand Up @@ -257,7 +287,7 @@ export class SubagentTool implements ISubagentTool {
: DEFAULT_PROFILE_NAME;
await this.catalog.ready;
const own = this.profile.data();
const allowlist = subagentAllowlistFor(this.catalog, own);
const allowlist = this.effectiveAllowlist(own, this.catalog.list());
if (allowlist !== undefined && !allowlist.includes(requestedProfileName)) {
throw new Error2(
ErrorCodes.AGENT_TYPE_NOT_ALLOWED,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { renderPrompt } from '#/_base/utils/render-prompt';

import {
DEFAULT_AGENT_PROFILE_NAME,
type AgentProfile,
type AgentProfileContext,
type EnvironmentDisclosureSnapshot,
type SystemPromptRenderResult,
} from './agentProfileCatalog';
import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from './builtinAgentProfileLoader';

import SYSTEM_PROMPT_TEMPLATE from './system.md?raw';

Expand All @@ -27,8 +29,68 @@ export function subagentAllowlistFor(
readonly profileName?: string;
readonly subagents?: readonly string[];
},
extras?: readonly string[],
): readonly string[] | undefined {
return caller.profileName === undefined ? catalog.getDefault().subagents : caller.subagents;
const declared = caller.subagents ?? catalog.getDefault().subagents;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inherit from the built-in default for overridden agent profiles

When a user or project agent.md sets override: true but omits subagents, catalog.getDefault() resolves to that same overridden profile, whose allowlist is also undefined. This therefore returns undefined, which both Agent and AgentSwarm interpret as unrestricted; because agent files without a tools field also receive the Agent tool, the overridden default can recursively spawn itself without declaring the documented explicit * opt-out. Resolve the fallback from the built-in default rather than the merged default profile.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent inherited allowlists from enabling self-recursion

When a custom agent overrides an allowlisted built-in such as coder with override: true but omits both tools and subagents, it receives the Agent tool and this fallback inherits ['coder', 'explore', 'plan']; the resulting coder can therefore spawn another coder indefinitely without the explicit delegation opt-in described by this change. Unlike the earlier default-profile case, the fresh evidence is an override of an allowlisted subagent profile whose own name appears in the inherited default list; exclude the caller itself from an inherited allowlist or otherwise give overridden built-in subagents a non-recursive fallback.

Useful? React with 👍 / 👎.

if (declared?.length === 1 && declared[0] === '*') return undefined;
if (extras === undefined || extras.length === 0) return declared;
return [...new Set([...(declared ?? []), ...extras])];
}

export function isDiscoveredAgentProfileSource(sourceId: string | undefined): boolean {
return (
sourceId !== undefined &&
sourceId !== BUILTIN_AGENT_PROFILE_SOURCE_ID &&
!sourceId.startsWith('feature:')
);
}

export function rootDelegationExtras(
catalog: {
inspect(name: string): { readonly sourceId: string } | undefined;
},
caller: {
readonly profileName?: string;
readonly subagents?: readonly string[];
},
profiles: readonly { readonly name: string }[],
): readonly string[] | undefined {
if (
caller.profileName !== undefined &&
caller.profileName !== DEFAULT_AGENT_PROFILE_NAME &&
caller.subagents !== undefined
) {
return undefined;
}
const discovered = profiles
.filter(
(profile) =>
profile.name !== DEFAULT_AGENT_PROFILE_NAME &&
isDiscoveredAgentProfileSource(catalog.inspect(profile.name)?.sourceId),
)
.map((profile) => profile.name);
return discovered.length === 0 ? undefined : discovered;
}

export function profileCanDelegate(
profile: Pick<AgentProfile, 'tools' | 'disallowedTools'>,
): boolean {
const possesses = (name: string) =>
(profile.tools === undefined || profile.tools.includes(name)) &&
!(profile.disallowedTools ?? []).includes(name);
return possesses('Agent') || possesses('AgentSwarm');
}

export function withoutDelegatingTargets(
catalog: {
get(name: string): Pick<AgentProfile, 'tools' | 'disallowedTools'> | undefined;
},
allowlist: readonly string[],
): readonly string[] {
return allowlist.filter((name) => {
const target = catalog.get(name);
return target === undefined || !profileCanDelegate(target);
});
}

export function subagentTypeNotAllowedMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { ISessionSwarmService, type SessionSwarmTask } from '#/features/swarm/se
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { IAgentProfileService } from '#/agent/profile/profile';
import {
rootDelegationExtras,
subagentAllowlistFor,
subagentTypeNotAllowedMessage,
withoutDelegatingTargets,
} from '#/app/agentProfileCatalog/profile-shared';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentSwarmService } from '#/features/swarm/agent/swarm';
Expand Down Expand Up @@ -142,7 +144,14 @@ export class AgentSwarmTool implements IAgentSwarmTool {
if ((args.items?.length ?? 0) > 0) {
await this.catalog.ready;
const own = this.profile.data();
const allowlist = subagentAllowlistFor(this.catalog, own);
const extras =
this.callerAgentId === 'main'
? rootDelegationExtras(this.catalog, own, this.catalog.list())
: undefined;
let allowlist = subagentAllowlistFor(this.catalog, own, extras);
if (allowlist !== undefined && own.subagents === undefined) {
allowlist = withoutDelegatingTargets(this.catalog, allowlist);
}
if (allowlist !== undefined && !allowlist.includes(profileName)) {
throw new Error2(
ErrorCodes.AGENT_TYPE_NOT_ALLOWED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ registerAgentProfile({
name: 'agent',
description: 'Default agent',
tools: AGENT_TOOLS,
subagents: ['coder', 'explore', 'plan'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve delegation to discovered custom profiles

When the built-in default profile is active, this hard-coded allowlist filters the Agent tool description and is enforced again at launch, so a discovered custom profile such as reviewer is neither advertised nor callable. Declaring subagents on reviewer only controls what that agent may spawn and cannot opt it into its parent's list, which regresses the documented custom-agent delegation flow; derive the restriction so it blocks recursive agent launches without excluding custom catalog profiles.

Useful? React with 👍 / 👎.

renderSystemPrompt: (context) =>
renderSystemPromptResult('', context, { skillActive: skillActiveFor(AGENT_TOOLS) }),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,15 @@ export class SessionAgentProfileCatalogService
});
continue;
}
merged.set(candidate.profile.name, candidate.profile);
const replaced = merged.get(candidate.profile.name);
const effective =
candidate.profile.subagents === undefined && replaced?.subagents !== undefined
? { ...candidate.profile, subagents: replaced.subagents }
: candidate.profile;
merged.set(candidate.profile.name, effective);
inspections.set(candidate.profile.name, {
name: candidate.profile.name,
profile: candidate.profile,
profile: effective,
sourceId: candidate.sourceId,
priority: candidate.priority,
suppressed: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef
options.path,
);
const rawSubagents = parseStringList(frontmatter['subagents'], 'subagents', options.path);
const subagents =
rawSubagents?.length === 1 && rawSubagents[0] === '*' ? undefined : rawSubagents;
const subagents = rawSubagents;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the parser header's wildcard contract

Preserving ['*'] here makes a lone wildcard explicitly unrestricted, while an omitted subagents field now inherits the default allowlist. The module header still says the wildcard and omission are equivalent and unrestricted, so it documents the opposite of the new contract; update the header to describe the distinction.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L36-L40

Useful? React with 👍 / 👎.


const prompt = parsed.body.trim();
if (prompt.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ describe('FullCompaction', () => {
properties: expect.objectContaining({
agent_id: 'main',
source: 'manual',
tokens_before: 3_302,
tokens_before: 3_220,
tokens_after: expect.any(Number),
duration_ms: expect.any(Number),
compacted_count: 6,
Expand Down Expand Up @@ -570,7 +570,7 @@ describe('FullCompaction', () => {
session_id: 'test-session',
cwd: dir,
trigger: 'auto',
token_count: 3_302,
token_count: 3_220,
});
expect(post).toMatchObject({
hook_event_name: 'PostCompact',
Expand Down Expand Up @@ -656,7 +656,7 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'manual',
tokens_before: 14_980,
tokens_before: 14_898,
retry_count: 1,
trace_id: 'trace-compact-1',
}),
Expand Down Expand Up @@ -1039,7 +1039,7 @@ describe('FullCompaction', () => {
properties: expect.objectContaining({
agent_id: 'main',
source: 'manual',
tokens_before: 14_980,
tokens_before: 14_898,
duration_ms: expect.any(Number),
round: 1,
retry_count: 0,
Expand Down Expand Up @@ -1264,7 +1264,7 @@ describe('FullCompaction', () => {
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
tokens_before: 14_980,
tokens_before: 14_898,
duration_ms: expect.any(Number),
retry_count: 4,
error_type: 'APIConnectionError',
Expand Down Expand Up @@ -1637,8 +1637,8 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'auto',
tokens_before: 3_309,
tokens_after: 3_293,
tokens_before: 3_227,
tokens_after: 3_211,
compacted_count: 7,
retry_count: 0,
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts

Large diffs are not rendered by default.

Loading
Loading