Joye Dev

Back

Voyager 业务线学习:Agent turn 异常终态、可续跑与用户可见恢复

这条业务线解决的不是“给聊天卡片换一种文案”,而是 Agent turn 在不同异常终止路径下,如何把真实原因传到持久化 transcript、下一轮模型输入、站点 affordance 和运行时观测中。用户能看到的差异包括


source_automation: voyager-merged-pr run_date: 2026-07-30 anchor_pr_number: 5425 pr_number: 5425 pr_title: “refactor(site): shared turn-notice shell; lock refused-over-interrupted priority” pr_url: https://github.com/adastralab-ai/voyager/pull/5425 author: “Jason Tan / banchichen” merged_at: “2026-07-30T04:24:41Z” modules:

  • backend/workers/agent/src/agent.ts
  • backend/workers/agent/src/routes/agentEval.ts
  • backend/workers/agent/src/sandbox/operations.ts
  • packages/common/src/agentChat/protocol.ts
  • packages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.test.tsx
  • agent-eval/cases/behavior/truncated-continue.yaml files_changed: 2 changed_files:
  • packages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.test.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.tsx learning_tags:
  • agent-runtime
  • abnormal-turn-finish
  • finish-reason
  • step-budget
  • continuation
  • safety-refusal
  • turn-state-machine
  • metadata-contract
  • transcript-persistence
  • user-visible-recovery
  • observability
  • reliability-test business_line: “Agent turn 异常终态、可续跑与用户可见恢复” related_prs: [5278, 5291, 5410, 5416, 5414] related_prior_prs: [5339, 5340] line_stage: “异常终态可观测 -> length 终态可续跑 -> budget 终态与单一预算源 -> content-filter 拒绝分支 -> refused/interrupted 优先级与共享 notice shell” open_questions:
  • “budget 只在 step 边界且最后一步 finishReason 为 tool-calls 时被标记;模型在边界返回其他终态、或框架在同一轮提前停下时,是否会再次落入 normal/length 误判,需要真实 turn 覆盖。”
  • “result.status=error 会保留 interrupted 但不一定有 finishReason;#5340 的 abandoned tool call 推断只覆盖未结算 tool,worker 已死亡但 tool 看起来已结算的路径仍需统一状态语义。”
  • “content-filter 若没有任何已流出的 parts,#5414 仍不会持久化可见消息;需要用 agent.turn.abnormal_finish 的真实分布决定是否补零流拒绝的 transcript marker。”
  • “step budget 对无人 platform task 没有用户可点击 Continue;#5410 已把‘少交付’显式化,但无人任务的自动续跑、失败和告警策略仍未在这条线内解决。”
  • “#5425 的 mutation test 只锁定组件状态优先级;真实拒绝、拒绝截断 tool input、reload、Continue/Regenerate 和多连接恢复之间的组合仍缺少端到端回归。”
  • “#5339 的 raceAbort 解除的是 worker 对 pending await 的等待,不会取消已经发给 sandbox 的底层进程;超时后的容器资源和后续 workspace checkpoint 需要运行时验证。” feishu_doc_url: null github_repository: joyehuang/ai-agent-field-notes github_path: outputs/voyager-daily-pr-study/2026-07-30-pr-5425-agent-turn-termination-recovery.md voyager_merge_commit: c4b504e41f3b0b74d70b944e6e62e65bad905414 voyager_origin_main_snapshot: 9054da9c1acc794fe106cc7045fe4c7a5c196ae0

业务线概览#

这条业务线解决的不是“给聊天卡片换一种文案”,而是 Agent turn 在不同异常终止路径下,如何把真实原因传到持久化 transcript、下一轮模型输入、站点 affordance 和运行时观测中。用户能看到的差异包括:

  • 模型输出达到 token 上限:已经完成的文件或页面不能重做,用户应该 Continue;
  • tool-call 步数预算耗尽:同样需要从已落地的工作继续,而不是被当作正常完成;
  • safety classifier 拒绝:不能给一个会诱导继续同一请求的 Continue;
  • worker/tool 在中途死亡:即使没有可靠的终态 metadata,也不能把半截回答伪装成正常结束。

