AgentTool 异步执行到 TaskNotification 回流 trace

这页回答什么

当 Claude Code 里的 AgentTool 以异步/background 方式启动一个子 agent 后,这个任务是如何运行、结束、发出通知,并重新回到主 query() 循环里的?

关键文件:

  • src/query.ts
  • src/services/tools/toolOrchestration.ts
  • src/services/tools/StreamingToolExecutor.ts
  • src/services/tools/toolExecution.ts
  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/agentToolUtils.ts
  • src/tasks/LocalAgentTask/LocalAgentTask.tsx
  • src/utils/attachments.ts
  • src/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.tsrunTools()

  • 先按 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 里,异步路径的核心行为是:

  1. 注册 foreground 或 async task
  2. 启动 runAgent()
  3. 如果 agent 被 background 化,立刻返回 async_launched
  4. 真正的 agent 执行在后台 closure 里继续

关键点不是“它后台跑了”,而是:

父 query loop 很快得到一个 launched 结果,而不是等待子 agent 全部完成。

这就是异步化的起点。

返回给父 agent 的是类似:

  • status: 'async_launched'
  • agentId
  • outputFile
  • description

所以当前这一轮 tool_result 只表达:

“子 agent 已经启动并被任务系统接管。”


5. 后台 closure 里真正继续跑 runAgent()

当任务进入 background 路径后,AgentTool.tsx 会在后台继续:

  • runAgent({... isAsync: true ...})
  • 持续收集 agentMessages
  • 更新 progress tracker
  • updateAsyncAgentProgress()
  • 必要时发 task progress 事件

然后在完成时:

  1. finalizeAgentTool(...)
  2. completeAsyncAgent(...)
  3. 计算 finalMessage / usage / worktree 信息
  4. enqueueAgentNotification(...)

注意顺序非常讲究:

  • completeAsyncAgent
  • notification embellishment / cleanup / enqueue

注释也写得很明白,这是为了让 TaskOutput 等读取方尽快看到任务进入完成态,不被额外收尾动作卡住。


6. enqueueAgentNotification() 把结果编码成 XML-ish payload

LocalAgentTask.tsxenqueueAgentNotification() 会:

  • 原子设置 notified,避免重复通知
  • 生成 summary
  • 拼出 output path
  • 可选带上:
    • toolUseId
    • result
    • usage
    • worktree

生成的消息结构大致是:

<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 后段,工具执行完成后,主循环会:

  1. queuedCommandsSnapshot = getCommandsByMaxPriority(...)
  2. 按 thread / agent scope 过滤
  3. getAttachmentMessages(...)
  4. 把 attachment yield 出去并 push 进 toolResults
  5. 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_command attachment
  • commandMode 会保留原始 mode(prompttask-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 不是外挂,而是被严密嵌入主对话协议里的。


下一步最值得继续追

  1. 同样做一份 remote-agent -> RemoteAgentTask -> task-notification 的端到端 trace
  2. query.ts 中 attachment 最终如何序列化成模型输入块
  3. 对比 sync AgentTool 与 async AgentTool 在 transcript 上的差异