Joye Dev

Back

feat(agent): send follow-ups mid-stream (soft interrupt)

💡 结论:#4844 把 Agent 长 turn 的控制面从“只能 Stop 或等完成”推进到“客户端暂存后续意图,服务端在 step 边界安全收束,客户端再按 FIFO 顺序续发”。它复用了前置的终态修复、framework recovery、durable asset replay 和 replay bur…


正文来源:飞书学习文档。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。

💡 结论:#4844 把 Agent 长 turn 的控制面从“只能 Stop 或等完成”推进到“客户端暂存后续意图,服务端在 step 边界安全收束,客户端再按 FIFO 顺序续发”。它复用了前置的终态修复、framework recovery、durable asset replay 和 replay burst batching,但没有引入新的持久化队列或回滚已执行副作用。

元数据#

字段内容
业务线Agent 对话软打断与可恢复进度可靠性
锚点 PR#4844 feat(agent): send follow-ups mid-stream (soft interrupt)
关联 PR#4599、#4658、#4720、#4895
锚点作者 / mergeHorcrux / magicismight;2026-07-18T14:40:10Z(Melbourne 2026-07-19 00:40:10 AEST)
origin/main 证据已执行 git fetch —prune origin;当前 origin/main=db050ce30a5a411dc1b80da784d61ecdc7b0051e。锚点及四个关联 PR 的 merge commit 均可由 origin/main 追溯。
模块backend/workers/agent;packages/site Agent chat;packages/site chat-composer;agents SDK patch
学习标签agent-runtime、soft-interrupt、step-boundary、FIFO queue、durable transcript、recovery、WebSocket stream、user-visible progress、idempotency、reliability test

业务线概览#

用户在 Agent 生成长内容、执行多步工具调用或等待重连恢复时,通常有两个冲突诉求:不想丢掉当前已经完成的工作,又想立即改变方向。当前这条线的范围是 turn 的生命周期与用户可见进度:异常恢复如何终态化、部分 transcript 如何继续被 provider 消费、历史图片资源如何在 replay 时恢复、客户端如何承受 replay burst,以及进行中的 turn 如何接受后续意图。

本次锚点解决的是最后一段:流式期间再次发送不再直接调用 sendMessage,也不立即 abort 当前 turn;输入先进入 React 本地 FIFO,向 Agent Durable Object 发一个幂等的 requestSoftInterrupt RPC,当前 turn 在下一个 step 边界结束,之后队列逐条发送。

今日锚点#

PR #4844 feat(agent): send follow-ups mid-stream (soft interrupt),作者 Horcrux(magicismight),合入时间 2026-07-18T14:40:10Z,链接见元数据。它是今天 Melbourne 自然日最合适的入口:今天 7 条候选中,#4844 同时修改 Agent worker、聊天上下文、composer、提交按钮和回归测试,共 389 additions / 25 deletions / 6 files,既有运行时状态协议,也有用户流程和跨层测试;#4940、#4950 偏观测补字段,#4937 偏 prompt/eval 指导,#4615 属于 CODE 编辑线,文档与依赖候选不满足本次业务线学习目标。

演进时间线#

阶段PR改变的层本次关注点
终态可见性#4599 2026-07-10T12:55:56ZAgent DO / recovery / WebSocketskip recovery 原本静默;补 durable terminal record、done terminal frame 和重连补发,使客户端能离开 streaming 并看到 interrupted。
恢复 ownership#4658 2026-07-12T13:17:36ZAgent DO / chat UI删除自定义 continueLastAssistantTurn 与 settleSkippedRecoveryTurn,恢复 framework chat recovery;Continue 改成普通 user message,使客户端天然拥有续跑 transport。
资源 replay#4720 2026-07-13T17:55:02Ztranscript schema / tool runtime持久化 assetId,不持久化会过期的 signed URL;每个 turn 重新 batchGetAssets,缺失资源降级为 view_asset 提示。
流传输 replay#4895 2026-07-16T15:07:26Zagents SDK client transport对 replay=true chunk 做同 part 合并和边界 flush,避免重连 burst 逐 chunk 触发 React 同步更新;有内容时先 flush 再交付 error chunk。
当前扩展#4844 2026-07-18T14:40:10ZAgent DO / chat context / composer把中途 follow-up 建模为本地 FIFO + step-boundary soft interrupt;队列排空前延迟 file-bound chat 的导航。

