feat(agent): send follow-ups mid-stream (soft interrupt)
💡 结论:#4844 把 Agent 长 turn 的控制面从“只能 Stop 或等完成”推进到“客户端暂存后续意图,服务端在 step 边界安全收束,客户端再按 FIFO 顺序续发”。它复用了前置的终态修复、framework recovery、durable asset replay 和 replay bur…
source_automation: “voyager-merged-pr” run_date: “2026-07-19” anchor_pr_number: 4844 pr_number: 4844 pr_title: “feat(agent): send follow-ups mid-stream (soft interrupt)” pr_url: “https://github.com/adastralab-ai/voyager/pull/4844 ↗” author: “Horcrux / magicismight” merged_at: “2026-07-18T14:40:10Z” modules: [“backend/workers/agent/src/agent.ts”,“packages/site/src/app/(main)/_agent/chat”,“packages/site/src/app/(main)/_agent/chat-composer”] files_changed: 6 learning_tags: [“agent-runtime”,“soft-interrupt”,“step-boundary”,“fifo-queue”,“durable-transcript”,“recovery”,“websocket-stream”,“user-visible-progress”,“idempotency”,“reliability-test”] business_line: “Agent 对话软打断与可恢复进度可靠性” related_prs: [4599,4658,4720,4895] line_stage: “终态 frame/repair -> framework recovery -> durable asset replay -> replay burst batching -> soft interrupt + FIFO follow-ups” open_questions: [“stopWhen 只在 step 边界检查,需要用真实长工具测量 soft interrupt 的尾延迟。”,“queuedMessages 仅在 React state 中,需验证 file handoff、重挂载、跨 tab 与 websocket 重连时的生命周期和错序风险。”,“requestSoftInterrupt 的 RPC 失败目前被静默 catch,需要决定日志、降级 UI 和自然完成竞态的处理。”,“需要用真实 agents WebSocket 覆盖多客户端追加、重连、messageId 幂等和队列 drain。”,“升级 agents SDK 或 Go asset API 时重新验证 replay burst、error flush 和 asset refresh。”] feishu_doc_url: “https://my.feishu.cn/docx/T4FYdhYjnoTytix4A7Qc2vcznWd ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 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 |
| 锚点作者 / merge | Horcrux / 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:56Z | Agent DO / recovery / WebSocket | skip recovery 原本静默;补 durable terminal record、done terminal frame 和重连补发,使客户端能离开 streaming 并看到 interrupted。 |
| 恢复 ownership | #4658 ↗ 2026-07-12T13:17:36Z | Agent DO / chat UI | 删除自定义 continueLastAssistantTurn 与 settleSkippedRecoveryTurn,恢复 framework chat recovery;Continue 改成普通 user message,使客户端天然拥有续跑 transport。 |
| 资源 replay | #4720 ↗ 2026-07-13T17:55:02Z | transcript schema / tool runtime | 持久化 assetId,不持久化会过期的 signed URL;每个 turn 重新 batchGetAssets,缺失资源降级为 view_asset 提示。 |
| 流传输 replay | #4895 ↗ 2026-07-16T15:07:26Z | agents SDK client transport | 对 replay=true chunk 做同 part 合并和边界 flush,避免重连 burst 逐 chunk 触发 React 同步更新;有内容时先 flush 再交付 error chunk。 |
| 当前扩展 | #4844 ↗ 2026-07-18T14:40:10Z | Agent 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 --> Dplaintext- 用户入口:AgentChat 将 canSubmitWhileGenerating 传入 ChatComposer;有输入时发送按钮保持可用,空输入时仍显示 Stop。QueuedMessages 在 composer 上方显示未发送的后续消息,只有未送出的项可取消。
- 客户端控制面:AgentActiveChat 以 {id, input, isSent} 保存队列。active turn、已有队列或 flush pending 时不直接发送;active turn 时调用 agent.stub.requestSoftInterrupt,然后生成 UUID 并追加到队列。
- 服务端运行时:Agent Durable Object 把 softInterrupt 从 none 置为 requested;streamText 的 stopWhen 在 step 完成后检查它,置为 stopped,关联 options.requestId,并让正常 stream 以 interrupted metadata 结束。
- 持久化与业务收尾:persistMessages 在 assistant batch 层补 interrupted;onChatResponse 通过 requestId 集合避免下一轮 reset 覆盖标记,并跳过被软打断 turn 的 suggestions 生成,但保留 activity 记录。已完成的工具副作用不回滚。
- 续发与顺序:客户端进入 ready 后只取第一个 !isSent 项,使用同一个 messageId 调用 sendUserMessage;等 transcript 中该 user message 后出现 assistant response 才移除 staged 项。队列清空后才触发 onMessageSubmitted,避免 file-bound chat 中途跳转。
- 可靠性依赖:如果历史消息含图片,#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 的可中断与续发。
我会怎么吸收#
- 把“用户意图追加”拆成控制面和数据面:控制面只请求当前执行边界收束,数据面由有明确 messageId 的新 user message 顺序发送。
- 对长 turn 先定义终态和恢复 ownership,再设计 UI:客户端必须知道当前请求何时真正结束,恢复消息必须由拥有 transport 的一方发起。
- 持久化稳定身份,运行时物化短生命周期资源:assetId、requestId、messageId 负责跨重连关联,signed URL 和 replay chunk 只在当轮/当次连接生成。
- 把跨层竞态写成状态机和回归测试:requested/stopped、isSent/unsent、streaming/ready、replay/error 都应有明确边界,而不是依靠 UI timing。
- 保留已完成副作用并让后续 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 链接#
- #4844 feat(agent): send follow-ups mid-stream (soft interrupt) ↗
- #4599 fix(agent): send a terminal frame when skipped recovery settles a turn ↗
- #4658 fix(agent): restore framework chat recovery, send Continue as a plain user message ↗
- #4720 fix(agent): resolve image preview URLs from assetIds at replay time ↗
- #4895 fix(agent): batch resumed-stream replay bursts in the agents SDK client ↗