KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 主循环:turn/step 的八步与三个反直觉约束 — keel 龙骨

主循环在 packages/core/agent-loop/src/agent.ts,647 行,一个类 ReactLoopAgent implements Agent。它的形状出人意料地小:

主循环在 packages/core/agent-loop/src/agent.ts,647 行,一个类 ReactLoopAgent implements Agent。它的形状出人意料地小:

private async kick(): Promise<void> {
  try {
    while (await this.turn()) {}
  } catch (_error) {
    // Reported failures and cancellation are contained at the driver boundary.
  } finally { /* 收尾相位 */ }
}

内核只有 while (await this.turn()) {} 一行。 turn 内部再套一层 while (true) 跑 step。这一章按这个嵌套结构讲清楚八步流程,然后重点讲三个违反直觉的约束——它们是这个循环真正难写的地方。

一、三层嵌套:driver / turn / step

turn() 返回布尔值:true 表示「本 turn 已闭合,但 inbox 里还有待处理输入,请再开一个 turn」,false 表示「停下来」。这个布尔值是 kick() 唯一的循环条件,这也意味着一个 turn 的结束不等于 driver 结束。

turn() 内部是 while (true) 循环 step。每一步的开头都会重新走一次 preStep,因此同一个 turn 里的每步都可能拿到不同的输入(target 在第一次之后固定为 'next-step')。

Phase 类型把相位显式建模成三态:

type Phase =
  | { kind: 'idle'; lastTurn: number }
  | { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
  | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }

maintenance 是给压缩、fork 这类「不跑模型但必须独占 agent」的工作准备的:runMaintenance(job) 期间 send() 只能把唤醒请求存进 wakeRequested 挂起,等 job 结束再补发。这是**「不能边压缩边收新输入」这条约束的实现方式**。

二、turn() 的八步

# 动作 落点
1 检查相位是 running,读 phase.abort.signal,throwIfAborted() turn() 开头
2 session.append('turn/start', { turn }) turn 边界的唯一开标记
3 preStep(target, { turn, step }) → inbox 取消息 → 装配 system prompt → 投影 runtime context → agent/pre-step waterfall 见下节
4 reject → turnEnds = { kind: 'blocked' },直接 return false 不写 step/start
5 session.append('step/start', { turn, step }),跑 step(),finally 里 session.append('step/end', ...) 派生的每一步
6 agent/turn-stopping(serial) 只在 turnEnds 有值且 nextStep 为空时
7 若 turnEnds 有值且 nextStep 仍为空 → break step 循环出口
8 finally 里 session.append('turn/end', { turn, reason }) 保证每一轮都有闭合

turn/end 的 reason 是 TurnEndReasonMap 的成员:completed / aborted / blocked / error / max-tokens / interrupted / forked。后两个 loop 自己从不写——interrupted 由崩溃恢复写,forked 由 buildForkSeed 写。

第 6 步的 serial 是「顺序 await,遇到 bail 值就停」。一个监听器在这里调 agent.steer(msg),消息进 nextStep inbox,第 7 步的条件就不再成立,于是同一个 turn 再开一步。这就是「插件可以在收尾时追加一步」的机制。

max-tokens 是粘性的:if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd。任何一步撞到输出上限,整个 turn 的结论就是 max-tokens,后续正常完成的步骤不能把它降级。

三、preStep:输入、提示词与拦截点

preStep 做四件事,顺序很重要:

const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall('agent/pre-step',
  { messages: claimed, ...position, signal },
  () => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context] }))

inbox.claim 先从队列里拿走消息(因此被拦截的消息也不会回队),随后才装配提示词。默认行为是把投影出的 runtime context 作为最后一条 user message 追加在 claimed 之后;agent/pre-step 的 waterfall 可以拒绝({kind:'reject'} → turn/end: blocked)或整段改写 messages。

systemPrompt.assemble 是异步的,因为它要读文件(agent-instructions)。assemble 之后立刻 throwIfAborted(),避免在已经取消的 turn 上继续。

四、step() 的八步

step(decision) 接收 { messages, assembly, startsRequestSeries? },内部还是一个 while (true)——这个内层循环是重试用的,不是工具循环。

# 动作 关键点
1 const renderedPrompt = renderPrompt(assembly) 在重试循环外,只渲染一次
2 prepareRequest(turn, step, signal) 折叠 requestHeader() 得到 seed config,跑 agent/request waterfall,再 llm.prepareCall
3 systemPrompt.project(...) → 逐条 session.append('system/message', ..., intent) 首个 commit 是 surface node 0
4 仅首次尝试:session.append('user/message', message, { surfaceOp: 'append' }) 重试不重复追加
5 buildRequest(...) 写 request/header(initial/resume/change/series)与 request/context;session.deriveMessages() 得到 frozen messages
6 preparedCall?.stream(request) ?? ctx.llm.stream(request),逐 chunk live.push(chunk) 走 AssistantStreamAttempt
7 结束:assistant/message(带 stream 与 usage)或 assistant/attempt(无 surface 消息) 失败/取消各有分支
8 message.content.filter(b => b.type === 'tool-call');空 → {kind:'completed'},否则 executeToolCalls(...) 工具阶段

