任务与子代理

任务与子代理

主 agent 遇到”搜遍代码库找所有 X”这种多步脏活时,会派生子 agent 代劳:子 agent 在自己的上下文里跑完,只把结论带回主对话,几十个文件原文不会灌进主 agent 的上下文窗口。
支撑这套玩法的是两层机制:

  • 任务框架(src/Task.ts + src/utils/task/framework.ts):统一的后台任务生命周期簿记。
  • 上下文派生器(src/utils/forkedAgent.ts 的 createSubagentContext,旧版笔记写成 src/query.ts,那是错的)。

和 JUC 类比:任务框架像 ThreadPoolExecutor 管 Future 的那套生命周期簿记,createSubagentContext 则像给子线程做了一份定制的 InheritableThreadLocal 快照,哪些继承、哪些隔离都是逐字段挑过的。

任务框架:七种后台工作,一个状态机

// 基于本地 Claude Code 反混淆源码 (2.1.88), src/Task.ts。商业实现,只允许签名级摘录。
export type TaskType = 'local_bash' | 'local_agent' | 'remote_agent'
  | 'in_process_teammate' | 'local_workflow' | 'monitor_mcp' | 'dream'  // 后台shell/后台子代理/远程代理/进程内队友/多代理工作流/MCP监控/dream
// src/Task.ts
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'killed'  // pending → running → 三个终态之一,无回头路
export function isTerminalTaskStatus(status: TaskStatus): boolean  // 三处调用方:防止往已死队友注入消息、把完成任务从 AppState 驱逐、孤儿清理

local_workflowmonitor_mcp 两种类型用 bun 的 feature() 编译期开关裹着(src/tasks.ts 里 feature('WORKFLOW_SCRIPTS') / feature('MONITOR_TOOL'))。
开关关掉时对应代码整块被死代码消除,连 require 都不会发生。
任务注册表 getAllTasks() 每次调用都重新拼数组,源码注释明说这是为了绕开顶层 const 的循环依赖,和 tools.ts 的工具注册表一个套路。

Task 接口本身瘦得出奇:

代码块TS · 5 行收起展开
// src/Task.ts。接口原本还有 spawn/render,PR #22546 发现它们从未被多态调用过,方法只剩 kill(name/type 两个字段还在)
export type Task = {
  name: string; type: TaskType  // 仅供注册表查找,不参与多态分发
  kill(taskId: string, setAppState: SetAppState): Promise<void>  // 全部六个 kill 实现只需要 setAppState,getAppState/abortController 是死重
}

这是个挺反直觉的演化结果:注册表 + 接口的多态设计里,真正需要按类型分发的只有”杀任务”这一件事。spawn 各有各的入口(AgentTool、Bash 的 run_in_background、Monitor 工具),render 各有各的 UI 组件,谁也不通过接口调谁。

任务 ID 的生成藏了一个安全设计:

代码块TS · 2 行收起展开
// src/Task.ts
export function generateTaskId(type: TaskType): string  // 类型前缀一个字母(b/a/r/t/w/m/d) + randomBytes(8) 逐字节取模映射到 0-9a-z 字母表

为什么用密码学随机而非自增序号:任务输出会落盘到 getTaskOutputPath(id),沙箱里的攻击者如果能预测下一个输出文件路径,就能提前在那里放一个指向任意文件的 symlink,骗宿主机上的 Claude Code 往攻击者指定的位置写内容。

  • ID 不可预测是第一道防线:36^8 约 2.8 万亿组合,暴力猜不动。
  • 打开输出文件时 O_NOFOLLOW | O_EXCL 双保险(src/utils/task/diskOutput.ts)(O_NOFOLLOW 在 Windows 上取不到,源码降级为 0,注释说明沙箱这条攻击面只存在于 Unix)。

字母表刻意只收数字加小写,在大小写不敏感的文件系统(Windows/macOS)上也不会碰撞。

createSubagentContext:默认全隔离,共享要显式申请

代码块TS · 3 行收起展开
// 基于本地 Claude Code 反混淆源码 (2.1.88), src/utils/forkedAgent.ts
export function createSubagentContext(
  parentContext: ToolUseContext, overrides?: SubagentContextOverrides): ToolUseContext  // 白名单式共享:不在 overrides 里点名的可变状态一律隔离

派生规则逐字段看下来是三类:

克隆的:readFileState(文件读取缓存)用 cloneFileStateCache 复制一份,子代理的读文件动作不污染父缓存。
contentReplacementState 也克隆,这个字段记录”哪些旧 tool result 被替换成了占位符”,fork 型子代理会重放父消息,
克隆保证子代理对同一批 tool_use_id 做出和父完全相同的替换决策,API 请求前缀逐字节一致,prompt cache 才能命中。

