任务与子代理
任务与子代理
主 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_workflow 和 monitor_mcp 两种类型用 bun 的 feature() 编译期开关裹着(src/tasks.ts 里 feature('WORKFLOW_SCRIPTS') / feature('MONITOR_TOOL'))。
开关关掉时对应代码整块被死代码消除,连 require 都不会发生。
任务注册表 getAllTasks() 每次调用都重新拼数组,源码注释明说这是为了绕开顶层 const 的循环依赖,和 tools.ts 的工具注册表一个套路。
Task 接口本身瘦得出奇:
代码块收起展开
// 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 的生成藏了一个安全设计:
代码块收起展开
// 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:默认全隔离,共享要显式申请
代码块收起展开
// 基于本地 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:子代理的完整生命周期
代码块收起展开
// 基于本地 Claude Code 反混淆源码 (2.1.88), src/tools/AgentTool/runAgent.ts
export async function* runAgent({ agentDefinition, promptMessages, toolUseContext,
canUseTool, isAsync,
// ...其余 16 个参数省略
}): AsyncGenerator<Message, void> // 同步/异步子代理共用的执行核心,一个 async generatorrunAgent 里最值得记的三件事:
上下文瘦身。只读型代理(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 才继续。
异步路径分两步。
registerAsyncAgent先在 AppState.tasks 里登记一条local_agent任务。
createTaskStateBase 的默认状态是 pending,但 registerAsyncAgent 当场覆盖成 running 并置 isBackgrounded: true,ID 就是 generateTaskId 出来的a前缀那种。
调用时给了name的话还会写进 agentNameRegistry,这样别的代理可以用SendMessage({to: name})找到它。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,它按顺序做五件事:
getAgentModel定模型- 做上面说的上下文瘦身
getAgentSystemPrompt渲染子代理自己的系统提示- 然后
createSubagentContext派生上下文 - 最后进 query() 的标准 agent 循环
循环里每条可记录消息都通过 recordSidechainTranscript 落盘,并用 lastRecordedUuid 手工串 parent 链,
这份 sidechain JSONL 就是之后 resume 子代理、以及用户在面板里查看子代理 transcript 的数据源(进程内队友额外设 preserveToolUseResults,transcript 里才能看到工具结果)。
有一条特殊路径值得单独说:fork 子代理(forkSubagent.ts 的 FORK_AGENT)。
它继承父的完整对话,追求的目标是 API 请求前缀和父逐字节相同,从而全量命中父的 prompt cache。
为此它做了三件普通子代理不做的事:
forkContextMessages带上父的全部消息。
runAgent 这条路先过filterIncompleteToolCalls滤掉没有结果的孤儿 tool_use,不然 API 直接报错。
而 forkedAgent.ts 的 runForkedAgent 反过来明确不过滤,源码注释说滤掉会整条 assistant 一起丢、把配对的结果变孤儿,交给 claude.ts 的 ensureToolResultPairing 下游修补。useExactTools直接复用父的工具数组,因为工具池按子代理自己的权限模式重建的话,工具定义的序列化会在第一个不同的工具处击穿缓存。- 系统提示不重新渲染,而是把父已经渲染好的字节通过
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 索引。