Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Claude Code Harness/第12章

第12章:Agent 调度 —— fork 与 fresh 两条路

17 分钟 · 更新于 2026-09-01

第12章:Agent 调度 —— fork 与 fresh 两条路

src/tools/AgentTool/ 有 20 个文件、6782 行。本章讲清一条链:AgentTool.call()runAgent()query(),以及它上面那个为 prompt cache 专门优化出来的 fork 路径。核心判断:fork 不是"再开一个普通 agent",它是一条独立的执行路径,存在的唯一理由是共享缓存。


一、三层分工

text
AgentTool.call()      调度总控:选 agent、分流模式、前置校验     1397 行
      ↓
runAgent()            子 agent 的上下文构造与生命周期管理        973 行
      ↓
query()               真正的模型循环(第 3 章)                 1729 行

分层很干净AgentTool 决定"派谁去、怎么派",runAgent 准备"它带着什么上路",query 才是真正跑。

1.1 工具的输入 schema

ts
const baseInputSchema = z.object({
  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 for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent."),
  run_in_background: z.boolean().optional(),
})

// 多 agent + 隔离参数
const multiAgentInputSchema = z.object({
  name: …, team_name: …, mode: …,
  isolation: z.enum(['worktree'])   // 内部版还有 'remote'
    .optional().describe('Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'),
  cwd: z.string().optional().describe('Absolute path to run the agent in. … Mutually exclusive with isolation: "worktree".'),
})

[源码 src/tools/AgentTool/AgentTool.tsx:82-100]

九个参数:任务描述、提示词、agent 类型、模型、后台、名字、团队、模式、隔离、工作目录。

一个意外的取证:这份构建的 USER_TYPE 是什么 AgentTool.tsx 里能看到这样的代码:

ts
isolation: ("external" === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional()
…
if ("external" === 'ant' && effectiveIsolation === 'remote') { … }

[源码 src/tools/AgentTool/AgentTool.tsx:99, 434]

process.env.USER_TYPE 被构建期 --define 替换成了字面量 "external",但这个 .tsx 文件的分支没有被进一步折叠掉(.ts 文件里的大多被折叠了)。

这是第 2 章那条"一份源码两个产物"的直接铁证,也再次确认:本教程读的是外部版


二、AgentTool.call() 的分流

call() 前 200 行全是分流和校验。按顺序:

2.1 五道前置校验

ts
// 1. 没有 Agent Teams 权限却传了 team_name
if (team_name && !isAgentSwarmsEnabled()) throw new Error('Agent Teams is not yet available on your plan.')

// 2. teammate 不能再生 teammate
if (isTeammate() && teamName && name) throw new Error(
  'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.')

// 3. 进程内 teammate 不能开后台 agent
if (isInProcessTeammate() && teamName && run_in_background === true) throw new Error(
  'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.')

[源码 src/tools/AgentTool/AgentTool.tsx:262-280]

第 2 条的注释解释了为什么是结构性限制:

TeamFile.members is a flat array with one leadAgentId — nested teammates land in the roster with no provenance and confuse the lead.

团队名册是扁平数组只有一个 leader,嵌套的 teammate 进去之后没有来源信息。 这不是策略选择,是数据结构决定的。

第 3 条的理由是生命周期:进程内 teammate 的生命周期绑在 leader 进程上,它开的后台任务没人管。而 tmux teammate 是独立进程,可以自己管。

三条错误消息都告诉了用户替代方案("省掉 name 参数就是普通 subagent"、"用 run_in_background=false")。又是第 9 章那条"错误消息要回答该怎么办"。

2.2 四条分流路径

text
teamName && name        → spawnTeammate()      多 agent 团队成员
isolation === 'remote'  → teleportToRemote()   远程 CCR 环境 [内部]
isolation === 'worktree'→ 建 git worktree 再跑
默认                     → runAgent()

runAgent() 内部又分前台同步后台异步两种生命周期。

2.3 fork vs fresh 的分流点

ts
// Fork subagent experiment routing:
// - subagent_type set: use it (explicit wins)
// - subagent_type omitted, gate on: fork path (undefined)
// - subagent_type omitted, gate off: default general-purpose
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)
const isForkPath = effectiveType === undefined

