AgentTool sync async remote 三路总图

这页回答什么

同样是 AgentTool.call(),Claude Code 到底怎么分流成 sync、local async、remote 三条执行路径?三条路哪些阶段相同,哪些阶段不同,最后又在哪里重新汇合?

关键文件:

  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/runAgent.ts
  • src/tasks/LocalAgentTask/LocalAgentTask.tsx
  • src/tasks/RemoteAgentTask/RemoteAgentTask.tsx
  • src/query.ts
  • src/utils/teleport.tsx

一句话结论

AgentTool 本质上是一个统一入口,前半段共享“选 agent / 组 prompt / 建上下文 / 决定隔离模式”,中段分裂成 sync、local async、remote 三条执行管线,尾段又重新汇合到同一种 reflow 协议:task-notification queued command attachment 下一轮 query context

也就是说:

  • 入口基本统一
  • 执行中段不同
  • 完成回流协议统一

1. 统一入口:AgentTool.call() 先做什么

无论最后走哪条路,AgentTool.call() 前半段都会做这些事:

  1. 解析参数

    • prompt
    • description
    • subagent_type
    • run_in_background
    • cwd
    • model
    • isolation
  2. 决定 agent definition

    • fork path
    • 普通 agent path
  3. 检查前置约束

    • deny rule
    • MCP 依赖
    • teammate / background 限制
  4. 解析执行元信息

    • resolvedAgentModel
    • effectiveIsolation
    • 是否 async
    • 是否 fork

这一段说明 AgentTool 不是只有一个“tool body”,而是一个完整的 agent launch planner


2. 第一处分叉:effectiveIsolation === 'remote'

AgentTool.tsx 里,remote 是最早发生的硬分叉。

如果:

  • effectiveIsolation === 'remote'

就不会进入本地 runAgent() 主流程,而是直接:

  1. checkRemoteAgentEligibility()
  2. teleportToRemote({ initialMessage, description, ... })
  3. registerRemoteAgentTask(...)
  4. 立即返回:
    • status: 'remote_launched'
    • taskId
    • sessionUrl
    • outputFile

所以 remote 路径的特征是:

本地不会直接执行子 agent query loop,而是先把任务投送成一个远端 session,再由 RemoteAgentTask 在外侧追踪。


3. 第二处分叉:本地路径里的 sync / async

如果没走 remote,就进入本地 agent 路径。

这时真正的分叉条件是:

  • shouldRunAsync

它受这些因素影响:

  • run_in_background === true
  • agent definition 自带 background: true
  • coordinator mode
  • fork-subagent 实验强制 async
  • kairos / proactive mode 强制 async
  • background task 是否被禁用

所以 sync 和 async 不是简单看用户有没有传 run_in_background,而是多个产品策略共同决定。


4. 三条路的共同前置阶段

虽然三路执行中段不同,但 remote 之外的本地两路 在分叉前共享很多组装步骤:

4.1 组系统提示词和 prompt 消息

  • fork path: 继承父 prompt / forked messages
  • normal path: selectedAgent.getSystemPrompt()
  • 可能再走 enhanceSystemPromptWithEnvDetails(...)

4.2 组 worker tools

  • assembleToolPool(...)
  • 按 worker permission mode 重建工具池

4.3 建立运行参数

  • runAgentParams
  • querySource
  • availableTools
  • forkContextMessages
  • override.systemPrompt
  • 可选 worktreePath

4.4 可选 worktree 隔离

  • effectiveIsolation === 'worktree' 时先建 worktree
  • 后续 agent 在 cwd override 下执行

所以本地 sync/async 的真正差别,不在 prompt 组装层,而在 task lifecycle 的外壳


5. Sync 路径:当前 tool call 内直接跑完

shouldRunAsync === false 时,进入同步执行路径。

核心特征:

  • 创建 syncAgentId
  • registerAgentForeground(...)
  • 直接拿 runAgent(...)[Symbol.asyncIterator]()
  • 当前 AgentTool.call() 持续消费 iterator
  • 一边转发 progress,一边累积 agentMessages
  • 最终 finalizeAgentTool(...)
  • 返回 status: 'completed'

这条路的本质是:

agent 结果在当前 tool 调用里 inline 收敛。

它不需要等待后续 task-notification 回流,因为结果已经当场变成 tool result。


6. 但 sync 还有一个“中途转 async”出口

sync 路径里有个很重要但容易漏掉的点:

foreground agent 可以被 background 化

流程大致是:

  • registerAgentForeground(...)
  • 持有 backgroundSignal
  • loop 里 Promise.race(nextMessage, backgroundSignal)
  • 一旦 backgroundSignal 触发
    • 清理 foreground iterator
    • 重新以 isAsync: true 跑 agent
    • 走 background lifecycle
    • 返回 status: 'async_launched'

所以 sync 并不意味着“绝对同步到底”。

更准确地说:

它是“先按前台跑,必要时可以热切换成后台任务”。

这个设计解释了为什么 foreground/background UI 和 LocalAgentTask 紧密耦合。