buildRequest 里有两个不显眼但重要的细节。其一,request/header 只在四种情况下追加:initial(log 里第一次)、resume(本 loop 首次请求但 log 已有 header)、change(header 与基线不等)、series(header 相同但显式开启新 series)。其二,boundaryMessages 只对首次见到的 message 调 deepFreeze,用 frozenMessages: WeakSet 记账——因为同一个 message 对象会在多次请求里被复用,重复 deepFreeze 是纯浪费。

五、反直觉之一:没有 maxSteps / max-turns

全仓库 grep maxSteps / maxTurns / MAX_STEPS / MAX_TURNS,在 packages/ 下只命中 subagent-claude-code(那是上游 Claude Agent SDK 自己的 error_max_turns),dsh 自己的循环里没有任何步数计数器。

停止只靠四种机制:

  1. concludesTurn:executeToolCalls 返回的 concluded 为真 → step() 返回 {kind:'completed'} → turn 结束。
  2. preStep reject:agent/pre-step 的 waterfall 不调 next() 或显式返回 reject。
  3. abort:signal.throwIfAborted() 在 agent.ts 里出现 15 次,每次都是合法的退出点。
  4. error:turn() 的 catch 把任何异常转成 {kind:'error', error: LlmError.failure 或 {message: errorChain(error), code:'UNKNOWN'}} 后 rethrow。

设计意图是「循环长度由任务语义决定,不由计数器决定」。代价是:一个不收敛的模型会一直跑到上下文被压缩机制接管,或者用户手动取消。把预算交给 guard/timeout-policy、compaction 和 provider 侧的 maxTokens,而不是一个任意常数。

六、反直觉之二:dispatch 可重叠,提交严格按 model order

工具并发在 tool-calls.ts。调度器同时维护两个指针:

const slots: (Slot | undefined)[] = group.map(() => undefined)
let committed = 0            // 只跨连续槽位前进
const commitReady = async () => {
  while (committed < group.length) {
    const slot = slots[committed]
    if (slot === undefined) break          // 前一个还没落地,谁都不能提交
    // finalize/finish → appendToolResult → acceptContext → concluded
    committed++
  }
}

startCall(index) 把结果写进 slots[index],commitReady() 只能从 committed 游标起连续地往前推。结果是:第 3 个工具可能先于第 1 个跑完,但它的 tool/result 一定在第 1、2 个之后才可能被写入日志。 exclusive 模式自成 barrier(const group = mode === 'parallel' ? planned.slice(next) : [first],非并行只取一个),并且必须在 commit() 完成之后才解除——注释专门说明「The barrier covers post-execute」。

并发上限来自 ctx.agentLoop.config.maxParallelToolCalls,schema 默认值是 constants.ts: DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10。

fillPool() 每轮都会重新读一次 ctx.tools.executionMode(nextCall.exec):