掐断的:

  • no-op 空函数:setAppState、setInProgressToolUseIDs、updateFileHistoryState。
  • 直接置 undefined:addNotification、setToolJSX 等 UI 回调。

子代理没资格碰主线程的 React 状态和终端 UI。
getAppState 默认被包一层,强行注入 shouldAvoidPermissionPrompts: true:后台跑的代理弹不出权限对话框,与其挂死不如自动拒绝。
只有共享 abortController 的交互式子代理例外(源码注释:能共享父信号说明它有 UI 可弹窗),那种情况原样透传父的 getAppState。

必须穿透的:setAppStateForTasks 永远指向根 store(parentContext.setAppStateForTasks ?? parentContext.setAppState)。
源码注释把后果写得很直白:异步代理的 setAppState 是 no-op,如果它内部再起后台 bash 任务,注册和查杀走不到根 store,主会话退出后这个 shell 就成了 PPID=1 的僵尸进程。
另一个穿透的是 localDenialTracking:权限被拒的计数器要是也 no-op 掉,子代理反复重试被拒绝的操作时计数永远是零,熔断逻辑就废了,所以给它一份本地实例。

abortController 介于两者之间:默认 createChildAbortController 造一个链到父信号的子控制器,父 abort 会传播下来,子代理自己 abort 不影响父。
旧版笔记说”可带 taskBudget 限制子代理烧多少 token”,核对后发现张冠李戴:taskBudget 是 query() 的参数,和 createSubagentContext 无关。
它由 SDK 从 QueryEngine.ts 传入,映射到 API 的 output_config.task_budget,管的是整个 agentic turn 的预算,compact 后还会扣减重算。

runAgent:子代理的完整生命周期

代码块TS · 5 行收起展开
// 基于本地 Claude Code 反混淆源码 (2.1.88), src/tools/AgentTool/runAgent.ts
export async function* runAgent({ agentDefinition, promptMessages, toolUseContext,
  canUseTool, isAsync,
  // ...其余 16 个参数省略
}): AsyncGenerator<Message, void>  // 同步/异步子代理共用的执行核心,一个 async generator

runAgent 里最值得记的三件事:

上下文瘦身。只读型代理(Explore/Plan)派生时直接把 CLAUDE.md 从 userContext 里剥掉。
源码注释给了量级:摊到 34M+ 次 Explore 派生上,省 5 到 15 Gtok/周(受 agentDefinition.omitClaudeMd 和 kill-switch tengu_slim_subagent_claudemd 双重把关,默认开)。
Explore/Plan 还会剥掉 systemContext 里的 gitStatus(最大 40KB 且明确标了 stale),理由是这两类代理要 git 信息自己跑 git status 拿新鲜的,这一刀另算 1 到 3 Gtok/周。
这解释了一个大方向:子代理的系统上下文按角色裁剪,读代码的代理不需要知道提交规范。

thinking 差异化。普通子代理 thinkingConfig 强制 disabled,控输出 token 成本。
fork 型子代理(useExactTools 路径)反过来必须继承父的 thinkingConfig,因为 thinking 配置是 prompt cache key 的一部分,改了就击穿父的缓存。

finally 里的清理清单

  • MCP 连接
  • 会话级 hooks
  • 克隆的文件缓存
  • todos 表里本代理的 key(不删的话每个用过 TodoWrite 的子代理都在 AppState 里留一个永久 key,大会话派生几百个代理就是几百个小泄漏)
  • 最后 killShellTasksForAgent 把本代理起的后台 bash 全杀掉,防僵尸

资源清理放 finally 保证 abort、报错、正常结束三条路都走到,和 Java 里 try-finally 关连接是同一个纪律。

原理串讲

一次典型调用从 AgentTool 开始。
主 agent 发出 Task 工具调用后,AgentTool.tsx 的 call() 先算 shouldRunAsync:

  • run_in_background === true
  • 代理定义里 background: true
  • coordinator 模式 / forceAsync / assistantForceAsync / proactive 激活

以上任一命中,且后台任务未被环境变量禁用(CLAUDE_CODE_DISABLE_BACKGROUND_TASKS),就走异步路径。
这个分叉决定了后面所有的共享策略。