[源码 src/tools/AgentTool/AgentTool.tsx:318]

一个可选参数的缺省,在两种模式下含义完全相反

subagent_typefork 开关关fork 开关开
给了用那个 agent用那个 agent(fresh,零上下文)
省略general-purpose agentfork 自己(继承全部上下文)

这就是第 5 章那两段截然不同的 getAgentToolSection() 的来源。

2.4 递归 fork 守卫:双保险

ts
// Recursive fork guard: fork children keep the Agent tool in their
// pool for cache-identical tool defs, so reject fork attempts at call
// time. Primary check is querySource (compaction-resistant — set on
// context.options at spawn time, survives autocompact's message
// rewrite). Message-scan fallback catches any path where querySource
// wasn't threaded.
if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}`
    || isInForkChild(toolUseContext.messages)) {
  throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.')
}

[源码 src/tools/AgentTool/AgentTool.tsx:332]

为什么 fork 子进程手里还有 Agent 工具? 因为工具集必须和父进程逐字节一致才能命中缓存——不能为了防递归就把 Agent 工具拿掉。所以只能在调用时拦。

两道防线

  1. querySource === 'agent:builtin:fork' —— 主检查,抗压缩(它在 context.options 上,autocompact 重写消息数组不会动它)
  2. isInForkChild(messages) —— 兜底,扫消息里有没有 fork 的样板标签

第二道的实现 [源码 src/tools/AgentTool/forkSubagent.ts:75]:

ts
export function isInForkChild(messages: MessageType[]): boolean {
  return messages.some(m =>
    m.type === 'user' && Array.isArray(m.message.content) &&
    m.message.content.some(b => b.type === 'text' && b.text.includes(`<${FORK_BOILERPLATE_TAG}>`)))
}

注释明确说了主检查抗压缩、兜底检查不抗压缩——压缩会把那段样板文字吃掉。所以两道防线的作用域不同,缺一不可。

可迁移的判断 ㉕ 当一个状态需要跨越"数据会被重写"的边界时,把它放在配置对象上而不是数据流里;数据流里的那份只当兜底。

Claude Code 这里的分工非常明确:querySourceoptions 上(不被压缩碰),消息里的标签在历史里(会被压缩吃掉)。两个都留着,并在注释里写清各自的失效条件。

2.5 MCP 依赖的等待与失败

某些 agent 声明了 requiredMcpServers。因为 MCP 是异步连接的,调用时可能还没连上:

ts
if (hasPendingRequiredServers) {
  const MAX_WAIT_MS = 30_000
  const POLL_INTERVAL_MS = 500
  const deadline = Date.now() + MAX_WAIT_MS
  while (Date.now() < deadline) {
    await sleep(POLL_INTERVAL_MS)
    currentAppState = toolUseContext.getAppState()
    // Early exit: if any required server has already failed, no point
    // waiting for other pending servers
    if (hasFailedRequiredServer) break
    if (!stillPending) break
  }
}

[源码 src/tools/AgentTool/AgentTool.tsx:376]

最多等 30 秒,每 500ms 轮询一次,任何一个必需服务器失败就立刻放弃等待。

而"连上了"的判据不是连接状态,是有没有工具

ts
// Get servers that actually have tools (meaning they're connected AND authenticated)

一个连上但没认证的 MCP 服务器,工具列表是空的。 所以用"有没有工具"作判据比用"连接状态"准确。

失败时的错误消息又一次给了下一步:

text
Agent 'X' requires MCP servers matching: A, B.
MCP servers with tools: C, D.
Use /mcp to configure and authenticate the required MCP servers.

三、fork 路径:为缓存而生

3.1 合成的 agent 定义

ts
/**
 * Synthetic agent definition for the fork path.
 *
 * Not registered in builtInAgents — used only when `!subagent_type` and the
 * experiment is active. `tools: ['*']` with `useExactTools` means the fork
 * child receives the parent's exact tool pool (for cache-identical API
 * prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
 * parent terminal. `model: 'inherit'` keeps the parent's model for context
 * length parity.
 *
 * The getSystemPrompt here is unused: the fork path passes
 * `override.systemPrompt` with the parent's already-rendered system prompt
 * bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
 * by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and
 * bust the prompt cache; threading the rendered bytes is byte-exact.
 */
export const FORK_AGENT = {
  agentType: 'fork',
  whenToUse: 'Implicit fork — inherits full conversation context. Not selectable via subagent_type…',
  tools: ['*'],
  maxTurns: 200,
  model: 'inherit',
  permissionMode: 'bubble',
  source: 'built-in',
  baseDir: 'built-in',
  getSystemPrompt: () => '',
}

[源码 src/tools/AgentTool/forkSubagent.ts:41]

四个字段全是为缓存服务的

字段为什么
tools: ['*'] + useExactTools工具池和父进程逐字节一致
model: 'inherit'换模型就没法复用缓存
getSystemPrompt: () => ''不用它——直接把父进程已渲染的字节透传过来
permissionMode: 'bubble'权限弹窗冒泡到父终端(第 10 章)

最后那条注释最关键:

Reconstructing by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and bust the prompt cache; threading the rendered bytes is byte-exact.

重新调用 getSystemPrompt() 生成的结果可能和父进程不一致——因为中间 GrowthBook 的缓存可能从冷变热,某个 A/B 分组的取值变了。所以不重新生成,直接把父进程渲染好的字符串传过去

这是第 4 章那条"在缓存前缀面前,时效性让位于稳定性"的第三次出现。

3.2 逐字节相同的前缀

ts
/** Placeholder text used for all tool_result blocks in the fork prefix.
 * Must be identical across all fork children for prompt cache sharing. */
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'

/**
 * Build the forked conversation messages for the child agent.
 *
 * For prompt cache sharing, all fork children must produce byte-identical
 * API request prefixes. This function:
 * 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
 * 2. Builds a single user message with tool_results for every tool_use block
 *    using an identical placeholder, then appends a per-child directive text block
 *
 * Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
 * Only the final text block differs per child, maximizing cache hits.
 */
export function buildForkedMessages(directive: string, assistantMessage: AssistantMessage): MessageType[]

[源码 src/tools/AgentTool/forkSubagent.ts:88-105]

场景:主 agent 在一条消息里同时发起三个 fork。那条 assistant 消息里有三个 tool_use block,三个 fork 子进程各自继承这条消息,但每个子进程只知道自己的指令。

问题是:API 要求每个 tool_use 都有对应的 tool_result。三个子进程各自要给三个 tool_use 都填结果,但只有自己那一个有意义。

解法:三个全填同一个占位字符串。 于是三个子进程的请求前缀完全一致:

text
[……历史……]
[assistant: tool_use(fork1), tool_use(fork2), tool_use(fork3)]
[user: tool_result(fork1, "Fork started — processing in background"),
       tool_result(fork2, "Fork started — processing in background"),
       tool_result(fork3, "Fork started — processing in background"),
       text("<fork-boilerplate>…</fork-boilerplate>\n\n<你自己的指令>")]     ← 只有这一块不同

只有最后一个 text block 不同,前面全部共享缓存。

代码里还留了一个 TODO 说明这个形状不完美:

ts
// TODO(smoosh): this text sibling creates a [tool_result, text] pattern on the wire
// (renders as </function_results>\n\nHuman:<text>). One-off per-child construction,
// not a repeated teacher, so low-priority. If we ever care, use smooshIntoToolResult
// from src/utils/messages.ts to fold the directive into the last tool_result.content.

这正是第 6 章那个 smoosh 问题。 但这里判断它优先级低——因为它每个子进程只出现一次,不是反复出现的"教学样本"。同一个问题,在不同频次下的处理决定不同。

3.3 给 fork 子进程的十条军规

ts
export function buildChildMessage(directive: string): string {
  return `<${FORK_BOILERPLATE_TAG}>
