query.ts 中 tool_use → tool_result 收束逻辑

目标

这页回答:

query() 主循环里,assistant 流式吐出来的 tool_use,最后是怎么被收束成成对的 tool_result,再继续下一轮推理的?

一句话结论

query() 对工具调用的要求非常严格:

只要 assistant 产出了 tool_use,系统就必须把对应的 tool_result 补齐,再把更新后的消息历史送回主循环。

这就是为什么它需要:

  • StreamingToolExecutor
  • runTools()
  • 中断补偿逻辑
  • fallback 时 synthetic result

query.ts 里能看到的几个关键点

1. 主循环会显式跟踪 tool_use

文件里反复围绕这些对象打转:

  • assistant message content 里的 tool_use
  • user message content 里的 tool_result
  • StreamingToolExecutor
  • runTools()

这说明 query() 的核心协议之一就是:

assistant tool_use

user tool_result

不是可选,而是必须成对。


2. StreamingToolExecutor 是流式路径的关键

query.ts 里能看到它会被创建,并在 streaming 过程中不断接收工具。

它的职责不是单纯“跑工具”,而是:

  • 工具边到边跑
  • progress 先吐
  • 最终结果按顺序吐
  • 出错 / fallback / 中断时补 synthetic result

3. fallback 时必须丢弃旧 executor

从 grep 出来的注释能看出一个很重要的设计点:

如果流式执行失败并 fallback,必须创建一个全新的 executor,避免旧的 tool_result 带着旧 tool_use_id 混进来。

这其实是在维护协议一致性:

  • tool_use 的 id
  • tool_result 的 id

必须严格配对。

不然 transcript 会变脏,甚至 API 直接报错。


4. query 会消费 getRemainingResults()

这个点很关键,注释已经写得很明:

就算工具流中断,也必须把 getRemainingResults() 消费完。

原因是:

  • executor 会为未完成工具生成 synthetic tool_result
  • 如果不把这一步吃完,就会留下孤儿 tool_use

而 Claude message protocol 对这种情况非常敏感。


收束逻辑的真正含义

你可以把 query() 里的工具阶段理解成这样:

阶段 1:assistant 正在流式产出 tool_use

系统先把这些 tool_use block 收集起来。

阶段 2:交给工具执行层

可能走:

  • StreamingToolExecutor.getRemainingResults()
  • runTools(...)

阶段 3:把产生的 user messages 回灌

工具执行层返回的是:

  • progress message
  • user message(包含 tool_result
  • context update

阶段 4:确保每个 tool_use 都有 tool_result

如果中途:

  • fallback
  • sibling error
  • user interrupt
  • abort

系统仍然会尽量补齐 synthetic result,保证配对完整。

阶段 5:更新后的 message history 再进入下一轮 query

也就是说,模型下一轮看到的,不是“我刚才想调工具”, 而是“我刚才调了工具,并且拿到了结果”。


为什么要这么严格?

因为在 Claude 这类工具调用协议下:

  • assistant 的 tool_use
  • user 的 tool_result

本来就是同一段轨迹的两半。

如果断在中间,会出大问题:

  • API 不接受
  • transcript 不一致
  • fallback 无法恢复
  • 后续 compact / cache / resume 都会出错

所以 query.ts 的核心不是“调用工具”,而是:

维护一条完整、合法、可继续的 assistant ↔ tool ↔ user 轨迹。


和 AgentTool 的关系

这对 AgentTool 一样成立。

即使 AgentTool.call() 内部又启动了一个子 agent, 在父主循环这层,它仍然必须表现成一次合法工具调用:

  • assistant 先发 tool_use(name=Agent)
  • 系统执行 AgentTool
  • 最后父循环一定拿到一个 tool_result
    • 可能是 completed
    • 可能是 async_launched
    • 可能是 remote_launched
    • 也可能是错误型结果

所以从父循环看,agent 调用和别的工具并没有协议上的特殊待遇。


当前结论

query.ts 对工具调用最核心的责任不是“让工具跑起来”,而是:

保证任何 tool_use 最终都会被收束成一条完整的、可继续推理的消息轨迹。

这也是整套系统能支持:

  • 流式工具执行
  • fallback 重试
  • 中断恢复
  • 子 agent 嵌套调用

却不把消息协议搞炸的关键。