7. Local async 路径:本地 query loop 在后台继续

shouldRunAsync === true 时:

  1. registerAsyncAgent(...)

  2. 立即返回:

    • status: 'async_launched'
    • agentId
    • outputFile
  3. 后台 void runAsyncAgentLifecycle(...)

  4. 里面真正执行:

    • runAgent(...)
    • 跟踪 progress
    • finalizeAgentTool(...)
    • completeAsyncAgent(...)
    • enqueueAgentNotification(...)

这条路和 sync 最大区别是:

runAgent() 仍然在本地执行,但不再阻塞当前 tool 调用,而是变成 LocalAgentTask 管理的后台生命周期。

所以 local async 不是另一套 agent engine,它仍然是同一套 runAgent/query 引擎,只是外面套了 task runtime。


8. Remote 路径:不跑本地 runAgent(),改跑 session teleport

remote 路径则更彻底。

它不是本地 runAgent() 后台化,而是:

  • teleportToRemote() 创建远端 session
  • registerRemoteAgentTask(...)
  • 后续由 remote poll / completion 机制推动
  • 最终 enqueueRemoteNotification(...)

所以 remote 和 local async 的关系是:

相同点

  • 都会立即返回 launched 状态
  • 都有 taskId
  • 都通过 task-notification 回流

不同点

  • local async 的执行引擎仍在本地 runAgent()
  • remote 的执行引擎已经移到 CCR session

这也是为什么之前那句总结成立:

shared reflow protocol, different execution middle


9. 三条路的状态返回差异

AgentTool 的用户可见返回状态大致分三类:

sync

  • status: 'completed'
  • 结果直接在当前 tool result

local async

  • status: 'async_launched'
  • 当前只拿到 task/agent 标识
  • 结果稍后通知回流

remote

  • status: 'remote_launched'
  • 当前拿到 taskId + sessionUrl
  • 结果稍后通知回流

如果压成一句:

  • sync = 现在给结果
  • async = 现在给句柄,稍后给结果
  • remote = 现在给远端句柄,稍后给结果

10. 三条路的真正汇合点不在 AgentTool,而在 notification reflow

这点非常关键。

local async 完成时:

  • enqueueAgentNotification(...)

remote 完成时:

  • enqueueRemoteNotification(...)

它们最后都会收束成:

  • enqueuePendingNotification({ value: message, mode: 'task-notification' })

然后由:

  • src/query.ts 在 turn 末尾 drain queued commands
  • src/utils/attachments.ts 转成 attachment
  • attachment 重新进入后续 query context

也就是说三路最终统一在:

不是统一在执行引擎层,而是统一在“完成后如何重新回到主对话”这一层。


11. runAgent() 的地位:只属于本地两路,不属于 remote

从代码上看:

sync / local async

都直接或间接依赖:

  • runAgent()
  • query()
  • createSubagentContext(...)

remote

不进入本地 runAgent() 主循环 而是走:

  • teleportToRemote()
  • RemoteAgentTask
  • 远端 session

所以如果做架构分层,可以这么看:

  • runAgent()local agent execution engine
  • teleportToRemote()remote launch bridge
  • LocalAgentTask / RemoteAgentTask两种 task runtime wrapper
  • task-notification reflow统一 completion protocol

12. 最简三路图

AgentTool.call()
  → resolve agent / prompts / tools / isolation
  → effectiveIsolation === remote ?
       → teleportToRemote()
       → registerRemoteAgentTask()
       → return remote_launched
    : shouldRunAsync ?
       → registerAsyncAgent()
       → background runAgent()
       → enqueueAgentNotification()
       → return async_launched
    :
       → foreground runAgent()
       → finalizeAgentTool()
       → return completed

如果把“后续回流”也加进去:

local async complete  → enqueueAgentNotification()
remote complete       → enqueueRemoteNotification()
                      → enqueuePendingNotification(mode=task-notification)
                      → query.ts drain queued commands
                      → attachments.ts
                      → next query context

13. 一个更准确的总判断

以前容易把 Claude Code 理解成:

  • AgentTool 调 runAgent
  • remote 只是 runAgent 的远端版

但更准确的说法应该是:

AgentTool 是统一入口没错,但 Claude Code 其实维护了两种不同的执行中台:本地 runAgent 中台远端 teleport/session 中台。两者靠统一的 task-notification 回流协议粘合在一起。

这也是为什么:

  • 本地 async / remote 在用户体验上相似
  • 但源码结构上明显不是同一条线

当前结论

三条路径的关系可以压缩成一句:

同一个 AgentTool 入口,分流到两种 execution engine(local / remote)和三种 lifecycle mode(sync / async / remote),最后再用同一种 notification re-entry 协议回到主线程。

这基本就是 Claude Code agent runtime 的总骨架。


下一步最值得继续追

  1. task-notification -> queued command -> attachment 再细化成序列图
  2. teleportToRemote()session_context / outcomes 字段语义
  3. 单独拆一页:foreground -> background 热切换为什么需要重新跑一条 async lifecycle