if (nextToStart > 0 && mode === 'parallel'
  && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break

这处理的是运行期注册表变化:一个并行批跑到一半,某个插件把后面的工具改成了 exclusive,那么当前位置就要断开,形成一道新 barrier。executionMode 本身也 fail-closed——只有 isConcurrencySafe(args) === true 才算 parallel,未知/未声明/抛异常一律 exclusive。

七、反直觉之三:abort 要给没跑的调用补合成结果

取消时最容易出的问题是 replay 不合法:模型发了 5 个 tool-call,日志里只有 3 个 tool/result,重建历史时这段就成了断的。

runGroup 的处理是:

if (aborted) {
  for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
  return { consumed: group.length, aborted: true, concluded }
}

appendSkippedToolCall 会为每个「已排入模型消息但没启动」的调用成对写入 tool/call + tool/result,错误码是 TOOL_ABORTED_BEFORE_DISPATCH(其字符串值是 'ABORTED_BEFORE_DISPATCH')。注意成对:tool/result 通过 sourceEventSeqs: [callSeq] 引用刚写的 tool/call 事件,保持 surface 引用完整。

上游 executeToolCalls 对 abort 还有一层:整个 step 的剩余调用(还没进任何 group 的)也要补。注释写得很清楚——「Abort records synthetic error results for skipped calls so replay stays valid. A terminal scheduler failure preserves already-recorded tool/call events without fabricating results.」,即调度器故障与用户取消的语义不同:前者不伪造结果,后者必须伪造。

agent.ts 侧对已启动但被中断的流另有一套补写:如果 live.interruptedBlocks() 非空,就写一条 assistant/message 带 interrupted: true;否则写 assistant/attempt。前者保留模型已经吐出来的文字,后者表示「这一轮没有任何可见内容」。

代码地图

机制 位置 要点
驱动外壳 packages/core/agent-loop/src/agent.ts: ReactLoopAgent.kick while (await this.turn()) {},catch 里吞掉已上报的失败
相位 packages/core/agent-loop/src/agent.ts: Phase idle / maintenance / running 三态
turn 边界 packages/core/agent-loop/src/agent.ts: ReactLoopAgent.turn 写 turn/start 与 turn/end,返回「还要不要下一 turn」
步前装配 packages/core/agent-loop/src/agent.ts: ReactLoopAgent.preStep inbox.claim → systemPrompt.assemble → agent/pre-step waterfall
单步执行 packages/core/agent-loop/src/agent.ts: ReactLoopAgent.step 内层 while (true) 是重试循环,不是工具循环
请求提案 packages/core/agent-loop/src/agent.ts: ReactLoopAgent.prepareRequest agent/request waterfall → llm.prepareCall
请求冻结 packages/core/agent-loop/src/agent.ts: ReactLoopAgent.buildRequest 写 request/header/request/context;frozenMessages 去重冻结
取消原因收敛 packages/core/agent-loop/src/agent.ts: abortedCancelCause 只复制 turn/end 需要的字段,避免把 Node fetch 的 stack 写进日志
工具调度入口 packages/core/agent-loop/src/tool-calls.ts: executeToolCalls 按 live executionMode 切 group
组内调度 packages/core/agent-loop/src/tool-calls.ts: runGroup slots + committed 游标
顺序提交 packages/core/agent-loop/src/tool-calls.ts: commitReady 只跨连续 model-order 槽位推进
补池与重分类 packages/core/agent-loop/src/tool-calls.ts: fillPool 每轮重读 executionMode,非 parallel 即断批
中断补写 packages/core/agent-loop/src/tool-calls.ts: appendSkippedToolCall 成对写 tool/call + 合成错误 tool/result
并发上限 packages/core/agent-loop/src/constants.ts: DEFAULT_MAX_PARALLEL_TOOL_CALLS 值 10
输入队列 packages/core/agent-loop/src/inbox.ts: ReactLoopInbox claim(target, turn) / splice / hasPending
流式尝试 packages/core/agent-loop/src/assistant-stream.ts: AssistantStreamAttempt push / settle / interruptedBlocks / replayState
服务注册 packages/core/agent-loop/src/index.ts: AgentLoop 第 355 行 super(ctx, 'agentLoop'),第 369 行 setFactory

关键取舍

没有 maxSteps 计数器,代价是循环长度不受上界保护。
好处是「跑多久」由工具结果和 concludesTurn 决定,不会出现「还剩 1 步但任务没做完」的截断。代价是不收敛的 agent 只能靠 abort、压缩、或用户取消停下来;一个死循环的工具调用序列会被 guard/repeat-tool-reminder 提醒,但不会被强制终止。

dispatch 重叠但提交按 model order,代价是内存里要保留已完成但未提交的结果。
slots 数组持有全部已落地结果直到 committed 追上。好处是模型看到的历史顺序与它发出的顺序完全一致(这是 replay 与缓存命中的前提)。代价是一个慢的第 1 个工具会让后面 9 个已完成的结果一直占着槽位。

abort 时伪造工具结果,代价是日志里出现「模型没见到过」的错误。
TOOL_ABORTED_BEFORE_DISPATCH 的结果并不来自真实执行,是 loop 构造的。好处是 replay 时每个 tool-call 都有配对结果,重建不出错。代价是这类合成结果必须是可识别的(错误码固定、文案固定),否则会与真实失败混淆。

Phase 显式建模 maintenance,代价是 send() 要处理挂起语义。
压缩和 fork 需要独占 agent 却不能算作运行中的 turn。把它们单独成一个相位,让 wakeDriver 有地方挂 wakeRequested。代价是 send()/cancel()/wakeDriver 三处都要判断当前相位,且 runMaintenance 在非 idle 时直接抛。

自测题

  1. kick() 的 while (await this.turn()) {} 只有一行。请说明 turn() 在什么情况下返回 true、什么情况下返回 false,以及为什么 kick 的 finally 里还需要再判一次 wakeRequested。
  2. agent/turn-stopping 用的是 serial 而不是 waterfall。如果一个监听器调了 agent.steer(msg),从 dispatch 到循环继续,完整的状态转移是什么?inbox.nextStep 在其中扮演什么角色?
  3. commitReady 只能从 committed 游标连续推进。假设模型一次发了 4 个并行安全的调用,第 4 个先完成而第 1 个卡住,此时日志里已经写了几条 tool/call?几条 tool/result?为什么可以这样?
  4. appendSkippedToolCall 会为未启动的调用伪造错误结果,但调度器内部故障时不会。请说明这个区别在 replay 语义上为什么必要,并给出一个「如果两者都不伪造」会破坏的场景。
  5. fillPool() 在每轮都会重读 executionMode。如果某个插件在第 2 个调用 dispatch 期间把第 3 个调用从 parallel 改成 exclusive,会发生什么?如果改成相反方向(exclusive → parallel)又会发生什么?

进入 keel 阅读