同步路径最简单:call() 直接 for await 消费 runAgent 的输出流,子代理共享父的 abortController(用户按 ESC 父子一起停)、shareSetAppState: true(runAgent 里写的是 shareSetAppState: !isAsync)。
跑完把最后一条 assistant 消息的文本作为 tool result 返回,主 agent 的这个 turn 才继续。

异步路径分两步。

  1. registerAsyncAgent 先在 AppState.tasks 里登记一条 local_agent 任务。
    createTaskStateBase 的默认状态是 pending,但 registerAsyncAgent 当场覆盖成 running 并置 isBackgrounded: true,ID 就是 generateTaskId 出来的 a 前缀那种。
    调用时给了 name 的话还会写进 agentNameRegistry,这样别的代理可以用 SendMessage({to: name}) 找到它。
  2. void runWithAgentContext(...runAsyncAgentLifecycle(...)),一个不 await 的 fire-and-forget 调用把真正的执行甩到后台,call() 立刻返回 { status: 'async_launched', agentId, outputFile }

为什么异步子代理要一个全新的、不链接父信号的 new AbortController():源码注释写明,后台代理必须在用户 ESC 打断主线程时活下来,它们只被 chat:killAgents 显式杀。
这条链的收尾在 LocalAgentTask.tsx 的 enqueueAgentNotification:任务到终态时往主循环的消息队列塞一条 task-notification,主 agent 下一个 turn 自然看到”你派出去的活干完了”。

两条路径殊途同归到 runAgent,它按顺序做五件事:

  1. getAgentModel 定模型
  2. 做上面说的上下文瘦身
  3. getAgentSystemPrompt 渲染子代理自己的系统提示
  4. 然后 createSubagentContext 派生上下文
  5. 最后进 query() 的标准 agent 循环

循环里每条可记录消息都通过 recordSidechainTranscript 落盘,并用 lastRecordedUuid 手工串 parent 链,
这份 sidechain JSONL 就是之后 resume 子代理、以及用户在面板里查看子代理 transcript 的数据源(进程内队友额外设 preserveToolUseResults,transcript 里才能看到工具结果)。

有一条特殊路径值得单独说:fork 子代理(forkSubagent.ts 的 FORK_AGENT)。
它继承父的完整对话,追求的目标是 API 请求前缀和父逐字节相同,从而全量命中父的 prompt cache。
为此它做了三件普通子代理不做的事:

  1. forkContextMessages 带上父的全部消息。
    runAgent 这条路先过 filterIncompleteToolCalls 滤掉没有结果的孤儿 tool_use,不然 API 直接报错。
    而 forkedAgent.ts 的 runForkedAgent 反过来明确不过滤,源码注释说滤掉会整条 assistant 一起丢、把配对的结果变孤儿,交给 claude.ts 的 ensureToolResultPairing 下游修补。
  2. useExactTools 直接复用父的工具数组,因为工具池按子代理自己的权限模式重建的话,工具定义的序列化会在第一个不同的工具处击穿缓存。
  3. 系统提示不重新渲染,而是把父已经渲染好的字节通过 toolUseContext.renderedSystemPrompt 原样传下去。

为什么不能重新调 getSystemPrompt:渲染结果依赖 GrowthBook 特性开关的冷热状态,父渲染时是冷缓存、子渲染时可能已经拉到新值,一个字节的差异就让整个缓存前缀作废。
缓存命中与否在这里是真金白银,fork 的全部意义就建立在”复述父上下文不重新计费”上。

设计取舍

  • 隔离用白名单不用黑名单:createSubagentContext 默认掐断一切可变状态,要共享的字段逐个 share* 开关申请。新增字段忘了处理时,失败模式是”子代理少个能力”而非”子代理污染父状态”。
  • Task 接口从三个方法砍到只剩 kill:多态抽象只保留真正被多态调用的部分,spawn/render 各回各的调用点。抽象不是越整齐越好。
  • 子代理上下文按角色裁剪(Explore 剥 CLAUDE.md 和 gitStatus)赌的是”读代码不需要工程规范”,省的是每周十几 Gtok 的舰队级开销,代价是极少数确实需要规范的场景要靠主 agent 兜底解读。
  • 异步代理自动拒绝权限提示(shouldAvoidPermissionPrompts)选了”宁可失败不要挂死”:后台没有 UI,等一个永远不会来的确认比直接被拒更糟。
  • 任务 ID 的密码学随机是纵深防御的一层,和 O_NOFOLLOW、O_EXCL 叠着用,任何一层单拿出来都不够。

主循环怎么调工具见 03-agent 主循环,工具接口见 01-Tool 抽象,工具清单见 02-内置工具全景,架构总览见 Claude Code 索引

延伸阅读