Phase 7:AgentTool 与 Subagent 机制

面试前先建立整体心智模型

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 等工程约束。


Phase 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

和 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”。


src/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 一样的基本结构:

  • name
  • description
  • prompt
  • inputSchema
  • outputSchema
  • call
  • checkPermissions
  • mapToolResultToToolResultBlockParam

所以 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:父模型这次发出 Agent tool_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。

src/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:forkContextMessagesuseExactTools
  • 覆盖项:overridemodelallowedTools
  • 持久化:worktreePathtranscriptSubdir
  • 进度/summary:onCacheSafeParamsonQueryProgress

这就是子代理启动的“完整参数包”。

初始 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_usetool_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

子代理启动时可以触发自己的扩展点:

  • SubagentStart hooks 可追加上下文。
  • 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,还包含完整生命周期管理。


src/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。


src/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 是“当前任务的并行分支”。


src/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 时,它会像用户消息一样进入模型上下文。


src/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。这就是前面同步路径可以中途转后台的基础。


src/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 还在,它就可以被继续唤醒。


src/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。


src/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 很适合面试讲“工具设计不只是代码接口,还要教模型什么时候调用”。


几条执行路径对比

路径 触发条件 上下文 工具池 返回给父模型
同步普通 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 风格通知

面试回答模板

如果问: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 继续运行。

Phase 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 的消息协议里。

更多推荐