一次关于嵌套 agent 的设计复盘:执行结束是状态,可靠交付是协议。

1. await 能等到什么⌗

设想一个父 agent 派出子任务,调用 task_wait,拿到结果后继续综合。最顺手的实现是保存子任务的 Promise,父任务 await Promise.all(...)。

只要所有参与者都活在同一个进程里,这条路很自然。但 agent 运行时还会重启、挂起、恢复;任务身份的寿命可以比创建它的进程更长。

这时,内存里没有 Promise,并不等于没有任务。反过来,某个 Promise 完成,也不能自动证明父线程已经留下了可恢复的结果记录。

这次改动前,嵌套等待会去 pendingDeliveries 查本机的投递 Promise。查不到时,如果任务仍是运行态,就报「没有可等待的本机轮次」。问题不是异步语法用错了,而是把本机有没有等待句柄当成了任务能不能被等待。

改动后的核心判断不再问这个进程记不记得,而是问数据库:

  1. 子任务当前是哪一轮?
  2. 这一轮是否已经结束?
  3. 对应的结果事件是否已经持久化?

这里的进步不是换了一种等待姿势,而是换了真值来源。

2. 等任务,必须等对轮次⌗

一个子任务可以运行多轮。它上次留下的结果,不能拿来回答这次的等待。

新实现 waitForInlineTaskResults 将任务表和父线程的结果事件关联。关联条件不只是任务身份,还包括当前 run_id:

父线程身份
    + 子任务身份
    + 当前 run_id
            │
            ▼
    对应的持久化 task-result

只有请求中的任务都满足「状态非空且不再是 running,同时存在匹配结果事件」,等待才完成。

已知情况可以结束等待吗原因
只有上一轮结果不可以任务对了,轮次错了
当前轮次结果事件已到,任务仍在运行态不可以终态记账尚未完成
任务已是终态,当前轮次结果尚未到不可以不能拿状态代替内容
当前轮次终态与结果事件都在可以两份事实对齐

结果事件和终态可以先后到达。新逻辑不要求某种固定顺序,而是等待二者同时成立。这比猜测「回调跑到这里,另一边应该也写好了」更扎实。

已经被消费的事件仍参与完成判定,但不再进入返回的 results。因此,第二次等待可以得到「已完成,新增结果为空」,而不是重放整份证据。

另一个细节也值得保留:判断父线程是不是任务线程,改为读取持久化线程类型,不再靠名字是否带某个前缀。身份是数据,不应该靠字符串长相推断。

3. 读到了,不等于交付了⌗

即使等待已经基于数据库,还有一个更细的坑:结果既可以作为 task_wait 的工具回执出现,也可以在之后的回合边界作为事件出现。

如果前者读走内容,却没有留下消费记录,同一份结果就可能再送一次。

但也不能在读取时顺手标记「已消费」。看两个互为镜像的失败窗口:

两步分开执行中间崩溃会怎样
先标记消费,再写工具回执结果不再待投递,但父线程历史里也没有它
先写工具回执,再标记消费历史里已有结果,事件仍待投递,之后可能重复

正确的问题不是「哪一步先做比较安全」,而是「这两步为什么可以分开提交」。

这次修复让返回结果携带 event_id。运行时持久化工具回执时,为那条工具消息预分配 ID,再把事件的消费指针指向它:

一个数据库事务
┌──────────────────────────────────────────┐
│ 写入工具消息 M,包含子任务结果              │
│ 将事件 E 的 consumed_message_id 设为 M      │
└──────────────────────────────────────────┘
             一起提交,或一起回滚

消费动作采用条件更新,概念上是:

UPDATE thread_pending_events
SET consumed_message_id = :tool_message_id
WHERE id = :event_id
  AND consumed_message_id IS NULL;

源码还检查更新行数必须为一。事件已被另一批消息消费,或者根本不存在,都使当前批次失败,而不是继续写一份重复回执。

这枚指针比一个 consumed = true 更有用:它不仅说明结果已交付,还能说明交付进了哪条消息。

4. 修复必须到达消息写入层⌗

