一次 Run 怎样停止、失败与继续
对照自然结束、shouldStopAfterTurn、工具 terminate、协议错误、AbortSignal 与 wrapper catch,解释不同终止路径留下的消息和后续可继续条件。
Agent “停了”可能指很多不同状态:模型给出最终答案,工具要求别再追问,控制面希望做完本轮就收手,用户按下取消,provider 返回错误,或者某个回调直接抛出异常。这些路径最后大多会看到 agent_end,但它们留下的最后消息、队列状态和可继续条件并不相同。
先用一张表定位置:
| 路径 | 当前 turn 是否完整 | 最后消息 | 是否先读队列 |
|---|---|---|---|
| 无工具、无 steering、无 follow-up | 完整 | provider assistant | steering 后、follow-up 后自然结束 |
全部工具结果 terminate: true | 完整 | assistant + ToolResult | 仍会读 steering 与 follow-up |
shouldStopAfterTurn() 为 true | 完整 | assistant + 当轮 ToolResult | 不再读 steering / follow-up |
stream final message 为 error / aborted | assistant 以终止消息收束,不执行工具 | 带 stopReason 的 assistant | 不读队列 |
executor 意外抛错,被 Agent catch | 外壳补合成一轮失败事件 | synthetic assistant | 不回到底层队列判断 |
continue() | 新开一场 run | 取决于新 run | 使用现有 transcript,不恢复旧 Promise |
自然结束与 terminate hint
一次 assistant response 没有工具调用时,hasMoreToolCalls 变为 false。turn 结束后若 steering 为空,内层退出;follow-up 也为空,外层 break,最后发 agent_end。这是自然耗尽,不需要 assistant 的 stopReason 一定写成某个产品层“完成”状态,控制流只关心没有剩余推进理由。
工具结果的 terminate 更容易被误读。批次函数只有在结果数组非空且每一个 finalized result 都是 terminate === true 时才返回 true。一个工具要停、另一个没有设置,整批仍会用结果继续请求模型。即便整批为 true,它也只把 hasMoreToolCalls 设为 false;后续 steering 或 follow-up 依然能恢复循环。
terminate 适合表达“别因为本批工具自动再问一次模型”,不适合表达运维层的硬停机。排队用户消息仍优先于这个 hint。
shouldStopAfterTurn 是完整 turn 之后的闸门
shouldStopAfterTurn 的位置更强。Pi 已经追加 assistant 与 ToolResult,发过 turn_end,也应用了可选的下一轮 context/model/thinking 更新;callback 返回 true 后直接发 agent_end,不会轮询 steering 与 follow-up。当前 turn 不会被腰斩,后续工作则留在队列里。
这适合上下文接近上限、达到产品层 turn budget 或外部控制面请求暂停。callback 的合同要求不要 throw;throw 不会变成 graceful stop,而会离开低层循环,交给 Agent 外壳的失败处理。
error 与 aborted 可以是协议终态
第二部已经看到 stream contract:请求、模型与运行错误应编码进 AssistantMessageEventStream,最终消息使用 stopReason: "error" 或 "aborted",而不是让 stream function throw。runLoop() 收到这两种 assistant message 后,发 turn_end 和 agent_end,不再查看其中的 tool calls,也不轮询队列。
Agent.abort() 做的事情只有 abortController.abort()。这个 signal 会传给 stream function、context transform、工具 execute 和 hooks。它是协作式取消:provider adapter、工具或 hook 需要观察 signal 并尽快结算。若一段工具代码无视 signal、永远等待自己的 Promise,Agent 没有线程级手段强行把它杀掉,waitForIdle() 也不会凭 signal 自动 resolve。
flowchart TD
accTitle: Agent run 的停止与继续路径
accDescr: 正常 turn 可以自然耗尽、被 graceful stop 截止或由全部工具 terminate 停止自动追问;协议错误直接结束,意外抛错由外壳尝试补成失败消息,continue 另建一场 run
T["turn complete"] --> GS{"shouldStopAfterTurn?"}
GS -->|yes| AE["agent_end"]
GS -->|no| Q{"tools / steering / follow-up?"}
Q -->|yes| NEXT["next turn"]
Q -->|no| AE
STREAM["stream error / aborted final"] --> AE
THROW["executor throws"] --> WRAP["Agent.handleRunFailure"]
WRAP --> SYN["synthetic failure message + turn_end + agent_end"]
AE --> IDLE["listeners settle, finishRun"]
IDLE --> CONT["optional new continue run"]
外壳会尝试把抛错补回事件协议
并非所有扩展都遵守低层合同。stream function 可能直接 throw,transformContext 或 callback 也可能 reject。Agent.runWithLifecycle() 在 active run 外围 catch executor error,调用 handleRunFailure() 合成一条空 content 的 assistant message:模型与 provider 取当前 state,usage 为零,stopReason 根据 signal 是否已 aborted 选择 aborted 或 error,errorMessage 保存异常文本。随后外壳补发 message start/end、turn_end、agent_end。
这层兼容不能替代合同。低层 runAgentLoop() 直接使用时没有这个 wrapper;listener 自己抛错时,补发失败事件还会再次经过 listeners,仍可能让公开 Promise reject。可靠的 extension listener 应自行处理持久化异常,不要依赖 synthetic failure 无限兜底。
finishRun() 无论正常还是失败都会在 finally 尝试执行:清掉 streaming message 与 pending tool ids,resolve idle promise,再移除 active run。和第 13 章一样,agent_end listener 全部正常结算以后,isStreaming 才会变成 false。
continue 不是把旧 run 从断点唤醒
runAgentLoopContinue() 校验 context 非空且末尾不是 assistant,建立新的 agent_start / turn_start,然后用现有 context 进入 runLoop()。它不重发旧 user 或 toolResult 的 message events,也不把旧消息放进本次返回的 newMessages。这是一场新 run,只是输入 transcript 延续了上次状态。
公开 Agent.continue() 还处理 assistant 尾部的队列特例,上一章已经展开。无论走哪条分支,它都要求当前没有 active run。Abort 后若终态 assistant 已写入 transcript,直接 continue() 通常仍会因 assistant 尾部被拒绝;调用方需要追加新的 user 指令、保留排队消息,或由更高层决定如何 retry。源码没有一个通用的“从任意中断点恢复未完成工具”按钮。
下面的实验只定位关键守卫;安装依赖后可用 mock 测试观察 abort 和 thrown stream function 的事件序列:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts |
nl -ba | sed -n '120,143p;192,275p;582,584p'
git -C "$repo" show v0.83.0:packages/agent/src/agent.ts |
nl -ba | sed -n '306,323p;471,520p'
# 可选:固定 checkout 已安装依赖时
npm --prefix "$repo/packages/agent" test -- \
test/agent.test.ts \
-t 'thrown run failures|active abort signal'
到这里,底层 Agent 路径已经闭合:事件怎样变成状态,一场 run 怎样在两层循环中推进,消息如何排队,工具如何检查与并发,退出又留下什么。下一部会引入 AgentHarness。先保留一个事实边界:在 v0.83.0 中,Harness 直接调用低层 runAgentLoop(),并没有把本章这个 Agent 实例包在里面;它的 snapshot、hook 和 session 语义需要重新按自己的所有权阅读。