数据截至 (上游 commit 99f6f02fecdb)
第 3 章 · 主循环:turn / step 状态机、Inbox 与一次模型请求的生死
30 秒导读: 第 1 章讲了插件树怎么拼出一个产品,第 2 章讲了会话日志怎么当唯一事实源。这一章讲中间那台机器——默认驱动器
ReactLoopAgent:它把日志和插件缝成一次真实对话,负责"什么时候发请求、请求里装什么、什么时候停"。
本章覆盖四件事,按依赖顺序排:
- turn / step 两层嵌套循环——对话的时间骨架。
- Inbox——外部输入怎么排队、怎么被一步"认领"。
- 一次模型请求的生死——从组装、发流、装配消息,到重试与收束。
- 工厂侧生命周期——谁创建、谁销毁这台机器,以及取消怎么被容器化。
工具调度只点到为止,细节见 第 4 章。
1. 先建直觉:turn 和 step 到底是什么
零基础版本的一句话:
- step(步)= 一次模型请求 + 这次请求叫起来的那批工具。
- turn(轮)= 从"认领一条输入"到"没人再欠东西"之间的所有 step。
一个 turn 里可以有 0 个 step,也可以有 10 个。用户说"帮我改这个 bug"是一个 turn;模型为此读文件、跑测试、再改代码,就是这个 turn 里的多个 step。
| turn | step | |
|---|---|---|
| 触发者 | 外部输入唤醒驱动器 | 上一步还欠模型一次回应 |
| 日志事件 | turn/start / turn/end | step/start / step/end |
| 结束条件 | 没有工具在跑、也没有新 steering | 模型回完这一条消息、工具跑完 |
| 数量关系 | 一次驱动器运行里 1..N 个 turn | 一个 turn 里 0..N 个 step |
为什么 0 个 step 也算一个 turn? 因为"有人试图说话、但被插件挡掉了"这件事也必须留痕。日志里会出现一对 turn/start + turn/end,中间没有任何 step——读日志的人能看出"这次尝试发生过,且被拒绝"。
1.1 嵌套循环的形状
驱动器只有三层循环,一层套一层,全在 packages/core/agent-loop/src/agent.ts:
kick() 循环:只要还有待办输入,就再开一个 turn
└─ turn() 循环:只要还欠模型一次回应,就再走一个 step
└─ step() 循环:只要请求失败且有人认领重试,就重发这一次请求
对应源码入口:kick()(agent.ts:210)、turn()(agent.ts:246)、step()(agent.ts:332)。三者都是 private,唯一的公共入口是"投输入 + 唤醒"。
1.2 一次 turn 的生命线
这张图从上往下读,就是日志里一个 turn 的事件顺序。左列是落账的会话事件,右列是活的插件扩展点。
落账(durable) 扩展点(live)
───────────────── ────────────────
turn/start
│
│ claim: 掏空 next-step + 取一条 next-turn
│──────────────────────► agent/pre-step (waterfall)
│ reject → 关 turn,不花 step
│ enter(空) → 首步则关 turn,不花 step
▼
step/start
user/message ×N
│──────────────────────► agent/request (waterfall)
request/header (变了才写)
request/context (变了才写)
assistant/chunk ×N ◄────────── llm 流
assistant/message
│──────────────────────► 工具流水线(第 4 章)
step/end
│ 还欠回应?→ 回到 claim,target 改成 next-step
│──────────────────────► agent/turn-stopping (serial)
▼
turn/end { reason }
docs/architecture.md:63 的 "Turn flow" 段给了同一张骨架的文字版;本章的判定顺序以源码为准(下面每处都会给行号)。
2. 三态 phase:机器只有三种活法
驱动器的全部内部状态就是一个三态联合体(agent.ts:38-46):
| phase | 什么时候在这个态 | 带什么 | 对外 status |
|---|---|---|---|
idle | 没有驱动器在跑 | lastTurn | idle |
maintenance | 在跑一个非对话的维护任务 | abort、lastTurn、wakeRequested | idle |
running | 在跑 turn | abort、turn、step、wakeRequested | running |
注意 maintenance 对外装成 idle。 status 的实现只有一行三元(agent.ts:99-101):只有 running 才叫 running。这不是偷懒——外部 UI 关心的是"模型在不在说话",压缩上下文、生成标题这类维护活儿不该让界面显示成"AI 正在思考"。
2.1 状态发布只走一个闸口
所有相位切换都必须过 setPhase(agent.ts:104-111),它只在对外可见的 status 真的翻转时才 emit agent/status:
// packages/core/agent-loop/src/agent.ts:104
private setPhase(next: Phase): void {
const previousStatus = this.status
this.phase = next
const status = this.status
if (status !== previousStatus) {
this.dispatch.emit('agent/status', { status })
}
}
于是 running → maintenance 这种"内部换挡"不会打扰任何监听者,而 idle → running 一定会通知。
2.2 三个公共状态 API
| API | 干什么 | 关键约束 |
|---|---|---|
status | 读当前对外状态 | 只有 idle / running 两值(runtime-types.ts:50) |
whenIdle() | 等到整台机器安静 | 会跟随接力:旧活动退休时若新驱动器已起来,继续等新的(agent.ts:195-200) |
runMaintenance(job) | 从真 idle 抢一次非对话任务 | 已有活动时同步抛错(agent.ts:143) |
whenIdle() 的 do-while 值得单独看一眼,它解决的是"我等的那个 activity 结束了,但它在 finally 里又拉起了下一个"这种接力:
// packages/core/agent-loop/src/agent.ts:195
async whenIdle(): Promise<void> {
let activity: Promise<void>
do {
await (activity = this.activityDone)
} while (activity !== this.activityDone)
}
runMaintenance 的 finally 是同一套收束模板(agent.ts:153-161):回到 idle → 如果期间有唤醒被闩住且队列还有货,就补一次 wakeDriver() → 最后 resolve 掉 activityDone。
3. 输入模型:三个入口、两条队列、一个闩
3.1 三个入口
外部(人、插件、上游 agent)向驱动器投递输入只有三个方法,全是 send() 的薄包装(agent.ts:122-132):
| 入口 | 落到哪条队列 | 唤醒驱动器? | 典型用途 |
|---|---|---|---|
followup(msg) | next-turn | 是 | 用户的下一条提问,独占一个新 turn |
steer(msg) | next-step | 是 | 中途插话:"别读那个文件了,先跑测试" |
inject(msg) | next-step | 否 | 插件塞的上下文,搭下一趟车,绝不自己开车 |
inject 不唤醒这一点是设计出来的:注入上下文不该凭空制造一次模型调用;idle 时它就躺在 inbox 里,等下一次 followup/steer 顺路带走(runtime-types.ts:135-143)。
3.2 abort 之后的重分类
send() 只有 8 行,但每一行都在处理竞态(agent.ts:113-120):
// packages/core/agent-loop/src/agent.ts:113
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
if (wakeup) this.wakeDriver(wakingAfterAbort)
}
两个要点:
- 重分类。 一条
steer(本来投next-step)如果撞上"活动已经被 abort 了",会被改投next-turn。理由写在注释里:被取消的活动不会再开新 step 了,这条 steering 挂在next-step上等于石沉大海,只能让它自己开一个新 turn。 - 先算后插。
wakingAfterAbort在splice之前算好。因为 splice 会同步派发session/event,观察者可以在回调里反手调cancel()——如果先插再判,这条消息的归属就会被这次重入的 cancel 改写。
3.3 唤醒闩(wake latch)
wakeDriver() 是"起不起新驱动器"的唯一决策点(agent.ts:172-193)。它分两条路:
wakeDriver(wakeAfterAbort)
│
├─ phase 不是 idle ──► 我送不出这次唤醒,闩住它
│ 条件:cancel 原因不是 'disposed'
│ 且 (在 maintenance | 这次是 abort 后唤醒)
│ → phase.wakeRequested = true ;直接 return
│
└─ phase 是 idle ────► 真开一个驱动器
activityDone ← 新 promise
setPhase(running, turn = lastTurn, step = 0)
agents.withInitiator(this, () => this.kick())
三条不显然的规则:
disposed不闩。 销毁中的 agent 如果还闩唤醒,teardown 就会等一次永远不该发生的模型调用;所以reason?.kind !== 'disposed'是硬门(agent.ts:178)。- 活着的驱动器不需要闩。 它自己会在下一个 step 边界去 inbox 取货。只有
maintenance(不看 inbox)和"已 abort 的驱动器"(不会再取货)需要别人替它记账。 - idle 时的唤醒一定开 turn,哪怕消息已经被清掉。 这是
wakeDriver头上那段 JSDoc 明说的语义(agent.ts:164-171):闩住的重放会在队列空时被抑制,但直接的 idle 唤醒永远开一个 turn 边界。
闩的兑现点有三处,写法一模一样:kick() 的 finally(agent.ts:216-221)、runMaintenance 的 finally(agent.ts:156-159)、以及 turn 之间的续跑(agent.ts:324-329)。第三处反而是把闩清掉:
// packages/core/agent-loop/src/agent.ts:324
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()
// A fresh controller makes a latch set on the old one stale
phase.wakeRequested = false
phase.step = 0
return true
换了新的 AbortController,旧 controller 上记的闩就过期了——活的驱动器自己认领队列,不需要重放。
4. Inbox:不是内存队列,是 durable splice 的投影
4.1 这里的核心反直觉
大多数 agent 框架的 inbox 就是一个数组。这里不是。Inbox(packages/core/agent/src/inbox.ts:25)持有两个数组,但它们只是回放出来的视图;权威在会话日志里的 agent/inbox/spliced 事件流。
写路径 读路径(重启/恢复)
splice(target,...) session.events
│ │ 过滤 agent/inbox/spliced
▼ ▼
validate 归一化 逐条 apply()
│ │
session.append(...) ── 落账 ──► ┌──────────┐
│ │ 两条数组 │
▼ │ next-turn│
内存数组 splice ◄────────────────│ next-step│
└──────────┘
构造函数就是回放(inbox.ts:32-39):从 session.header.seedLength 之后开始扫,只认 agent/inbox/spliced,任何一条应用失败就带着 seq 报错。这意味着进程崩了重启,没来得及处理的用户输入还在。
4.2 落账先于投影
mutate() 里的顺序是刻意的(inbox.ts:185-191):
// packages/core/agent/src/inbox.ts:185
this.validate(splice)
const event = this.session.append('agent/inbox/spliced', splice)
const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
两个细节:
- 先 append 再改内存。 所以同步的
session/event观察者看到的是改动前的队列,配合事件里的归一化坐标(start/removedCount),它能自己算出被删掉的是哪几条(types.ts:19-25 的事件文档明说了这条契约)。 - 插入用
event.data.inserted,不是调用者传进来的数组。 投影里存的是日志那份(已冻结的)副本,调用者事后改自己手里的对象也污染不了投影。
validate()(inbox.ts:203-219)做两件事:splice 坐标必须落在合法区间;以及两条队列合起来不能有重复的 message id——它用 toSpliced 先算出候选结果再查重,所以"把 A 换成 A"这种原地替换不会误判。
4.3 claim:批次语义
claim() 是 step 边界的取货动作(inbox.ts:71-78),语义很具体:
// packages/core/agent/src/inbox.ts:71
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
}
for (const message of claimed) this.notifications.claimed(message, turn)
return claimed
}
| 规则 | 具体行为 |
|---|---|
| next-step 全清 | 一次 claim 掏空整条 next-step,不留尾巴 |
| next-turn 只取一条 | 排队的提问一个 turn 一条,绝不合并 |
| 顺序 | steering / 注入的上下文排在那条 turn 提问前面 |
| 落账形式 | 纯删除的 splice,discardRemoved = false——不算"取消",不发 discarded |
最后一列很重要:被 claim 的消息和被 cancel 掉的消息,在日志里长得不一样。取消会带 outcome: 'canceled'(inbox.ts:177),claim 不会。
还有一个容易漏的后果:如果这一步被 agent/pre-step 拒了,这批被 claim 的消息就此消失——不会退回 inbox,也不会写成 user/message(runtime-types.ts:188-192)。日志里只留下 agent/inbox/claimed 这条活事件的记录和一个空 turn。
5. agent/pre-step:决定模型能看见什么
preStep()(agent.ts:225-243)是模型可见内容的最后一道闸。它的顺序是:
claim 一批消息
↓
systemPrompt.assemble(...) ← 拿到 prompt 段落 + 工具 schema
↓
renderContextSections → RuntimeContextProjection.project(...)
↓ (运行时上下文变了才追加一条快照消息)
waterfall 'agent/pre-step'
↓
{ kind:'reject' } 或 { kind:'enter', messages, assembly }
默认行为(也就是没有插件插手时 next() 返回的值)是 enter + "claim 到的消息 + 可选的一条运行时上下文快照"(agent.ts:235-239)。运行时上下文由 RuntimeContextProjection.project() 决定要不要产出——只有内容和上一份保留 的快照不同时才生成新消息(packages/core/agent-loop/src/runtime-context.ts:64),避免每步都往日志里灌一份一模一样的环境描述。
插件在这个 waterfall 里能做三件事:
| 返回 | 后果 | turn 结局 |
|---|---|---|
{ kind: 'reject' } | 这一步不进,消息已被吞掉 | blocked(agent.ts:268-270) |
{ kind: 'enter', messages: [...] } | 用改写后的消息进步 | 正常继续 |
{ kind: 'enter', messages: [] } 且是首步 | 开了 turn,但一个 step 都不花 | completed(agent.ts:274-277) |
第三行是个很漂亮的设计:
// packages/core/agent-loop/src/agent.ts:273
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
"唤醒时消息已被清掉"和"插件把消息改写成空"这两种情况,都落到这一支:turn 边界照开,模型调用不花。日志因此保留了"有人敲过门"的证据,账单上却没有多余的一次请求。
注意 preStep 前后各有一次 signal.throwIfAborted()(agent.ts:231、241)——prompt 组装和 waterfall 都可能是异步的、可能很慢,取消必须能在这两个缝隙里插进来。
6. 一次请求的组装:buildRequest
这是全章最密的一段。buildRequest(agent.ts:407-495)要在 90 行内解决五个独立问题。先看流程:
持久化的 header(上一次落账的 EpochHeader)
│
▼
① 路由继承:算出 seedConfig
│ 首次 → 用 AgentOptions 的 provider/model/maxTokens
│ 之后 → requestProposal(上一份 header),剥掉 adapter 塞的默认值
▼
② waterfall 'agent/request' → proposedConfig(插件可整体换路由)
│
▼
③ llm.prepareCall(proposedConfig)
│ 成功 → 拿到 config + adapterDefaults + retryPolicy + contextWindow
│ NO_ADAPTER → 降级:用 proposedConfig,走 ctx.llm.stream
▼
④ 落账 request/header(initial | resume | change)+ request/context(变了才写)
│
▼
⑤ deepFreeze + markAgentLoopRequest → 交给流
6.1 ① 路由继承:谁的 reasoningEffort 算数
一个 loop 实例从自己声明的路由起步,不无条件继承日志里的旧配置。恢复旧 reasoningEffort 要同时满足三个条件(agent.ts:422-426):
- 持久化 config 的
provider等于本实例声明的 provider; model也相等;- 且这个值不是 adapter 自己填的(
adapterDefaults.reasoningEffort !== true)。
第三条靠 requestProposal() 这个小函数守着(agent.ts:55-61):把 header 交给插件重新提议之前,先把 adapter 派生的字段删掉。
// packages/core/agent-loop/src/agent.ts:55
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
const proposal = { ...header.config }
if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
return proposal
}
为什么要这么小心? 因为 adapter 填的默认值 属于"那一个具体模型"。换了模型还把上一个模型的 maxTokens 当成用户意图带过去,就是拿 A 的默认值去约束 B。adapterDefaults 这个标记位(packages/core/session/src/types.ts:201-208 的 EpochHeader)存在的全部意义,就是让"用户显式指定的"和"adapter 兜底的"在日志里可区分。
6.2 ② agent/request waterfall
插件在这里能整体替换调用配置(runtime-types.ts:244)。契约里有一句关键限制:这个 waterfall 不能改 messages。想让模型看见新内容,只能走落账的渠道(inject → inbox → user/message)。这就是仓库那条"model-visible ⟺ logged"规矩在主循环里的具体落点。
出口有个硬校验:provider 或 model 为空就直接抛,错误消息还顺手告诉你两条修法(agent.ts:443-445)。
6.3 ③ prepareCall 与 NO_ADAPTER 降级
llm.prepareCall()(packages/llm/llm/src/index.ts:824)返回一个一次性句柄 PreparedLlmCall:它把 adapter 注册、重试策略、上下文窗口、解析后的 config 绑成一包,stream() 只能派发一次,且派发时会校验 config 没被改过。这样即使中途发生 HMR(插件热重载换了 adapter),也 不会出现"用 A 的能力探测结果去打 B 的 wire"。
失败时有一条窄降级(agent.ts:451-455):
} catch (error: unknown) {
// Middleware may serve an unregistered route; terminal dispatch still requires an adapter.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfig
}
只有 NO_ADAPTER 一种错误被吞。 场景是:某个 llm/stream 中间件自己就能服务这个路由(比如录制回放、mock provider),根本走不到终端 adapter。这时 preparedCall 是 undefined,后面 step() 里就退回 this.loopCtx.llm.stream(request)(agent.ts:345),同时也就没有 retryPolicy 可给 agent/request-error 用了。其它任何错误照抛。
6.4 ④ header 落账的三种理由
request/header 事件带一个 reason(packages/core/session/src/types.ts:228),判定逻辑就 6 行(agent.ts:464-470):
| reason | 触发条件 | 白话 |
|---|---|---|
initial | 本实例第一次落 header,且日志里之前没有 header | 全新对话的第一次请求 |
resume | 本实例第一次落 header,但日志里已有 header | 进程重启 / fork 后接着聊 |
change | 之后的请求,headerEquals 判定与基线不同 | 中途换了模型、改了 system、加了工具 |
requestHeaderLogged 是实例级布尔(agent.ts:77),不是会话级——所以"新 loop 实例接管老日志"必然先落一条 resume 锚点。这条锚点让读日志的人知道"从这里开始是另一个进程/另一台机器在说话"。
比较用的是 headerEquals(packages/core/session/src/request-header.ts:44),落账前先过 canonicalHeader(同文件:21)做归一化——空工具数组、空 system 这类"等价的不同写法"不会造成假变更。
request/context 是另一条独立的账(agent.ts:472-483),只记 provider / model / contextWindow,同样只在变化时写。它服务的是"还剩多少上下文"这类 UI 和压缩决策。
6.5 ⑤ 冻结与打标
最后一步(agent.ts:486-493):
const request = markAgentLoopRequest(deepFreeze({
...header.config,
messages: boundaryMessages,
...
sessionId: this.session.id,
signal,
}))
deepFreeze迭代式深冻(packages/llm/llm/src/call-config.ts:88),带环检测,且故意跳过AbortSignal——冻了 signal 就取消不了。这保证任何llm/stream中间件都不能就地篡改请求,要改只能造新对象。markAgentLoopRequest把这个对象身份记进一个进程内的 WeakSet(call-config.ts:66)。下游中间件可以用isAgentLoopRequest()区分"这是主循环发的对话请求"和"这是某个插件自己发的旁路请求"(比如生成标题、做压缩)——两者往往需要完全不同的策略。
注意 messages 来自 this.session.deriveMessages()(agent.ts:341,实现在 packages/core/session/src/index.ts:726)。驱动器不维护任何对话历史数组;每一次请求的消息列表都是当场从日志 surface 投影出来的。这是第 2 章那条"唯一事实源"在主循环里的兑现方式。
7. 流式消费与重试
step() 的主体是一个 while (true) 重试循环(agent.ts:339-400)。一轮的形状:
buildRequest ──► stream ──► for await (chunk)
│ 每个 chunk 都 append 成 assistant/chunk,记下 seq
│ 同时喂给 BlockAssembler
▼
assembler.finish
│
┌─────────────────────┼──────────────────────┐
│ error / aborted │ 其它 │
▼ ▼ │
waterfall assistant/message │
agent/request-error (sourceEventSeqs = 那批 chunk seq)
│ │
retry? ──是──► continue ├─ max-tokens → 返回 {kind:'max-tokens'}
│ ├─ 无 tool-call → 返回 {kind:'completed'}
否 → 抛 LlmError └─ 有 tool-call → 执行 → concluded ? completed : null
7.1 chunk 全落账,message 反向索引
每个 chunk 都是一条 assistant/chunk 事件(agent.ts:349),它们的 seq 攒成 chunkSeqs,最后写 assistant/message 时作为 sourceEventSeqs 挂上去(agent.ts:389)。这条反向索引让 UI 能把"那条最终消息"和"生成它的原始流"对上,也是重放和 surface 替换(压缩)能工作的前提。
一个可以从源码直接读出的后果: 重试时 continue 会新建 assembler 和空的 chunkSeqs(agent.ts:340-346),而失败那次的 chunk 事件已经在日志里了,只是没有任何 assistant/message 引用它们。所以一次重试后的日志里,会存在一段"孤儿 chunk"。
7.2 agent/request-error:默认不重试
waterfall 的默认值是 undefined(agent.ts:364),也就是没人认领就是终止。listener 想接管恢复,就返回 { kind: 'retry' } 且不调 next();想让给下一个 listener,就调 next()(runtime-types.ts:245-260)。payload 里带了 retryPolicy——来自 preparedCall 捕获的那个 adapter 注册(NO_ADAPTER 降级时是 undefined),listener 据此决定退避多久、还剩几次。
重试是重建整个请求,不是重发同一个对象:continue 回到循环顶,重新 buildRequest,于是消息重新投影、agent/request waterfall 重新跑、prepareCall 重新拿一个一次性句柄(因为旧句柄只能派发一次)。
7.3 max-tokens 是粘性的
step() 返回 { kind: 'max-tokens' } 后,turn() 里那行判断保证它不会被后面的步骤降级(agent.ts:287-290):
const stepEnd = await this.step(decision.assembly)
// max-tokens stays sticky: a later completed step must not downgrade the turn outcome.
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
为什么重要? 因为有插件会在撞顶后接着让模型继续写(continuation)。如果后续那个正常完成的 step 把 turn 的结论改回 completed,读日志的人就再也看不出"这一轮曾经被输出上限截断过"。TurnEndReasonMap['max-tokens'] 的文档也是这么写的:至少有一步撞顶就算(packages/core/session/src/types.ts:167-168)。
7.4 step 的三种返回值
| 返回 | 含义 | turn 会怎样 |
|---|---|---|
{ kind: 'completed' } | 模型没叫工具,或工具结果带了 concludesTurn | 准备收束 |
{ kind: 'max-tokens' } | 流以输出上限结束 | 准备收束,且结论锁死 |
null | 叫了工具且没有结论 | 模型还欠一次回应,直接开下一步 |
null 这一支来自 executeToolCalls 的 concluded 标志(agent.ts:395-399,实现见 packages/core/agent-loop/src/tool-calls.ts:59)。工具执行细节在 第 4 章;主循环这里只需要知道两件事:工具产生的追加上下文会被塞进 next-step(agent.ts:397),以及任何一条工具结果带 concludesTurn 就能提前收束这一轮。
8. turn 的收束:谁说了算
8.1 agent/turn-stopping 是个 serial 事件
收束前的最后一次机会(agent.ts:295-299):
if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
signal.throwIfAborted()
}
if (turnEnds && this.inbox.nextStep.length === 0) break
同一个条件判断了两次,中间隔着一次 serial 派发。 这就是它的全部机制:listener 如果不同意关 turn,它的做法不是"返回 false",而是调 agent.steer(...) 往 next-step 里塞东西;第二次判断读到队列非空,循环继续。
这个设计的收益写在事件文档里(runtime-types.ts:261-278):数据决定结果,所以 listener 的注册顺序影响不了结局。三个插件同时想续跑,塞三条 steering,turn 就多走几步;一个都不塞,turn 就关。没有"谁最后一个说了算"的问题。
反向控制(提前停)也是数据:工具结果里的 concludesTurn。而且文档明确了它不会短路已提交的工作——同一步产生的 additionalContexts 或竞态进来的 steering 照跑,turn 只在 inbox 真的排空时才关。
8.2 TurnEndReason:六种结局
turn/end 一定会写,finally 里兜着(agent.ts:316-323)。可能的 reason(packages/core/session/src/types.ts:155-175):
| kind | 谁产生 | 场景 |
|---|---|---|
completed | 主循环 | 正常收束,含"空首步"这种零 step 收束 |
blocked | 主循环 | agent/pre-step 返回 reject |
aborted | 主循环 | 捕获异常时 signal.aborted 为真,带 AgentCancelCause |
error | 主循环 | 其它异常,带结构化 LlmFailure |
max-tokens | 主循环 | 至少一步撞输出上限 |
interrupted | 持久化后端 | 重载时给崩溃遗留的未关闭 turn 补一个墓碑;主循环从不写 |
错误的结构化很值得抄(agent.ts:308-314):LlmError 保留它自己的 failure 事实;其它任何东西一律拍平成 { message: errorChain(error), code: 'UNKNOWN' }。于是"turn 为什么失败"在日志里永远是同一种可读的形状,不会出现一坨 stack trace 或 [object Object]。
TurnEndReasonMap 是可合并扩展的接口,插件能往里加自己的结局种类——这也是为什么它写成 map 而不是裸 union。
9. 取消与错误:容器化在驱动器边界
9.1 cancel 只做三件事
// packages/core/agent-loop/src/agent.ts:134
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
this.inbox.clear()
if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
}
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}
| 动作 | 条件 | 说明 |
|---|---|---|
| 清空 inbox | 默认;keepInbox 可跳过 | 落账为带 outcome: 'canceled' 的 splice |
| 清掉唤醒闩 | 同上 | 队列都清了,重放没有意义 |
| abort 当前活动 | 有活动才做 | idle 时 cancel 是纯 no-op,不会给未来的活动预埋取消 |
AgentCancelCause 只有四种(packages/core/session/src/types.ts:143-148):user(人按了停)、parent(父 agent 取消了子 agent)、hook(带 reason 的钩子否决)、disposed(生命周期销毁)。这个 cause 会一路走进 turn/end 的 aborted.reason,所以事后能从日志区分"用户主动停的"和"被销毁带走的"。
9.2 错误的两跳
throwError()(agent.ts:203-208)先在失败发生的那个位置发 agent/error(带 turn/step 坐标),然后原样重抛。重抛的目的是让 turn() 的 finally 有机会写 turn/end,最后被 kick() 的 catch 吞掉:
// packages/core/agent-loop/src/agent.ts:210
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (_error) {
// Reported failures and cancellation are contained at the driver boundary.
} finally {
if (this.phase.kind === 'running') { /* 回 idle + 兑现闩 */ }
}
}
一个空 catch 加一行注释。 它的正当性是:错误已经在 agent/error 上报过了,turn/end 也已落账,再往上抛只会变成 unhandled rejection——因为 kick() 的 promise 只被 wakeDriver 里的 driver.resolve/reject 接住,没有真正的调用者在等它。
finally 里的收束是无条件的:不管这轮是正常结束、被取消、还是炸了,phase 一定回到 idle,闩一定被兑现。这就是"容器化"——失败不会把机器卡在 running。
10. 工厂侧:谁创建、谁销毁这台机器
ReactLoopAgent 自己不管生命周期。管的是 AgentLoop 服务(packages/core/agent-loop/src/index.ts:296),它同时是 Cordis 服务、AgentFactory 实现,和声明式 agents 的启动器。
10.1 FactoryOwnership:工厂级的账本
(index.ts:40-90)它记四样东西:
| 记什么 | 方法 | 用途 |
|---|---|---|
| 还接不接新活 | isActive() | accepting 标志 + fiber 状态不在 UNLOADING/DISPOSED/FAILED 里 |
| 活着的 agent 的 teardown | track(dispose) | 工厂卸载时逐个调用 |
| 启动期的异步活 | trackStartup(job) | 配置驱动的 create/resume 还没产出 agent 时也要等 |
| 公共 create/resume 的续程 | trackWrapper(job) | 吞掉成败,只等它落定 |
dispose() 一次性:停止接活 → abort 掉 signal(reason 是一个带明确文本的 Error)→ resolve inactive → Promise.all 等所有 agent teardown 和启动任务。
10.2 prepare():先注册退路,再造资源
prepare()(index.ts:459-578)是这个包里最讲究的一段。它的核心手法是倒序注册:
① 建 AbortController,把三个取消源熔到一起
caller 的 signal ─┐
owner fiber 卸载 ─┼─► abort(各自带自己的 reason)
工厂 teardown ───┘
② 定义 dispose(记忆化,只跑一次) ← 此时资源还不存在
③ ownership.track(dispose) + ownerCtx.effect(...) ← 退路先挂上
④ new ReactLoopAgent(...) ← 资源现在才造
⑤ 返回 { agent, signal, publish, dispose }
为什么要这个顺序?注释说得很直白(index.ts:475-478):卸载可能在 scope 还在铸造的过程中到达,那时必须已经有一个能工作的 disposer,否则就是泄漏。所以 dispose 闭包建在可变槽位(machine、detachSession、detachAgent)之上,先挂上,后填值;machineReady 这个 promise 专门解决"dispose 比构造函数先跑"的竞态(index.ts:493、505)。
dispose 的顺序是严格倒序(index.ts:497-520):
abort 掉融合信号
→ machine.cancel({ kind: 'disposed' }) // 销毁就是一次 disposed 取消
→ await machine.whenIdle() // 等安静
→ await machine.scope.dispose() // 卸掉 agent 作用域的所有注册
→ detachAgent() / detachSession() // 离开注册表
→ untrack() / unfollowOwner() // 清账本
注意 cancel({ kind: 'disposed' }) 与 §3.3 那条"disposed 不闩唤醒"是配套的:正因为销毁期的唤醒不会被闩住,whenIdle() 才不会永远等下去。
10.3 publish():五步,每步之间查一次死活
(index.ts:556-571)
sessions.enter(session) → agents.enter(agent, owner)
→ sessions.announce(session) [assertLive]
→ agents.announce(agent) [assertLive]
→ emit 'agent/session-start' [assertLive]
assertLive() 密集出现是因为 announce 会同步派发 listener,而 listener 可以就地发起 teardown。每一步之间重新检查,才能保证不会往一个已经在拆的 agent 上继续挂东西。
agent/session-start 是第一个可以驱动启动逻辑的扩展点——setup 阶段只允许做组装(runtime-types.ts:206-217)。想在第一次 turn 之前塞上下文,就在这个事件里调 agent.inject()。
10.4 创建与恢复:两条路的 abort 融合
| 入口 | 路径 | 特点 |
|---|---|---|
create(id, options, meta) | 同步 | 配置驱动用;using 管住 SessionPreparation(index.ts:589) |
createAgent(ownerCtx, options) | 异步 | 走 setupAndPublish,支持 setup 与 signal(index.ts:606) |
resume(ownerCtx, options) | 异步 | 需要 sessionPersistence,没有就明确报错(index.ts:653) |
resumeWith()(index.ts:662-710)里的融合最完整:
const fused = AbortSignal.any([
...options.signal === undefined ? [] : [options.signal],
ownerAbort.signal,
this.ownership.signal,
])
三个源:调用方取消、owner fiber 卸载、工厂 teardown。加载完成后还要再查两道(index.ts:693-694):ownerCtx.fiber.assertActive() 和 ownership.isActive()——因为 await 期间世界可能已经变了。
配套的 raceAbortCall(index.ts:109-130)有个细节:如果加载在 abort 之后才返回,那个被遗弃的 SessionPreparation 会被单独释放(releaseAbandoned 回调调 [Symbol.dispose]()),不会挂住那个 session id。
10.5 声明式 agents 与 launcher 身份覆盖
cordis.yml 里可以直接声明要启动哪些 agent(Config.agents,index.ts:262-271)。构造函数遍历它们,分两条路(index.ts:355-381):
每个配置项
├─ 有 resumeSessionId ─► ctx.effect + ctx.inject(['sessionPersistence'])
│ → resumeWith(...)(等持久化服务就绪)
└─ 否则 ─► 有 sessionId 且有持久化 ─► restoreOrCreateConfigured(...)
否则 ────────────────────► create(新 uuid 组合 id)
restoreOrCreateConfigured()(index.ts:406-428)解决"同一个固定 id 反复挂载"的问题:
- 等同 id 的旧生命周期从注册表里退干净(
waitForDrainingConfiguredIdentity,index.ts:430-451); - 先当作 resume 试一次;
- 失败了,用
persistence.list()查这个 id 到底存不存在——存在就把错误抛出去(说明是损坏或后端故障,不能掩盖),真的不存在才退回create()新建。
这一步的克制值得学:"加载失败"和"本来就没有"必须分开,否则一次磁盘故障就会静悄悄地把用户的历史会话换成一个空白会话。
applyLauncherIdentities()(index.ts:221-234)处理另一个正交问题:会话身份该由启动器(它知道这个会话存不存在)决定,而模型路由该留在 cordis.yml 里当普通可 patch 的配置。做法是启动器在任何 Loader 条目挂载前用 ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, ...) 放一份 map,构造时按配置项的 id 覆盖:
// packages/core/agent-loop/src/index.ts:229
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
return identity.resume
? { ...rest, resumeSessionId: identity.id }
: { ...rest, sessionId: identity.id }
两个身份键一起替换,绝不允许配置里的身份和启动器给的身份并存——否则就会出现"启动器说 resume 这个 id,配置说 create 那个 id"的双头状态。
启动失败不是静默的:reportConfiguredStartupFailure(index.ts:385-404)会 emit agent-loop/config-start-failed,让那些替这个身份缓冲了工作的消费者能直接拒绝,而不是无限等一个永远不会出现的 agent。
11. 巧妙之处(可以带走的几招)
| # | 招 | 妙在哪 | 位置 |
|---|---|---|---|
| 1 | 空首步也开 turn | 保留"有人敲过门"的证据,但不花模型调用 | agent.ts:274-277 |
| 2 | 唤醒闩 + abort 后重分类 | 把"取消收敛期到达的输入"这个经典竞态压成两个布尔 | agent.ts:113-120、172-193 |
| 3 | inbox 是 splice 事件的投影 | 崩溃重启后未处理的用户输入还在;顺带得到完整的输入审计 | inbox.ts:32-39 |
| 4 | 落账先于内存改动 | 同步观察者能用归一化坐标反推被删的消息 | inbox.ts:185-191 |
| 5 | adapterDefaults 标记位 | 把"用户指定的"和"adapter 兜底的"分开,换模型时不会串味 | agent.ts:55-61、422-426 |
| 6 | 一次性 PreparedLlmCall | HMR 也不会把 A 的能力探测结果配到 B 的 wire 上 | llm/index.ts:779 |
| 7 | turn-stopping 用数据表决 | listener 注册顺序影响不了结局,三个插件同时续跑也不打架 | agent.ts:295-299 |
| 8 | max-tokens 粘性 | 续写插件无法抹掉"这轮被截断过"的事实 | agent.ts:287-290 |
| 9 | 先注册 disposer 再造资源 | 铸造中途被卸载时手里已有能工作的退路 | index.ts:475-520 |
| 10 | resume 失败要查 list() | 区分"加载失败"和"本来就没有",故障不会静默吞掉历史 | index.ts:419-427 |
| 11 | deepFreeze 跳过 AbortSignal | 冻结防篡改,但不能把取消通道也冻死 | call-config.ts:88 |
12. 边界与局限(诚实版)
- 重试会在日志里留孤儿 chunk。 失败那次的
assistant/chunk已经落账,重试后没有任何assistant/message引用它们(agent.ts:340-351 与 373-390 对照可读出)。消费方需要靠sourceEventSeqs而不是"扫全部 chunk"来重建消息。 agent/request改不了消息。 想影响模型看到的内容,只能走落账渠道,不能在请求组装期临时插一段(runtime-types.ts:236-239)。maintenance期间的输入不被消费。 维护任务不看 inbox,唤醒只能闩住等它结束(agent.ts:142-162)。维护任务跑得久,用户的输入就等得久。inject()可能错过一趟车。 如果目标 step 的preStep已经 claim 完了,这条注入只能等下一个 step 边界(runtime-types.ts:135-143 明说了这点)。runMaintenance有活动时同步抛错,不排队(agent.ts:143)。调用方自己负责先whenIdle()。interrupted这个 turn 结局主循环永远不写,只有持久化后端在重载时补(session/types.ts:172-175)。所以看到它就说明上次进程是异常终止的。- 驱动器不做上下文长度管理。 请求消息就是
deriveMessages()的全量投影;压缩靠 surface 替换(第 2 章)和 compaction 插件,不在这一层。
13. 和相邻章节的关系
- 循环里所有
ctx.xxx服务、ctx.effect、waterfall 语义,底座在 第 1 章 · Cordis 插件树。 session.append/deriveMessages/ surface 投影,在 第 2 章 · 会话事件日志。executeToolCalls、并发分组、concludesTurn、additionalContexts,在 第 4 章 · 工具。llm.prepareCall背后的 adapter 注册 和 provider 替换,在 第 5 章 · 能力接缝。- 声明式 agents、preset、启动器身份覆盖的产品用法,在 第 6 章 · 每会话独立组装与产品外壳。
14. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 默认驱动器本体 | packages/core/agent-loop/src/agent.ts | ReactLoopAgent |
| 三态相位 | packages/core/agent-loop/src/agent.ts | Phase、setPhase、status |
| 驱动器外层循环 | packages/core/agent-loop/src/agent.ts | kick |
| turn 状态机 | packages/core/agent-loop/src/agent.ts | turn |
| step 与重试循环 | packages/core/agent-loop/src/agent.ts | step |
| 步前闸门 | packages/core/agent-loop/src/agent.ts | preStep、PreparedStep |
| 请求组装 | packages/core/agent-loop/src/agent.ts | buildRequest、requestProposal |
| 输入入口与唤醒 | packages/core/agent-loop/src/agent.ts | send、followup、steer、inject、wakeDriver |
| 取消 / 静默 / 维护 | packages/core/agent-loop/src/agent.ts | cancel、whenIdle、runMaintenance、throwError |
| 运行时上下文快照 | packages/core/agent-loop/src/runtime-context.ts | RuntimeContextProjection |
| 待办输入投影 | packages/core/agent/src/inbox.ts | Inbox、claim、splice、mutate、validate |
| 输入队列的事件 | packages/core/agent/src/types.ts | InboxTarget、agent/inbox/spliced |
| Agent 接口与全部扩展点 | packages/core/agent/src/runtime-types.ts | Agent、PreStepDecision、RequestErrorAction |
| 融合派发器 | packages/core/agent/src/dispatch.ts | agentEvents、assembleContextFor、emitAgentEvent |
| 工厂 / 服务 | packages/core/agent-loop/src/index.ts | AgentLoop、FactoryOwnership、prepare、publish |
| 声明式启动 | packages/core/agent-loop/src/index.ts | applyLauncherIdentities、restoreOrCreateConfigured、resumeWith |
| turn 结局与取消原因 | packages/core/session/src/types.ts | TurnEndReasonMap、AgentCancelCause、EpochHeader |
| header 归一化与比较 | packages/core/session/src/request-header.ts | canonicalHeader、headerEquals、foldRequestHeader |
| 流装配与请求打标 | packages/llm/llm/src/assembler.ts、packages/llm/llm/src/call-config.ts | BlockAssembler、markAgentLoopRequest、deepFreeze |
| 一次性调用句柄 | packages/llm/llm/src/index.ts | prepareCall、PreparedLlmCall |