工程范围从 Think 的 step finish/stop 条件开始,经 Agent.onChatResponse 归一化为 ChatMessageMetadata,写回 Durable Object transcript,再进入模型投影 marker 和 AgentMessageList 的状态机。并行的 sandbox abort 与 abandoned-tool 推断是这条链的边界,但不应和 finish-reason 主线混成一个无关 PR 集合。

今日锚点 #5425 表面上是 AgentMessageList 的共享 notice shell 重构,实际锁住了一个更重要的不变量:同一条消息可能同时满足“有未结算 tool call”和“content-filter 拒绝”,拒绝必须先于 interrupted 判断,否则用户会得到不适用的 Continue。它因此是异常终态从 worker metadata 收敛到用户操作语义的最后一层。

今日锚点#

字段内容
PR#5425 refactor(site): shared turn-notice shell; lock refused-over-interrupted priority
作者Jason Tan / banchichen
merge 时间2026-07-30 14:24:41 AEST / 2026-07-30T04:24:41Z
Voyager merge commitc4b504e41f3b0b74d70b944e6e62e65bad905414
变更规模2 个文件,60 additions / 26 deletions
选择理由今日 Melbourne 窗口内它同时覆盖状态优先级、用户操作边界和回归测试;前置 PR 已在 origin/main 建立 finish reason、Continue 和 refusal metadata,能串成一条真实代码演进。

#5425、正式关联的 #5278/#5291/#5410/#5416/#5414,以及并行边界 #5339/#5340 的 merge commit 都已用 git merge-base --is-ancestor <commit> origin/main 验证。本文引用的行号以本次读取的 origin/main snapshot 9054da9c1acc794fe106cc7045fe4c7a5c196ae0 为准。

演进时间线#

阶段PR 与真实代码变化改变的层形成的契约
异常终态可观测#5278,2026-07-28 02:05:39 AEST,merge 6594fff86ec4db52e938149bcecebdfaf48b683eWorker runtime / telemetryonStepFinish 保存最后一步的 finishReason、raw reason 和 refusal details;完成时把 length/content-filter 写入 agent.turn.abnormal_finishAGENT_USAGE
length 终态可续跑#5291,2026-07-28 14:31:40 AEST,merge fd7177a8e504164738ec68ef35dfd2bcdaf4bba6Worker -> protocol -> model/site只有真实 output cut 才映射为 finishReason: "length";消息继续带 interrupted、清除 finishTime,下一轮注入 <turn_truncated>,并用 behavior eval 验证 Continue 不从头重做。
budget 终态显式化#5410,2026-07-30 00:05:10 AEST,merge bac24a3bf653ac882c64d06149ba5f58c383ebf0Think stop condition / transcript metadatastep 预算边界不再表现为普通 tool-calls 完成;runtime.budgetExhausted 变成 finishReason: "budget",同时保留 interrupted: true、不写 finishTime、不生成建议。
预算源收敛#5416,2026-07-30 00:58:00 AEST,merge 1102619c1da562d641fefbd12e218213d5770de7Think 配置边界同一个 stepBudget 同时交给框架的 maxSteps 和自定义 stopWhen;避免 Think 内部的 stepCountIs 与 Voyager 自己的 budget marker 分叉。
safety refusal 可见化#5414,2026-07-30 11:30:28 AEST,merge ff0f1a7ba525728b0c44886bed6025cca1c2e68eWorker post-turn / site statecontent-filter 变成拒绝 notice,不提供 Continue;worker 也跳过由拒绝文本生成的 suggestions。拒绝 metadata 持久化后,reload 仍能重建这个状态。
今日锚点:状态优先级与共享壳#5425,2026-07-30 14:24:41 AEST,merge c4b504e41f3b0b74d70b944e6e62e65bad905414Site presentation / regression testRefusedTurnNoticeInterruptedTurnNotice 共用 TurnNoticeShell;新增拒绝截断 streaming tool 的测试,确保 refused 优先于 interrupted