STOP. READ THIS FIRST.

You are a forked worker process. You are NOT the main agent.

RULES (non-negotiable):
1. Your system prompt says "default to forking." IGNORE IT — that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
2. Do NOT converse, ask questions, or suggest next steps
3. Do NOT editorialize or add meta-commentary
4. USE your tools directly: Bash, Read, Write, etc.
5. If you modify files, commit your changes before reporting. Include the commit hash in your report.
6. Do NOT emit text between tool calls. Use tools silently, then report once at the end.
7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas.
8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise.
9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud.
10. REPORT structured facts, then stop

Output format (plain text labels, not markdown headers):
  Scope: <echo back your assigned scope in one sentence>
  Result: <the answer or key findings, limited to the scope above>
  Key files: <relevant file paths — include for research tasks>
  Files changed: <list with commit hash — include only if you modified files>
  Issues: <list — include only if there are issues to flag>
</${FORK_BOILERPLATE_TAG}>

${FORK_DIRECTIVE_PREFIX}${directive}`
}

[源码 src/tools/AgentTool/forkSubagent.ts:160]

第 1 条是这段文字存在的核心理由:fork 子进程继承了父进程的完整系统提示词,那份提示词里写着"默认应该 fork"。如果不显式覆盖,子进程会接着 fork——虽然有 §2.4 的守卫会拦住,但那是抛错误,浪费一轮。

所以这段文字的功能是在共享系统提示词的前提下,用消息里的指令覆盖它

第 6、7、9 条都是上下文预算:

  • 不要在工具调用之间输出文字(那些文字会进 fork 自己的上下文,也会进最终报告)
  • 严格待在自己的范围内,发现范围外的东西最多提一句——"其它 worker 覆盖那些区域"
  • 报告必须以 Scope: 开头,不要铺垫

第 5 条很实际:改了文件要先 commit 再报告,报告里带 commit hash。 因为多个 fork 可能同时改文件,不 commit 就说不清谁改了什么。

3.4 worktree 隔离时的额外提醒

ts
export function buildWorktreeNotice(parentCwd: string, worktreeCwd: string): string {
  return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.`
}

