从本篇开始,我们进入本系列的重心:Agent 核心。前面几篇铺好了地基——事件溯源的会话日志、一切皆插件的架构。现在要回答的问题是:一个 agent 究竟如何运转?输入到达之后,是谁、按什么顺序驱动模型请求与工具调用?取消与失败又如何被忠实记录而不污染日志?
dsh 的答案在 packages/core/agent-loop 包:ReactLoopAgent(src/agent.ts:98)把轮次与步骤当作状态机来驱动,是整个 harness 的默认循环。AgentLoop 服务(src/index.ts:330)在启动时把它注册为 ctx.agents 的工厂,消费方拿到的是 Agent 句柄,看到的是 send/followup/steer/inject 这组投递动词,而循环内部的全部调度都收拢在这一个类里。
这个分包关系值得注意:core/agent 只声明接口与事件词汇,扩展插件依赖它而绝不依赖 agent-loop,因此循环本身是可替换的。我们读 ReactLoopAgent 读的是"参考实现",不是"唯一可能"——但它是默认产品循环,理解了它就理解了所有事件何时发出。本文基于 2026 年 10 月的 master 分支(0.2.1-alpha.1 前后);项目处于 developer preview,API 可能变动。
轮次与步骤:两个先钉死的名词
官方架构文档(docs/architecture.zh.md:90)的定义值得原文引用:一个**步骤(step)是一次模型请求加上它调用的工具;一个轮次(turn)**包含零个或多个步骤,它在领取首条输入之前打开,并在不再欠下任何工作时关闭。
这不是文字游戏。turn/start、turn/end、step/start、step/end 都是持久会话事件,轮次与步骤的边界就是日志回放的最小骨架;扩展点也挂在这两条边界上(第 8 篇展开)。拿到一份日志,先数轮次、再数步骤,每一步里找一条 assistant/message 或 assistant/attempt 与若干对 tool/call/tool/result,整段对话的结构就还原了。轮次的结局 TurnEndReason 是一个可合并扩展的映射(packages/core/session/src/types.ts:201):completed、max-tokens、aborted(带拷贝后的取消原因)、blocked、error,以及只有崩溃恢复才会补写的 interrupted。模型请求的失败不直接终结轮次——失败步骤的结局先被记入,轮次再以 error 收尾。
"在领取首条输入之前打开"有个微妙之处。打开 turn()(agent.ts:296),turn/start 的追加发生在 claim 之前(agent.ts:303-305):开轮次的决定先于对输入内容的任何判断。所以一个被 agent/pre-step 拒绝的轮次,会留下 turn/start 紧接 turn/end 的空轮次。同理,"不欠工作"是数据判据:模型不再欠响应、没有存活的工具调用、inbox 的 next-step 列表为空。
Phase:驱动器的三态与唤醒锁存
公开状态 AgentStatus 只有 idle/running 两值(runtime-types.ts:109),但驱动器内部维护的是三态 Phase(agent.ts:42):idle 记上一个轮次号;running 持有本轮的 AbortController、轮次号、步骤号和 wakeRequested 锁存位;maintenance 同样持有一个 AbortController 和锁存位,但公开状态保持 idle。三态而非两态,是为了让"维护任务"和"排空轮次"互不借用自己的取消信号。
输入经 send() 到达时,wakeDriver()(agent.ts:214)在 idle 相直接启动驱动;在 running/maintenance 相则只锁存 wakeRequested,由当前的驱动器自己发现。send() 还有一个防御细节(agent.ts:154-157):它在插入 inbox 之前捕获"活动已中止"的快照,把唤醒输入改投 next-turn,防止重入的 cancel() 从 splice 观察者里改写分类。
真正驱动排空的是 kick()(agent.ts:252):
private async kick(): Promise<void> {
try {
while (await this.turn()) {}turn() 返回一个布尔:轮次关闭时若 inbox 里还有排队工作,就换新 AbortController、清零步骤号、返回 true,同一个驱动器接着开下一个轮次;否则返回 false,驱动器落定回 idle。这就是"一个驱动器连续排空多个排队轮次"的含义——你在 UI 里连发三条消息,它们由同一个 running 区间内的三个轮次依次消费,中途不会回到 idle。公开状态因此只翻转两次:进入 running 一次,全部排空后回 idle 一次,与中间跑了几个轮次无关。
runMaintenance()(agent.ts:183)是第三条路:它只在 idle 相认领任务,同步开跑,执行期间后到的输入留在 inbox 等任务落定;若此刻已有驱动或别的维护任务在跑,它同步抛错而不是排队。它常与 whenIdle() 配合,做压缩、摘要这类不欠模型的杂务。
stateDiagram-v2
[*] --> idle
idle --> running: wakeDriver 唤醒投递
running --> running: kick 连续排空轮次
running --> maintenance: runMaintenance 认领空闲
running --> idle: 排空完毕 settle
maintenance --> idle: 任务落定 settle
note right of running
abort 控制器 + turn/step 号 + wakeRequested 锁存
end noteturn 内部:claim、pre-step 与收尾
一个轮次内部的事件骨架是:turn/start → claim → agent/pre-step → (step/start → step() → step/end)* → agent/turn-stopping → turn/end{reason}。
claim 的语义在 inbox.ts:109:
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))两条规则:轮次边界领走 next-step 的全部外加 next-turn 的一条;步骤之间只领 next-step。这个不对称是刻意的:next-turn 的每条消息应该是"自己那一轮的普通消息"(followup 的注释明确说它是其轮次的唯一普通消息),而 steering 和注入的上下文追求的是"下一个步骤边界就被看见"。
设想一个具体场景:驱动器正在排空轮次 T1,你连发两条 followup 又插入一条 steer。T1 的 turn 边界早已过去,steer 落在 next-step;两条 followup 依次落在 next-turn。T1 不欠工作后,kick 开启 T2,claim 领走全部 next-step(那条 steer)加 next-turn 的第一条 followup;若 pre-step 放行,steer 与第一条 followup 同批进入 T2 的第一个步骤。T2 后续步骤之间,如果又有 steering 到达,就只领 next-step——轮次因此可以连续延伸,而不必为每条中途输入单开轮次。
领取是纯删除的 splice——没有 outcome、没有"原因",随后逐条发 agent/inbox/claimed 通知。被领走的消息如果随后被 pre-step 拒绝,就终止在 claimed:既不 discarded,也不会变成 user/message(runtime-types.ts 里 agent/inbox/claimed 的注释写明了这一点)。领取之后新插入的输入不受影响,留给后续批次。
inbox 本身以 agent/inbox/spliced 会话事件持久化(agent/src/types.ts:96),投影 fold 定义在 inbox.ts:27,且在 Session.append() 返回前同步提交。因此即使没有活跃 Agent,冷读方也能从日志重建待处理输入——这是"持久事实优先"思想的直接体现,第 8 篇讨论事件分类时会再用到。fold 同时是校验者:越界或不安全的 splice 坐标、跨两份列表重复的 message id 都会被拒绝,格式错误的持久历史会由事件 seq 指认。
pre-step(agent.ts:267)在提示词组装完成后运行,agent/pre-step waterfall 的监听者可以返回 reject 或 enter(messages)。reject,或首批 enter 为空,轮次就不产生任何步骤直接关闭;决策权完全在监听者返回的值上。enter 还带一个 startsRequestSeries 开关:置位时循环会记录新的 request/header(原因为 series),相当于告诉下游"从这里起是一个新的请求序列"。压缩(dsh-compaction-basic)就挂在这个事件上:它在派生请求之前处理上下文压力,需要换 surface 时通过替换消息推进。
收尾时,若模型不再欠响应、next-step 为空,循环发出 agent/turn-stopping。这个事件没有 next(),监听者反对轮次关闭的唯一方式是 agent.steer() 写入新数据,机器再重读 inbox 决定。先记住结论:轮次的生死最终由数据决定,不由任何监听者的嗓门决定;具体机制属于第 8 篇。
反向的控制同样是数据:工具结果可以携带 concludesTurn 标记,在所属步骤把轮次句号画上。但它不会短路已提交的工作——同步骤的 additionalContexts、竞速到达的 steering 仍会执行,轮次要等这批 inbox 排空才真正关闭。开与关,两个方向都遵守同一条纪律:看数据,不看调用时机。
flowchart TD
subgraph TURN["turn() 轮次边界"]
direction TB
A["turn/start 落盘(领取首条输入之前)"] --> B["claim 领取批次"]
B --> C{"agent/pre-step"}
C -->|"reject 或首批为空"| Z["turn/end 零步骤关闭"]
C -->|"enter(messages)"| S1["step/start 落盘"]
subgraph STEP["step() 一次模型请求及其工具"]
direction TB
S1 --> S2["agent/request 与 prepareCall 绑定适配器"]
S2 --> S3["提示词准入 + user/message(仅首尝试)"]
S3 --> S4{"流式请求结局"}
S4 -->|"失败"| S5["agent/request-error"]
S5 -->|"retry"| S2
S5 -->|"终态"| S6["assistant/attempt 落盘"]
S4 -->|"成功"| S7["assistant/message,可能带工具批次"]
S6 --> S8["step/end 记录步骤结局"]
S7 --> S8
end
S8 --> D{"轮次还欠工作吗"}
D -->|"next-step 有待处理"| B
D -->|"不欠"| E["agent/turn-stopping serial"]
E --> F["turn/end 带 reason"]
endstep 内部:重试循环与 sticky 结局
step()(agent.ts:398)是一个 while (true) 重试循环。每轮尝试从 prepareRequest()(agent.ts:547)开始:折叠日志里最新的 request/header,经 agent/request waterfall(默认决策是 agent options 的 seedConfig,agent.ts:576)再调 ctx.llm.prepareCall(config, signal) 一次性绑定适配器注册与精确模型元数据——"一代"注册,防 HMR 把 A 代的端点和 B 代的能力拼接。随后做提示词准入、首尝试追加 user/message、buildRequest()(agent.ts:599)从日志派生并冻结请求,才把绑定的调用送进 llm/stream。
流式响应由 AssistantStreamAttempt(assistant-stream.ts:18)同时喂给三处:紧凑持久化、BlockAssembler 和 agent/assistant-stream 实时帧。settlement 区分两类:正常结束(包括空内容、max-tokens)落 assistant/message;失败、取消或带内错误落 assistant/attempt,只进日志不进派生历史;流中途被打断但已有可见内容时,提交带 interrupted: true 的 assistant/message。
请求本身也带着身份标记:buildRequest 给冻结请求打上 markAgentLoopRequest(agent.ts:678),llm/stream 监听者据此识别循环请求、只读不改写——到达这个 waterfall 的请求已经深冻结,改写即抛错。
失败时先发 agent/request-error waterfall。某个监听者返回 { kind: 'retry' } 且不委托 next(),循环就 continue 开新一轮尝试:重新 prepareCall、重新准入,但不重跑组装、pre-step 和用户消息准入——同一份已渲染的组装结果贯穿整个步骤的所有尝试。重试退避本身由 dsh-llm-retry 插件在这个事件上实现(指数退避加抖动,按 provider 与 policyKey 记账,取消即中止等待),循环保持无策略;适配器层则被禁止内置重试,一次适配器调用就是一次提供方尝试,失败事实原样上交。
结局统计上有一个容易被忽略的设计:max-tokens 在轮内 sticky(agent.ts:339-341)。某步以 max-tokens 结束后,后续正常完成的步骤不允许把轮次结局"降级"回 completed。语义上,截断一旦发生,这一轮欠模型的部分就没有被真正交付。
取消:两个异步阶段,两份都不落盘
步骤里有两个异步阶段发生在任何 system/user 落盘之前:agent/request waterfall 和 prepareCall() 的解析(agent.ts:408-424 的顺序是 prepareRequest → throwIfAborted → 提示词 project → user/message)。任一阶段被取消,系统提示词与用户消息都不提交。日志里不存在"模型可能见过但没记录"的内容——模型可见性与持久事实永远一致,这是第 9 篇系统提示词篇的准入主线。
cancel()(agent.ts:175)默认先清空 inbox(keepInbox 可保留待处理工作,也不记录取消产生的 splice)再 abort 当前活动。取消原因由 TypeScript 联合类型约束在进程内:user/parent/hook{reason}/disposed,同一个 cause 对象同时暴露为运行时 AbortSignal.reason——但 signal 不授予协作监听者任何分类权限,拿到 signal 不等于拿到授权。一次活动以第一个 cause 为准,后续 cancel 不改写已 abort 的信号。落盘前还要过一次拷贝:Node 的 fetch 会给活的 AbortSignal.reason 挂上 stack 字段,直接把同一个对象写进 JSONL 会污染日志。abortedCancelCause()(agent.ts:80-95)的解法是按 kind 重建一个纯数据副本:
function abortedCancelCause(signal: AbortSignal): AgentCancelCause | undefined {
if (!signal.aborted) return undefined
const cause = signal.reason as AgentCancelCause
switch (cause.kind) {
case 'user':
case 'parent':
case 'disposed':
return { kind: cause.kind }
case 'hook':
return { kind: 'hook', reason: cause.reason }
default:
return assertNever(cause)
}
}失败步骤的配对补全
步骤可能因为模型请求失败或取消而中断,但它可能已经发出了 tool/call 却永远等不到对应的 tool/result。配对缺失会让日志回放失真。ToolCallRecovery(packages/core/session/src/repair.ts:105)在步骤作用域内监听事件,步骤关闭前为无结果的调用补一条错误结果:已记录 tool/call、无结果 → 补 TOOL_OUTCOME_UNKNOWN,内容提示模型"只重试只读/幂等操作";连 tool/call 都没记录 → 补 TOOL_NOT_STARTED;已提交的结果保持原样,结果不明的操作不自动重试。
turn() 在 step() 外围包了一层 try/catch(agent.ts:330-352):步骤抛错时先写入补全结果,再把原始错误向上抛;若补全本身也失败,两者聚合成 AggregateError。如果进程在轮次中途硬崩溃,事后还有第二道保险:interruptedTurnClosers()(repair.ts:209)会为孤儿轮次合成 interrupted 收尾,resume 与冷读查询各自补写。诚实记录优先于漂亮失败。
小结
轮次与步骤是 dsh 驱动器的骨架:轮次在领取输入前打开、不欠工作时关闭;步骤是"一次模型请求加其工具"。Phase 三态加 wakeRequested 锁存,让唤醒、排空、维护三类活动互不踩脚;claim 的纯删除语义让领取本身成为无争议的持久事实;取消被挡在 system/user 落盘之前,原因落盘前要剥掉运行时杂物;失败步骤由 ToolCallRecovery 配对补全,崩溃孤儿由 interruptedTurnClosers 事后合拢。
如果只想改循环行为而不读全部代码,读法也很直接:先找你想介入的边界(轮次开闭、步骤开闭、请求前后、工具前后),再看那个边界发出什么事件——事件的分类与默认决策,就是下一篇的主题。
这套骨架上挂满了扩展点。轮次边界有 agent/turn-stopping,步骤边界有 agent/pre-step、agent/request,工具周围还有一整组 tools/*。它们按什么分类、默认决策是什么、监听顺序为何不影响结果——这就是下一篇的主题:事件系统。