并行边界:没有 metadata 时如何把 dead turn 暴露出来#

这两个 PR 读了真实 diff,但没有放进正式 related_prs,因为它们处理的是“工具/容器死亡”支线,而不是 finishReason campaign:

  • #5339backend/workers/agent/src/sandbox/operations.ts 增加 raceAbort。sandbox SDK 的 exec 只有 container-side timeout,没有 AbortSignal;容器中途消失时,原来的 await 会一直 pending,turn 甚至没有 metadata、usage 或 Sentry 事件。现在请求级 abort 至少能让 worker 退出等待。
  • #5340 在 site 侧复用 agents/chattoolPartHasSettledResultpartAwaitsClientInteraction,从未结算且无人继续交付的 tool call 推断 interrupted,补上 worker reset 后没有 onChatResponse metadata 的窗口。

这条支线说明:主线的 metadata 不是所有异常的唯一来源;#5425 的拒绝优先级测试正是为了防止两套信号叠加时把不可继续的拒绝误报成可继续中断。

当前架构与数据流#

flowchart LR
  A[用户消息] --> B[Think turn]
  B --> C[onStepFinish 保存 finish]
  B --> D[stopWhen 判断 soft stop 或 step budget]
  C --> E[Agent.onChatResponse]
  D --> E
  E --> F[ChatMessageMetadata]
  F --> G[addMessages 写回 DO transcript]
  F --> H[injectTurnStateModelMarkers]
  H --> I[下一轮模型输入]
  F --> J[getAssistantTurnState]
  J --> K[refused notice]
  J --> L[interrupted notice + Continue]
  E --> M[agent.turn.abnormal_finish]
  E --> N[AGENT_USAGE outcome blob]
  O[dead tool / no metadata] --> P[hasAbandonedToolCall]
  P --> J
plaintext
  1. onStepFinish 是 provider 结果进入 Voyager runtime 的第一处归一化。#5278 保存 reasonrawReason 和 Anthropic refusal details;这样 onChatResponse 不需要从已经失去上下文的最终 message 文本猜原因。
  2. stopWhen 同时承载用户 soft interrupt 和步数预算。#5410 只把最后一步为 tool-calls 的预算边界标成 budget,避免“到达调用上限”被误认为模型已经正常完成;#5416 再把相同数值交给 Think 的 maxSteps
  3. onChatResponse 计算 outcome -> finishReason -> isResumable/isPartiallengthbudget 没有 finishTime,保留 interrupted,因此下一轮可以从已完成副作用继续;content-filter 仍可有 finishTime,但有独立 finishReason,由 site 禁止 Continue。
  4. addMessages 把 metadata 与 assistant message 一起 upsert 到 Durable Object transcript。它使 reload、其他 tab 和后续模型 turn 看到同一终态;injectTurnStateModelMarkers 再把 lengthbudget 和 generic interrupted 映射成不同的 system-like user marker。
  5. site 的 getAssistantTurnState 只在 status === "ready" 下判断异常终态。stopped 先于一切,refused 先于 interrupted,然后才读取显式 interruptedhasAbandonedToolCall;这是一台小型状态机,而不是几个 notice 的独立 if。
  6. 观测支路不依赖 UI:agent.turn.abnormal_finish 只记录 length/content-filter,AGENT_USAGEoutcome 写入位置固定的 blob 列。这样可以分别回答“用户看到了什么”和“线上到底发生了多少异常”。

关键代码#

1. 先记录 closing step,再决定是否异常#

来源:PR #5278,当前 origin/mainbackend/workers/agent/src/agent.ts:1669-1705

private logAbnormalFinish(runtime: TurnRuntime, outcome: string): void {
  if (outcome !== "length" && outcome !== "content-filter") return;
  logger.error("agent.turn.abnormal_finish", {
    finishReason: outcome,
    rawFinishReason: runtime.finish?.rawReason,
    stopDetails: runtime.finish?.refusalStopDetails,
    steps: runtime.steps,
  });
}
plaintext