[源码 src/tools/AgentTool/forkSubagent.ts:196]

继承上下文 + 换工作目录 = 上下文里的路径全是错的。 所以要显式告诉子进程"翻译路径"、"编辑前重读文件"、"你的改动不影响父进程"。

这是 fork 这个设计的一个内在张力:继承上下文是它的价值,但上下文里的东西可能已经不适用了。


四、runAgent():子 agent 带什么上路

973 行,按顺序做的事 [源码 src/tools/AgentTool/runAgent.ts:248-700]:

text
① 解析模型、创建 agentId、注册 Perfetto 追踪
② 过滤父进程消息里未完成的工具调用(避免 API 报错)
③ 准备 userContext / systemContext
④ 只读 agent 瘦身:砍掉 claudeMd 和 gitStatus
⑤ 构造 agent 专属的权限上下文
⑥ 解析工具池(fork 用 useExactTools)
⑦ 拿系统提示词(fork 直接用父进程渲染好的字节)
⑧ 决定 abortController(异步 agent 用新的、同步 agent 共享父的)
⑨ 跑 SubagentStart 钩子,收集额外上下文
⑩ 注册 frontmatter 里声明的钩子
⑪ 预加载 frontmatter 里声明的技能
⑫ 初始化 agent 专属的 MCP 服务器
⑬ 合并 MCP 工具
⑭ 构造子 agent 的 ToolUseContext
⑮ 调 query()
⑯ 记录 sidechain transcript、写元数据
⑰ 清理:MCP、钩子、Perfetto、todo、bash 任务、文件状态缓存

