Joye Dev

Back

fix(agent): batch resumed-stream replay bursts in the agents SDK client

这条线解决的是 Agent 聊天在网络断线重连、长时间多步执行、工具任务失败和页面刷新后恢复时的连续性问题:用户看到的文字不能闪烁或重复,健康完成的 turn 不能被误报成错误,失败任务不能被模型无意重试,已生成的页面和资源要按可见进度逐步落地


正文来源:飞书学习文档。以下为通过个人 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 创建;链接见最终回复。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back