这里的设计点是“异常的可观测性从 runtime 事实开始”,而不是把前端的 interrupted 当作指标。#5278 同时把 outcome 追加到 WAE blob;blob 是位置寻址,所以代码注释明确把顺序当成契约。

2. 只把可安全 Continue 的 length 映射出来#

来源:PR #5291,当前 origin/mainbackend/workers/agent/src/agent.ts:534-545packages/common/src/agentChat/protocol.ts:34-45

function metadataFinishReason(outcome: string, rawReason: string | undefined) {
  if (outcome === "content-filter") return "content-filter";
  if (outcome === "length" &&
      (rawReason === "max_tokens" || rawReason === "MAX_TOKENS")) {
    return "length";
  }
  return undefined;
}
plaintext

length 不是所有 provider 的“长度相关”都可以续跑:context window overflow 也可能被 provider 归一为 length,但 Continue 会把同一份历史继续变大。PR 因此只接受明确的 output-token cut;这是一个有意的保守边界。

协议字段是可选的,当前允许 length | budget | content-filter。历史 transcript 没有字段时仍走原来的 interrupted/normal 分支;新字段只增加信息,不要求数据迁移。

3. budget 的检测要和框架停机条件在同一个边界#

来源:PR #5410PR #5416,当前 origin/mainbackend/workers/agent/src/agent.ts:1330-1365

const stepBudget = MAX_STEP_COUNT;

return {
  maxSteps: stepBudget,
  stopWhen: ({ steps }) => {
    if (this.softInterruptRequested) {
      this.softInterruptRequested = false;
      runtime.softStopped = true;
      return true;
    }
    if (steps.length < stepBudget) return false;
    runtime.budgetExhausted = steps.at(-1)?.finishReason === "tool-calls";
    return true;
  },
};
plaintext

#5410 解决“到第 50 步但 metadata 像干净完成”的问题;#5416 解决“自定义 stopWhen 看 50,但 Think 的 stepCountIs/maxSteps 可能先按另一份值停”的配置分叉。两个 PR 合起来才是单一预算源,而不是只把一个 if 改得更明显。

4. 把可续跑、完成时间和 suggestions 绑定到同一套分类#

来源:PR #5410,当前 origin/mainbackend/workers/agent/src/agent.ts:1575-1629

const finishReason = budgetExhausted
  ? "budget"
  : metadataFinishReason(outcome, runtime?.finish?.rawReason);
const isResumable = finishReason === "length" || finishReason === "budget";
const isPartial = isResumable || softInterrupted || result.status === "error";

finishTime: result.status === "completed" && !isResumable
  ? new Date().toISOString()
  : undefined,
interrupted: isPartial ? true : undefined,
finishReason,
plaintext

同一份分类控制三个 downstream 行为:时间戳是否存在、UI 是否展示 Continue,以及是否生成 suggestions。这样不会出现“UI 说继续,但 metadata 被视为完成”或“半截答案继续拿来生成下一步建议”的组合。

5. refused 必须在 abandoned tool / interrupted 前面#

来源:PR #5414 与锚点 PR #5425,当前 origin/mainpackages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.tsx:480-500

if (message.metadata?.finishReason === "content-filter") {
  return "refused";
}
if (message.metadata?.interrupted === true || hasAbandonedToolCall(message)) {
  return "interrupted";
}
plaintext

#5414 首次引入 refused state 并让 worker 跳过 refusal suggestions;#5425 把 priority 变成可回归的事实。原因不是视觉偏好:content-filter 可能恰好截断一个还处于 input-streaming 的 tool part,若先判断 hasAbandonedToolCall,用户就会看到一个无法兑现的 Continue。

6. 共享壳只复用视觉结构,不合并操作语义#

来源:PR #5425,当前 origin/mainpackages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.tsx:1073-1147 与测试 :675-691

function TurnNoticeShell({ icon, title, description, action }) {
  return (
    <div className="flex justify-start">
      <div role="status" className="flex w-full ...">
        {icon}
        <div className="min-w-0 flex-1">
          <p>{title}</p>
          <p>{description}</p>
        </div>
        {action}
      </div>
    </div>
  );
}
plaintext