其中 #4599 是重要但已被 #4658 部分取代的中间方案:它说明“终态 frame 是 transport contract”,而 #4658 将恢复 ownership 交回 framework。#4844 不是恢复 Continue 的回归,而是另一种轻量 RPC:只请求当前 turn 收束,不负责重新跑旧 turn。

当前架构与数据流#

flowchart LR
  U[用户在流式期间输入] --> C[ChatComposer]
  C --> Q[AgentChatContext 本地 FIFO]
  Q -->|当前 turn active| R[requestSoftInterrupt RPC]
  R --> D[Agent Durable Object]
  D -->|stopWhen| S[下一个 step 边界停止]
  S --> P[interrupted transcript + terminal status]
  P --> F[客户端 status ready]
  F --> N["sendMessage(messageId)"]
  N --> D
  D --> W[WebSocket UI-message stream]
  W --> Q
  D --> A[AssetResolver / fresh signed URLs]
  A --> D
plaintext
  1. 用户入口:AgentChat 将 canSubmitWhileGenerating 传入 ChatComposer;有输入时发送按钮保持可用,空输入时仍显示 Stop。QueuedMessages 在 composer 上方显示未发送的后续消息,只有未送出的项可取消。
  2. 客户端控制面:AgentActiveChat 以 {id, input, isSent} 保存队列。active turn、已有队列或 flush pending 时不直接发送;active turn 时调用 agent.stub.requestSoftInterrupt,然后生成 UUID 并追加到队列。
  3. 服务端运行时:Agent Durable Object 把 softInterrupt 从 none 置为 requested;streamText 的 stopWhen 在 step 完成后检查它,置为 stopped,关联 options.requestId,并让正常 stream 以 interrupted metadata 结束。
  4. 持久化与业务收尾:persistMessages 在 assistant batch 层补 interrupted;onChatResponse 通过 requestId 集合避免下一轮 reset 覆盖标记,并跳过被软打断 turn 的 suggestions 生成,但保留 activity 记录。已完成的工具副作用不回滚。
  5. 续发与顺序:客户端进入 ready 后只取第一个 !isSent 项,使用同一个 messageId 调用 sendUserMessage;等 transcript 中该 user message 后出现 assistant response 才移除 staged 项。队列清空后才触发 onMessageSubmitted,避免 file-bound chat 中途跳转。
  6. 可靠性依赖:如果历史消息含图片,#4720 的 turn-local AssetResolver 在建 tools 前重新解析 assetId;如果连接重连,#4895 的 patched WebSocketChatTransport 先压缩 replay burst,再把内容交给 ai-sdk。

关键代码#

以下片段均来自已合入 origin/main 的真实 diff 或当前实现;行号以当前 origin/main 为准,PR diff 位置用于标识变更来源。