挑三处讲。

4.1 只读 agent 的上下文瘦身

ts
// Read-only agents (Explore, Plan) don't act on commit/PR/lint rules from
// CLAUDE.md — the main agent has full context and interprets their output.
// Dropping claudeMd here saves ~5-15 Gtok/week across 34M+ Explore spawns.
// Explicit override.userContext from callers is preserved untouched.
// Kill-switch defaults true; flip tengu_slim_subagent_claudemd=false to revert.
const { claudeMd: _omittedClaudeMd, ...userContextNoClaudeMd } = baseUserContext
ts
// Explore/Plan are read-only search agents — the parent-session-start
// gitStatus (up to 40KB, explicitly labeled stale) is dead weight. If they
// need git info they run `git status` themselves and get fresh data.
// Saves ~1-3 Gtok/week fleet-wide.
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } = baseSystemContext

[源码 src/tools/AgentTool/runAgent.ts:385-406]

两个数字

  • 砍掉 CLAUDE.md:每周省 50–150 亿 token,基数是"每周 3400 万次以上的 Explore 调用"
  • 砍掉 gitStatus:每周省 10–30 亿 token,因为那玩意最大 40KB 而且明确标着"可能已过时"

每周 3400 万次 Explore 调用 —— 这个数字本身说明了为什么这个团队会为一个 section 的几百 token 做重构。

而且判断依据很清楚:"Explore 不会去执行 commit / PR / lint 规则,主 agent 才会解读它的输出""要 git 信息就自己跑 git status,还能拿到新鲜的"

对应的 agent 定义上有个字段 [源码 src/tools/AgentTool/built-in/exploreAgent.ts:81]:

ts
omitClaudeMd: true,

4.2 权限上下文的构造

ts
const agentGetAppState = () => {
  const state = toolUseContext.getAppState()
  // Override permission mode if agent defines one (unless parent is
  // bypassPermissions, acceptEdits, or auto)
  …
  // Set flag to auto-deny prompts for agents that can't show UI
  // Use explicit canShowPermissionPrompts if provided, otherwise:
  //   - bubble mode: always show prompts (bubbles to parent terminal)
  //   - default: !isAsync (sync agents show prompts, async agents don't)
  const shouldAvoidPrompts = …
  // For background agents that can show prompts, await automated checks
  // (classifier, permission hooks) before showing the permission dialog.
  // Since these are background agents, waiting is fine — the user should
  // only be interrupted when automated checks can't resolve the permission.
  …
  // Scope tool permissions: when allowedTools is provided, use them as session rules.
  // IMPORTANT: Preserve cliArg rules (from SDK's --allowedTools) since those are
  // explicit permissions from the SDK consumer that should apply to all agents.
  // Only clear session-level rules from the parent to prevent unintended leakage.
}

[源码 src/tools/AgentTool/runAgent.ts:416-480]

四条规则:

  1. agent 定义的权限模式可以覆盖,但父进程如果是 bypass/acceptEdits/auto 就不覆盖(父进程更宽松时保持宽松)
  2. 不能显示 UI 的 agent 自动拒绝权限请求——bubble 模式除外(它冒泡给父终端)
  3. 后台 agent 在弹窗前先等自动检查跑完——"用户只应该在自动检查解决不了时才被打扰"
  4. 父进程的 session 级规则不传给子 agent(防泄漏),但 CLI 参数传进来的规则要保留(那是 SDK 消费者的显式授权)

第 4 条那条区分很精细:用户在这次会话里点的"总是允许"不该自动传给子 agent,但启动时用 --allowedTools 声明的应该传。

4.3 agent 可以带自己的 MCP 服务器

