fix(agent): batch resumed-stream replay bursts in the agents SDK client
这条线解决的是 Agent 聊天在网络断线重连、长时间多步执行、工具任务失败和页面刷新后恢复时的连续性问题:用户看到的文字不能闪烁或重复,健康完成的 turn 不能被误报成错误,失败任务不能被模型无意重试,已生成的页面和资源要按可见进度逐步落地
source_automation: “voyager-merged-pr” run_date: “2026-07-17” anchor_pr_number: 4895 pr_number: 4895 pr_title: “fix(agent): batch resumed-stream replay bursts in the agents SDK client” pr_url: “https://github.com/adastralab-ai/voyager/pull/4895 ↗” author: “Horcrux / magicismight” merged_at: “2026-07-16T15:07:26Z” modules: [“patches/agents@0.17.3.patch”,“pnpm-workspace.yaml”,“pnpm-lock.yaml”,“packages/site/src/app/(main)/_agent/chat”,“backend/workers/agent/src”,“agent-eval”] files_changed: 3 learning_tags: [“agent-runtime”,“websocket-stream”,“resume-replay”,“ui-message”,“terminal-error”,“asset-lifetime”,“idempotency”,“agent-eval”,“dependency-patch”,“reliability”] business_line: “Agent 对话可恢复流与用户可见进度可靠性” related_prs: [4516,4591,4720,4637] line_stage: “终态失败协议 -> 长 turn 逐页进度 -> replay-time 资源物化 -> durable agent profile -> 客户端恢复流批处理” open_questions: [“为真实 agents SDK transport 增加 replay frame 回归测试,覆盖长 replay、首个 live chunk、done、replayComplete、空 buffer error 和带内容 error。”,“评估 ReplayChunkBuffer 的内存上限/背压,以及 providerMetadata 高频出现时的更新压力。”,“升级 agents 版本时验证 patch hunk、lockfile hash、resume 行为和上游是否已内含修复。”] feishu_doc_url: “https://my.feishu.cn/docx/HirydysE2o40U7xaqf9cGWAFnxr ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。
元数据#
- 业务线:Agent 对话可恢复流与用户可见进度可靠性
- 锚点:PR #4895;merge commit bf6e719345358442ca17539d2f595f1af8704465,已可达 origin/main
- 相关 PR:#4516、#4591、#4720、#4637;均已可达 origin/main
- 锚点作者:Horcrux / magicismight;合并时间:2026-07-16T15:07:26Z(2026-07-17 01:07:26 AEST)
- 锚点文件:patches/agents@0.17.3.patch、pnpm-lock.yaml、pnpm-workspace.yaml;共 3 个文件
- 学习标签:agent-runtime、websocket-stream、resume-replay、ui-message、terminal-error、asset-lifetime、idempotency、agent-eval、dependency-patch、reliability
业务线概览#
这条线解决的是 Agent 聊天在网络断线重连、长时间多步执行、工具任务失败和页面刷新后恢复时的连续性问题:用户看到的文字不能闪烁或重复,健康完成的 turn 不能被误报成错误,失败任务不能被模型无意重试,已生成的页面和资源要按可见进度逐步落地。
当前范围横跨浏览器端 WebSocket/UI-message 流、Cloudflare AIChatAgent Durable Object 的消息与模式状态、资产 URL 的按 turn 重物化、图片任务的幂等与终态协议,以及 agent-eval 对进度和不重试行为的约束。本次锚点补上的是最靠近用户渲染入口的“恢复流突发交付”边界;它不改服务端生成 replay 帧的协议。
今日锚点#
PR #4895:fix(agent): batch resumed-stream replay bursts in the agents SDK client ↗,作者 Horcrux / magicismight,2026-07-16T15:07:26Z 合并(Melbourne:2026-07-17 01:07:26 AEST)。
它是今天最合适的入口:7 条今日候选中,只有这条直接处理了 Agent SDK 的恢复流、React 更新压力、tool continuation 和终态 error 的交互,并且将修复作为 agents@0.17.3 的精确 pnpm patch 接入,能把前置的持久化、幂等和 eval 设计串成一条真实运行时链路。
演进时间线#
PR #4516:fix(agent): surface terminal image failures as results, never throw ↗,2026-07-08T05:07:53Z(AEST 15:07:53):把图片任务的 credits、resolution、no_image、error 统一为带 reason 的失败结果;终态任务删除幂等记录后返回结果,让模型看到 final call 而不是可重试的 tool-error。变化层:任务 runtime、tool schema、提示词、聊天 UI 和 agent-eval。
PR #4591:fix(agent): commit each deck page before starting the next ↗,2026-07-10T15:43:14Z(AEST 2026-07-11 01:43:14):多页 deck 先写入并 commit 当前页,再开始下一页;补充 20 分钟的真实长 turn 超时和 trajectory assertion。变化层:生成流程的用户可见进度、eval provider 和设计技能契约。
PR #4720:fix(agent): resolve image preview URLs from assetIds at replay time ↗,2026-07-13T17:55:02Z(AEST 2026-07-14 03:55:02):transcript 只保存 durable assetId,turn 开始批量刷新历史资产,工具执行时向 AssetResolver 注入新签名 URL;刷新失败退化为 view_asset 提示。变化层:DO transcript、worker tool 输出、Go asset API 和可靠性测试。
PR #4637:feat(agent): mode-scoped agent profiles with cross-mode handoff ↗,2026-07-15T02:39:07Z(AEST 12:39:07):把创建模式作为持久但可显式切换的 profile,区分 durable mode 与本 turn 的 directive,并通过 switch_creation_mode 和 trajectory eval 约束切换。变化层:DO 状态、prompt cache 前缀、tool protocol 和 eval。
PR #4895:fix(agent): batch resumed-stream replay bursts in the agents SDK client ↗,2026-07-16T15:07:26Z(AEST 2026-07-17 01:07:26):在 patched WebSocketChatTransport 中缓存 replay=true 的 chunk,只合并相邻同 part delta,在 replayComplete、done 或首个 live chunk 处 flush;有 error 时先 flush 已有内容,再追加 UI error chunk 并 close。变化层:客户端 SDK transport 和依赖装配。
当前架构与数据流#
- 用户从站点 AgentChatContext 进入聊天;packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx:872-912 创建以 chatId 为 name 的 Agent、调用 useAgentChat,并开启 resume: true。experimental_throttle: 50 仍负责一般快速 chunk,但不能约束 replay 路径中的同步更新压力。
- 站点通过 WebSocket 连接 Cloudflare Agent;服务端响应使用 cf_agent_use_chat_response 帧,live、replay、replayComplete、done 和 error 是恢复边界的控制信号。
- PR #4895 的 patches/agents@0.17.3.patch 在 pnpm-workspace.yaml:111-113 注册,并通过 pnpm-lock.yaml 的 patch_hash 固定生效。修复位于客户端 WebSocketChatTransport:replay chunk 先入 ReplayChunkBuffer,再交给 AI SDK UI-message stream,避免每个 chunk 都重建 chat state。
- worker 侧 Agent.onChatMessage 以 Durable Object 的 this.messages 为 transcript,先在 backend/workers/agent/src/agent.ts:976-989 刷新历史 assetId,再在 :1004-1091 组装工具,最后在 :1131-1149 调用 streamText。这里的 AssetResolver 解决的是“恢复内容能否被模型重新读取”,#4895 解决的是“恢复内容能否被浏览器稳定消费”,两者是相邻但不同的边界。
- 图片任务沿 Go API -> task worker -> agent tool 返回 durable assetId;#4516 把 terminal failure 变成结构化结果,#4720 将短期签名 URL 延迟到当前 turn 生成,避免旧 URL 破坏后续模型请求。
- 多页设计继续通过 update_design / insert_page 逐页提交;#4591 的 trajectory eval 检查下一页 HTML 不能在上一页 commit 前开始,因此用户可见进度不是 UI 假象,而是工具调用顺序的协议。
关键代码#
1)先合并相邻同 part 的 delta,保留跨 part 顺序。来源:PR #4895 diff,patches/agents@0.17.3.patch:16-39。
if (chunk.type === "tool-input-delta" && prev.type === "tool-input-delta" && prev.toolCallId === chunk.toolCallId) {
prev.inputTextDelta += chunk.inputTextDelta;
return;
}
for (const chunk of batch) controller.enqueue(chunk);plaintext设计点:合并条件只覆盖连续同一 tool call;text-delta 和 reasoning-delta 还要求 part id 相同且没有 providerMetadata。带 providerMetadata 的 chunk 不被拼接,避免破坏供应商签名或元数据边界。
2)只在恢复边界交付批次,首个 live chunk 先 flush replay。来源:PR #4895 diff,patches/agents@0.17.3.patch:77-86 和 :120-129。
if (data.replay && !data.done && !data.replayComplete) replayBuffer.push(chunk);
else {
replayBuffer.flush(controller);
controller.enqueue(chunk);
}plaintext设计点:修复位于 transport,而不是 React 组件或 throttle 参数;因此重连时的 burst 从源头变成少量 UI-message 交付,正常 live 流仍然保持直通。
3)重放中的终态错误先交付已缓冲内容,再以 UI error chunk 结束。来源:PR #4895 diff,patches/agents@0.17.3.patch:59-69。
if (replayBuffer.isEmpty) controller.error(error);
else {
replayBuffer.flush(controller);
controller.enqueue({ type: "error", errorText: error.message });
controller.close();
}plaintext设计点:空 buffer 保留原 controller.error() 语义;非空时避免清空尚未消费的内容,同时让 AI SDK 的 error chunk 走与原 error 相同的 onError 终态。
4)资产只以 durable id 进入 transcript,URL 在当前 turn 查找;解析不到时给模型可行动的降级提示。来源:PR #4720 diff,backend/workers/agent/src/tools/assetResolver.ts:1-45。
const url = resolver.get(assetId);
if (url) return { type: "image-url", url };
return {
type: "text",
text: `Preview unavailable. Use view_asset with assetId "${assetId}".`,
};plaintext设计点:把持久化稳定性和资源短期可用性拆开;旧 URL 过期不会让整个 Anthropic 请求失效,模型仍能通过 view_asset 继续处理。
5)终态图片任务返回结果而不是抛异常,并清理已死亡的幂等记录。来源:PR #4516 diff,backend/workers/agent/src/tools/imageTaskTool.ts,diff hunk @@ -167,10 +171,15 @@。
if (outcome.status === "failed") {
await store.delete(idempotencyKey);
return {
status: "failed",
reason: "error",
...(outcome.errorCode != null ? { errorCode: outcome.errorCode } : {}),
};
}plaintext设计点:把“可重试的异常”与“已经退款、任务已死亡的业务失败”分开;后者必须是模型可读的 final result,避免重复调用和重复扣费。
6)把用户可见进度写成 trajectory 约束。来源:PR #4591 diff,agent-eval/cases/design/deck-interleave.yaml:1-29。
if (step.name === "update_design" || step.name === "insert_page") {
uncommitted = null;
continue;
}
// A new page must not start while the previous page is uncommitted.
if (uncommitted) return { pass: false, score: 0, reason: "uncommitted page" };plaintext设计点:eval 不只检查最终文本,而是检查工具调用顺序;这为长 turn 的恢复、进度展示和错误定位提供可观察的协议证据。
工程取舍#
- 修复层级:通过 pnpm patch 覆盖 agents@0.17.3 的客户端 dist,避免在业务 UI 中复制 transport 逻辑;pnpm-workspace.yaml 和 lockfile 让修复可审计、可复现。上游修复进入依赖后可以删除 patches/agents@0.17.3.patch。
- 性能:PR 描述称重放交付从按 chunk 处理降为按 part 处理;实现只合并相邻 delta,不重排跨 part 数据。代价是长 replay burst 会在内存中暂存,且带 providerMetadata 的 chunk 不能合并。
- 可靠性:#4895 修的是“已生成内容如何到达 UI”,#4720 修的是“历史内容如何获得新资源 URL”,#4516 修的是“工具失败如何结束 turn”,#4591 修的是“多页工作如何产生可见进度”;每层都保留自己的失败语义。
- 兼容性:error chunk 的处理依赖 AI SDK AbstractChat 将 onError 视为 terminal;patch 精确绑定 0.17.3,依赖升级或 dist 改版时必须重新验证 hunk 和 lockfile。
- 测试策略:#4516 有真实 tool execute 到 toModelOutput 的单测和两条 agent-eval;#4591 有长 turn provider 超时和 trajectory assertion;#4720 有批量资产刷新、去重、降级和 abort 测试。#4895 的 PR 描述给出 2,000 replay chunk 的行为冒烟,但当前 PR 自身只改 patch、lock 和 workspace,没有把这组回归测试纳入仓库。
和最近学习记录的关系#
有强关系但不是重复锚点。学习日志已经覆盖 #4720 的 assetId/签名 URL replay-time 物化、#4637 的 durable agent profile/handoff,以及上次 #4815 中的 render-time 资源解析。本次新增的是浏览器端 WebSocket transport:前几次保证“恢复后的消息和资源仍然可解释”,本次保证“这批恢复内容不会以 React 无法承受的突发方式交付”。
因此今天没有重新复述 #4815 的 DOC 封面管线,也没有把 #4637 的模式切换当成主线;只把它们作为持久状态和恢复语义的前置证据。
我会怎么吸收#
- 遇到高频更新事故,优先把修复放在数据/协议边界,先阻断不必要的状态提交,再考虑组件层节流。
- 把 durable transcript、turn-local resource、UI delivery 三种生命周期明确拆开,不让短期 URL 或客户端节奏污染持久状态。
- 把业务失败建模成带 reason 的 final result,把真正可能继续运行的 transient failure 保留为可重试异常。
- 对长任务验证工具调用时序和中间产物可见性,而不是只断言最终页面存在。
- 对第三方依赖 patch 记录精确版本、接入点、删除条件和最小行为冒烟,给未来升级留下明确收口路径。
边界、风险与未解问题#
- PR #4895 没有在 Voyager 仓库中增加 replay frame 的自动化回归测试;当前行为主要依赖 PR 描述中的手工冒烟。需要在真实 agents SDK transport 上覆盖长 replay、首个 live chunk、done、replayComplete、空 buffer error 和带内容 error。
- ReplayChunkBuffer 没有显式大小上限或背压策略;极长 turn 或异常重复 replay 可能提高浏览器内存峰值,需要结合服务端 transcript 上限继续验证。
- 双路径 resume 和 tool-continuation 各自维护一份 flush/error 逻辑,未来修复容易发生语义漂移;可以继续观察是否值得抽出共享纯函数,但当前 patch 选择了最小变更。
- providerMetadata 会阻止相邻 delta 合并,这是正确性优先的选择;需要确认实际模型/供应商是否会高频产生 metadata,避免退化为高频交付。
- 站点仍配置 experimental_throttle: 50,它只覆盖一般 chunk;应明确监控指标区分 live burst、replay burst、React error 和 isServerStreaming 卡住,避免把两类问题混为一谈。
- 上游 agents 版本升级后 patch 可能失效或已内含修复;升级时要检查 pnpm patch hash、dist hunk、resume 行为和依赖锁定,而不是只看安装成功。
候选说明#
按 Australia/Melbourne 当前日期窗口统计:今天 2026-07-17 有 7 条已合入候选;昨天 2026-07-16 有 21 条。今天候选没有已作为锚点学习过的 PR;昨天候选中 #4815 已在 2026-07-16 学习日志中出现,因此去重跳过 1 条。
选择 #4895 的原因是它同时具备明确的运行时边界、恢复/并发行为、兼容性收口和跨 PR 前后关系;#4889 是较窄的路由移除,#4896 是局部编辑选择装饰,#4882 是复用型 UI 重构,#4903 是 OTel 成本调优。它们各自有价值,但在今天的候选中没有 #4895 这样自然串起至少两个前置阶段的证据链。
飞书文档#
本报告已通过个人 Feishu 账号 joye 创建;链接见最终回复。