RefusedTurnNotice 不传 actionInterruptedTurnNotice 才传 Continue button。复用的是容器、排版和视觉语言,不是把 refused 与 interrupted 合成一个“异常卡片”状态。新增测试构造一个未结算的 streaming write_file,再叠加 finishReason: "content-filter",断言拒绝文案出现且 Continue 不存在;PR body 还记录了交换判断顺序后测试会失败的 mutation 验证。

工程取舍#

状态边界与兼容性#

  • 把 provider 原因、应用预算和用户 Stop 分开。 length/budget 是可续跑的异常完成,content-filter 是不可续跑的拒绝,aborted 是用户主动停止;它们不能都压成 interrupted,否则下一轮模型和 UI 无法知道该做什么。
  • Continue 是新的用户消息,不是隐式重放。 #5291 的 eval 明确要求从截断位置继续,而不是从第一个列表项重启;已提交的文件/页面副作用保持为上下文的一部分。
  • 新增 optional metadata,不做双写或迁移。 旧 transcript 没有 finishReason 仍可渲染;新代码的 finishTimeinterruptedfinishReason 同时被 upsert,避免 continuation segment 继承旧的完成状态。
  • 只在 worker 侧做 suggestions gate。 拒绝文本不是下一步建议的可靠输入;在站点隐藏按钮不足以阻止后台生成和产生错误引导。

可靠性与并发#

  • #5278 的 closing-step 记录与 #5410 的 budget flag 都存在 TurnRuntime,没有把状态挂到可被下一轮覆盖的共享 transcript 字段上。当前代码注释也说明 turn lock 在 onChatResponse 前释放,所以 runtime 必须是本轮快照。
  • #5340 证明 metadata 不是 dead turn 的完备信号:worker reset 时 onChatResponse 可能根本没跑。站点使用框架的 tool predicates,而不是重新维护一份审批/客户端交互状态表;这降低了漂移,但仍需要 worker repair 与 site inference 的组合回归。
  • #5339raceAbort 让调用方不再永远等待,但 Promise race 本身不会杀掉底层 sandbox exec。它解决的是 turn 可见性和 checkpoint 机会,不等于已经证明底层进程资源被回收。

性能、观测和测试策略#

  • abnormal finish 用可查询的 logger.error,不把每次正常的 length/refusal 变成一个 Sentry issue;WAE 继续记录每轮 usage 和 outcome,方便按模型、工具、步骤统计。
  • #5291 用 behavior eval 验证“截断后 Continue 不重启”;#5414#5425AgentMessageList 测试验证 refusal no-Continue 及 priority。后者的 mutation test 很有价值,因为普通的 refusal fixture 不会触发 abandoned-tool 分支,交换 if 顺序也可能错误地通过。
  • 这些测试仍是分层的:PR body 报告的 tsc、worker/unit、component 和本地 DO 验证不能等同于真实 provider refusal、Cloudflare DO reset、浏览器 reload/多 tab 的端到端覆盖。报告保留这个差距,不把手工验证写成自动化能力。

