Main Chain B · Step 05
脊柱:从 kick 出发
进入 packages/core/agent-loop/src/agent.ts(647 行)——整个 harness 的心脏。这一页先看它最外层:ReactLoopAgent 的构造、三态 Phase、kick 循环与 turn 的开合。
示例本次示例:用户说「帮我列一下当前目录」
卷一全程跟踪一条消息:用户在 web UI 里输入「帮我列一下当前目录」。它从 inbox 到模型回答,沿途落下的完整事件序列(每一条都是后面 06-14 页的主题):
turn 1 ──────────────────────────────────────────────────────
step 1 ────────────────────────────────────────────────────
[inbox] claim: next-step[] + next-turn[「帮我列一下当前目录」] ← 07 页
[event] turn/start {turn:1} ← 本页 304 行
[llm] prepareRequest + systemPrompt.project ← 本页 390-400 行
[event] system/message {turn:1,step:1}(surface 节点 0) ← 0.1.5 新增
[event] step/start {turn:1,step:1} ← 本页 328 行
[event] user/message「帮我列一下当前目录」surfaceOp:append ← 本页 403 行
[prep] preStep 四动作 ← 08 页
[event] request/header {config,tools} reason:initial(无 system 字段)← 09 页
[llm] assistant 流×N「我来帮你列一下目录」 ← 10/11 页
[event] assistant/message(完成锚点,引用上面 chunk 的 seq)
[llm] 模型请求工具 bash("ls -la") ← 本页 514 行
[event] tool/call {callId:"c1", name:"bash", arguments:"..."} ← 13 页
[tools] pre-execute→execute→post-execute ← 14 页
[event] tool/result {callId:"c1", 内容=目录列表, sourceEventSeqs:[callSeq]}
[event] step/end {turn:1,step:1} ← 本页 338 行
step 2 ────────────────────────────────────────────────────
[inbox] claim: next-step[](工具结果已在日志,无新输入)
[event] step/start {turn:1,step:2}
[llm] 模型看到工具结果,生成最终回答
[event] assistant/message「目录里有这些文件...」
[event] step/end {turn:1,step:2}
[check] turnEnds=completed 且 nextStep 空 → agent/turn-stopping(342 行)
[event] turn/end {turn:1, reason:completed} ← 本页 367 行
kick 收敛:inbox 无货 → turn() 返回 false → phase 回 idle(260 行)
# step 1 结束时模型请求了工具(本页 516 行 executeToolCalls) # 结果不 concludesTurn → step 返回 null(本页 520 行) # → turnEnds 仍为 null → 不 break → target='next-step' → step 2 # step 2 模型只输出文本 → 返回 completed(本页 512 行)→ turnEnds 记录 → 关 turn # 这就是「一轮包含零个或多个步骤」的最小实例。
§1构造器:agent 手里握着什么
97export class ReactLoopAgent implements Agent {
98 readonly inbox: ReactLoopInbox
99 private phase: Phase
112 private requestSurfaceGeneration: number
117 private readonly systemPrompt: SystemPromptProjection // 0.1.5 新增system prompt 投影器
119 private readonly frozenMessages = new WeakSet<Message>()
121 constructor(
122 private loopCtx: Context,
123 public readonly id: SessionId,
124 public readonly options: AgentOptions,
125 public readonly session: Session,
126 ) {
127 this.requestSurfaceGeneration = session.surface.contentGeneration0.1.6:contentGeneration
128 this.dispatch = agentEvents(loopCtx, this)
129 this.scope = createScope(loopCtx, this)
130 this.ctx = this.scope.ctx // 0.1.5:不再 extend({ agent: this })
131 this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch)
133 const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0
134 this.phase = { kind: 'idle', lastTurn }
135 this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
136 this.systemPrompt = new SystemPromptProjection(session)
137 }
ReactLoopAgent 实现 Agent 接口。三个关键对象:inbox(输入队列投影,类型是 driver 自己的 ReactLoopInbox)、phase(状态机真相)、session(持久日志)。
0.1.5 改:requestSurfaceGeneration 从「undefined 起步、首次 buildRequest 时判空赋值」改为构造时就快照当前 generation。原因是它的用途扩大了——现在同时服务 system prompt 的「是否开启新系列」判定(agent.ts:395)。
0.1.5 新增:systemPrompt: SystemPromptProjection(runtime-context.ts:60)。system prompt 不再塞进 request/header,而是作为 system/message 事件进入 surface——由这个投影器决定「哪一次进入、进入几条」。
frozenMessages:本 loop 冻结过的消息身份集合(WeakSet 不阻止已替换的历史被 GC)。buildRequest 只对未冻结的消息做深冻结,避免每步重复遍历整段历史。
0.1.6 改:快照的是 contentGeneration 而非 replaceGeneration。surface 现在有两个计数器:replaceGeneration 只统计位置性替换(compaction 的 replace),contentGeneration 还额外统计插件对既有消息的改写(surface.ts 的 SessionMessageProjection 机制,详见 06 页)。loop 关心的是「模型看到的内容变了吗」,所以必须用后者——否则插件改写了一条历史消息,loop 不会重发 system prompt。
0.1.5 简化:改成 this.ctx = this.scope.ctx。旧版这里要 extend({ agent: this }) 才能让子插件拿到 ctx.agent;现在 Agent 接口定义迁进 types.ts,靠 declaration merging 挂到 Context 上,省掉一次 ctx 派生。
建 inbox 需要 ctx.sessionProjections(投影注册表)——inbox 本身就是一个 session projection(07 页)。
从投影恢复位置:stateOf(session, 'turnBoundary') 拿 lastTurn——loop 自己注册该单元,所以键恒在(agent.ts:132 的 v8 ignore 注释就是这个断言)。resume 的 agent 仍从持久状态续编号。
两个投影器:RuntimeContextProjection(动态上下文 → 候选 user/message,08 页第三步用)与 SystemPromptProjection(rendered prompt → system/message 提交,11 页 364-372 行用)。
§2三态 Phase 与 setPhase
41type Phase =
42 | { kind: 'idle'; lastTurn: number }
43 | {
44 kind: 'maintenance'
45 abort: AbortController
46 lastTurn: number
47 wakeRequested: boolean
48 }
49 | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
139 get status(): AgentStatus {
140 return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running'
141 }
144 private setPhase(next: Phase): void {
145 const previousStatus = this.status
146 this.phase = next
147 const status = this.status
148 if (status !== previousStatus) {
149 this.dispatch.emit('agent/status', { status })
150 }
151 }
三态各有自己的载荷:running 和 maintenance 都带 abort(可取消边界)和 wakeRequested(挂起的唤醒请求);running 额外带当前 turn/step 编号。idle 只记住 lastTurn——下个 turn 从它 +1。
status 是 phase 的派生投影:maintenance 对外报 idle——维护任务不是「在跑 turn」,但它同样独占 agent(agent.ts:183 的守卫会拒绝并发维护)。
setPhase 是 phase 的唯一写入口(private)。只在 status 实际变化时发 agent/status——invariant 断言的「无 no-op 重复迁移」就靠这个 if。
§3kick:最外层循环
251 private async kick(): Promise<void> {
252 try {
253 while (await this.turn()) {}
254 } catch (_error) {
255 // Reported failures and cancellation are contained at the driver boundary.
256 } finally {
258 if (this.phase.kind === 'running') {
259 const { turn, wakeRequested } = this.phase
260 this.setPhase({ kind: 'idle', lastTurn: turn })
261 if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
262 }
263 }
264 }
三层循环的最外层:只要 turn() 返回 true 就再来一轮。false 表示「欠账清了」;true 表示「还有活」。
driver 边界吞掉一切错误——但注释写明了:已报告的失败和取消在这里被包含。错误不是被静默丢弃:turn() 内部已通过 throwError 发出 agent/error(§4),这里只是让 driver 能正常收敛、不把 rejection 泄漏到 207 行的 driver.reject。
收敛逻辑:phase 还在 running(说明 turn 循环因异常跳出,正常路径 372-377 行已自己归位),把它收成 idle;若期间有人挂起了唤醒请求且 inbox 有货,重新拉起 driver——这正是 §5 里 latch 的重放点。
§4turn:开一轮,跑 step,关一轮
295 private async turn(): Promise<boolean> {
296 if (this.phase.kind !== 'running') {
297 this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
298 }
299 const phase = this.phase
300 const { signal } = phase.abort
301 signal.throwIfAborted()
302 const turn = phase.turn + 1
303 try {
304 this.session.append('turn/start', { turn })
305 } catch (error: unknown) {
306 this.throwError(error)
307 }
308 phase.turn = turn
309 let turnEnds: TurnEndReason | null = null
310 let target: InboxTarget = 'next-turn'
311 try {
312 while (true) {
313 signal.throwIfAborted()
314 const step = phase.step + 1
315 const decision = await this.preStep(target, { turn, step })
316 if (decision.kind === 'reject') {
317 turnEnds = { kind: 'blocked' }
318 return false
319 }
320 if (turnEnds && decision.messages.length === 0) break0.1.5:终局已定且无新输入 → 收
323 if (phase.step === 0 && decision.messages.length === 0) {
324 turnEnds = { kind: 'completed' }
325 return false
326 }
327 signal.throwIfAborted()
328 this.session.append('step/start', { turn, step })
329 phase.step = step
330 try {
333 const stepEnd = await this.step(decision)
336 if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
337 } finally {
338 this.session.append('step/end', { turn, step })
339 }
340 signal.throwIfAborted()
341 if (turnEnds && this.inbox.nextStep.length === 0) {
342 await this.dispatch.serial('agent/turn-stopping', { turn, signal })
343 signal.throwIfAborted()
344 }
345 if (turnEnds && this.inbox.nextStep.length === 0) break
346 target = 'next-step'
347 }
348 } catch (error: unknown) {
350 const cause = abortedCancelCause(signal) // 0.1.7 新增:归一化取消原因见下方
351 if (cause !== undefined) {
352 turnEnds = { kind: 'aborted', reason: cause }
353 throw error
354 }
357 turnEnds = {
358 kind: 'error',
359 error: error instanceof LlmError
360 ? error.failure
361 : { message: errorChain(error), code: 'UNKNOWN' },
362 }
363 this.throwError(error)
364 } finally {
365 try {
367 this.session.append('turn/end', { turn, reason: turnEnds! })
368 } catch (error: unknown) {
369 this.throwError(error)
370 }
371 }
372 if (!this.inbox.hasPending) return false
373 phase.abort = new AbortController()
375 phase.wakeRequested = false
376 phase.step = 0
377 return true
378 }
前置守卫:turn 只能被 driver 内部调用(phase 必须是 running)——违反就是编程错误,立刻 throwError。
先落盘,再改内存:turn/start 先 append 进日志,成功后才更新 phase.turn。边界一旦开了就永远在日志里——即使下一秒 abort。
两个循环变量:turnEnds(turn 的结局,null = 尚无结局)和 target(首个 step 从 next-turn 队列 claim)。
abort 信号在每个边界检查。这是取消语义的落点:取消不打断正在执行的代码,只在边界生效。
调 preStep(08 页详述)拿 decision。reject → turn 以 blocked 关闭,返回 false 结束 kick 循环。注意:即使被拒,claim 过的消息也已离开 inbox(claim 在 preStep 内部)。
0.1.5 新增的提前收敛:结局已定(turnEnds 非空)且这一步没带来新输入 → 直接 break,不再空跑一次 step。这是「终局后不再消费队列」的廉价短路。
空首步仍关 turn:被移除的唤醒消息、或 enter 被改写为空,仍然拥有 turn 边界(turn/start 已落盘),只是不花模型调用、也不产生 step 事件。日志诚实记录「有过这次尝试」。
step/start 先落盘,phase.step 后更新——与 turn/start 同一纪律。
跑真正的 step()(11 页详述)——一次模型调用 + 工具执行。注意 0.1.5 起 user/message 的 append 搬进了 step 内部(agent.ts:401-405),因为要在 system/message 之后、且只在首次尝试时写一次。
max-tokens 是 sticky 的:一旦某步触顶,后续正常完成的 step 不能把结局降级成 completed。条件 turnEnds === null || turnEnds.kind !== 'max-tokens' 精确表达这一点。
finally 里无条件 append step/end——即使 step() 抛出,step 边界也闭合。
agent/turn-stopping 是 serial 事件(没有 next()):给策略一次「拦截 turn 关闭」的机会——监听器在这时 agent.steer(...),机器随后重读 inbox。看 319:若 steer 加了货,nextStep 非空,就不 break——数据决定,监听顺序改不了结果。
下一轮 target 改为 next-step——后续 step 优先消费转向/注入队列。
错误路径两分支:abort → turnEnds=aborted 且 rethrow(交给 kick 的 catch 收尾);其他错误 → 结构化失败:LlmError 保留其 failure 事实,其余压平为 errorChain 文本 + UNKNOWN 码。随后 throwError(agent.ts:244-249)先发 agent/error 再 throw——错误通知与错误传播分离。
finally 无条件 append turn/end 带 reason。六种 reason:completed / max-tokens / blocked / error / aborted / interrupted(interrupted 是持久化后端专用的 crash 恢复标记,loop 自己不发)。注意 337 又包了一层 try——连「写 turn/end 失败」也要走 throwError 上报,不静默。
返回值的语义:inbox 还有货 → 换新 AbortController(旧 latch 随之作废——活 driver 自己 claim 队列,346 行注释即此意)、清 wakeRequested、step 归零,返回 true 再来一轮;没货 → false,kick 收敛。
§5wakeDriver:谁把 agent 拉起来
213 private wakeDriver(wakeAfterAbort = false): void {
214 if (this.phase.kind !== 'idle') {
218 const reason = abortedCancelCause(this.phase.abort.signal) // 0.1.7 改以前是 as AgentCancelCause 断言
219 if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
220 this.phase.wakeRequested = true
221 }
222 return
223 }
224 const driver = Promise.withResolvers<void>()
225 this.activityDone = driver.promise
226 this.setPhase({
227 kind: 'running',
228 abort: new AbortController(),
229 turn: this.phase.lastTurn,
230 step: 0,
231 wakeRequested: false,
232 })
233 this.loopCtx.agents.withInitiator(this, () => this.kick())
234 .then(driver.resolve, driver.reject)
非 idle 分支:latch。不能现在跑,就把 wakeRequested 置 true 挂起,等收敛后重放(§3 的 261 行)。两个例外不挂起:disposed(要销毁了,不等人)和「活 driver 自己会 claim 队列」。maintenance 与 aborted 的 driver 无法送达唤醒,必须 latch。
activityDone 是「整个 agent 活动的静默哨兵」——whenIdle()(agent.ts:236)就是等它,且用 do-while 重读以防等待期间又起了新活动。每次起 driver 都换新的 promise。
直接 setPhase 到 running:turn 从 lastTurn 起步(turn() 里 +1),step 归零,新 AbortController——这个 controller 就是本轮可取消的边界。
withInitiator:基于 AsyncLocalStorage 的进程本地因果归因——「这个 turn 是谁发起的」。它是归因不是授权,跨进程/持久化即失效。kick 的完成驱动 driver.resolve,失败驱动 driver.reject。
§60.1.7 新增:abortedCancelCause
agent.ts 从 620 行涨到 647 行,全部来自一个新增的模块级函数(79-94 行)及三处调用点替换。没有新增类成员、没有新增事件。
79function abortedCancelCause(signal: AbortSignal): AgentCancelCause | undefined {
80 if (!signal.aborted) return undefined // 返回值即「是否已取消」一条语句同时表达两件事
81 // `cancel()` is the only aborter of the signals this loop owns.
82 const cause = signal.reason as AgentCancelCause
83 switch (cause.kind) {
84 case 'user':
85 case 'parent':
86 case 'disposed':
87 return { kind: cause.kind } // 重建干净对象,丢掉附加的 stack关键
88 case 'hook':
89 return { kind: 'hook', reason: cause.reason }
90 /* v8 ignore next -- cancel accepts the closed AgentCancelCause union */
91 default:
92 return assertNever(cause)
93 }
94}
它解决一个具体问题:cause 可能是被 Node 附加过 stack 的 live reason 对象,而 Session.append 要么把它写进日志、要么因非 JSON 序列化而拒绝。第 87 行重建一个干净的 { kind } 对象——只保留需要的事实,丢掉附加字段。
返回值同时表达两件事:undefined = 未取消;有值 = 已取消且原因是 X。所以三处调用点都从 if (signal.aborted) 变成 const cause = abortedCancelCause(signal); if (cause !== undefined)——读取原因与判断是否取消合为一步,不可能出现「aborted 为真但拿不到原因」的不一致状态。
assertNever(cause):AgentCancelCause 是闭联合,新增一种取消原因时这里会编译失败——逼你处理。这是仓库约定「Closed unions end in assertNever」的实例。
三处调用点:runMaintenance(198 行)、wakeDriver(218 行)、turn 的 catch(350 行)。