ts
async function initializeAgentMcpServers(agentDefinition, …) {
  // If no agent-specific servers defined, return parent clients as-is
  …
  // When MCP is locked to plugin-only, skip frontmatter MCP servers for
  // USER-CONTROLLED agents only. Plugin, built-in, and policySettings agents
  // are admin-trusted — their frontmatter MCP is part of the admin-approved
  // surface. Blocking them (as the first cut did) breaks plugin agents that
  // legitimately need MCP, contradicting "plugin-provided always loads."
  const agentIsAdminTrusted = isSourceAdminTrusted(agentDefinition.source)
  …
  for (const spec of agentDefinition.mcpServers) {
    if (typeof spec === 'string') {
      // Reference by name - look up in existing MCP configs
      // This uses the memoized connectToServer, so we may get a shared client
    } else {
      // Inline definition as { [name]: config }
      // These are agent-specific servers that should be cleaned up
    }
  }
  // Only clean up newly created clients (inline definitions), not shared/referenced ones
}

[源码 src/tools/AgentTool/runAgent.ts:95-212]

两种声明方式,两种生命周期

写法语义清理
mcpServers: ["github"]引用已有配置不清理(父进程共享)
mcpServers: [{ "myserver": {…} }]agent 内联定义agent 结束时清理

而"管理端锁定 MCP 只能来自插件"时的处理,注释记录了一次修正:第一版把所有 agent 的 frontmatter MCP 都拦了,结果插件 agent 用不了 MCP,和"插件提供的东西总是加载"的原则矛盾。 改成按 agent 来源判断是否 admin-trusted。

4.4 frontmatter 技能预加载

ts
const skillsToPreload = agentDefinition.skills ?? []
if (skillsToPreload.length > 0) {
  const allSkills = await getSkillToolCommands(getProjectRoot())
  for (const skillName of skillsToPreload) {
    // 三种解析策略:
    // 1. Exact match (hasCommand checks name, userFacingName, aliases)
    // 2. Fully-qualified with agent's plugin prefix (e.g., "my-skill" → "plugin:my-skill")
    // 3. Suffix match on ":skillName" for plugin-namespaced skills
  }
  // Load all skill contents concurrently and add to initial messages
  const loaded = await Promise.all(validSkills.map(async ({…}) => ({
    …, content: await skill.getPromptForCommand('', toolUseContext),
  })))
}

[源码 src/tools/AgentTool/runAgent.ts:577-646]

一个 agent 可以在 frontmatter 里声明"我需要这几个技能",它们的完整内容会作为初始消息注入。

这让 agent 定义变成了一个 prompt 容器:系统提示词 + 工具限制 + MCP 服务器 + 钩子 + 预加载的技能。


五、前台 / 后台 / 远程:三套生命周期

ts
is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled

两个来源都能让 agent 变成后台:调用时传参,或者 agent 定义里写 background: true(比如第 13 章的验证 agent)。

三套生命周期的差别:

前台同步后台异步远程 [内部]
abortController共享父进程的新建,独立远程侧
主线程等结果继续干活继续干活
权限弹窗显示默认自动拒绝(bubble 除外)——
完成通知直接返回<task-notification> 消息同左
可以中途转后台————

后台完成时通过队列送一条 task-notification 回主线程——就是第 3 章那个"每个循环只捞走写给自己的"机制。

模型侧的提示词对此有明确规定 [源码 src/tools/AgentTool/prompt.ts:263]:

You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.

Foreground vs background: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.

判据是"你需不需要它的结果才能继续"。


六、写给模型的委派协议

AgentTool 的工具描述(第 5 章讲过它是动态生成的)里有两段特别值钱。

6.1 "怎么写 prompt"

text
## Writing the prompt

When spawning a fresh agent (with a `subagent_type`), it starts with zero context.
Brief the agent like a smart colleague who just walked into the room — it hasn't seen
this conversation, doesn't know what you've tried, doesn't understand why this task matters.
- Explain what you're trying to accomplish and why.
- Describe what you've already learned or ruled out.
- Give enough context about the surrounding problem that the agent can make judgment calls
  rather than just following a narrow instruction.
- If you need a short response, say so ("report in under 200 words").
- Lookups: hand over the exact command. Investigations: hand over the question —
  prescribed steps become dead weight when the premise is wrong.

