第 1 章说过一个反直觉的事实:185 个包里,只有 dsh-agent-loop(1295 行)包含具体的循环逻辑。这一章拆开它:session / turn / step 三级生命周期怎么转,inbox 怎么投递消息,agent 创建怎么做到事务性发布,以及"发起方作用域"这个容易被误解的机制。
本章大部分细节来自该包的官方 README 与打包源码,事件序列来自本机真实会话日志。所有引文都可复核。

配图说明:session 是一条追加日志,内含多个 turn;一个 turn 内含多个 step(无上限,工具调用或 steering 会延续轮次);一个 step = 一次模型调用 + 它请求的工具执行。放大看 step:pre-step 裁决(waterfall,可 reject/enter)→ 模型调用(assistant/chunk × N)→ 工具执行(五段流水线)→ 继续或结束。底部是 inbox 双队列(next-turn / next-step)与 followup / steer / inject 三种投递。
先看官方事件词汇表里的权威注释:
session(会话):Session 是「agent 全部交互历史的仅追加真源」,一个 agent 与其会话共享同一个 SessionId。 【来源:dsh-session/README.zh.md;dsh-agent/lib/index.js】
turn(轮次):turn/start 事件在循环领取排队输入或运行 pre-step 之前打开轮次。拒绝、空输入、取消或失败可能让它无 step 直接关闭。 【来源:dsh-session/lib/types/types.d.ts】
step(步骤):step/start 打开 turn 的 step —— 一次模型调用加上它请求的工具执行。 【来源:dsh-session/lib/types/types.d.ts】
层级关系:
这是和"轮次预算"直觉相反的地方。README 原话:
没有内置轮次预算:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期扩展点(如 agent/turn-stopping)执行取消。 —— dsh-agent-loop/README.zh.md
源码里的 turn 循环是个 while (true),退出条件只有几处:
【来源:dsh-agent-loop/lib/index.js:538-573】
翻译成人话:turn 在以下三种情况结束——
只要模型还在调工具、或者有人 steer 塞了 next-step 消息,turn 就继续。
| kind | 含义 |
|---|---|
| completed | 正常完成 |
| aborted | 取消请求打断了活跃轮次(原因细分 user / parent / hook / disposed) |
| blocked | pre-step 拒绝 |
| error | 轮次失败;错误总是结构化失败(LlmError 事实原样,或 {message, code:'UNKNOWN'}) |
| max-tokens | 至少一个 step 触达输出 token 上限,即使插件延续了轮次 |
| interrupted | 持久化后端重载时关闭崩溃遗留的轮次;循环本身从不发出此标记 |
【来源:dsh-session/lib/types/types.d.ts:135-169】
从本机真实会话日志提取 [实测](本教程第 1 轮对话,turn 1):
这个序列揭示了第 8 章的核心设计:每一步都是事件流,会话日志按事件原样记录,消息历史由它派生。
每个 agent 有一个 inbox,两个队列:
| 队列 | 语义 |
|---|---|
| next-turn | 等待独立轮次的提示词 |
| next-step | 等待下一个 step 边界的输入 |
【来源:dsh-agent/lib/types/inbox.d.ts】
统一 send() 原语按(目标队列 × 是否唤醒驱动器)路由,三个方法是预设别名:
| 方法 | 目标队列 | 唤醒 | 语义 |
|---|---|---|---|
| followup() | next-turn | ✅ | 追加一条普通轮次消息并唤醒 |
| steer() | next-step | ✅ | 中途引导;idle 时同步启动轮次,运行中在下一个 step 边界消费 |
| inject() | next-step | ❌ | 不唤醒的上下文;运行中的驱动器在最近 pre-step 边界领取,idle 则待处理直到被唤醒 |
【来源:dsh-agent-loop/README.zh.md;dsh-agent/README.zh.md】
源码实现:
【来源:dsh-agent-loop/lib/index.js:390-404】
三个方法分别什么时候用?
- followup = 给它一个全新的任务("继续查下一个问题")
- steer = 打断它正在做的事,引导方向("先别查了,看这个文件")
- inject = 只给它补充上下文,不打断("AGENTS.md 变了,这是新内容")
第 12 章会看到 agent-instructions 插件正是用 inject 语义向模型注入嵌套 AGENTS.md 的。
领取通过仅执行删除的 splice 移除整批消息,每移除一条发一次 agent/inbox/claimed { message, turn }。同时每次 inbox 变更都会在修改实时投影前先发布持久的 agent/inbox/spliced 事件。
实时通知(逐消息最小载荷):inserted / claimed / discarded;持久投影:agent/inbox/spliced(规范化的 splice 坐标)。同步观察方可以从 splice 前投影重建被移除的值。
【来源:dsh-agent-loop/README.zh.md;dsh-agent/README.zh.md】
【来源:dsh-agent/lib/types/runtime-types.d.ts:235-241】
这是一个 waterfall 事件(监听器按序处理,每个可以调用 next() 把决定权传给下一个),且是 scope-filtered(agent 作用域监听器只收到该 agent 的事件)。
【来源:dsh-agent/lib/types/runtime-types.d.ts:47-52】
如果没有监听器改写,循环在 waterfall 之前先做 systemPrompt.assemble() 和 runtime-context 投影,把 runtime-context 作为一条额外消息附在已领取批次之后:
【来源:dsh-agent-loop/lib/index.js:496-508】
这就是第 9 章"系统提示词由多个插件拼装"的挂载点 —— 模型看到的每一条消息,都经过这道 waterfall 才能进入 step。
创建与恢复属于同一个受回滚保护的事务:构造私有会话、具体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 session/created 和 agent/created;发出 agent/session-start;此后才启动驱动器。 —— dsh-agent-loop/README.zh.md
源码里的发布序列:
【来源:dsh-agent-loop/lib/index.js:1157-1171】
注意每一步之间的 assertLive() —— 任何一步发现 abort,整个发布就回滚。
【来源:dsh-agent/lib/index.js:601-626】
enter() 做三件事:① 强制 agent.id === agent.session.id;② 权威 ID 冲突检查;③ 不通知插入,返回绑定到确切条目的 detach。
不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。 —— dsh-agent/README.zh.md
teardown 顺序有明确规定:
停止并排空 → 撤销作用域 → detach agent → detach 会话。私有作用域清理完成后,该 id 即可复用。 —— dsh-agent-loop/README.zh.md
一个精妙的细节:每次 detach 都绑定到确切进入的对象,因此"陈旧 disposer 无法移除之后出现的同 id 替代项"。
disposer 是消费方能力 —— 只持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 与工厂提供方是结构化共同拥有者,任意一方调 dispose() 都到达同一个记忆化的完全停稳边界。
【来源:dsh-agent/README.zh.md】
默认值 10,来自两处源码确认:
【来源:dsh-agent-loop/lib/index.js:896-898】
README 的说明:
限制每个 agent 针对并行安全调用使用的滚动池,默认值为 10;1 是串行。
还有一个热更新特性:
它同时也是 agent-loop Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用。
并发模型的准确描述:
在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发和调用主体的执行会发生重叠。策略、持久结果和结果上下文仍保持模型顺序。
【来源:dsh-agent-loop/README.zh.md】
【来源:dsh-agent/lib/index.js:2, 418-419】
四个 API:currentInitiator() / requireInitiator() / withInitiator(agent, op) / withoutInitiator(op)。
驱动器启动时就进入该边界:
【来源:dsh-agent-loop/lib/index.js:458】
场景:父 agent 派了一个子 agent,子 agent 在跑。此刻发生了某个事件,事件处理器需要知道"谁发起的工作"——是父 agent 还是子 agent?
没有 initiator scope,你得把 agent 对象一路手工传参。有了它,ctx.agents.currentInitiator() 一行就能读出来,而且自动跟随异步链(AsyncLocalStorage 的语义)。
该作用域携带 Agent 本身,并且只在进程内有效。环境中的身份既不是存活证明,也不是授权;在服务、worker、进程、持久化和 wire 边界,显式 Agent 字段仍是权威来源。 —— dsh-agent/README.zh.md
发起方作用域只存在于进程内:worker、子进程、HTTP、持久队列和重启必须显式传递所需身份。 —— dsh-agent/README.zh.md
环境身份可能比存活状态更久:消费方在生命周期敏感工作前,仍要检查 agent.status、取消状态和所属能力约定。 —— dsh-agent/README.zh.md
一句话总结:initiator scope 是个"进程内的便利",不是跨进程的身份机制。 跨了进程边界,就得自己显式传 agent 字段。
Agent Loop 是 dsh 的心脏,但它被刻意做成了最薄的包:
下一章看模型的入口:模型调用 —— provider 中立的 LLM seam。