AgentTool 异步执行到 TaskNotification 回流 trace
这页回答什么
当 Claude Code 里的
AgentTool以异步/background 方式启动一个子 agent 后,这个任务是如何运行、结束、发出通知,并重新回到主query()循环里的?
关键文件:
src/query.tssrc/services/tools/toolOrchestration.tssrc/services/tools/StreamingToolExecutor.tssrc/services/tools/toolExecution.tssrc/tools/AgentTool/AgentTool.tsxsrc/tools/AgentTool/agentToolUtils.tssrc/tasks/LocalAgentTask/LocalAgentTask.tsxsrc/utils/attachments.tssrc/utils/messageQueueManager.ts
一句话结论
AgentTool的异步子 agent 并不是“发出去以后等下次用户说话再处理”。它完成后会先被编码成task-notification放进全局队列,再由主query()循环在回合末主动 drain,并作为 attachment 注入下一轮上下文。
这条链把“后台任务”重新缝回“同步对话循环”。
1. 起点:模型发出 tool_use(name=Agent)
主链起点仍然是:
QueryEngine.submitMessage()
→ query()
→ assistant message with tool_use(name=Agent)query.ts 在流式消费模型输出时:
- 收集
assistantMessages - 抽取
toolUseBlocks - 如果启用了
StreamingToolExecutor,会在消息流还没结束时就把 tool block 交给工具执行器
这里的关键事实是: AgentTool 并不享有特殊入口,它只是普通 tool_use block 之一。
2. 工具执行层:runTools() / StreamingToolExecutor
工具分发有两条路径:
A. 普通工具执行
toolOrchestration.ts 的 runTools():
- 先按
isConcurrencySafe()分批 - 只读工具并发
- 非并发安全工具串行
- 每个工具最终走
runToolUse()
B. 流式工具执行
StreamingToolExecutor:
- 工具 block 一边流进来,一边排队执行
- 保留顺序语义
- 对并发安全工具并发执行
- 对非并发安全工具独占执行
这说明 AgentTool 是否异步,并不是 query loop 自己理解的概念,
而是先落到统一工具执行协议里。
3. runToolUse() 把 control 交给 AgentTool.call()
toolExecution.ts 负责:
- 找到 tool definition
- 做校验、权限、hook、telemetry
- 调具体工具的
call() - 把输出包装成
tool_result
因此对 AgentTool 来说,真正的调度入口是:
runToolUse()
→ AgentTool.call(...)到这里为止,它和 BashTool、ReadTool 在协议层仍然是同类。
4. AgentTool.call() 决定是否进入 background 路径
在 AgentTool.tsx 里,异步路径的核心行为是:
- 注册 foreground 或 async task
- 启动
runAgent() - 如果 agent 被 background 化,立刻返回
async_launched - 真正的 agent 执行在后台 closure 里继续
关键点不是“它后台跑了”,而是:
父 query loop 很快得到一个 launched 结果,而不是等待子 agent 全部完成。
这就是异步化的起点。
返回给父 agent 的是类似:
status: 'async_launched'agentIdoutputFiledescription
所以当前这一轮 tool_result 只表达:
“子 agent 已经启动并被任务系统接管。”
5. 后台 closure 里真正继续跑 runAgent()
当任务进入 background 路径后,AgentTool.tsx 会在后台继续:
- 调
runAgent({... isAsync: true ...}) - 持续收集
agentMessages - 更新 progress tracker
- 调
updateAsyncAgentProgress() - 必要时发 task progress 事件
然后在完成时:
finalizeAgentTool(...)completeAsyncAgent(...)- 计算 finalMessage / usage / worktree 信息
enqueueAgentNotification(...)
注意顺序非常讲究:
- 先
completeAsyncAgent - 后 notification embellishment / cleanup / enqueue
注释也写得很明白,这是为了让 TaskOutput 等读取方尽快看到任务进入完成态,不被额外收尾动作卡住。
6. enqueueAgentNotification() 把结果编码成 XML-ish payload
LocalAgentTask.tsx 的 enqueueAgentNotification() 会:
- 原子设置
notified,避免重复通知 - 生成 summary
- 拼出 output path
- 可选带上:
toolUseIdresultusageworktree
生成的消息结构大致是:
<task_notification>
<task_id>...</task_id>
<tool_use_id>...</tool_use_id>
<output_file>...</output_file>
<status>completed|failed|killed</status>
<summary>...</summary>
<result>...</result>
<usage>...</usage>
</task_notification>然后调用:
enqueuePendingNotification({ value: message, mode: 'task-notification' })这一步是整个桥接点:
后台 agent 的终态被翻译成一个标准化 queued command。
7. notification 不是在 QueryEngine 外围消费,而是在 query() 末尾主动 drain
这是这次 trace 最关键的发现。
在 query.ts 后段,工具执行完成后,主循环会:
- 取
queuedCommandsSnapshot = getCommandsByMaxPriority(...) - 按 thread / agent scope 过滤
- 调
getAttachmentMessages(...) - 把 attachment
yield出去并 push 进toolResults removeFromQueue(consumedCommands)
也就是说:
主
query()循环自己就会在回合末把 task-notification 从队列里吸进来。
它不是另一个外部系统稍后处理,而是 loop 内建行为。
8. 为什么 agent scope 很重要
drain 阶段不是无脑全吃。
过滤逻辑是:
main thread
- 只吃
agentId === undefined的 queued commands
subagent
- 只吃
mode === 'task-notification' - 且
cmd.agentId === currentAgentId
这说明消息队列虽然是全局单例,但消费是按 agent scope 切开的。
所以系统支持:
- 主线程只接自己的通知
- 子 agent 只接发给自己的通知
- prompt 流不会乱灌进 subagent
这是多 agent 并存时非常关键的隔离点。
9. getAttachmentMessages() 把 queued command 重新翻译成 attachment
在 attachments.ts 里:
- queued command 会被转成
queued_commandattachment commandMode会保留原始 mode(prompt或task-notification)- provenance / origin / isMeta 也尽量保留
所以 task-notification 在进入模型前,已经不是裸字符串,而是:
一个 attachment 语义对象,再被转换成消息内容。
这就是为什么它能无缝混进当前回合的 toolResults,并参与后续模型推理。
10. 因果链没有丢:toolUseId 被一路保留
notification payload 里会尽量带 toolUseId。
这意味着系统在异步化之后,仍然努力保留:
- 这个后台任务
- 对应父会话里哪次
tool_use
所以不是“某个后台 agent 完成了”这种松散提示, 而是:
“当时那次 AgentTool 调用,对应的任务现在完成了。”
这对 transcript 可读性和模型后续 continuation 都很重要。
11. 这条完整路径可以压成一张图
用户输入
→ QueryEngine.submitMessage()
→ query()
→ assistant 输出 tool_use(name=Agent)
→ runToolUse()
→ AgentTool.call()
→ 注册 LocalAgentTask / 背景执行 runAgent()
→ 父回合先收到 async_launched tool_result
→ 后台 agent 持续运行
→ completeAsyncAgent()
→ enqueueAgentNotification()
→ enqueuePendingNotification(mode='task-notification')
→ query() 回合末 drain queue
→ getAttachmentMessages()
→ task-notification 变成 attachment
→ 进入下一轮上下文
→ 主 agent 继续推理 / 告知用户这就是“异步 agent 如何被重新缝回同步 loop”的完整路径。
12. 这条链为什么设计得好
我觉得最妙的地方有三个:
A. 不给 AgentTool 特权协议
先把它当普通 tool call,复杂度不污染主循环入口。
B. 用 Task 层承接长期生命周期
状态、输出文件、通知、kill、progress 都不塞回 query 本体。
C. 用 queued command + attachment 把异步结果重新折回消息语义
这一步最漂亮。不是 callback,不是 toast,不是边栏状态,而是重新成为对话输入的一部分。
这让后台 agent 真正变成“会话的一部分”,而不只是“会话旁边的某个进程”。
当前结论
AgentTool 的异步执行链,本质上是:
一次标准 tool call 先返回 launched 状态,把长期执行交给 LocalAgentTask;任务完成后再用
task-notification回到全局消息队列,并由主query()循环在回合末主动 drain 成 attachment,重新注入后续推理。
这套设计说明 Claude Code 的 async agent 不是外挂,而是被严密嵌入主对话协议里的。
下一步最值得继续追
- 同样做一份
remote-agent -> RemoteAgentTask -> task-notification的端到端 trace - 追
query.ts中 attachment 最终如何序列化成模型输入块 - 对比 sync AgentTool 与 async AgentTool 在 transcript 上的差异