For fresh agents, terse command-style prompts produce shallow, generic work.

**Never delegate understanding.** Don't write "based on your findings, fix the bug" or
"based on the research, implement it." Those phrases push synthesis onto the agent instead
of doing it yourself. Write prompts that prove you understood: include file paths,
line numbers, what specifically to change.

[源码 src/tools/AgentTool/prompt.ts:100]

三条最重要的:

① "像给刚进门的聪明同事做简报" —— 一个具体到可操作的心智模型,比"提供足够上下文"有用得多。

② "查找类给确切命令,调查类给问题" ——

prescribed steps become dead weight when the premise is wrong

当前提是错的时候,规定好的步骤就是累赘。 这是委派设计里一条很深的判断:你越确定该怎么做,就越该给步骤;你越不确定,就越该给目标。

③ "永远不要委派理解" —— 禁止写"基于你的发现修复 bug"这种偷懒 prompt。因为综合(synthesis)是主 agent 的职责,把它推给子 agent 就等于没人做。

这条是本教程认为整个 AgentTool 提示词里最值钱的一句。很多多 agent 系统效果差,就是因为主 agent 在层层转包,最后没有任何一层真正理解了任务。

6.2 "什么时候 fork"

text
## When to fork

Fork yourself (omit `subagent_type`) when the intermediate tool output isn't worth keeping
in your context. The criterion is qualitative — "will I need this output again" — not task size.
- **Research**: fork open-ended questions. If research can be broken into independent
  questions, launch parallel forks in one message. A fork beats a fresh subagent for this —
  it inherits context and shares your cache.
- **Implementation**: prefer to fork implementation work that requires more than a couple
  of edits. Do research before jumping to implementation.

Forks are cheap because they share your prompt cache. Don't set `model` on a fork — a
different model can't reuse the parent's cache. Pass a short `name` (one or two words,
lowercase) so the user can see the fork in the teams panel and steer it mid-run.

**Don't peek.** The tool result includes an `output_file` path — do not Read or tail it
unless the user explicitly asks for a progress check. You get a completion notification;
trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context,
which defeats the point of forking.

**Don't race.** After launching, you know nothing about what the fork found. Never fabricate
or predict fork results in any format — not as prose, summary, or structured output. The
notification arrives as a user-role message in a later turn; it is never something you write
yourself. If the user asks a follow-up before the notification lands, tell them the fork is
still running — give status, not a guess.

**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* —
what to do, not what the situation is. Be specific about scope: what's in, what's out, what
another agent is handling. Don't re-explain background.

[源码 src/tools/AgentTool/prompt.ts:84]

四条规则,每条都在防一种具体的失败:

规则防什么
判据是"我还需不需要这个输出",不是任务大小防"小任务不值得 fork"的误判
不要偷看 output_file防"把 fork 的工具噪音又拉回主上下文"——那就白 fork 了
不要抢跑防模型编造 fork 还没返回的结果
fork 的 prompt 是指令不是背景防重复解释已经在上下文里的东西

"不要抢跑"那条写得特别细致:"通知是在之后某一轮以 user 角色消息的形式到达的,它永远不是你自己写出来的"

这是在纠正一类很具体的幻觉:模型知道自己启动了一个 fork,然后在同一轮里"想象"出了 fork 的返回结果。因为在训练数据里,"我发起了一个动作"后面通常跟着"这个动作的结果"。

配套的示例甚至演示了用户中途追问的情况:

text
user: "so is the gate wired up or not"
<commentary>
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't
returned. The coordinator does not have this answer. Give status, not a fabricated result.
</commentary>
assistant: Still waiting on the audit — that's one of the things it's checking. Should land shortly.

可迁移的判断 ㉖ 异步委派的提示词必须显式禁止"预测结果",并给一个正面示例演示"用户追问时怎么答"。

光说"不要编造"不够——模型不觉得自己在编造,它觉得自己在合理推断。必须给出"给状态,不给猜测"这个替代动作,并且演示一遍。


