ReAct 与工具生命周期
将模型文本、完整工具参数、授权和完成事件接成可取消的异步执行循环。
先归一化模型事件
一次编程任务会在“模型决定下一步”和“工具产生环境反馈”之间反复切换。读取文件后再修改、命令失败后调整参数、权限拒绝后换成只读检查,都需要运行时把结果重新交回模型,而不是在界面里固定写死执行步骤。
不同模型协议对文本增量、工具参数和停止原因的表达不同。客户端把它们归一化为运行时可消费的事件,Agent 主循环据此生成文本、工具调用、工具结果、用量、错误与完成事件,界面无需复制各协议的判断。
事件中工具 ID 与工具名需要一起保留,失败结果也要带回原调用身份,否则对话历史中可能出现“有结果但找不到调用”的孤立记录。
| 事件类别 | 运行时需要保留的信息 | 用途 |
|---|---|---|
| 文本增量 | 内容与当前运行 | 连续显示模型输出 |
| 工具调用 | 工具 ID、名称、完整参数 | 建立执行与结果的关联 |
| 权限请求 | 工具信息、审批身份与理由 | 将决定交给授权路径 |
| 工具结果 | 原调用 ID、输出、错误与耗时 | 回写历史并更新工具状态 |
| 错误与完成 | 当前运行、停止原因与用量 | 确定本轮终止和后续处理 |
模型参数通常分多次传来,只有完整调用才能执行。文本已经出现工具名或 JSON 前缀,不代表参数模型已可校验;过早启动会让工具接收到半份参数,也会让审批内容与最终执行内容不一致。
边生成,边调度
完整工具参数到达后先经过统一授权。允许的工具可以交给 StreamingExecutor,由 asyncio.create_task 并行推进;还需要审批的调用不能因调度提前而绕过确认。
工具任务最终按提交顺序收集结果,完成的先后顺序不应打乱历史配对。普通工具异常转为带身份的错误结果;取消异常继续向上传播,不能把取消伪装成正常结果。
sequenceDiagram
participant M as 模型流
participant A as Agent
participant P as 授权管线
participant T as 工具任务
M->>A: 完整工具调用 A
A->>P: 解析工具、Hook 与权限检查
P-->>A: 允许
A->>T: 创建异步任务 A
M->>A: 后续文本或工具调用
T-->>A: 结果 A
A->>A: 按提交顺序整理结果与历史
A->>M: 下一次模型调用
这里的并发是调用已提交后的执行重叠,不是运行时自动证明不同写入互不冲突。若两个工具操作同一文件,仍需要任务设计、工具语义或 worktree 隔离避免竞争。模型一次提出多个调用,也不构成跨工具事务。
授权与执行只走一条管线
运行时先检查工具是否存在、是否启用,再运行 pre_tool_use Hook 和权限判断。未知工具、禁用工具和 Hook 拒绝都会形成明确结果;获得允许后,才验证参数模型并调用工具。
参数校验失败不算已执行,不触发执行后的 Hook;真正发起执行后,无论正常输出或普通工具错误,都会走恢复快照与后置 Hook 的处理。把这些分支集中起来,可以避免流式工具、延后审批工具与普通执行分支各自实现一套略有差异的规则。
错误结果使模型有机会修正调用,但不保证一定能自行解决。参数错误、权限拒绝、外部命令失败应分别呈现原因,不能都简化为“运行失败,请重试”。
取消必须等待收尾
取消界面上的加载状态,不代表工具已经停止。生成器关闭、异常、外层任务取消以及模型输出达到上限后的续跑路径,都需要清理已创建的子任务。
async def cancel_and_wait(self) -> None:
"""取消所有子任务并等待回收,然后清空任务列表。
每个退出路径(正常完成、max_tokens continue、异常、外层取消、
生成器 aclose)都必须在 finally 里调用它,避免工具任务在
「取消」之后继续跑、副作用落地。
"""
tasks = [task for _, task, _, _ in self._tasks]
self._tasks.clear()
for task in tasks:
if not task.done():
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
finally 回收让运行时能够知道本轮已结束,但无法撤销工具已经完成的外部副作用。需要恢复的文件操作仍应依靠版本控制或项目的文件历史机制,不能把“可取消”描述成任意操作都可回滚。
清理不仅覆盖用户主动点停止,也覆盖流生成器关闭、模型输出上限后的续跑、工具异常和调用方取消。cancel() 发出的是取消请求,随后 await gather(...) 才能回收任务的终止结果;不等待收尾就开启下一轮,可能留下前一轮仍在改变文件的任务。
这也解释了为什么 CancelledError 必须向上传播。把它包成普通工具错误后继续循环,会让用户以为已经停止的任务再次触发模型或工具调用。
Remote 的关联约束
Remote 服务为本轮维护 run 身份及待审批 Future,只有权限 ID 和运行身份都匹配才接受响应。取消后的旧响应被忽略,防止上一轮允许写入的点击落到新一轮请求上。
def _handle_permission_response(self, data: dict[str, Any]) -> None:
"""处理来自 Web UI 的权限回复。
回复必须命中当前 run 的 pending future:perm id 或 run id 对不上
(过期 run 的回复)一律丢弃,绝不落到新一轮的 future 上。
"""
perm_id = data.get("id", "")
response_str = data.get("response", "deny")
future = self._pending_perms.get(perm_id)
if future is None:
return
if future.done():
self._pending_perms.pop(perm_id, None)
self._pending_perm_runs.pop(perm_id, None)
return
perm_run_id = self._pending_perm_runs.get(perm_id, "")
requested_run_id = data.get("runId") or self._run_id
if perm_run_id != self._run_id or perm_run_id != requested_run_id:
return
self._pending_perms.pop(perm_id, None)
self._pending_perm_runs.pop(perm_id, None)
# 映射字符串到枚举
mapping = {
"allow": PermissionResponse.ALLOW,
"deny": PermissionResponse.DENY,
"allowAlways": PermissionResponse.ALLOW_ALWAYS,
}
response = mapping.get(response_str, PermissionResponse.DENY)
# future 可能已经结束(本轮被取消时会被 cancel),此时不能再回填结果
if not future.done():
future.set_result(response)
本站 mock 用独立的运行对象与审批 ID 保留同一不变量:取消后再响应旧审批不写入虚拟文件;后续仍能开始新任务。它不实现真实 Python 任务、系统进程和网络工具的取消。
检查这一层时看什么
首先检查工具调用与结果是否一一对应,再检查拒绝是否确实没有执行,最后检查停止后是否回收当前任务且能开始新轮。文本生成成功不能替代这三项验证,因为界面可正常显示文字时,工具历史或后台任务仍可能已经不一致。
