从单次循环到有状态 Agent
同一个 Agent 连续完成两次运行
第 08 章结束时,runAgentLoop() 已经能用 read、write、edit 和 bash 完成一次任务。
它返回完整的 AgentRunResult,随后函数里的局部变量便结束了。下一条用户输入若要继续
上一段对话,调用者得自己保存消息、阻止两个循环同时运行,还要把运行中的新指令交到
正确的模型回合。
这一章在循环外增加一个长期存在的 Agent 对象。下面的时间线始终使用同一个实例:
toolResult 仍按原始 call 顺序写入。steering 排在完整工具批次
之后,所以它不会切进一对 call/result 中间。run 1 自然结束以后,run 2 再把
user("提交结论") 追加到这份已完成的历史后面。
这里出现了两种时间尺度。runAgentLoop() 只拥有一次运行里的模型回合和工具批次;
Agent 活得更久,保存两次运行之间仍需存在的消息与配置。第 10 章才会把完成后的
消息写入磁盘。本章的控制器、流式文字和输入队列仍只存在于内存中。
Agent 保存状态,ActiveRun 保存临时所有权
Agent 内部的数据可以按寿命分成两层:
每次
prompt() 都创建一份新的 ActiveRun:
id 区分 run 1 和 run 2;控制器只取消这一轮;两条数组保存运行中到达的用户消息。
acceptingInput 在循环发布 turn_end 时变成 false,避免终态已经形成后仍接收一条
永远不会被读取的消息。
Agent 不接管第 07、08 章的模型与工具状态机。它把 context、signal、事件回调和两
个取队列函数交给 runAgentLoop()。循环返回后,Agent 先释放本次 ActiveRun,再发布
run_end。此时 reduceAgentState(oldState, run_end) 计算出新快照,Agent 保存这个
返回值并通知订阅者;reducer 本身不持有状态。此后 run 2 才能开始。
这条时间线只维护一个所有权事实:同一个实例在任意时刻最多有一份有效的 ActiveRun,
旧运行也只清理自己创建的那一份。
Checkpoint 09 · 让同一个 Agent 管理两次运行模式: 重建起终点:
parent 6b3b1b77 是第 08 章完成后的起点;target d055d832 是
两份教学文件完成、11 项聚焦测试通过后的终点。教学文件: packages/pi-course/src/agent.ts、
packages/pi-course/src/agent-loop.ts学习脚手架: practice 目录会用 starters/09-agent.ts 和
starters/09-agent-loop.ts 覆盖这两份教学文件。agent.ts 固定公共状态、事件和方法
签名;agent-loop.ts 保留前两章的循环,并标出本章施工位。两份脚手架都能编译,
但没有本章答案。动手前只需知道: AgentEvent 记录 run_start、带 runId 的 loop 事件和
run_end。reducer 根据事件派生公开状态;ActiveRun 则由 prompt() 创建和清理,
它不是 transcript 的一部分。第一步: 先不看 target diff,只实现纯函数 reduceAgentState()。先处理
run_start,再用当前 activeRunId 过滤迟到的 loop 与 run_end。第一次红灯: 脚手架初始 build 可以通过。只运行 Lab 9.1 时,首个可见失败是
Lab 9.1 reducer 尚未实现,不是缺模块或隐式 any。聚焦测试: packages/pi-course/test/09-stateful-agent.test.ts定位命令: npm run checkpoint -w @pi/course -- 09练习目录: npm run practice -w @pi/course -- 09聚焦运行: npm run build -w @pi/course,然后运行
node --test packages/pi-course/dist/test/09-*.test.js通过证据: 五段局部测试依次得到 2/2、2/2、2/2、2/2、3/3。它们
分别观察状态派生、两次顺序运行、公开副本、取消传播和队列取出时机。run_start 把 run 1 投影成公开状态
AgentState 是某一时刻给界面读取的快照:
AgentEvent,返回新状态。run 1 的三个阶段对应三种事件:
run_start 要深复制原有 state.messages 和
event.message;run_end 同样要深复制 result.messages。调用者稍后改动事件或
结果时,已经派生出的状态不会随之变化。
run 2 开始以后,run 1 的事件仍可能迟到。若状态中的 activeRunId 已是 2,
loop(1, ...) 和 run_end(1) 都原样返回当前状态。测试没有规定乱序到达的
run_start;这个过滤只覆盖当前实现中的 loop 与 run_end。
TypeScript 对嵌套联合有时不会继续保留收窄结果。确认 event.type === "loop" 后,
把 event.event 保存为 const loop = event.event,再按 loop.type 分支。这个局部变量让
编译器继续携带已经取得的类型证据,代码不需要用 as any 跳过检查。
实践 9.1 · 派生 run 1 的状态目标: 让同一组生命周期事件总能得到同一份状态,并忽略旧运行迟到的事件。文件: 预期:
packages/pi-course/src/agent.ts动作:- 实现
run_start,记录运行 id、用户消息与running状态,并深复制两部分消息。 - 处理
text_delta、tool_start、tool_end、tool_skipped与assistant_message。 - 实现
run_end,保存结果消息的深副本、结束原因和idle状态。 - 在处理
loop和run_end前核对当前状态与runId。 - 删除 Lab 9.1 的显式异常,只运行本段测试。
2/2。第一项检查主要状态迁移和输入副本;第二项证明 run 1 的迟到事件
不会覆盖 run 2。prompt() 先结算 run 1,再开放 run 2
run 1 开始时,prompt("检查 README") 从两份数据构造局部 contextMessages:
ActiveRun(1),发布 run_start,并把同一份局部副本交给
runAgentLoop()。这份局部副本就是本轮输入;若发布事件后再回读
this.state.messages,回调重入产生的 run_start 可能仍在 FIFO 队列中,reducer 还没
把当前用户消息写入公开状态。发布前完成的快照避开了这个时序差。
循环返回后,顺序固定为:
prompt("提交结论") 才创建 ActiveRun(2)。run 2 的
contextMessages 由 run 1 已经完成的历史和当前用户消息组成,所以第二次模型请求能
看到前一次输入、工具结果、steering 和最终回答。
同一实例拒绝两个并行循环
如果ActiveRun(1) 仍存在,第二个 prompt() 会以 Agent is busy 拒绝。调用者
可以把运行中的修正交给 steer(),把自然结束后的追加工作交给 followUp();
prompt() 本身不会猜测第二条输入属于哪一种语义。
结束阶段还要处理订阅回调的重入。run 1 的 run_end 回调可以同步启动 run 2。
run 1 因此用局部变量保留自己创建的 ActiveRun,清理时核对对象身份,并在发布
run_end 前完成清理。旧运行不能在回调返回后无条件清除 this.activeRun,否则它会
删掉 run 2 刚创建的控制器与队列。
回调重入产生的新事件不能插队。emit() 把新事件放进 FIFO,当前事件通知完所有
订阅者后才继续分发。两个订阅者因而都看到 start1 → end1 → start2 → end2,不会有
一个先看到 start2、另一个还停在 end1。
模型请求抛错也在单次循环内结算。若工具已经执行,第二次模型请求抛错,
runAgentLoop() 仍会保留对应的工具调用和工具结果。它把异常转换成
stopReason: "error" 的 assistant 消息,追加后再返回;Agent.prompt() 外层的兜底
只处理循环之外的意外错误。这样已经发生的副作用不会从 transcript 中消失。
实践 9.2 · 让 run 1 和 run 2 顺序共享 transcript目标: 用一个 预期:
Agent 完成两次顺序运行,拒绝并行 prompt(),并让重入回调看到
稳定的生命周期顺序。文件: packages/pi-course/src/agent.ts、
packages/pi-course/src/agent-loop.ts动作:- 在
Agent中保存 options、state、订阅集合、事件 FIFO、nextRunId和当前ActiveRun。 - 实现最小可用的
subscribe()、getState()与emit();每个事件先经过 reducer。 prompt()在发布run_start前构造局部消息副本和新的ActiveRun。- 把 model、tools、signal、context、
onEvent和队列回调交给runAgentLoop()。 - 脚手架把
runAgentLoop作为值导入;它会在运行时被调用,因此不能变成只在编译期 存在的import type。 - 在 loop 内把模型请求异常转换为 error assistant,并保留当前
messages。 - 用对象身份清理本次运行,再发布唯一的
run_end。 - 删除 Lab 9.2 的显式异常,只运行本段测试。
2/2。第一项观察 busy guard、两次顺序运行和模型失败后的完整消息;第二项
从 run 1 的 run_end 回调启动 run 2,确认旧清理没有碰到新运行。三个公开出口各自得到一份副本
状态已经跨运行保存,接下来要明确谁能修改它。内部消息会从三个出口离开Agent:
三条路径都使用
structuredClone()。只复制 messages 数组不够,因为
messages[0].content[0] 仍会指向同一个 block。emit() 先用事件更新 state,再把各自
独立的事件副本交给订阅者;回调在 run_start 中调用 getState() 时,应该已经看到
running 和当前 runId。
单个订阅者抛错不会终止 run 1。emit() 把错误文字追加到 diagnostics,继续通知其余
订阅者。unsubscribe() 只删除创建它的那个 listener。
ToolResultMessage.details 是 unknown,其中可能出现函数等不可复制值。工具此时可能
已经执行并产生了副作用,只是原结果无法安全进入 transcript。循环会生成一条标准、
可复制的错误结果,沿用原 call 的 id 和 name。这样 call/result 配对仍然完整,Agent
也能发布 run_end 并回到 idle;这条消息不表示工具副作用已经回滚。
实践 9.3 · 隔离状态、事件和返回值目标: 让三个公开出口都与内部状态隔离,并让一个失败订阅者不影响运行。文件: 预期:
packages/pi-course/src/agent.ts、
packages/pi-course/src/agent-loop.ts动作:getState()和prompt()返回深副本。emit()先更新 state,再遍历订阅集合的快照。- 给每个订阅者单独复制事件,捕获它自己的异常并记录诊断。
- 让
unsubscribe()只删除对应回调。 - 在 loop 中把不可结构化复制的工具结果转换成标准错误结果。
- 删除 Lab 9.3 的显式异常,只运行本段测试。
2/2。第一项观察状态先更新、坏 subscriber 隔离和精确退订;第二项修改
三个公开出口,并用不可复制的工具结果检查标准错误与 idle 终态。abort() 只向当前运行发出取消信号
Agent.abort() 不直接制造 run_end。它只调用当前 ActiveRun 的控制器:
run_start(1) 中立刻取消,控制器已经存在,而循环还没有请求模型。
runAgentLoop() 在循环入口看到 signal.aborted,直接以 aborted 结算,模型请求数仍是
零。重复调用 abort() 只是重复设置同一个信号。
若取消发生在工具执行期间,工具调用已经进入 transcript。循环等待执行器形成配对
toolResult,把结果写入消息后再以 aborted 结束。run 2 会创建新的控制器;旧 signal
不能污染下一次运行。
取消是协作协议。模型忽略 signal 并返回普通文本时,循环收到完整消息后还要再次
检查取消状态,并在 stop 分支优先以 aborted 结束。若模型或工具永久不返回,单个
AbortController 无法强制杀死它。
实践 9.4 · 取消 run 1,不影响 run 2目标: 让模型请求前和工具执行中的取消都只结算当前 预期:
ActiveRun。文件: packages/pi-course/src/agent.ts、
packages/pi-course/src/agent-loop.ts动作:abort()只操作当前运行的控制器,并把同一 signal 交给 loop。- 入口已取消时不请求模型,只发布一次
run_end。 - 工具执行中取消时,等待现有调用形成配对结果。
- 模型忽略
signal时,在文本stop分支再次检查并返回aborted。 - 清理后让下一次
prompt()创建新控制器。 - 删除 Lab 9.4 的显式异常,只运行本段测试。
2/2。第一项检查预取消、重复 abort 和 run 2 的新控制器;第二项比较模型
与工具收到的 signal,并观察取消后的配对结果和忽略 signal 的文本模型。队列消息只在完整协议边界进入 transcript
run 1 运行期间,steer() 与 followUp() 都会创建 UserMessage,但它们承诺的时间
不同:
开篇的两条 steering 在工具仍执行时进入 FIFO。
fast 可以先完成,loop 仍等 slow
结束,并按 call 顺序写入两条结果。然后才调用 takeSteeringMessages():
assistant(stop) 后,loop 先检查取消,再取 steering;
没有 steering 才取 follow-up。取到任一批消息都会继续 run 1,而不是开启新的
prompt()。
error 或 aborted 直接结束运行,剩余队列在 ActiveRun 清理时丢弃,不会进入下一次
run。取消检查一定先于队列消费;以 error 或 aborted 结束后,未消费消息不会进入下一次
运行。turn_end 一发布,acceptingInput 已经是 false,新的 steer() 和
followUp() 会明确拒绝。
这条队列规则只覆盖 stop/error/aborted 等完整终态。steering 或 follow-up 若恰好在
最后一个允许回合到达,现有测试没有规定它应写入还是丢弃;当前实现也可能先取队列,
随后才返回 maxSteps。因此 maxSteps 边缘仍是未定的产品策略。
实践 9.5 · 在完整边界消费两条队列目标: 固定 steering 与 follow-up 的取出顺序,并阻止终止运行的队列泄漏到 run 2。文件: 预期: 局部
packages/pi-course/src/agent.ts、
packages/pi-course/src/agent-loop.ts动作:- 在
ActiveRun中保存两条 FIFO;idle 或收到turn_end后拒绝新输入,错误文字包含只在当前 run 尚未结束。 - 把
takeSteeringMessages()与takeFollowUpMessages()交给 loop。 - 工具批次先写完所有配对结果,再检查取消并取 steering。
- 文本
stop先检查取消,再依次尝试 steering 和 follow-up。 - error、aborted 与运行清理都不把剩余队列带入 run 2。
- 删除 Lab 9.5 的显式异常,先运行本段,再运行全章测试。
3/3,全章 11/11。三项测试分别观察工具批次后的 FIFO steering、
文本 stop 期间到达的 steering,以及自然 stop 后的 follow-up;组合场景再检查取消
优先、终态拒绝和失败队列隔离。诊断旧运行误删新运行
正常实现会在run_end 之前用身份检查清理当前运行。保留你已有的清理和身份检查,
然后在发布 run_end 之后,故意增加一次不带身份检查的清理:
run_end 之前。若把它整体移到事件之后,回调启动 run 2 时仍会看到
run 1 busy,测试只能等到超时。上面的变体先允许回调创建 run 2,再让旧代码把新记录
删掉,因此能直接观察所有权错误。
这份 Agent 的边界是单实例、内存内生命周期
11 项测试把当前层固定在三个范围内:- 一个
Agent实例串行拥有一份ActiveRun,subscriber 在emit()调用中同步执行。 异步回调的背压与优先级需要另一层调度协议。 abort()把同一个 signal 送到模型和工具;运行何时真正结束,仍取决于它们是否响应 signal。steering 与 follow-up 在maxSteps边缘的取舍属于产品策略。- transcript 和公开状态都保存在当前进程。会话持久化、分支与压缩由后续章节加入;多个 进程共同写一条会话需要更外层的协调。
getState()、subscriber event 与 prompt() result
的副本边界。Loop 输入 context 和模型返回 assistant 也会深复制,但这两处目前只有源码
路径,没有各自的别名变异用例。
与固定上游 Pi 对照固定提交
8479bd8 的 packages/agent/src/agent.ts 同样在底层循环外保存消息、工具和
当前运行的 AbortController,也区分 steering 与 follow-up。上游还支持更多队列模式、
运行中切换模型与配置、异步订阅回调和更多产品状态。课程只保留这条时间线所需的形状:一个实例同一时刻只有一个运行;每次运行拥有独立
控制器;公开数据不共享内部可变引用;两种消息只在规定的完整边界进入 transcript。
这些课程约束不代表上游所有队列模式都与本章相同。两次运行的时间线与验收
Checkpoint 09 · run 1 结束后 run 2 仍有明确所有者在隔离 practice 目录运行:结果应为
11/11。随后画出两条时间线。第一条从 run 1 的结果开始:标出它何时清理自己的 ActiveRun、何时发布
run_end(1)、listener 何时创建 run 2,以及第三次并行 prompt() 在哪里被拒绝。两个
listener 看到的顺序都应是 start1 → end1 → start2 → end2。第二条从 assistant(call slow, call fast) 开始:标出 fast 与 slow 的实际完成顺序、
结果按 call 顺序写入 transcript 的时间、两条 steering 何时排队、何时按 FIFO 写入消息历史,
以及下一次模型请求何时发起。最后再指出 run 2 的 context 从哪里取得 run 1
已经完成的历史。还要分别用测试说明 getState()、订阅事件与 prompt() 返回值为什么不能修改内部
状态,并在源码中找到对应的 structuredClone()。重新定位可运行
npm run checkpoint -w @pi/course -- 09;重做时新建 practice 目录,不复用已改过的
脚手架。可选迁移练习
小结
同一个Agent 现在能完成 run 1,保存它的 transcript,再用这份历史开始 run 2。
ActiveRun 给每次运行独立的 id、控制器和消息队列;身份检查保证旧运行不会清理新
运行。reducer 把生命周期事件变成公开状态,事件 FIFO 让所有同步订阅者观察同一顺序,
深副本隔离三个公开出口。
run 1 中到达的 steering 只在完整工具批次或文本 stop 后进入消息;follow-up 只在自然
stop 后进入。取消和失败先结算当前运行,未消费队列不会泄漏到 run 2。
第 10 章会把已经完成的消息历史写成 session tree。ActiveRun、AbortController、
流式文字和尚未消费的队列仍是运行时状态,不会进入持久化记录。