和最近学习记录的关系#

  • 上一次 #5294 页面写入契约与图片交付 处理的是 Agent 如何把读取结果写成 page delta;本次处理的是 turn 如何把执行结果写成可解释的 transcript metadata。两者共同的做法是:把不可逆的语义边界放在 schema/runtime,而不是只写在 prompt 或 UI 文案里。
  • #5199 Agent Durable Object 生命周期与重连 关注 DO 是否被错误销毁、replay 与冷 DO transcript;本次关注一轮已经结束或异常终止后,消息应该呈现什么状态。二者交界处仍是开放风险:DO recovery、message upsert、site state inference 还没有一条真实端到端测试把生命周期和终态同时覆盖。
  • #5107 eval 与生产 turn 的提示一致性和成本可观测性 的复用原则在这里继续出现:#5291 增加 truncated-continue.yaml,让异常终态的模型输入也有行为契约;但本条业务线新增的重点是“异常后怎么继续/停止”,不是 pricing 口径。
  • 最近的导出 Workflow 报告(先前的 #5320)与本线没有代码关系,因此没有为了凑数量拼入同一份报告。

我会怎么吸收#

  1. 为长生命周期 Agent 先画一张“原始信号 -> 持久化字段 -> 模型 marker -> UI affordance -> telemetry”的状态表,再逐个确认每个边界是否有唯一来源。
  2. 任何框架已有的隐式停止条件,都要和应用层的显式 stop/budget 逻辑对齐;配置值应只定义一次,多个消费者共享同一个局部变量。
  3. 对不可继续的状态,测试不只断言“展示了正确文案”,还要断言禁止的操作不存在;对优先级状态,加入会同时满足两个分支的 fixture,并尝试 mutation 验证测试真的锁住顺序。
  4. 先把 provider/runtime 的事实写入稳定 metadata,再让前端做展示和模型做下一轮投影;不要从部分文本、按钮状态或 timeout 文案反推原因。
  5. 在复盘中严格区分自动化覆盖、PR 描述的本地手工验证和仍未走通的真实 provider/DO/browser 路径,避免把“能复现一次”误记成可靠性保证。

边界、风险与未解问题#

代码验证的边界#

  • #5425 自身是 site component 重构和 priority test,没有改变 worker 的 finish-reason 计算;它依赖 #5414 已经把 content-filter 持久化并在 UI 中区分。
  • #5410 的 budget 标记依赖最后一步 finishReason === "tool-calls",并将 step cap 当作应用层可恢复边界;这不是 provider 的标准 finish reason,未来框架升级时需要重新审计。
  • #5291 只把明确的 output-token cut 标记为 length,故意把 context overflow 留在不可 Continue 的普通/错误路径;这个取舍避免重复溢出,但用户侧降级语义仍需明确。
  • #5339/#5340 说明没有 metadata 的 dead turn 有另一套推断来源;目前没有证据证明所有 worker reset、已批准但未续段、跨 tab 或重连组合都被同一状态机覆盖。

尚待确认的问题#

  • 需要真实 content-filter 产生包含和不包含 tool part 的两种 transcript,验证 reload 后 priority、Regenerate 和 Continue 入口是否都符合意图;当前 PR body 明确说生产中尚未能按需触发真实 refusal。
  • 需要把 MAX_STEP_COUNT 临时降到 3/更小值,覆盖模型在边界返回 text、tool-calls、length、error 的组合,并观察 finishReasonfinishTime、suggestions 和 AGENT_USAGE 是否一致。
  • 需要在 DO reset/recovery、sandbox timeout、长 replay 和多 tab 同时发生时,验证 addMessages 的 upsert 顺序不会让旧 segment 的 finishTime 或新 segment 的 interrupted 互相覆盖。
  • 需要决定无人 platform task 到达 budget 后的产品策略:自动续跑、进入失败队列,还是只记录少交付并告警;当前“用户可以点 Continue”的契约对它并不适用。
  • 需要检查 raceAbort 之后 sandbox 侧的孤儿进程、workspace checkpoint 和下一个 turn 的共享 sandbox 是否会产生资源泄漏或脏状态。

候选说明#

本次以 Australia/Melbourne 日期窗口执行:2026-07-30 当天在当前时间点查到 15 个 merged PR,优先在当天选择;当天已有足够候选,不扩大到 2026-07-29。#5425 未出现在既有 anchor 日志中,且与同日的 #5414/#5416/#5410 在 agent turn termination 文件和 metadata 契约上有直接代码关系。

其他候选没有被强行拼入:#5301 是 CODE preview protocol 的模块化,和前一条 CODE presentation 学习可连但不是本次状态线;#5400/#5367 是 Sheet thumbnails/text editing;#5432 是文档 Markdown codec 清理;#5339/#5340 虽然解释 dead-tool 边界,但属于并行故障恢复支线,因此只作为背景证据而非正式 related_prs。这保证报告学习单位仍是“Agent turn 异常终态与恢复”,而不是标题相近的 PR 集合。

GitHub 文档#

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

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

← Back