diff --git a/.changeset/subagent-delegation-cap.md b/.changeset/subagent-delegation-cap.md new file mode 100644 index 00000000000..382f31fd0e3 --- /dev/null +++ b/.changeset/subagent-delegation-cap.md @@ -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 `*`). diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index ae511883d5b..83c87b1b512 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -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 @@ -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____` 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`). diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index d25ba177c67..4d3fe30fed8 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -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 拿到结果时,背后的工作也已经真正完成。 ## 调用方式 @@ -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`)。 diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index e052694654b..29f78a374e0 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -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'; @@ -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 @@ -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); + } + private knownToolReferences(): ToolReference[] { const refs = new Map(); for (const contribution of this.contributions.items) { @@ -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, diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts index 893b4b6b82d..5321d24c163 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts @@ -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'; @@ -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; + 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, +): 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 | undefined; + }, + allowlist: readonly string[], +): readonly string[] { + return allowlist.filter((name) => { + const target = catalog.get(name); + return target === undefined || !profileCanDelegate(target); + }); } export function subagentTypeNotAllowedMessage( diff --git a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts index 6d94de090c9..18d8a5d7397 100644 --- a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts @@ -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'; @@ -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, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 77f26482f0b..8ac78aad8d3 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -93,6 +93,7 @@ registerAgentProfile({ name: 'agent', description: 'Default agent', tools: AGENT_TOOLS, + subagents: ['coder', 'explore', 'plan'], renderSystemPrompt: (context) => renderSystemPromptResult('', context, { skillActive: skillActiveFor(AGENT_TOOLS) }), }); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index 7b5770d466d..4e5a53a99ce 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -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: [ diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts index 940425dc12c..469420fe8fd 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts @@ -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; const prompt = parsed.body.trim(); if (prompt.length === 0) { diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index ba95ede1d85..59d736a086b 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -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, @@ -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', @@ -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', }), @@ -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, @@ -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', @@ -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, }), diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 15c1ae7e10c..8dae0756532 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -134,8 +134,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "