Main Chain B · Step 05

脊柱:从 kick 出发

进入 packages/core/agent-loop/src/agent.ts(647 行)——整个 harness 的心脏。这一页先看它最外层:ReactLoopAgent 的构造、三态 Phase、kick 循环与 turn 的开合。

(谁唤醒它) wakeDriver:213 ← send:153 ← followup/steer/inject → agent.ts:251 kick → :295 turn → (下一步) turn 内 :315 preStep → 08 页

示例本次示例:用户说「帮我列一下当前目录」

卷一全程跟踪一条消息:用户在 web UI 里输入「帮我列一下当前目录」。它从 inbox 到模型回答,沿途落下的完整事件序列(每一条都是后面 06-14 页的主题):

示例轨迹 05-1 · 整条消息的 turn/step 全景
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 行)
来源:事件顺序与行号全部来自本页/06-14 页的源码;JSONL 行格式来自仓库真实 fixture(packages/test-support/acp-snapshot/tests/fixtures/suite/plain-turn/)
示例轨迹 05-2 · 一个 turn 两个 step 的原因
# step 1 结束时模型请求了工具(本页 516 行 executeToolCalls)
# 结果不 concludesTurn → step 返回 null(本页 520 行)
# → turnEnds 仍为 null → 不 break → target='next-step' → step 2
# step 2 模型只输出文本 → 返回 completed(本页 512 行)→ turnEnds 记录 → 关 turn
# 这就是「一轮包含零个或多个步骤」的最小实例。
来源:本页 340-346 行 + 512-520 行

§1构造器:agent 手里握着什么

packages/core/agent-loop/src/agent.ts构造器97-137
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  }
97-99

ReactLoopAgent 实现 Agent 接口。三个关键对象:inbox(输入队列投影,类型是 driver 自己的 ReactLoopInbox)、phase(状态机真相)、session(持久日志)。

112

0.1.5 改:requestSurfaceGeneration 从「undefined 起步、首次 buildRequest 时判空赋值」改为构造时就快照当前 generation。原因是它的用途扩大了——现在同时服务 system prompt 的「是否开启新系列」判定(agent.ts:395)。

92

0.1.5 新增:systemPrompt: SystemPromptProjection(runtime-context.ts:60)。system prompt 不再塞进 request/header,而是作为 system/message 事件进入 surface——由这个投影器决定「哪一次进入、进入几条」。

119

frozenMessages:本 loop 冻结过的消息身份集合(WeakSet 不阻止已替换的历史被 GC)。buildRequest 只对未冻结的消息做深冻结,避免每步重复遍历整段历史。

127

0.1.6 改:快照的是 contentGeneration 而非 replaceGeneration。surface 现在有两个计数器:replaceGeneration 只统计位置性替换(compaction 的 replace),contentGeneration 还额外统计插件对既有消息的改写(surface.ts 的 SessionMessageProjection 机制,详见 06 页)。loop 关心的是「模型看到的内容变了吗」,所以必须用后者——否则插件改写了一条历史消息,loop 不会重发 system prompt。

130

0.1.5 简化:改成 this.ctx = this.scope.ctx。旧版这里要 extend({ agent: this }) 才能让子插件拿到 ctx.agent;现在 Agent 接口定义迁进 types.ts,靠 declaration merging 挂到 Context 上,省掉一次 ctx 派生。

131

建 inbox 需要 ctx.sessionProjections(投影注册表)——inbox 本身就是一个 session projection(07 页)。

133-134

从投影恢复位置:stateOf(session, 'turnBoundary') 拿 lastTurn——loop 自己注册该单元,所以键恒在(agent.ts:132 的 v8 ignore 注释就是这个断言)。resume 的 agent 仍从持久状态续编号。

135-136

两个投影器:RuntimeContextProjection(动态上下文 → 候选 user/message,08 页第三步用)与 SystemPromptProjection(rendered prompt → system/message 提交,11 页 364-372 行用)。

§2三态 Phase 与 setPhase

packages/core/agent-loop/src/agent.tsPhase 类型与状态转换41-49, 139-151
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  }
41-49

三态各有自己的载荷:running 和 maintenance 都带 abort(可取消边界)和 wakeRequested(挂起的唤醒请求);running 额外带当前 turn/step 编号。idle 只记住 lastTurn——下个 turn 从它 +1。

139-141

status 是 phase 的派生投影:maintenance 对外报 idle——维护任务不是「在跑 turn」,但它同样独占 agent(agent.ts:183 的守卫会拒绝并发维护)。

144-151

setPhase 是 phase 的唯一写入口(private)。只在 status 实际变化时发 agent/status——invariant 断言的「无 no-op 重复迁移」就靠这个 if。

idle(status: idle) lastTurn running(status: running) abort · turn · step · wakeRequested maintenance(status: idle) abort · lastTurn · wakeRequested wakeDriver(188 行) runMaintenance(157 行) kick 的 finally 收敛回 idle;若 wakeRequested 且 inbox 有货 → 重新 wakeDriver
图 5-1 · 三态状态机。虚线为收敛路径。

§3kick:最外层循环

packages/core/agent-loop/src/agent.tskick251-264
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  }
251-253

三层循环的最外层:只要 turn() 返回 true 就再来一轮。false 表示「欠账清了」;true 表示「还有活」。

254-255

driver 边界吞掉一切错误——但注释写明了:已报告的失败和取消在这里被包含。错误不是被静默丢弃:turn() 内部已通过 throwError 发出 agent/error(§4),这里只是让 driver 能正常收敛、不把 rejection 泄漏到 207 行的 driver.reject。

258-262

收敛逻辑:phase 还在 running(说明 turn 循环因异常跳出,正常路径 372-377 行已自己归位),把它收成 idle;若期间有人挂起了唤醒请求且 inbox 有货,重新拉起 driver——这正是 §5 里 latch 的重放点。

§4turn:开一轮,跑 step,关一轮

packages/core/agent-loop/src/agent.tsturn(节选)295-378
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  }
296-298

前置守卫:turn 只能被 driver 内部调用(phase 必须是 running)——违反就是编程错误,立刻 throwError。

304, 308

先落盘,再改内存:turn/start 先 append 进日志,成功后才更新 phase.turn。边界一旦开了就永远在日志里——即使下一秒 abort。

309-310

两个循环变量:turnEnds(turn 的结局,null = 尚无结局)和 target(首个 step 从 next-turn 队列 claim)。

313, 327, 340

abort 信号在每个边界检查。这是取消语义的落点:取消不打断正在执行的代码,只在边界生效。

315-319

调 preStep(08 页详述)拿 decision。reject → turn 以 blocked 关闭,返回 false 结束 kick 循环。注意:即使被拒,claim 过的消息也已离开 inbox(claim 在 preStep 内部)。

320

0.1.5 新增的提前收敛:结局已定(turnEnds 非空)且这一步没带来新输入 → 直接 break,不再空跑一次 step。这是「终局后不再消费队列」的廉价短路。

323-326

空首步仍关 turn:被移除的唤醒消息、或 enter 被改写为空,仍然拥有 turn 边界(turn/start 已落盘),只是不花模型调用、也不产生 step 事件。日志诚实记录「有过这次尝试」。

328-329

step/start 先落盘,phase.step 后更新——与 turn/start 同一纪律。

333

跑真正的 step()(11 页详述)——一次模型调用 + 工具执行。注意 0.1.5 起 user/message 的 append 搬进了 step 内部(agent.ts:401-405),因为要在 system/message 之后、且只在首次尝试时写一次。

336

max-tokens 是 sticky 的:一旦某步触顶,后续正常完成的 step 不能把结局降级成 completed。条件 turnEnds === null || turnEnds.kind !== 'max-tokens' 精确表达这一点。

337-338

finally 里无条件 append step/end——即使 step() 抛出,step 边界也闭合。

341-345

agent/turn-stopping 是 serial 事件(没有 next()):给策略一次「拦截 turn 关闭」的机会——监听器在这时 agent.steer(...),机器随后重读 inbox。看 319:若 steer 加了货,nextStep 非空,就不 break——数据决定,监听顺序改不了结果。

346

下一轮 target 改为 next-step——后续 step 优先消费转向/注入队列。

348-363

错误路径两分支:abort → turnEnds=aborted 且 rethrow(交给 kick 的 catch 收尾);其他错误 → 结构化失败:LlmError 保留其 failure 事实,其余压平为 errorChain 文本 + UNKNOWN 码。随后 throwError(agent.ts:244-249)先发 agent/error 再 throw——错误通知与错误传播分离。

364-371

finally 无条件 append turn/end 带 reason。六种 reason:completed / max-tokens / blocked / error / aborted / interrupted(interrupted 是持久化后端专用的 crash 恢复标记,loop 自己不发)。注意 337 又包了一层 try——连「写 turn/end 失败」也要走 throwError 上报,不静默。

372-377

返回值的语义:inbox 还有货 → 换新 AbortController(旧 latch 随之作废——活 driver 自己 claim 队列,346 行注释即此意)、清 wakeRequested、step 归零,返回 true 再来一轮;没货 → false,kick 收敛。

§5wakeDriver:谁把 agent 拉起来

packages/core/agent-loop/src/agent.tswakeDriver213-234
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)
214-222

非 idle 分支:latch。不能现在跑,就把 wakeRequested 置 true 挂起,等收敛后重放(§3 的 261 行)。两个例外不挂起:disposed(要销毁了,不等人)和「活 driver 自己会 claim 队列」。maintenance 与 aborted 的 driver 无法送达唤醒,必须 latch。

224-225

activityDone 是「整个 agent 活动的静默哨兵」——whenIdle()(agent.ts:236)就是等它,且用 do-while 重读以防等待期间又起了新活动。每次起 driver 都换新的 promise。

226-232

直接 setPhase 到 running:turn 从 lastTurn 起步(turn() 里 +1),step 归零,新 AbortController——这个 controller 就是本轮可取消的边界。

233-234

withInitiator:基于 AsyncLocalStorage 的进程本地因果归因——「这个 turn 是谁发起的」。它是归因不是授权,跨进程/持久化即失效。kick 的完成驱动 driver.resolve,失败驱动 driver.reject。

§60.1.7 新增:abortedCancelCause

agent.ts 从 620 行涨到 647 行,全部来自一个新增的模块级函数(79-94 行)及三处调用点替换。没有新增类成员、没有新增事件。

packages/core/agent-loop/src/agent.tsabortedCancelCause79-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}
82-87

它解决一个具体问题:cause 可能是被 Node 附加过 stack 的 live reason 对象,而 Session.append 要么把它写进日志、要么因非 JSON 序列化而拒绝。第 87 行重建一个干净的 { kind } 对象——只保留需要的事实,丢掉附加字段。

80

返回值同时表达两件事:undefined = 未取消;有值 = 已取消且原因是 X。所以三处调用点都从 if (signal.aborted) 变成 const cause = abortedCancelCause(signal); if (cause !== undefined)——读取原因与判断是否取消合为一步,不可能出现「aborted 为真但拿不到原因」的不一致状态。

90-92

assertNever(cause):AgentCancelCause 是闭联合,新增一种取消原因时这里会编译失败——逼你处理。这是仓库约定「Closed unions end in assertNever」的实例。

三处调用点:runMaintenance(198 行)、wakeDriver(218 行)、turn 的 catch(350 行)。