1. 服务端只在 step 边界软停止(#4844)#

stopWhen: [
  stepCountIs(MAX_STEP_COUNT),
  () => {
    if (this.softInterrupt !== "requested") return false;
    this.softInterrupt = "stopped";
    const requestId = options?.requestId;
    if (requestId) this.softInterruptedRequestIds.add(requestId);
    return true;
  },
],
plaintext

设计点:软打断不是 abortSignal,也不是立即杀掉正在执行的工具;它复用 streamText 的 step 完成边界,保留已完成工作,并用 requestId 把当前 turn 的 interrupted 状态与后续 onChatResponse 对齐。

2. 持久化层显式区分 aborted 与 interrupted(#4844)#

const markInterrupted =
  this.softInterrupt === "stopped" &&
  isAssistant &&
  lastMetadata?.interrupted !== true;

metadata: {
  ...(lastMetadata ?? {}),
  ...(markAborted ? { aborted: true } : {}),
  ...(markInterrupted ? { interrupted: true } : {}),
}
plaintext

设计点:用户主动 Stop 和系统按 soft interrupt 收束是两个语义。两者都需要在 batch 层标记,因为 sanitizeMessageForPersistence 逐消息执行时看不到当前 batch 的最后 assistant 是否就是被截断的 turn。

3. 客户端先排队,再按稳定 id 顺序发送(#4844)#

const isTurnActive = isStreaming || status === "submitted";
if (
  status !== "error" &&
  (isTurnActive || queuedMessages.length > 0 || flushingRef.current)
) {
  if (isTurnActive) {
    void Promise.resolve(agent.stub.requestSoftInterrupt?.()).catch(() => {});
  }
  const id = crypto.randomUUID();
  setQueuedMessages((prev) => [...prev, { id, input, isSent: false }]);
  return true;
}
const next = queuedMessages.find((queued) => !queued.isSent);
const submitted = await sendUserMessage(next.input, next.id);
plaintext

设计点:队列和服务端 transcript 解耦。isSent 项先隐藏服务端可能广播的同一 user message,直到其后出现 assistant response;messageId 让后续发送具备幂等/去重锚点,FIFO 由 find 第一个未发送项保证。

4. 终态 frame 先于 UI 收敛(#4599,后被 framework recovery 方案取代)#

private chatTerminalFrame(requestId: string): string {
  return JSON.stringify({
    type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
    id: requestId,
    body: "",
    done: true,
    replay: true,
  });
}
plaintext

设计点:#4599 证明客户端只有收到 requestId 对应的 terminal frame 才能退出 streaming;它还把 terminal record 持久化并在 onConnect 重放。#4658 随后删除这套自定义 Continue/RPC,改用 framework recovery 和普通 user message,但“必须有明确终态”这一约束仍是当前线的基础。

5. replay 时重新物化图片资源(#4720)#

export function buildAssetImagePart(
  assetId: string,
  resolver: AssetResolver,
): AssetImagePart {
  const url = resolver.get(assetId);
  if (url) return { type: "image-url", url };

  return {
    type: "text",
    text: "Preview unavailable. Use view_asset with assetId ...",
  };
}
plaintext

设计点:transcript 只保存 durable assetId,签名 URL 在 turn 内重新生成;解析失败降级为文本提示而不是让 provider 拒绝整个 replay。这样软打断后续的 queued turn 不会被旧资源 URL 反噬。

6. replay burst 在客户端边界 flush(#4895)#

class ReplayChunkBuffer {
  chunks = [];
  push(chunk) {
    const prev = this.chunks[this.chunks.length - 1];
    // merge only compatible adjacent deltas
    this.chunks.push(chunk);
  }
  flush(controller) {
    const batch = this.chunks;
    this.chunks = [];
    for (const chunk of batch) controller.enqueue(chunk);
  }
}
plaintext

设计点:重连时把“服务端已积累的内容”按 replayComplete、done、首个 live chunk 等边界批量交给 ai-sdk;有内容的 error 先 flush 再发 error chunk,避免 controller.error 清掉尚未消费的恢复内容。

工程取舍#

  • 边界:soft interrupt 只改变当前 turn 的继续条件,不改变 Go API、任务存储或 billing schema;已执行的工具副作用保留,下一轮从持久化 transcript 继续。
  • 并发:softInterrupt 状态在单个 Agent DO 内幂等设置;softInterruptedRequestIds 解决下一轮 onChatMessage 重置状态后,旧 turn 的 onChatResponse 仍需保留 interrupted marker 的竞态。客户端 flushingRef 防止 ready 窗口重复 flush。
  • 兼容性:#4658 选择普通 user message 作为 Continue,以获得 ai-chat transport ownership;#4844 的 follow-up 仍用 callable RPC 只发送控制信号,避免把“停止当前 turn”伪装成新 user message。
  • 性能:#4895 将 replay 应用从逐 chunk 状态更新降到按 part 批次;#4720 用一次 batchGetAssets 预热历史 asset,而不是每个 tool output 单独取 URL。
  • 测试:#4844 新增 AgentChatContext 测试,覆盖两条 queued follow-up、FIFO、每轮完成后再发下一条、队列清空后才导航;#4599 覆盖在线/离线重连终态,#4720 覆盖资源刷新失败降级,#4895 做 2000 replay chunk 和 error 收尾冒烟。

和最近学习记录的关系#

这条线已经被最近记录覆盖,但本次不是重复 #4895。2026-07-17 的学习记录以 #4895 为锚点,重点是 agents SDK 的 replay burst batching;2026-07-14 的 #4720 重点是 durable assetId 与 replay-time signed URL。本次新增的阶段是“用户在 turn 尚未结束时主动追加意图”:关注客户端本地队列、服务端 step-boundary stopWhen、interrupted 与 aborted 的语义分离,以及队列排空前的文件导航约束。

#4591 曾在最近记录中作为并行背景说明多页生成要逐页 commit;它证明用户可见进度是 Agent 协议的一部分。本报告不重复其 prompt/eval 细节,只把同一原则落到通用聊天 turn 的可中断与续发。

我会怎么吸收#

  1. 把“用户意图追加”拆成控制面和数据面:控制面只请求当前执行边界收束,数据面由有明确 messageId 的新 user message 顺序发送。
  2. 对长 turn 先定义终态和恢复 ownership,再设计 UI:客户端必须知道当前请求何时真正结束,恢复消息必须由拥有 transport 的一方发起。
  3. 持久化稳定身份,运行时物化短生命周期资源:assetId、requestId、messageId 负责跨重连关联,signed URL 和 replay chunk 只在当轮/当次连接生成。
  4. 把跨层竞态写成状态机和回归测试:requested/stopped、isSent/unsent、streaming/ready、replay/error 都应有明确边界,而不是依靠 UI timing。
  5. 保留已完成副作用并让后续 turn 从 canonical transcript 继续,避免为了可见的“取消”引入不可逆的全量 rollback。

边界、风险与未解问题#

  • step-boundary 延迟:stopWhen 只在 step 间检查;如果当前工具调用本身很慢,用户点击后仍会等待该工具返回,需要用真实长工具测量尾延迟。
  • 队列生命周期:queuedMessages 位于 React state,PR 明确接受刷新丢弃未发送草稿;需要继续确认 file handoff、组件重挂载、跨 tab 和 websocket 重连时的实际生命周期是否与这个取舍一致。
  • 控制信号可观测性:requestSoftInterrupt 的 RPC 错误被 catch 后忽略,当前用户仍会看到排队消息;需要决定是否记录失败、展示降级状态或在当前 turn 已自然完成时区分体验。
  • 多连接竞态:server-side softInterrupt 是 DO 级状态,queue 却是单客户端状态;双 tab 同时追加、重连与新 turn 交错时,需要验证 requestId、transcript snapshot 和 stagedIds 不会造成重复或错序。
  • 资源与 patch 维护:#4720 的 asset refresh 仍依赖 Go batchGetAssets;#4895 依赖 agents@0.17.3 patch。升级 SDK 或 Go API 时要重新验证 replay 边界、error flush 和资源降级。
  • 测试缺口:当前新增测试主要验证 React context 的模拟状态,不覆盖真实 agents WebSocket、step 边界、长工具、刷新丢草稿和多客户端并发。

候选说明#

Melbourne 时间窗口按 UTC+10 计算:今天为 2026-07-18T14:00:00Z 至 2026-07-19T14:00:00Z,共 7 条 merged PR;昨天为 2026-07-17T14:00:00Z 至 2026-07-18T14:00:00Z,共 6 条。两天窗口内没有命中本地 study-log.jsonl 中已作为锚点学习过的 PR,因此去重跳过数量为 0;更早历史中的 #4895 和 #4815 保留为背景,不再重复选作锚点。

选择 #4844 的原因是它同时覆盖 Agent worker、WebSocket chat UI、composer 状态和测试,并且能自然串起 #4599、#4658、#4720、#4895 四个已合入 origin/main 的阶段。#4937 虽有 eval 价值但偏图片工具描述;#4940/#4950 主要是观测补充;#4939/#4942/#4941 是文档;#4932/#4922 是依赖/模板升级;它们都不是今天这条业务线的更好入口。

PR 链接#

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

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

← Back