只改 task_wait 的函数体做不到上述保证。工具知道结果是什么,却不负责决定回执最终以哪条消息持久化。

所以改动跨过了几个层次:

层次责任
等待函数读取当前轮次的持久化终态与结果,返回事件 ID
原生 agent 循环为工具回执分配消息 ID,准备同事务消费操作
消息写入网关将消费操作传入消息批次的事务
CLI 工具回执路径在写回执的同一事务内消费事件
回合边界读取只取尚未被消费的事件

CLI 路径还把 resultAppended = true 移到了成功写入之后。这是同一个原则的内存版本:不要把「准备写」记录成「已经写」。

事件 ID 的提取也有明确范围:只有成功完成的 task_wait 返回,才会被识别为这类内联结果消费。普通工具返回一个看起来像事件 ID 的字段,不会因此获得消费语义。

这不是为了给工具多加几层包装。恰恰相反,是把交付确认放回唯一能作出可靠承诺的地方:消息提交事务。

5. 五项测试,把承诺拆开看⌗

该提交新增的 task-inline-wait.test.ts 包含五项测试。以下是测试代码检查的行为,不是本文重新执行测试得到的运行报告。

测试构造断言的性质
没有本地投递 Promise,先到旧轮次事件,再到当前轮次事件不依赖本机句柄,不误收旧结果
结果事件先到,终态稍后写入不把事件到达误当成终态完成
中断一个仍在等待的调用等待报中断,子任务仍是运行态
经原生工具循环写入结果消费指针指向工具消息;后续边界及再次等待不重复返回证据
同事务消费中故意加入不存在的事件整批回滚;证据保持待消费;重试可提交,重复消费被拒绝

最后一项尤其关键。它不只测试正常情况下「会返回结果」,而是检查失败后两个存储位置有没有分裂:消息不能写一半,事件也不能丢一半。

不过,测试范围也有边界:这里没有实际杀进程的故障注入,没有大规模并发压测,也没有在这五项中覆盖 CLI 的完整集成链路。源码建立了机制,测试验证了其中明确列出的路径;不必把它夸成全系统可靠性证明。

6. 这不是端到端 exactly-once⌗

这套改动解决的是一个具体边界:持久化结果事件进入父线程消息历史时,消费记录与消息写入保持原子一致。

它没有承诺子任务只执行一次,也没有让外部 API 的副作用自动幂等,更不能保证模型只思考一次。把局部事务一致性宣传成整个 agent 系统的 exactly-once,会把刚修好的边界又说糊涂。

还有实际代价。实现每次查询未满足条件后等待 250 毫秒,再读数据库;等待期间不需要模型参与。这个间隔来自源码,不是延迟性能承诺,查询耗时与调度也会影响观察到完成的时间。

轮询换来了不依赖进程内句柄的简单恢复语义,也增加了数据库读取。若将来需要降低读取成本,可以考虑用通知提示「该再查了」;但通知只能加速发现,不能重新变成真值来源。这是可选的后续设计,不是本次已有功能。

另一个边界是:只有终态、始终没有匹配事件时,等待不会假装成功。当前实现会继续等,直到条件满足或等待被中断。它没有顺便增加一套丢失结果的自动修复协议。

7. 真正该留下的是收据⌗

这次改动并不否定 Promise。Promise 很适合表达一个进程内的控制流;它不适合独自承担跨重启的交付事实。

嵌套 agent 要可靠,至少得把三件事分开:

  • 执行结束:当前轮次已经进入终态。
  • 结果可取:当前轮次的结果事件已经持久化。
  • 交付入账:结果已经进入父线程历史,并有同事务的消费记录。

前两件事决定什么时候可以返回,第三件事决定恢复后还要不要再送。

进程可以忘记自己曾经等过什么。数据库里不能只剩一句「应该送到了」。


源码依据:提交 781de22 中的 src/dispatch/task-runner.ts、src/dispatch/task-results.ts、src/runtime/direct-loop-host.ts、src/runtime/gateway/append-chunk-batch-op.ts、src/threads/inline-task-results.ts 与 src/threads/pending-events.ts;测试依据为 src/dispatch/task-inline-wait.test.ts。