七、动手复核

bash
cd claude-code-deep-dive/extracted-source

# 1. 输入 schema 与那个 "external" === 'ant' 铁证
sed -n '82,140p' src/tools/AgentTool/AgentTool.tsx
grep -n '"external" === ' src/tools/AgentTool/AgentTool.tsx

# 2. 五道前置校验与四条分流
sed -n '250,470p' src/tools/AgentTool/AgentTool.tsx

# 3. fork 的全部实现(210 行,值得整读)
cat src/tools/AgentTool/forkSubagent.ts

# 4. runAgent 的十七步
grep -n '  // ' src/tools/AgentTool/runAgent.ts | head -60

# 5. 两处上下文瘦身与它们的省量数据
grep -n -B4 -A4 'Gtok/week' src/tools/AgentTool/runAgent.ts

# 6. agent 专属 MCP 的两种生命周期
sed -n '95,212p' src/tools/AgentTool/runAgent.ts

# 7. 委派协议的两段核心提示词
sed -n '80,155p' src/tools/AgentTool/prompt.ts

本机侧:

bash
# 子 agent 的 sidechain transcript
ls ~/.claude/projects/*/*/ 2>/dev/null | head

# 看当前可用的 agent 列表(也能看到 system-reminder 里的 agent_listing_delta)
/agents

八、总结

  1. 三层分工AgentTool.call() 调度分流 → runAgent() 构造上下文与生命周期 → query() 跑循环
  2. subagent_type 省略时的含义由一个开关决定:fork 模式下是"fork 自己",否则是"用 general-purpose"
  3. fork 路径的四个字段全为缓存服务:工具池逐字节一致、模型继承、系统提示词直接透传父进程渲染好的字节(重新生成可能因 GrowthBook 冷热而漂移)、权限冒泡
  4. 所有 fork 子进程用同一个占位字符串填 tool_result,让前缀逐字节相同,只有最后一个 text block 不同
  5. 递归 fork 用双保险守卫querySource(抗压缩,在 options 上)+ 消息扫描(会被压缩吃掉的兜底)
  6. 给 fork 子进程的十条军规里,第 1 条是覆盖继承来的系统提示词——因为它写着"默认应该 fork"
  7. 只读 agent 砍掉 CLAUDE.md 和 gitStatus,每周省 60–180 亿 token;基数是每周 3400 万次以上的 Explore 调用
  8. 子 agent 的权限有四条规则,其中"session 级规则不传给子 agent 但 CLI 参数规则要传"区分得很精细
  9. agent 可以带自己的 MCP 服务器,引用式共享不清理、内联式独占要清理
  10. 委派协议里最值钱的一句是"永远不要委派理解" —— 综合是主 agent 的职责,转包出去就没人做了
  11. "不要偷看"和"不要抢跑"各防一种具体失败:偷看 fork 的 transcript 等于白 fork;抢跑是模型把"我发起了动作"补全成"动作的结果"

下一章看六个内建 agent 各自被裁成了什么形状,以及那个被 prompt 成"你的工作是想办法弄坏它"的验证 agent。


  • 第11章-钩子系统-27个事件的治理层
  • 第13章-内建Agent-专业化分工与对抗式验证
  • 第4章-模型调用与缓存经济学 —— fork 共享缓存的机制前提
  • 第5章-系统提示词-一个可编排的装配架构 —— 两种 getAgentToolSection()
  • 第10章-权限模型-六种模式与被外包的沙箱 —— bubble 模式
  • Codex 教程第 14 章 —— 子进程与协作图的对照
  • dsh 教程第 11 章 —— 三种多 agent 机制的对照

本章目录
一、三层分工二、AgentTool.call() 的分流三、fork 路径:为缓存而生四、runAgent():子 agent 带什么上路五、前台 / 后台 / 远程:三套生命周期六、写给模型的委派协议七、动手复核八、总结Related Documents
苏ICP备2025204887号-2