AgentTool 与 Subagent 分工
Subagent 把完整 Agent Loop 封装成工具,可同步执行、后台运行,也能继续对话。
flowchart LR A[主 Agent] --> B[AgentTool] B --> C[createSubagentContext] C --> D[runAgent] D --> E[子 Agent Loop] E --> F[结果摘要] F --> A E --> G[LocalAgentTask 后台任务] G --> H[SendMessage / ResumeAgent]
Phase 7:AgentTool 与 Subagent 机制
1面试前先建立整体心智模型
Claude Code 里的 AgentTool 不是“模型自己开了一个线程”,也不是简单地把一段 prompt 拼给另一个模型。它本质上是一个普通工具,但这个工具的 call() 里会再启动一套子代理运行环境:选择 agent 定义、构造子代理 system prompt、筛选工具池、隔离 ToolUseContext、启动新的 query() loop,最后把子代理的回答再包装成父代理能读取的 tool_result。
可以先这样记:
父 Agent Loop
assistant/tool_use: Agent(...)
|
v
本地工具系统执行 AgentTool.call()
|
v
选择 subagent / fork agent / teammate / remote agent
|
v
runAgent(...)
|
v
子 Agent Loop: query(...)
|
v
子代理完成后 finalizeAgentTool(...)
|
v
父 loop 收到 user/tool_result
Phase 1 讲的是 query() 如何形成“模型 -> 工具 -> 工具结果 -> 下一轮”的闭环。Phase 7 的重点是:AgentTool 本身就是这个闭环里的一个工具,只不过它的工具执行结果来自另一个完整的 agent loop。
如果面试官问“Claude Code 的 subagent 是怎么实现的”,不要只回答“它调用了一个新的模型”。更准确的回答是:
Claude Code 把 subagent 实现为 AgentTool。
父模型发出 Agent tool_use 后,本地 runtime 选择一个 AgentDefinition,
为这个 agent 构造独立 ToolUseContext、工具池、权限模式、system prompt 和初始消息,
然后通过 runAgent 再进入一次 query()。
同步 subagent 的结果会直接作为 Agent 工具的 tool_result 回填。
后台 subagent 会注册成 LocalAgentTask,先返回 async_launched,
完成后再通过 <task-notification> 进入父会话。
fork subagent 则复用父会话上下文和工具列表,以换取 prompt cache 前缀一致。
你可以把它理解成 C++ 里的“主协程调度一个子协程”:
ToolResult AgentTool::call(Input input, ToolUseContext parent) {
AgentDefinition def = select_agent(input);
ToolUseContext child = create_subagent_context(parent, def);
std::vector<Message> child_messages = run_query_loop(child);
return finalize_as_tool_result(child_messages);
}
但是源码里的真实复杂度在于:子代理不只是多一次模型调用,它还有权限、工具、MCP、hooks、skills、worktree、后台任务、resume、prompt cache、安全 handoff 等工程约束。
2Phase 7 主线地图
建议按这个顺序记源码:
src/tools/AgentTool/AgentTool.tsx
-> 定义 Agent 工具 schema / prompt / call / result mapping
-> 选择普通 subagent、fork subagent、teammate、remote agent
-> 决定同步还是后台
-> 调用 runAgent
src/tools/AgentTool/runAgent.ts
-> 真正创建子代理上下文
-> 准备 system prompt / userContext / systemContext / tools / MCP / hooks / skills
-> 调用 query()
-> 记录 sidechain transcript 并清理资源
src/utils/forkedAgent.ts
-> createSubagentContext:复制和隔离 ToolUseContext
-> runForkedAgent:复用同一套上下文隔离思想的 fork agent helper
src/tools/AgentTool/forkSubagent.ts
-> fork agent 特殊路径
-> 复用父消息和父 system prompt,追求 prompt cache 命中
src/tools/AgentTool/agentToolUtils.ts
-> 过滤工具、收束结果、后台生命周期、安全 handoff 分类
src/tasks/LocalAgentTask/LocalAgentTask.tsx
-> 后台子代理作为 task 存活
-> progress / pending messages / notification / output file
src/tools/SendMessageTool/SendMessageTool.ts
src/tools/AgentTool/resumeAgent.ts
-> 给运行中或已停止的后台 agent 继续发消息
src/tools/AgentTool/loadAgentsDir.ts
src/tools/AgentTool/prompt.ts
-> AgentDefinition 从哪里来
-> 如何教模型正确使用 AgentTool
3和 Phase 1-4 的关系
AgentTool 不是孤立机制,它是在前面几期源码主线上的一个“递归应用”。
| 前置 Phase | 在 Phase 7 中的对应点 |
|---|---|
| Phase 1 Agent Loop | runAgent() 内部再次调用 query(),子代理自己也有模型-工具-结果循环 |
| Phase 2 Message | 子代理输入是 Message[],输出最终仍变成父代理的 user/tool_result |
| Phase 3 Tool 抽象 | AgentTool 是一个普通 ToolDef,走 checkPermissionsAndCallTool -> tool.call -> mapToolResultToToolResultBlockParam |
| Phase 4 Read/Grep/Glob | 子代理拿到自己的工具池后,可以独立搜索、读取、编辑;工具结果进入子代理上下文 |
一句话:Phase 7 是“把 Phase 1 的 agent loop 包装成 Phase 3 的 tool”。
4src/tools/AgentTool/AgentTool.tsx:AgentTool 是子代理入口和路由器
输入 schema:模型调用 Agent 时能传什么
源码位置:src/tools/AgentTool/AgentTool.tsx:81-125
const baseInputSchema = z.strictObject({
description: z.string().describe('A short (3-5 word) description of the task'),
prompt: z.string().describe('The task for the agent to perform'),
subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'),
model: z.enum(['sonnet', 'opus', 'haiku']).optional().describe('Optional model override...'),
run_in_background: z.boolean().optional().describe('Set to true to run this agent in the background...'),
})
const fullInputSchema = baseInputSchema.extend({
name: z.string().optional().describe('Name for the spawned agent...'),
team_name: z.string().optional().describe('Team name for the spawned agent...'),
mode: z.enum(['subagent']).optional().describe('Spawn mode. Only subagent is currently supported.'),
isolation: z.enum(['none', 'worktree']).optional().describe('Filesystem isolation mode...'),
cwd: z.string().optional().describe('Working directory for the agent...'),
})
const inputSchema = memoize(() => {
const shouldUseFullSchema =
isAgentSwarmsEnabled() || isForkSubagentEnabled() || feature('BACKGROUND_TASKS')
...
})
这里能看到 Agent 工具不是只接受一个 prompt。它有几类输入:
description:短描述,主要给 UI、task、progress、notification 看。prompt:真正给子代理的任务。subagent_type:指定 agent 类型,例如 general-purpose、Explore、Plan、用户自定义 agent。model:允许本次子代理覆盖模型。run_in_background:让子代理后台跑。isolation/cwd:控制工作目录和 worktree 隔离。name/team_name:多 agent/team 路径用来命名 teammate。
注意 inputSchema() 是动态的。源码会根据 feature flag 决定是否暴露完整字段。如果后台任务、fork subagent、多 agent swarms 没开,就只给模型更小的 schema。这样能降低模型误用复杂字段的概率。
你可以这样记:
AgentTool input =
任务描述 description
+ 子代理任务 prompt
+ 选择哪个 agent subagent_type
+ 是否后台 / 是否隔离 / 是否指定模型
输出 schema:同步完成和后台启动是两种不同结果
源码位置:src/tools/AgentTool/AgentTool.tsx:141-155
const outputSchema = z.union([
agentToolResultSchema.extend({
status: z.literal('completed'),
prompt: z.string(),
}),
z.object({
isAsync: z.literal(true),
status: z.literal('async_launched'),
agentId: z.string(),
description: z.string(),
prompt: z.string(),
outputFile: z.string(),
canReadOutputFile: z.boolean(),
}),
])
同步子代理完成时,输出是 status: "completed",里面包含 content、token、工具次数、耗时等。
后台子代理不会马上给最终内容,而是返回 status: "async_launched",并告诉父代理:
agentId:之后可以用SendMessage继续它。outputFile:后台输出文件路径。canReadOutputFile:父代理是否有 Read/Bash 能力去看输出文件。
这解释了为什么后台 subagent 是“两阶段协议”:
第一阶段:Agent tool_result 告诉父代理:后台任务已经启动。
第二阶段:后台任务完成后,LocalAgentTask 注入 <task-notification>。
ToolDef 注册:AgentTool 本身仍是普通工具
源码位置:src/tools/AgentTool/AgentTool.tsx:196-239
export const AgentTool = buildTool({
name: AGENT_TOOL_NAME,
aliases: ['Task'],
maxResultSizeChars: 1_000_000,
async description() { return 'Launch a new agent to handle complex, multi-step tasks' },
async prompt({ options: { agentDefinitions }, getAppState }) {
const appState = getAppState()
const filteredAgents = filterDeniedAgents(
getActiveAgentsWithRequiredMcpServers(agentDefinitions.activeAgents, appState.mcp.clients),
appState.toolPermissionContext,
)
return getPrompt(filteredAgents, isForkSubagentEnabled(), appState.toolPermissionContext.mode)
},
get inputSchema() { return inputSchema() },
outputSchema,
async call(...) { ... }
})
这一段要和 Phase 3 联系起来看。AgentTool 通过 buildTool 注册,拥有和 Read/Grep/Bash 一样的基本结构:
namedescriptionpromptinputSchemaoutputSchemacallcheckPermissionsmapToolResultToToolResultBlockParam
所以 AgentTool 不绕过工具系统。父模型仍然只是输出一个 tool_use block;本地 runtime 仍然通过 Phase 3 的工具执行链调用 AgentTool.call();最后仍然通过 mapToolResultToToolResultBlockParam() 生成 tool_result。
prompt:动态告诉模型有哪些 agent 可用
源码位置:src/tools/AgentTool/AgentTool.tsx:197-224
async prompt({ options: { agentDefinitions }, getAppState }) {
const appState = getAppState()
let agentsWithAvailableMcp = getActiveAgentsWithRequiredMcpServers(
agentDefinitions.activeAgents,
appState.mcp.clients,
)
if (
appState.toolPermissionContext.mode === 'default' ||
appState.toolPermissionContext.mode === 'plan'
) {
agentsWithAvailableMcp = agentsWithAvailableMcp.filter(agent => {
const { resolvedTools } = resolveAgentTools(agent, appState.availableTools, false)
return resolvedTools.length > 0
})
}
const filteredAgents = filterDeniedAgents(
agentsWithAvailableMcp,
appState.toolPermissionContext,
)
return getPrompt(filteredAgents, isForkSubagentEnabled(), appState.toolPermissionContext.mode)
}
这里的 prompt 不是静态文本。它会先过滤 agent:
1. required MCP server 不可用的 agent 不展示。 2. 默认/plan 权限模式下,如果某个 agent 解析出来没有可用工具,也不展示。 3. 被权限规则拒绝的 agent 不展示。 4. 最后交给 getPrompt() 生成给模型看的 Agent 工具说明。
这个设计很重要:模型只能选择“当前真的能跑”的 agent。否则模型可能生成 subagent_type: "foo",但本地根本没有对应能力。
call 开头:先拿当前 app state 和权限模式
源码位置:src/tools/AgentTool/AgentTool.tsx:239-280
async call(
{ prompt, subagent_type, model, description, run_in_background: runInBackground, ...multiAgentParams },
toolUseContext,
canUseTool,
assistantMessage,
{ options: { agentDefinitions }, abortController, readFileState, setAppState },
onProgress,
) {
const startTime = Date.now()
const appState = toolUseContext.getAppState()
const rootSetAppState = toolUseContext.setAppStateForTasks ?? setAppState
const permissionMode = appState.toolPermissionContext.mode
...
}
AgentTool.call() 拿到的不是裸 prompt,而是父 agent 当前的整个工具执行上下文:
toolUseContext:父 loop 的工具上下文、消息、权限、文件状态、UI 回调等。canUseTool:真正执行子代理工具时仍然需要的权限函数。assistantMessage:父模型这次发出Agenttool_use 的 assistant message。agentDefinitions:当前可用 agent 定义。abortController/readFileState/setAppState:来自父工具执行环境。
这里要记住一个核心点:子代理不是脱离父会话的独立进程。它从父会话继承很多运行时资源,但后面会通过 createSubagentContext() 做隔离和筛选。
teammate 分支:team_name + name 不是普通 subagent
源码位置:src/tools/AgentTool/AgentTool.tsx:282-316
if (teamName && name) {
const result = await spawnTeammate({
teamName,
agentName: name,
agentType: subagent_type,
prompt,
model,
mode,
isolation,
cwd,
toolUseContext,
canUseTool,
assistantMessage,
agents: agentDefinitions.activeAgents,
spawnBackend: getTeamSpawnBackend(),
getParentSessionId,
})
return {
data: {
status: 'teammate_spawned',
teammate_id: result.id,
name: result.name,
team_name: result.teamName,
},
}
}
这个分支容易和普通 subagent 混在一起。判断标准很简单:
有 team_name + name
-> 走 spawnTeammate,多 agent/team 协议
没有 team_name + name
-> 走普通 AgentTool subagent/fork/background 路径
本 Phase 主线重点是普通 subagent,但面试可以顺手提一句:Claude Code 的 AgentTool 同时承载了普通子代理、fork 子代理和 team teammate 的路由职责。
选择 agent:subagent_type、fork、general-purpose
源码位置:src/tools/AgentTool/AgentTool.tsx:318-356
let selectedAgent: AgentDefinition
const effectiveType =
subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)
const isFork = effectiveType === undefined
if (isFork) {
if (
toolUseContext.options.querySource === 'agent:builtin:fork' ||
isInForkChild(toolUseContext.getMessages())
) {
return { data: { status: 'async_launched', ... } }
}
selectedAgent = FORK_AGENT
} else {
const agentsWithAvailableMcp = getActiveAgentsWithRequiredMcpServers(...)
const filteredAgents = filterDeniedAgents(agentsWithAvailableMcp, appState.toolPermissionContext)
selectedAgent = filteredAgents.find(_ => _.agentType === effectiveType)
if (!selectedAgent) {
throw new Error(`Agent type '${effectiveType}' not found...`)
}
}
这段是 AgentTool 的路由核心。
如果模型显式传了 subagent_type,就按这个类型找 agent。找不到就报错,并提示可用 agent 类型。
如果模型没有传 subagent_type:
- fork subagent 功能没开:默认使用
general-purpose。 - fork subagent 功能开了:
effectiveType保持undefined,这代表走 fork agent。
这是一处非常关键的语义变化:
旧语义:
Agent(prompt) 默认 = general-purpose subagent
fork 开启后:
Agent(prompt) 默认 = fork current context
Agent(subagent_type="general-purpose", prompt) 才是 fresh general-purpose
源码还做了递归 fork 防护:
querySource === 'agent:builtin:fork'- 或
isInForkChild(messages)
fork 子代理里再 fork 会破坏上下文和 prompt cache 设计,所以它直接返回一个后台启动式的拒绝说明,而不继续生成新 fork。
required MCP server:agent 声明的外部工具必须可用
源码位置:src/tools/AgentTool/AgentTool.tsx:369-409
if (selectedAgent.requiredMcpServers && selectedAgent.requiredMcpServers.length > 0) {
const pendingServers = selectedAgent.requiredMcpServers.filter(name =>
appState.mcp.clients.some(client => client.name === name && client.type === 'pending')
)
if (pendingServers.length > 0) {
await Promise.race([ waitForMcpServers(pendingServers, toolUseContext), sleep(30000) ])
}
...
const missingTools = requiredTools.filter(toolName => !connectedMcpToolNames.includes(toolName))
if (missingTools.length > 0) {
throw new Error(`Required tools not found...`)
}
}
有些 agent 不是只依赖内置工具,它可能要求某个 MCP server 或某些 MCP tools 已经连接。
这段做了两层检查:
1. 如果 required MCP server 还在 pending,最多等 30 秒。 2. 等完后检查 required tools 是否真的存在,不存在就报错。
这防止了模型把任务交给一个“看起来存在,但关键外部工具不可用”的子代理。
构造 promptMessages 和 systemPrompt:普通 subagent 与 fork 不同
源码位置:src/tools/AgentTool/AgentTool.tsx:483-541
let systemPrompt: SystemPrompt | undefined
let promptMessages: UserMessage[] | Message[]
if (isFork) {
if (toolUseContext.renderedSystemPrompt) {
systemPrompt = toolUseContext.renderedSystemPrompt
} else {
const defaultSystemPrompt = await getSystemPrompt(...)
systemPrompt = buildEffectiveSystemPrompt(...)
}
promptMessages = buildForkedMessages(prompt, assistantMessage)
} else {
const baseSystemPrompt = selectedAgent.getSystemPrompt()
systemPrompt = asSystemPrompt(await enhancePromptWithEnvironmentDetails(baseSystemPrompt, model))
const initialPrompt = selectedAgent.initialPrompt ?? ''
const fullPrompt = initialPrompt ? `${initialPrompt}\n\n${prompt}` : prompt
promptMessages = [createUserMessage({ content: fullPrompt })]
}
普通 subagent 和 fork subagent 最大区别在这里。
普通 subagent 是 fresh context:
systemPrompt = selectedAgent.getSystemPrompt() + environment details
messages = [user(prompt)]
fork subagent 是继承父 context:
systemPrompt = 父 agent 已渲染 system prompt
messages = buildForkedMessages(prompt, assistantMessage)
为什么 fork 要复用父 system prompt?源码注释说得很明确:为了让多个 fork 子代理共享 byte-identical prompt cache prefix。也就是说,fork 不是普通“新 agent”,它更像“把当前父上下文复制给一个 worker,让它从这里分岔执行”。
决定是否后台运行
源码位置:src/tools/AgentTool/AgentTool.tsx:555-568
const forceAsync =
(isFork && isForkSubagentEnabled()) ||
isAssistantMode() ||
isCoordinatorMode() ||
isProactiveOrchestrationEnabled()
const isAsync =
!isBackgroundTasksDisabled &&
(forceAsync || runInBackground === true || selectedAgent.background === true)
后台运行不是只由模型的 run_in_background 决定。源码会强制某些情况后台化:
- fork subagent 开启时,fork 默认后台。
- assistant mode / coordinator mode / proactive orchestration 下后台。
- agent frontmatter 声明
background: true。 - 模型显式传
run_in_background: true。
这解释了一个面试点:Claude Code 的 subagent 有同步和异步两种生命周期,而不是统一阻塞父 loop。
子代理工具池:worker permission mode + assembleToolPool
源码位置:src/tools/AgentTool/AgentTool.tsx:568-578
const workerPermissionContext = {
...appState.toolPermissionContext,
mode: selectedAgent.permissionMode ?? 'acceptEdits',
}
const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools)
子代理工具池不是简单复用父代理 options.tools。普通子代理会先构造 workerPermissionContext:
mode = selectedAgent.permissionMode ?? "acceptEdits"
再调用 assembleToolPool() 重新组装工具。
这意味着子代理默认是更偏“可执行任务”的权限姿态:如果 agent 没声明 permissionMode,worker 用 acceptEdits。当然,后面 runAgent() 里还会结合父权限模式、allowedTools、async 权限提示策略进一步收窄。
worktree 隔离:让子代理在独立 Git 工作树里改代码
源码位置:src/tools/AgentTool/AgentTool.tsx:579-636
const stableAgentId = earlyAgentId
let worktreeInfo: AgentWorktreeInfo | undefined
if (effectiveIsolation === 'worktree') {
worktreeInfo = await createAgentWorktree({
agentId: stableAgentId,
agentType: selectedAgent.agentType,
parentCwd: getCwd(),
})
}
const runAgentParams = {
agentDefinition: selectedAgent,
promptMessages,
toolUseContext,
canUseTool,
isAsync,
querySource: getQuerySourceForAgent(...),
model,
override: isFork
? { systemPrompt }
: worktreeInfo || cwd
? undefined
: { systemPrompt },
availableTools: isFork ? toolUseContext.options.tools : workerTools,
forkContextMessages: isFork ? toolUseContext.getMessages() : undefined,
...(isFork && { useExactTools: true }),
...(worktreeInfo && { worktreePath: worktreeInfo.path }),
}
worktree 隔离会创建稳定 agentId 对应的工作树,并把子代理运行 cwd 切到 worktree。这样子代理可以改文件,但不会直接污染父会话所在工作区。
注意 fork 路径和普通路径对 tools/system prompt 的处理不同:
| 路径 | system prompt | availableTools | forkContextMessages |
|---|---|---|---|
| 普通 subagent | agent 自己的 system prompt | workerTools | 无 |
| fork subagent | 父 system prompt | 父工具列表 | 父 messages |
fork 子代理需要 useExactTools: true,因为它追求与父请求尽量一致的 prompt cache 前缀。普通子代理则更像一个新员工:给它独立说明书和独立工具箱。
异步路径:registerAsyncAgent + runAsyncAgentLifecycle
源码位置:src/tools/AgentTool/AgentTool.tsx:686-764
if (isAsync) {
const agentBackgroundTask = registerAsyncAgent({
agentId: stableAgentId,
description,
prompt,
selectedAgent,
setAppState: rootSetAppState,
toolUseId: toolUseContext.toolUseId,
})
void runWithAgentContext(asyncAgentContext, () =>
wrapWithCwd(() =>
runAsyncAgentLifecycle({
taskId: agentBackgroundTask.agentId,
abortController: agentBackgroundTask.abortController!,
makeStream: onCacheSafeParams =>
runAgent({ ...runAgentParams, override: { agentId, abortController } }),
...
}),
),
)
return {
data: {
isAsync: true,
status: 'async_launched',
agentId: agentBackgroundTask.agentId,
outputFile: getTaskOutputPath(agentBackgroundTask.agentId),
canReadOutputFile,
},
}
}
异步路径做两件事:
1. 立即注册一个 LocalAgentTask,让 UI/task 系统知道有后台子代理在跑。 2. 用 void runWithAgentContext(...) 启动后台生命周期,不阻塞当前工具调用。
然后 AgentTool.call() 马上返回 async_launched。父模型看到的不是最终结果,而是“后台 agent 已启动,稍后会通知你”。
这里的语义和普通工具很不一样:
普通工具:
call() 完成 = 工具结果完成
后台 AgentTool:
call() 完成 = 子代理刚启动
子代理真正完成 = 之后由 task notification 传回
同步路径:foreground task,可以中途 background
源码位置:src/tools/AgentTool/AgentTool.tsx:765-1126
const syncAgentId = asAgentId(earlyAgentId)
return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => {
const agentMessages: MessageType[] = []
const syncTracker = createProgressTracker()
const registration = registerAgentForeground({
agentId: syncAgentId,
description,
prompt,
selectedAgent,
setAppState: rootSetAppState,
toolUseId: toolUseContext.toolUseId,
autoBackgroundMs: getAutoBackgroundMs() || undefined,
})
const agentIterator = runAgent({ ...runAgentParams, override: { agentId: syncAgentId } })[Symbol.asyncIterator]()
while (true) {
const nextMessagePromise = agentIterator.next()
const raceResult = backgroundPromise
? await Promise.race([nextMessagePromise.then(...), backgroundPromise])
: { type: 'message', result: await nextMessagePromise }
if (raceResult.type === 'background' && foregroundTaskId) {
...
return { data: { status: 'async_launched', agentId: backgroundedTaskId, ... } }
}
if (result.done) break
agentMessages.push(result.value)
...
}
}))
同步路径也会先注册 foreground task。这一点很细:即使用户没有要求后台,Claude Code 仍然把同步 subagent 登记成一个可以被 background 的 foreground task。
原因是子代理可能运行很久。源码用 Promise.race 同时等待:
- 子代理下一条 message。
- 用户/系统把这个 foreground task 转后台的 signal。
如果中途 background,就会:
1. 清理当前 foreground iterator。 2. 重新用 isAsync: true 继续跑 runAgent()。 3. 立即给父模型返回 async_launched。
所以同步 subagent 不是“永远阻塞到底”,它可以动态切到后台。
同步完成收束:finalizeAgentTool -> completed
源码位置:src/tools/AgentTool/AgentTool.tsx:1220-1260
if (syncAgentError) {
const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant')
if (!hasAssistantMessages) {
throw syncAgentError
}
logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`)
}
const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata)
if (feature('TRANSCRIPT_CLASSIFIER')) {
const handoffWarning = await classifyHandoffIfNeeded(...)
if (handoffWarning) {
agentResult.content = [{ type: 'text', text: handoffWarning }, ...agentResult.content]
}
}
return {
data: {
status: 'completed',
prompt,
...agentResult,
...worktreeResult,
},
}
同步完成后,finalizeAgentTool() 会从子代理消息里提取最终 assistant 文本,并统计:
- agentId
- agentType
- content
- totalToolUseCount
- totalDurationMs
- totalTokens
- usage
如果子代理中途出错,但已经产生过 assistant message,源码会尽量返回部分结果,而不是直接丢掉子代理已经做出的分析。这是一个工程上很实用的降级策略。
mapToolResultToToolResultBlockParam:把 Agent 输出翻译回父 loop
源码位置:src/tools/AgentTool/AgentTool.tsx:1298-1379
mapToolResultToToolResultBlockParam(data, toolUseID) {
if (data.status === 'async_launched') {
const prefix = `Async agent launched successfully.
agentId: ${data.agentId} ...`
const instructions = data.canReadOutputFile
? `Do not duplicate this agent's work ... output_file: ${data.outputFile} ...`
: `Briefly tell the user what you launched and end your response...`
return { tool_use_id: toolUseID, type: 'tool_result', content: [{ type: 'text', text }] }
}
if (data.status === 'completed') {
const contentOrMarker = data.content.length > 0 ? data.content : [...]
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [...contentOrMarker, {
type: 'text',
text: `agentId: ${data.agentId} ... <usage>...</usage>`
}]
}
}
}
这里和 Phase 2/3 完全接上了:无论 AgentTool 内部跑了多复杂的子代理,最终回到父模型时,仍然是一个 tool_result block。
同步完成时,父模型看到子代理最终内容和 usage trailer。后台启动时,父模型看到的是启动说明和后续协作规则:
- 不要重复后台 agent 正在做的工作。
- 如果可读输出文件,可以 Read/Bash tail 查看进度。
- 结果完成后会自动收到通知。
- 可以用
SendMessage给 agentId 继续发消息。
你可以这样记:
AgentTool.call() 内部是复杂生命周期;
AgentTool.mapToolResultToToolResultBlockParam() 对父模型隐藏复杂度,
统一变成 user/tool_result。
5src/tools/AgentTool/runAgent.ts:真正启动子代理 query loop
runAgent 的参数:子代理启动包
源码位置:src/tools/AgentTool/runAgent.ts:248-329
export async function* runAgent({
agentDefinition,
promptMessages,
toolUseContext,
canUseTool,
isAsync,
canShowPermissionPrompts,
forkContextMessages,
querySource,
maxTurns,
override,
model,
availableTools,
allowedTools,
onCacheSafeParams,
contentReplacementState,
useExactTools,
worktreePath,
description,
transcriptSubdir,
onQueryProgress,
}: ...): AsyncGenerator<Message, void> {
...
}
AgentTool.call() 只是入口,真正跑子代理的是 runAgent()。
它的参数可以分成几类:
- agent 定义:
agentDefinition - 初始消息:
promptMessages - 父上下文:
toolUseContext - 权限函数:
canUseTool - 生命周期:
isAsync - fork/cache:
forkContextMessages、useExactTools - 覆盖项:
override、model、allowedTools - 持久化:
worktreePath、transcriptSubdir - 进度/summary:
onCacheSafeParams、onQueryProgress
这就是子代理启动的“完整参数包”。
初始 messages:fork 会拼上父上下文
源码位置:src/tools/AgentTool/runAgent.ts:368-379
const contextMessages: Message[] = forkContextMessages
? filterIncompleteToolCalls(forkContextMessages)
: []
const initialMessages: Message[] = [...contextMessages, ...promptMessages]
const agentReadFileState =
forkContextMessages !== undefined
? cloneFileStateCache(toolUseContext.readFileState)
: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)
普通 subagent 的 initialMessages 只有任务 prompt。fork subagent 的 initialMessages 会先放父上下文,再放 fork 指令。
这里还会过滤 incomplete tool calls。原因是 Anthropic API 要求 tool_use 和 tool_result 配对。如果父上下文最后有未完成工具调用,直接拿来给 fork 子代理会触发 API 错误。
文件读取状态也不同:
fork subagent:
clone parent readFileState
因为它继承父上下文,父读过的文件状态对它有意义
普通 subagent:
create fresh limited readFileState
因为它是 fresh worker
userContext/systemContext:子代理会瘦身上下文
源码位置:src/tools/AgentTool/runAgent.ts:380-410
const [baseUserContext, baseSystemContext] = await Promise.all([
override?.userContext ?? getUserContext(),
override?.systemContext ?? getSystemContext(),
])
const shouldOmitClaudeMd =
agentDefinition.omitClaudeMd &&
!override?.userContext &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_slim_subagent_claudemd', true)
const resolvedUserContext = shouldOmitClaudeMd ? userContextNoClaudeMd : baseUserContext
const resolvedSystemContext =
agentDefinition.agentType === 'Explore' || agentDefinition.agentType === 'Plan'
? systemContextNoGit
: baseSystemContext
子代理不一定继承完整用户上下文。源码里有两个优化:
1. 某些只读 agent 可以省掉 CLAUDE.md,因为主 agent 已经拥有完整项目规则。 2. Explore/Plan 这类只读搜索 agent 会省掉父会话启动时的 stale gitStatus,需要时自己运行新命令拿新状态。
这不是功能正确性的核心,但很适合面试讲工程权衡:subagent 是高频能力,少带一点上下文就能省大量 token。
权限模式:agent 可覆盖,但父级强权限优先
源码位置:src/tools/AgentTool/runAgent.ts:412-498
const agentPermissionMode = agentDefinition.permissionMode
const agentGetAppState = () => {
const state = toolUseContext.getAppState()
let toolPermissionContext = state.toolPermissionContext
if (
agentPermissionMode &&
state.toolPermissionContext.mode !== 'bypassPermissions' &&
state.toolPermissionContext.mode !== 'acceptEdits' &&
!(feature('TRANSCRIPT_CLASSIFIER') && state.toolPermissionContext.mode === 'auto')
) {
toolPermissionContext = { ...toolPermissionContext, mode: agentPermissionMode }
}
const shouldAvoidPrompts =
canShowPermissionPrompts !== undefined
? !canShowPermissionPrompts
: agentPermissionMode === 'bubble'
? false
: isAsync
if (shouldAvoidPrompts) {
toolPermissionContext = { ...toolPermissionContext, shouldAvoidPermissionPrompts: true }
}
if (allowedTools !== undefined) {
toolPermissionContext = {
...toolPermissionContext,
alwaysAllowRules: {
cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg,
session: [...allowedTools],
},
}
}
...
}
这段是 AgentTool 安全模型的核心之一。
规则可以这样记:
agent 可以声明 permissionMode
但父会话如果已经是 bypassPermissions / acceptEdits / auto,父级模式优先。
async agent 默认不能弹权限 UI,
所以设置 shouldAvoidPermissionPrompts。
allowedTools 会作为子代理 session allow rules,
但保留 CLI 传入的全局 allow rules。
后台 agent 不能随时打断用户弹窗,所以 async 时通常会避免 permission prompt。bubble 模式是例外,它允许权限请求冒泡到父终端。
工具解析与 agent system prompt
源码位置:src/tools/AgentTool/runAgent.ts:500-519
const resolvedTools = useExactTools
? availableTools
: resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools
const agentSystemPrompt = override?.systemPrompt
? override.systemPrompt
: asSystemPrompt(
await getAgentSystemPrompt(
agentDefinition,
toolUseContext,
resolvedAgentModel,
additionalWorkingDirectories,
resolvedTools,
),
)
这里再次区分 fork 和普通 subagent:
- fork:
useExactTools,直接使用传入工具列表,避免改变 prompt cache 前缀。 - 普通:调用
resolveAgentTools(),按 agent frontmatter 的tools/disallowedTools过滤。
system prompt 也一样:如果 override.systemPrompt 存在就用它,否则根据 agentDefinition 生成 agent 专属 prompt。
hooks、skills、MCP:子代理有自己的生命周期扩展点
源码位置:src/tools/AgentTool/runAgent.ts:530-665
for await (const hookResult of executeSubagentStartHooks(...)) {
if (hookResult.additionalContexts?.length) {
additionalContexts.push(...hookResult.additionalContexts)
}
}
if (additionalContexts.length > 0) {
initialMessages.push(createAttachmentMessage({ hookName: 'SubagentStart', ... }))
}
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
registerFrontmatterHooks(rootSetAppState, agentId, agentDefinition.hooks, `agent '${agentDefinition.agentType}'`, true)
}
const skillsToPreload = agentDefinition.skills ?? []
...
initialMessages.push(createUserMessage({ content: [{ type: 'text', text: metadata }, ...content], isMeta: true }))
const { clients: mergedMcpClients, tools: agentMcpTools, cleanup: mcpCleanup } =
await initializeAgentMcpServers(agentDefinition, toolUseContext.options.mcpClients)
const allTools =
agentMcpTools.length > 0
? uniqBy([...resolvedTools, ...agentMcpTools], 'name')
: resolvedTools
子代理启动时可以触发自己的扩展点:
SubagentStarthooks 可追加上下文。- agent frontmatter 里的 hooks 会注册到 agent 生命周期,Stop hook 会转换成
SubagentStop。 - agent frontmatter 里的 skills 会提前加载成 meta user message。
- agent-specific MCP servers 会启动,并把 MCP tools 合并到子代理工具池。
这说明 Claude Code 的 subagent 不是只靠一段 system prompt,它是一个完整运行单元。
createSubagentContext:真正隔离父子上下文
源码位置:src/tools/AgentTool/runAgent.ts:667-714
const agentOptions: ToolUseContext['options'] = {
isNonInteractiveSession: useExactTools
? toolUseContext.options.isNonInteractiveSession
: isAsync
? true
: (toolUseContext.options.isNonInteractiveSession ?? false),
tools: allTools,
mainLoopModel: resolvedAgentModel,
thinkingConfig: useExactTools
? toolUseContext.options.thinkingConfig
: { type: 'disabled' },
mcpClients: mergedMcpClients,
agentDefinitions: toolUseContext.options.agentDefinitions,
...(useExactTools && { querySource }),
}
const agentToolUseContext = createSubagentContext(toolUseContext, {
options: agentOptions,
agentId,
agentType: agentDefinition.agentType,
messages: initialMessages,
readFileState: agentReadFileState,
abortController: agentAbortController,
getAppState: agentGetAppState,
shareSetAppState: !isAsync,
shareSetResponseLength: true,
criticalSystemReminder_EXPERIMENTAL: agentDefinition.criticalSystemReminder_EXPERIMENTAL,
contentReplacementState,
})
这段是子代理真正“变成子代理”的地方。
agentOptions 决定子代理能看到哪些工具、用哪个模型、是否非交互、thinking 如何配置。普通 subagent 会关闭 thinking 来控制输出成本;fork 子代理继承父 thinkingConfig 以保持 prompt cache 前缀一致。
createSubagentContext() 会基于父 ToolUseContext 创建一个新上下文:
- messages 换成子代理 initialMessages。
- tools 换成子代理工具池。
- readFileState 换成 clone 或新 cache。
- agentId/agentType 换成子代理身份。
- sync agent 可以共享 setAppState;async agent 更隔离。
- response length 仍然共享,让父 UI 能统计响应长度。
最终进入 Phase 1 的 query()
源码位置:src/tools/AgentTool/runAgent.ts:747-806
for await (const message of query({
messages: initialMessages,
systemPrompt: agentSystemPrompt,
userContext: resolvedUserContext,
systemContext: resolvedSystemContext,
canUseTool,
toolUseContext: agentToolUseContext,
querySource,
maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
onQueryProgress?.()
if (message.type === 'stream_event' && message.event.type === 'message_start') {
toolUseContext.pushApiMetricsEntry?.(message.ttftMs)
continue
}
if (message.type === 'attachment') {
if (message.attachment.type === 'max_turns_reached') break
yield message
continue
}
if (isRecordableMessage(message)) {
await recordSidechainTranscript([message], agentId, lastRecordedUuid)
if (message.type !== 'progress') lastRecordedUuid = message.uuid
yield message
}
}
这就是 Phase 7 和 Phase 1 的直接连接点:runAgent() 最终调用的还是同一个 query()。
子代理 query loop 产出的 message 会被:
1. 用于父 UI/SDK 的进度更新。 2. 写入 sidechain transcript。 3. yield 回 AgentTool.call(),由同步/后台生命周期继续处理。
这里的 sidechain transcript 很关键:后台 agent 可以被 resume,就靠这些 transcript 持久化。
finally:子代理运行完要清理一堆资源
源码位置:src/tools/AgentTool/runAgent.ts:816-859
} finally {
await mcpCleanup()
if (agentDefinition.hooks) {
clearSessionHooks(rootSetAppState, agentId)
}
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
cleanupAgentTracking(agentId)
}
agentToolUseContext.readFileState.clear()
initialMessages.length = 0
unregisterPerfettoAgent(agentId)
clearAgentTranscriptSubdir(agentId)
rootSetAppState(prev => {
if (!(agentId in prev.todos)) return prev
const { [agentId]: _removed, ...todos } = prev.todos
return { ...prev, todos }
})
killShellTasksForAgent(agentId, toolUseContext.getAppState, rootSetAppState)
...
}
子代理不是一次无副作用函数调用。它可能创建 MCP 连接、注册 hooks、写 todos、启动 shell background task、占用 read file cache、注册 perfetto trace。因此 finally 必须做资源回收。
面试时可以强调:Claude Code 的 subagent 设计不是只有 prompt engineering,还包含完整生命周期管理。
6src/utils/forkedAgent.ts:createSubagentContext 的隔离语义
CacheSafeParams:哪些东西必须保持缓存安全
源码位置:src/utils/forkedAgent.ts:46-68
export type CacheSafeParams = {
systemPrompt: SystemPrompt
userContext: UserContext
systemContext: SystemContext
toolUseContext: ToolUseContext
forkContextMessages: Message[]
}
CacheSafeParams 的名字很直白:这些参数共同决定模型请求前缀是否能复用 prompt cache。
在 AgentTool 里,后台 summary 和 fork 子代理都会关心这些参数。只要 system prompt、上下文、工具上下文和 fork messages 稳定,就更容易复用缓存。
createSubagentContext:复制必要字段,隔离危险字段
源码位置:src/utils/forkedAgent.ts:345-462
export function createSubagentContext(
parentContext: ToolUseContext,
options: CreateSubagentContextOptions,
): ToolUseContext {
const childController = new AbortController()
if (parentContext.abortController && !options.abortController && !options.shareAbortController) {
parentContext.abortController.signal.addEventListener('abort', () => childController.abort())
}
const contentReplacementState =
options.contentReplacementState ??
cloneContentReplacementState(parentContext.contentReplacementState)
return {
...parentContext,
options: options.options,
agentId: options.agentId ?? parentContext.agentId,
agentType: options.agentType ?? parentContext.agentType,
getMessages: () => options.messages ?? parentContext.getMessages(),
readFileState: options.readFileState ?? cloneFileStateCache(parentContext.readFileState),
abortController,
getAppState: options.getAppState ?? parentContext.getAppState,
setAppState: options.shareSetAppState ? parentContext.setAppState : () => {},
setAppStateForTasks: parentContext.setAppStateForTasks,
setResponseLength: options.shareSetResponseLength ? parentContext.setResponseLength : () => {},
...
contentReplacementState,
}
}
这段是理解 subagent 的关键抽象。它不是深拷贝整个父上下文,也不是完全共享父上下文,而是选择性共享。
可以这样记:
共享:
根 task store 写入通道
必要 UI/metrics/response length
权限函数 canUseTool
替换:
messages
tools/options
agentId/agentType
readFileState
abortController
隔离:
local denial tracking
dynamic skill triggers
nested memory references
content replacement state clone
setAppState 对 async 默认 no-op
C++ 类比:
struct ToolUseContext {
Options* options;
MessageProvider getMessages;
FileState readFileState;
AbortController abort;
AppStateWriter setAppState;
};
ToolUseContext createSubagentContext(const ToolUseContext& parent) {
ToolUseContext child = parent; // 继承基础能力
child.options = childOptions; // 换工具和模型
child.getMessages = childMessages; // 换消息视图
child.readFileState = clone(parent); // 避免读文件状态互相污染
child.setAppState = noop_if_async; // 后台 agent 不能随便改父 UI 状态
return child;
}
runForkedAgent:同一套隔离思想也用于 compact/session memory
源码位置:src/utils/forkedAgent.ts:489-620
export async function* runForkedAgent({
systemPrompt,
userContext,
systemContext,
toolUseContext,
forkContextMessages,
promptMessages,
querySource,
canUseTool,
maxTurns,
...
}) {
const initialMessages = [...forkContextMessages, ...promptMessages]
const subagentContext = createSubagentContext(toolUseContext, {
messages: initialMessages,
options: { ...toolUseContext.options, tools, ... },
agentId,
readFileState: cloneFileStateCache(toolUseContext.readFileState),
...
})
for await (const message of query({
messages: initialMessages,
systemPrompt,
userContext,
systemContext,
canUseTool,
toolUseContext: subagentContext,
querySource,
maxTurns,
})) {
...
}
}
runForkedAgent() 不完全等同于 AgentTool 的 fork subagent,但它展示了同一类设计:在不破坏父会话的前提下,临时 fork 一份上下文跑模型任务。
Phase 8 的 compact summary 也会借这个思路:fork 一个只负责总结的 agent,复用主会话上下文和 prompt cache。
7src/tools/AgentTool/forkSubagent.ts:fork 不是普通 subagent
feature gate:fork 只在合适模式开启
源码位置:src/tools/AgentTool/forkSubagent.ts:18-39
export function isForkSubagentEnabled(): boolean {
if (!feature('FORK_SUBAGENT')) return false
if (isCoordinatorMode()) return false
if (isNonInteractiveSession()) return false
return true
}
fork subagent 是一个实验/受控能力。它不会在 coordinator mode 或 non-interactive session 中开启。
原因可以从后面行为推出来:fork 继承父上下文、用父工具、走后台任务通知模型,这套交互更适合交互式主会话。
FORK_AGENT:合成出来的内置 agent
源码位置:src/tools/AgentTool/forkSubagent.ts:44-71
export const FORK_AGENT: BuiltInAgentDefinition = {
agentType: 'fork',
whenToUse: 'When you need to split the current task into parallel workstreams...',
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble',
source: { type: 'built-in' },
getSystemPrompt: () => {
throw new Error('FORK_AGENT.getSystemPrompt should not be called...')
},
}
FORK_AGENT 看起来像一个 built-in agent,但它有一个非常特殊的点:getSystemPrompt() 不应该被调用。
普通 agent 的 system prompt 来自自己的定义;fork agent 的 system prompt 来自父 agent 已渲染 system prompt。这是为了保证:
父 agent prompt prefix
fork child prompt prefix
尽可能 byte-identical
这也是为什么 fork agent 的 tools: ['*']、model: 'inherit'、permissionMode: 'bubble' 都是围绕“像父 agent 的分身”设计的。
buildForkedMessages:复制父 assistant message + 填补 tool_result
源码位置:src/tools/AgentTool/forkSubagent.ts:91-169
export function buildForkedMessages(
directive: string,
assistantMessage: AssistantMessage,
): Message[] {
const assistantContent = Array.isArray(assistantMessage.message.content)
? assistantMessage.message.content
: [{ type: 'text', text: assistantMessage.message.content }]
const fullAssistantMessage = createAssistantMessage(assistantContent)
const toolUseBlocks = assistantContent.filter(block => block.type === 'tool_use')
const toolResults = toolUseBlocks.map(block => ({
type: 'tool_result',
tool_use_id: block.id,
content: buildChildMessage(directive),
}))
const toolResultMessage = createUserMessage({ content: toolResults })
return [fullAssistantMessage, toolResultMessage]
}
这段非常有意思。fork 子代理不是简单创建一条 user prompt,而是:
1. 复制父模型刚刚产生的完整 assistant message。 2. 找出里面所有 tool_use block。 3. 为每个 tool_use 构造一个对应 tool_result。 4. 这个 tool_result 的内容不是工具真实结果,而是 fork child directive。
为什么要这么做?
因为 Anthropic API 要求 assistant 的 tool_use 后必须有 user 的 tool_result。父 assistant message 里可能同时发出了多个 Agent tool_use。如果每个 fork child 都拿同一个 assistant prefix,那么它们必须各自补齐自己视角下的 tool_result。
可以这样记:
父 assistant:
tool_use Agent A
tool_use Agent B
tool_use Agent C
fork child A 输入:
同一条 assistant message
+ 对每个 tool_use 填一个 tool_result
+ 当前 child 的 directive 放在 tool_result 文本里
这样多个 fork child 的前缀尽量一致,只有 directive 部分不同,有利于 prompt cache。
buildChildMessage:fork worker 的行为约束
源码位置:src/tools/AgentTool/forkSubagent.ts:171-198
function buildChildMessage(directive: string): string {
return `<fork-subagent-instructions>
You are a forked subagent, derived from the parent agent's current state...
CRITICAL: You are already a forked worker. Do NOT spawn additional subagents...
...
Begin your response with "Scope:" followed by a concise description...
</fork-subagent-instructions>
<assigned-task>
${directive}
</assigned-task>`
}
fork worker 会收到一组强约束:
- 你已经是 forked worker,不要再 spawn subagents。
- 直接用工具完成自己的 assigned task。
- 如果改了文件,要在汇报前 commit。
- 不要在工具调用之间输出普通文本。
- 最终汇报 500 字以内,并以
Scope:开头。
这和普通 subagent 的定位不同。普通 subagent 是“专门角色”,fork worker 是“当前任务的并行分支”。
8src/tools/AgentTool/agentToolUtils.ts:工具过滤、结果收束与后台生命周期
filterToolsForAgent:不是所有工具都能给子代理
源码位置:src/tools/AgentTool/agentToolUtils.ts:70-116
export function filterToolsForAgent(
tools: Tool[],
isAsync: boolean,
agentDefinition?: AgentDefinition,
): Tool[] {
return tools.filter(tool => {
if (isMcpTool(tool.name)) return true
if (tool.name === EXIT_PLAN_MODE_TOOL_NAME && isPlanMode()) return true
if (DISALLOWED_AGENT_TOOLS.has(tool.name)) return false
if (agentDefinition?.source.type === 'custom' && DISALLOWED_CUSTOM_AGENT_TOOLS.has(tool.name)) return false
if (isAsync && !getAsyncAgentSafeTools().has(tool.name)) return false
return true
})
}
子代理不能随便拿所有工具:
- MCP 工具允许。
- plan mode 下允许 ExitPlanMode。
- 有些工具所有 agent 都不能用。
- custom agent 还有额外禁用工具。
- async agent 只能拿 async-safe tools。
后台 agent 的工具限制尤其重要,因为它不能像前台一样随时和用户交互确认。
resolveAgentTools:frontmatter tools/disallowedTools 决定工具池
源码位置:src/tools/AgentTool/agentToolUtils.ts:122-225
export function resolveAgentTools(
agentDefinition: AgentDefinition,
availableTools: Tool[],
isAsync: boolean,
): ResolvedAgentTools {
const filteredAvailableTools = filterToolsForAgent(availableTools, isAsync, agentDefinition)
let tools = agentDefinition.tools ?? ['*']
...
if (tools.includes('*')) {
return { resolvedTools: filteredAvailableTools, allowedAgentTypes }
}
const resolvedTools = tools
.map(toolName => filteredAvailableTools.find(tool => tool.name === toolName))
.filter((tool): tool is Tool => tool !== undefined)
return { resolvedTools, allowedAgentTypes }
}
AgentDefinition.tools 支持几种语义:
- 未声明:默认
['*']。 '*':拿过滤后的所有可用工具。- 显式工具名:只拿这些工具。
disallowedTools:从工具列表里再减掉一部分。Agent(worker, reviewer)这种 spec:限制该 agent 可再调用哪些 agent 类型。
这说明 AgentTool 既控制“子代理能用哪些普通工具”,也控制“子代理能不能再开哪些下级 agent”。
finalizeAgentTool:从子代理 transcript 里提取最终结果
源码位置:src/tools/AgentTool/agentToolUtils.ts:276-357
export function finalizeAgentTool(
agentMessages: Message[],
agentId: string,
metadata: AgentToolMetadata,
): AgentToolResult {
const lastAssistantMessage = agentMessages.findLast(_ => _.type === 'assistant') as AssistantMessage | undefined
let content = lastAssistantMessage?.message.content ?? []
if (Array.isArray(content) && content.every(block => block.type !== 'text')) {
const mostRecentTextAssistant = agentMessages.findLast(
m => m.type === 'assistant' && Array.isArray(m.message.content) &&
m.message.content.some(block => block.type === 'text'),
)
content = mostRecentTextAssistant?.message.content ?? content
}
...
return {
agentId,
agentType: metadata.agentType,
content,
totalToolUseCount,
totalDurationMs,
totalTokens,
usage,
}
}
子代理完成后,不是把整个 transcript 都回给父代理。finalizeAgentTool() 主要取最后一条 assistant message 作为结果。
有一个细节:如果最后一条 assistant 只有 tool_use,没有文本,源码会回退到最近一条含 text 的 assistant。这样可以避免子代理最后停在纯工具调用状态时,父代理拿不到可读总结。
classifyHandoffIfNeeded:自动权限模式下的安全提醒
源码位置:src/tools/AgentTool/agentToolUtils.ts:389-481
export async function classifyHandoffIfNeeded({
agentMessages,
tools,
toolPermissionContext,
abortSignal,
subagentType,
totalToolUseCount,
}: ...): Promise<string | null> {
if (toolPermissionContext.mode !== 'auto') return null
if (totalToolUseCount === 0) return null
...
const result = await classifySubagentHandoff(transcript, tools, abortSignal)
if (result.decision === 'needs_user_review') {
return `<subagent-handoff-warning>...</subagent-handoff-warning>`
}
}
在 auto permission mode 下,子代理的产物可能涉及安全 handoff:父代理是否应该直接信任子代理输出,还是先提醒用户复核?
源码会把子代理 transcript 交给分类器。如果分类器认为需要用户 review,就在子代理结果前插入 warning。
这是 AgentTool 和权限/安全系统的交叉点:子代理不是天然可信,尤其当它用了工具、做了文件操作、产生了建议交接给主代理时。
runAsyncAgentLifecycle:后台 agent 的完整生命周期
源码位置:src/tools/AgentTool/agentToolUtils.ts:508-685
export async function runAsyncAgentLifecycle({
taskId,
abortController,
makeStream,
metadata,
description,
toolUseContext,
rootSetAppState,
agentIdForCleanup,
enableSummarization,
getWorktreeResult,
}: ...): Promise<void> {
const agentMessages: Message[] = []
const tracker = createProgressTracker()
try {
for await (const msg of makeStream(onCacheSafeParams)) {
agentMessages.push(msg)
updateProgressFromMessage(tracker, msg, resolveActivity, toolUseContext.options.tools)
updateAsyncAgentProgress(taskId, getProgressUpdate(tracker), rootSetAppState)
}
const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
completeAsyncAgent(agentResult, rootSetAppState)
...
enqueueAgentNotification({ taskId, status: 'completed', finalMessage, usage, ... })
} catch (error) {
if (error instanceof AbortError) {
killAsyncAgent(taskId, rootSetAppState)
enqueueAgentNotification({ taskId, status: 'killed', finalMessage: partialResult, ... })
return
}
failAsyncAgent(taskId, errMsg, rootSetAppState)
enqueueAgentNotification({ taskId, status: 'failed', error: errMsg, ... })
} finally {
stopSummarization?.()
clearInvokedSkillsForAgent(agentIdForCleanup)
clearDumpState(agentIdForCleanup)
}
}
后台生命周期可以分成四段:
1. 运行子代理 stream,收集 messages。 2. 更新 task progress 和 SDK progress。 3. 完成时 finalize + complete task + enqueue notification。 4. 异常/取消时 fail/kill task + enqueue notification。
后台 agent 完成后不是直接修改父 loop 的 messages,而是通过 enqueueAgentNotification() 把完成通知排进 pending notification。下一次父 loop drain 时,它会像用户消息一样进入模型上下文。
9src/tasks/LocalAgentTask/LocalAgentTask.tsx:后台子代理作为任务存在
LocalAgentTaskState:后台 agent 的状态结构
源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:116-148
export type LocalAgentTaskState = {
type: 'local_agent'
agentId: string
description: string
prompt: string
selectedAgent: AgentDefinition
agentType?: string
model?: string
abortController: AbortController | null
status: TaskStatus
result: AgentToolResult | null
error?: string
progress: AgentProgress
messages: Message[]
pendingMessages: string[]
isBackgrounded: boolean
retainOutputUntil?: number
diskLoaded?: boolean
evictAfter?: number
}
后台 agent 在 AppState 里是一个 task。它保存:
- agent 身份和描述。
- 原始 prompt。
- 选中的 AgentDefinition。
- abortController。
- result/error/progress。
- messages。
- pendingMessages。
- output retention 信息。
这使后台 agent 可以被 UI 展示、被 kill、被 SendMessage 追加消息、被 resume。
pendingMessages:SendMessage 给运行中 agent 的消息先排队
源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:162-192
export function queuePendingMessage(agentId: string, msg: string, setAppState: SetAppState) {
setAppState(prev => {
const task = prev.tasks[agentId]
if (!isLocalAgentTask(task)) return prev
return updateTask(prev, agentId, {
pendingMessages: [...task.pendingMessages, msg],
})
})
}
export function drainPendingMessages(agentId: string, setAppState: SetAppState): string[] {
const task = getTask(agentId)
if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) return []
const drained = task.pendingMessages
setAppState(prev => updateTask(prev, agentId, { pendingMessages: [] }))
return drained
}
运行中的后台 agent 不会立刻中断当前工具调用来读用户新消息。SendMessage 会把消息放入 pendingMessages,等 agent 下一轮工具边界/合适位置 drain。
这和主 Agent Loop 的“下一轮模型调用才看到新上下文”很一致。
enqueueAgentNotification:后台结果如何回到主模型
源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:197-262
export function enqueueAgentNotification({
taskId,
description,
status,
finalMessage,
error,
usage,
outputFile,
worktreePath,
worktreeBranch,
toolUseId,
setAppState,
}: ...) {
const message = `<task-notification>
<task-id>${taskId}</task-id>
<status>${status}</status>
<summary>${description}</summary>
<output-file>${outputFile}</output-file>
...
</task-notification>`
enqueuePendingNotification({ value: message, mode: 'task-notification' })
}
后台 agent 完成后,会生成一个 XML-like 的 <task-notification> 文本。里面包括:
- task id
- status
- summary
- output file
- final result 或 error
- usage
- worktree 信息
这条 notification 后续会作为 pending notification 进入父会话。也就是说,后台 agent 的最终结果仍然通过 message 系统回到 Agent Loop,而不是绕开模型直接展示给用户。
registerAsyncAgent / registerAgentForeground
源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:466-614
export function registerAsyncAgent({ agentId, description, prompt, selectedAgent, ... }) {
initializeTaskOutput(agentId)
const abortController = new AbortController()
const task: LocalAgentTaskState = {
type: 'local_agent',
agentId,
description,
prompt,
selectedAgent,
abortController,
status: 'running',
result: null,
progress: initialProgress(),
messages: [],
pendingMessages: [],
isBackgrounded: true,
}
...
}
export function registerAgentForeground(...) {
const backgroundSignal = new Promise<void>(resolve => ...)
const task: LocalAgentTaskState = {
...
isBackgrounded: false,
}
...
return { taskId, backgroundSignal, cancelAutoBackground }
}
registerAsyncAgent() 用于一开始就后台运行的 agent。
registerAgentForeground() 用于同步 agent,但它仍然注册 task,并提供 backgroundSignal。这就是前面同步路径可以中途转后台的基础。
10src/tools/SendMessageTool 与 resumeAgent:后台子代理可以继续对话
SendMessageTool:运行中 agent 排队,停止 agent 自动 resume
源码位置:src/tools/SendMessageTool/SendMessageTool.ts:800-873
if (typeof input.message === 'string' && input.to !== '*') {
const appState = context.getAppState()
const registered = appState.agentNameRegistry.get(input.to)
const agentId = registered ?? toAgentId(input.to)
if (agentId) {
const task = appState.tasks[agentId]
if (isLocalAgentTask(task) && !isMainSessionTask(task)) {
if (task.status === 'running') {
queuePendingMessage(agentId, input.message, context.setAppStateForTasks ?? context.setAppState)
return { data: { success: true, message: `Message queued...` } }
}
const result = await resumeAgentBackground({ agentId, prompt: input.message, ... })
return { data: { success: true, message: `Agent was stopped; resumed...` } }
} else {
const result = await resumeAgentBackground({ agentId, prompt: input.message, ... })
return { data: { success: true, message: `resumed from transcript...` } }
}
}
}
SendMessage 是后台 agent 的继续通信入口。
三种情况:
1. agent 正在运行:把消息排进 pendingMessages。 2. task 还在 AppState,但已停止/完成/失败:调用 resumeAgentBackground() 继续它。 3. task 已从 AppState 清掉,但有 transcript:从磁盘 transcript resume。
这解释了 AgentTool 返回值里为什么要提示:
agentId: ... (use SendMessage with to: '...' to continue this agent)
resumeAgentBackground:从 sidechain transcript 恢复后台 agent
源码位置:src/tools/AgentTool/resumeAgent.ts:42-260
export async function resumeAgentBackground({ agentId, prompt, toolUseContext, canUseTool, invokingRequestId }) {
const [transcript, meta] = await Promise.all([
getAgentTranscript(asAgentId(agentId)),
readAgentMetadata(asAgentId(agentId)),
])
if (!transcript) throw new Error(`No transcript found for agent ID: ${agentId}`)
const resumedMessages = filterWhitespaceOnlyAssistantMessages(
filterOrphanedThinkingOnlyMessages(
filterUnresolvedToolUses(transcript.messages),
),
)
const resumedReplacementState = reconstructForSubagentResume(...)
let selectedAgent: AgentDefinition
let isResumedFork = false
if (meta?.agentType === FORK_AGENT.agentType) {
selectedAgent = FORK_AGENT
isResumedFork = true
} else if (meta?.agentType) {
selectedAgent = found ?? GENERAL_PURPOSE_AGENT
} else {
selectedAgent = GENERAL_PURPOSE_AGENT
}
const runAgentParams = {
agentDefinition: selectedAgent,
promptMessages: [...resumedMessages, createUserMessage({ content: prompt })],
isAsync: true,
...
}
const agentBackgroundTask = registerAsyncAgent({ agentId, ... })
void runWithAgentContext(asyncAgentContext, () =>
runAsyncAgentLifecycle({ makeStream: onCacheSafeParams => runAgent(...), ... })
)
}
resume 的关键点:
- 读取 sidechain transcript。
- 过滤掉 unresolved tool_use / orphaned thinking / whitespace assistant message,避免 API 请求不合法。
- 根据 metadata 找回原 agentType。
- fork agent resume 时要重建父 system prompt,并且
useExactTools: true。 - 把旧 transcript messages + 新 user prompt 拼成新的 promptMessages。
- 注册新的 async task,继续跑后台生命周期。
这说明子代理不是一次性 disposable。只要 transcript 还在,它就可以被继续唤醒。
11src/tools/AgentTool/loadAgentsDir.ts:AgentDefinition 从哪里来
AgentDefinition 的主要字段
源码位置:src/tools/AgentTool/loadAgentsDir.ts:73-165
const AgentJsonSchema = () => z.object({
description: z.string(),
tools: z.union([z.string(), z.array(z.string())]).optional(),
disallowedTools: z.union([z.string(), z.array(z.string())]).optional(),
prompt: z.string(),
model: z.string().optional(),
effort: z.union([z.string(), z.number()]).optional(),
permissionMode: z.enum(PERMISSION_MODES).optional(),
mcpServers: z.array(AgentMcpServerSpecSchema()).optional(),
hooks: HooksSchema().optional(),
maxTurns: z.number().positive().int().optional(),
skills: z.union([z.string(), z.array(z.string())]).optional(),
initialPrompt: z.string().optional(),
memory: z.enum(['user', 'project', 'local']).optional(),
background: z.boolean().optional(),
isolation: z.enum(['worktree', 'remote']).optional(),
})
一个 agent 定义不只是名字和 prompt。它可以声明:
- 什么时候使用:
description/whenToUse - 能用哪些工具:
tools/disallowedTools - 系统提示词:
prompt或 markdown body - 模型和 effort
- 权限模式
- MCP servers
- hooks
- maxTurns
- skills
- memory
- background
- isolation
这就是为什么 AgentTool 可以作为“角色化工作单元”而不是单纯 prompt 模板。
active agents 的覆盖顺序
源码位置:src/tools/AgentTool/loadAgentsDir.ts:193-221
export function getActiveAgentsFromList(allAgentsList: AgentDefinition[]): AgentDefinition[] {
const agentsMap = new Map<string, AgentDefinition>()
const orderedSources = [
'built-in',
'plugin',
'userSettings',
'projectSettings',
'flagSettings',
'policySettings',
] as const
for (const sourceType of orderedSources) {
const agentsForSource = allAgentsList.filter(agent => getSourceType(agent.source) === sourceType)
for (const agent of agentsForSource) {
agentsMap.set(agent.agentType, agent)
}
}
return Array.from(agentsMap.values())
}
同名 agent 可以被后面的来源覆盖。顺序是:
built-in
plugin
userSettings
projectSettings
flagSettings
policySettings
因为 Map 后写覆盖先写,所以 policySettings 优先级最高。
从 markdown 和 plugin 加载 agent
源码位置:src/tools/AgentTool/loadAgentsDir.ts:296-378
const markdownFiles = await loadMarkdownFilesForSubdir('agents', cwd)
const customAgents = markdownFiles
.map(({ filePath, baseDir, frontmatter, content, source }) =>
parseAgentFromMarkdown(filePath, baseDir, frontmatter, content, source)
)
.filter(agent => agent !== null)
let pluginAgentsPromise = loadPluginAgents()
...
const pluginAgents = await pluginAgentsPromise
const builtInAgents = getBuiltInAgents()
const allAgentsList: AgentDefinition[] = [
...builtInAgents,
...pluginAgents,
...customAgents,
]
const activeAgents = getActiveAgentsFromList(allAgentsList)
AgentDefinition 来源主要有三类:
1. built-in agents。 2. plugin agents。 3. agents/ 目录下 markdown 定义的 custom agents。
加载失败时会记录 failedFiles,但整体回退到 built-in agents,避免一个坏 agent 文件把整个 AgentTool 搞挂。
parseAgentFromMarkdown:frontmatter + markdown body
源码位置:src/tools/AgentTool/loadAgentsDir.ts:541-748
export function parseAgentFromMarkdown(filePath, baseDir, frontmatter, content, source) {
const agentType = frontmatter['name']
let whenToUse = frontmatter['description'] as string
if (!agentType || typeof agentType !== 'string') return null
if (!whenToUse || typeof whenToUse !== 'string') return null
const modelRaw = frontmatter['model']
const backgroundRaw = frontmatter['background']
const memoryRaw = frontmatter['memory']
const isolationRaw = frontmatter['isolation']
const parsedEffort = parseEffortValue(frontmatter['effort'])
const permissionModeRaw = frontmatter['permissionMode']
const maxTurns = parsePositiveIntFromFrontmatter(frontmatter['maxTurns'])
let tools = parseAgentToolsFromFrontmatter(frontmatter['tools'])
const disallowedTools = parseAgentToolsFromFrontmatter(frontmatter['disallowedTools'])
const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills'])
const systemPrompt = content.trim()
const agentDef: CustomAgentDefinition = {
baseDir,
agentType,
whenToUse,
tools,
disallowedTools,
skills,
getSystemPrompt: () => systemPrompt,
source,
filename,
model,
permissionMode,
maxTurns,
background,
memory,
isolation,
}
return agentDef
}
markdown agent 的结构很自然:
---
name: reviewer
description: Use when you need code review
tools: Read,Grep,Glob
model: sonnet
background: true
---
你是一个代码审查 agent...
frontmatter 决定 agent 的配置,正文决定 system prompt。
12src/tools/AgentTool/prompt.ts:如何教模型正确使用 AgentTool
agent 列表不能随便塞进 tool description
源码位置:src/tools/AgentTool/prompt.ts:43-64
export function formatAgentDefinition(agent: AgentDefinition): string {
return `- ${agent.agentType}: ${agent.whenToUse}`
}
export function shouldInjectAgentListInMessages(): boolean {
return getUseDynamicToolDescriptions() && !isForkSubagentEnabled()
}
源码里专门考虑了 prompt cache:如果动态 agent 列表直接放进 tool description,任何 agent 列表变化都可能让工具描述变化,影响缓存。某些模式下会把 agent list 放到 message attachments,而不是动态改 tool description。
getPrompt:普通 subagent 和 fork subagent 的使用说明不同
源码位置:src/tools/AgentTool/prompt.ts:66-286
export function getPrompt(
agents: AgentDefinition[],
isForkEnabled: boolean,
permissionMode: PermissionMode,
allowedAgentTypes?: Set<string>,
): string {
const filteredAgents = allowedAgentTypes
? agents.filter(agent => allowedAgentTypes.has(agent.agentType))
: agents
...
return `
Launch a new agent that has access to the following tools...
${isForkEnabled ? `Omitting subagent_type creates a fork...` : `When using the Agent tool, you must specify a subagent_type...`}
When to use the Agent tool:
- If you are searching for a keyword like "config"...
- If you need to process many files...
- If you need independent investigation...
When NOT to use the Agent tool:
- If you want to read a specific file path...
- If you are searching for a specific class definition...
...
`
}
getPrompt() 是给模型看的使用手册。它主要教几件事:
1. 什么时候应该开 subagent:开放式搜索、多文件调查、独立并行任务。 2. 什么时候不该开:已经知道具体文件、只读一个路径、查一个确定符号。 3. 怎么写 prompt:fresh subagent 没有父上下文,要给足任务说明。 4. fork 模式怎么用:省略 subagent_type 是 fork;普通 subagent 要显式写类型。 5. 后台 agent 怎么处理:结果对用户不可见,完成后通过 notification,必要时 SendMessage 继续。 6. 隔离模式怎么处理:worktree/remote 的行为提示。
这段 prompt 很适合面试讲“工具设计不只是代码接口,还要教模型什么时候调用”。
13几条执行路径对比
| 路径 | 触发条件 | 上下文 | 工具池 | 返回给父模型 |
|---|---|---|---|---|
| 同步普通 subagent | Agent(subagent_type=..., prompt=...) 且不后台 | fresh user prompt | agent tools 过滤后 | completed tool_result |
| 后台 subagent | run_in_background / agent background / forceAsync | fresh user prompt | async-safe agent tools | async_launched,完成后 task-notification |
| fork subagent | fork 开启且省略 subagent_type | 父 messages + fork directive | 父 tools,useExactTools | 通常 async_launched |
| worktree subagent | isolation: "worktree" 或 agent 默认 worktree | cwd 切到新 worktree | agent tools | completed/notification 带 worktreePath |
| teammate | team_name + name | team 协议上下文 | teammate 后端决定 | teammate_spawned |
| remote agent | isolation: "remote" 且内部环境支持 | remote CCR session | remote 环境 | remote_launched |
| resume agent | SendMessage(to=agentId/name) 且 task 停止或 evicted | sidechain transcript + 新 prompt | 原 agent/fork 工具规则 | async_launched 风格通知 |
14面试回答模板
如果问:AgentTool 是什么?
AgentTool 是 Claude Code 用来启动子代理的工具。父模型只是发出 Agent tool_use;
本地 runtime 在 AgentTool.call() 中选择 AgentDefinition,创建子代理 ToolUseContext,
筛选工具池和权限模式,然后通过 runAgent() 再启动一次 query()。
所以 subagent 本质上是“嵌套的 agent loop”,最后结果仍通过 tool_result 回到父 loop。
如果问:普通 subagent 和 fork subagent 有什么区别?
普通 subagent 是 fresh worker:使用自己的 agent system prompt、自己的工具过滤规则、
自己的初始 user prompt。
fork subagent 是当前上下文的分支:复用父 system prompt、父 messages、父工具列表,
通过 buildForkedMessages() 构造符合 tool_use/tool_result 配对规则的输入。
它的目标是并行探索当前任务,同时保持 prompt cache prefix 尽可能一致。
如果问:后台 subagent 怎么把结果交回主会话?
后台 subagent 会先 registerAsyncAgent 成 LocalAgentTask,AgentTool 立即返回 async_launched。
真正运行由 runAsyncAgentLifecycle 管理。完成、失败或被 kill 后,
它调用 enqueueAgentNotification() 生成 <task-notification>,
后续作为 pending notification 注入父会话,让主模型继续处理结果。
如果问:子代理权限如何控制?
AgentTool 不是直接继承父工具池。它会构造 workerPermissionContext,
runAgent 里再根据 agentDefinition.permissionMode、父权限模式、allowedTools、
async 是否可弹权限 UI 等规则生成 agentGetAppState。
工具池还会经过 filterToolsForAgent / resolveAgentTools,后台 agent 只能拿 async-safe tools。
如果问:为什么需要 sidechain transcript?
子代理尤其是后台 agent 可能运行很久,也可能完成后被 SendMessage 继续。
runAgent 会把子代理消息写入 sidechain transcript;
resumeAgentBackground 读取 transcript,过滤不合法 tool_use/thinking message,
再追加新的 user prompt,重新注册 async task 继续运行。
15Phase 7 总结:真正要记住什么
AgentTool 机制可以抽象成五层:
第一层:Tool 层
AgentTool 是普通工具,父模型只产生 tool_use。
第二层:路由层
AgentTool.call 选择普通 subagent / fork / teammate / remote / worktree。
第三层:上下文层
runAgent + createSubagentContext 创建子代理独立 ToolUseContext。
第四层:执行层
子代理通过 query() 跑自己的 agent loop。
第五层:回填层
同步结果变成 Agent tool_result;
后台结果变成 LocalAgentTask + task-notification;
SendMessage 可以继续或 resume 子代理。
最重要的一句话:
Claude Code 的 subagent 不是一个特殊模型调用,而是把完整 Agent Loop 封装进 AgentTool:
父 loop 调工具,工具内部再跑子 loop,子 loop 的结果最终仍回到父 loop 的消息协议里。