L7 · 主线精读
Lesson 07 · 模块 B · 读懂

主线精读:一次 turn 的
完整生命周期。

前六课分别拿下了 Cordis 内核、四种事件派发和事件溯源会话日志。这一课把它们串成一条主线: 从用户按下回车,到回复出现在屏幕上,agent loop 到底走了哪几步、产生了哪些事件、 在哪些地方给你留了下手的钩子。走完这条线,packages/core 的代码你就能顺着读了。

精读 20 分钟 练习 10 分钟 · 6 题 前置 见课程首页
学完你将能够
  • 用一句话分别定义 step 与 turn,说清 turn 何时打开、何时关闭(包括零 step 的 turn)
  • 按顺序走完一次 turn 的八个阶段,指出每个事件的类型:durable / waterfall / serial
  • 面对「改写消息、拒绝请求、拦工具、拦停 turn」四类需求,立刻说出该挂哪个事件
  • 跑一次带工具调用的 headless 任务,在会话日志里按 seq 找出本课每个持久事件
01 · Definitions

turn 与 step:主线的两个时间单位

整个 agent loop 只用两个时间单位组织工作,定义都来自 docs/architecture.zh.md 的轮次流程一节:

  • step(步骤)= 一次模型请求 + 它调用的工具。不是「一条消息」,也不是「一次工具调用」——一次请求加上它欠下的所有工具执行,合起来才算一步。
  • turn(轮次)= 零个或多个 step。它在认领首条输入之前打开——turn/start 先落日志,然后才去 inbox 认领消息;它在不再欠下任何工作时关闭:没有未兑现的工具请求、inbox 里没有 next-step 输入、也没有新的 steering(中途引导)。

「零个 step」不是修辞:agent/pre-step 瀑布可以拒绝首批认领的消息(或把它们改写为空), 此时循环会关闭一个不含任何 step 的持久 turn——turn/startturn/end 照常写入日志, 这次尝试被完整记录,只是没有花费任何一次模型请求。被拒绝的批次保持已删除状态,不会退回 inbox。

这条主线上出现的事件分两类,分错类是读代码时最常见的迷路原因:

  • 持久会话事件(durable)turn/*step/*user/messageassistant/*tool/*。它们是追加到会话日志、并经 session/event 广播的事实,重载后仍在。
  • 实时扩展点agent/*tools/*llm/stream。它们只在进程内短暂存在,是给插件观察与拦截用的钩子,不进日志。
铁律 · 模型可见 ⟺ 已记录

抵达模型请求的一切都必须能从日志重建(deriveMessages() 投影),一项运行时不变式(dsh-invariants,见 docs/subsystems/invariants.zh.md)在运行时断言这一点。推论:新增一种模型可见输入,就必须新增一个会话事件——反过来,凡是模型看到的,日志里一定找得到。

02 · Pipeline

全流程逐阶段精讲

下图是一次 turn 的纵向 pipeline:八个阶段,每个节点标注了事件名与类型。 注意 step/end 左侧的虚线——只要还欠着工具请求、或 inbox 又到了 next-step 输入, 循环就回到认领阶段进入下一 step,直到谁也不欠谁,才走向 turn/end

① turn/start — 轮次开启 认领首条输入之前打开;durable 会话事件 durable ② inbox 认领输入 agent/inbox/spliced + 逐条 claimed · inject() 的注入在此等待唤醒 durable emit ③ agent/pre-step 改写或拒绝 enter 批次;首批被拒/为空 → 关闭零 step 的持久 turn waterfall ④ step/start · 追加 user/message 组装 prompt 段落与工具 schema · deriveMessages() 投影历史 durable ⑤ agent/request → llm/stream assistant/chunk* 原样落日志,汇成 assistant/message(durable) waterfall durable ⑥ tool/call → 三段工具瀑布 → tool/result tools/pre-execute → tools/execute → tools/post-execute durable waterfall ×3 ⑦ step/end — 本步收尾 欠工具请求或有 next-step 输入 → 回到 ② 认领,进入下一 step durable ⑧ agent/turn-stopping → turn/end 边界提交前最后被 await:异议者 steer(),机器重读 inbox 再决定 serial durable 虚线回路:欠工具请求或有新输入 → 回到 ② 认领
图 7-1 · 一次 turn 的纵向 pipeline:八个阶段节点,各标注事件类型——durable 持久会话事件 / waterfall 瀑布 / serial 串行 / emit 通知。虚线是 step 之间的回路。

对照图 7-1,逐阶段走一遍(事件签名均见 docs/subsystems/core.zh.md 的 Cordis API 目录):

  1. turn/start(durable)。排队的输入唤醒驱动器(agent/status 变为 running),它做的第一件事不是读消息,而是在日志上开启轮次。
  2. inbox 认领claim 以一次纯删除 agent/inbox/spliced 取走全部 next-step 输入,外加轮次边界上的一条 next-turn 消息,再逐条发出 agent/inbox/claimedagent.inject() 注入的上下文不唤醒驱动器,就留在 inbox 里等某次认领把它带走。
  3. agent/pre-step(waterfall)。payload 携带独占的已领取批次 messages、坐标 turn/step 与取消 signal;每个监听器拿到 (payload, next)——调 next() 委托下去并保留当前消息,不调 next() 直接返回即短路。决策类型是 PreStepDecisionreject 不开启 step;enter(messages) 以改写后的批次进入 step,还可带 startsRequestSeries: true 开启新的 request 系列(对应一条 request/header 日志)。包装 next() 的监听器要改写下游决策时,必须 { ...decision, messages } 展开保留原有字段,除非有意替换。
  4. step/start(durable)。enter 批次逐条追加为 user/message;每个 step 都重新读取插件注册的提示词段落与工具 schema(组装结果以 request/header 落日志),并用 deriveMessages() 从日志投影出模型历史——历史不单独存储,永远从日志派生。
  5. agent/request → llm/stream(两个 waterfall)agent/request 可以替换冻结的调用配置 LlmCallConfig(provider / model / maxTokens),但不能改消息;随后 llm/stream 流出 StreamChunk,驱动器把 assistant/chunk 分片原样落日志,流结束后汇成一条 assistant/message(durable),并用 sourceEventSeqs 精确列出它由哪些分片汇聚而成。请求失败时,失败的 step 先正常关闭,再由 agent/request-error 瀑布决定是重试还是保持终态。
  6. 工具流水线。模型以 tool-calls 结束、请求里带 tool-call 时:tool/call 先落持久日志,然后调用依次经过 tools/pre-execute(allow / deny / ask 的审批瀑布)→ 单调 guard → tools/execute(环绕分派的包装层,超时/重试/指标挂这里)→ tools/post-execute(接受、替换或阻止已归一化的结果)→ tools/result(实时、冻结的权威结果)→ tool/result(durable)落日志。
  7. step/end(durable)。本步收尾后清算欠账:工具结果欠模型一次回答,或 inbox 又到了 next-step 输入——回到阶段 ② 认领,进入下一 step。一个 turn 因此可以有任意多个 step。
  8. agent/turn-stopping(serial)→ turn/end(durable)。自然停止且 inbox 排空时,循环在边界提交前依次 await 每个监听器;有异议的监听器不能用返回值否决,只能 agent.steer() 注入新输入——机器随后重读 inbox:有新 steering 就再跑一个 step,没有就写下 turn/endreason: completed),agent/status 回到 idle

以下是一次真实 headless 运行(带一次 bash 工具调用)的会话日志节选,事件按 seq 排列——和第 04 节你亲手跑出来的日志会是同一形态:

{"type":"turn/start","seq":4,"data":{"turn":1}} {"type":"step/start","seq":6,"data":{"turn":1,"step":1}} {"type":"user/message","seq":7,"data":{...}} # enter 批次逐条落日志 {"type":"request/header","seq":10,"data":{...}} # 组装好的 system 段落与工具 schema {"type":"assistant/chunk","seq":13,"data":{"chunk":{"type":"block-start",...}}} {"type":"assistant/chunk","seq":14,"data":{"chunk":{"type":"tool-call-delta",...}}} # … block-end / usage / finish{"kind":"tool-calls"} … {"type":"assistant/message","seq":18,"data":{...},"sourceEventSeqs":[13,14,15,16,17]} {"type":"tool/call","seq":19,"data":{"name":"bash",...}} {"type":"tool/result","seq":20,"data":{...},"sourceEventSeqs":[19]} {"type":"step/end","seq":21,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"data":{"turn":1,"step":2}} # 欠一次工具后的回答 → 下一 step # … assistant/chunk* → assistant/message(finish: stop)… {"type":"step/end","seq":30,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":31,"data":{"turn":1,"reason":{"kind":"completed"}}}
读日志的姿势

日志里找不到 agent/pre-stepagent/requesttools/*——实时扩展点不落日志。这也反过来帮你定位代码:凡是日志里的事件,去 agent-loop 的 driver 里搜事件名,就能找到产生它的那一行。

03 · Interception Points

拦截点全景:四类需求各挂哪个事件

「一切皆插件」落到这条主线上,就是一组位置精确的钩子。想动手之前先背下这张对照表—— 它同时也回答了「为什么不需要改 loop 本身」:

  • 改写或拒绝进入 step 的消息agent/pre-step(waterfall)。返回 { kind: 'reject' }{ kind: 'enter', messages };压缩等「推导前的预处理」都挂在这里。
  • 替换一次模型请求的配置(换 provider/model、调 maxTokens)→ agent/request(waterfall)。await next() 拿到机器原本要用的配置,返回替换值即可。
  • 接管失败的模型请求agent/request-error(waterfall)。返回 { kind: 'retry' } 且不调 next() 即接管恢复;默认 undefined 让失败保持终态。
  • 审批或拒绝一次工具调用tools/pre-execute(waterfall)。allow 放行、deny 直接物化为错误结果、ask 走审批服务;其后的单调 guard 还能施加不可撤销的最终拒绝。
  • 包裹工具执行(超时、重试、指标)→ tools/execute(waterfall)。包装层只能替换 exec.signal,调用身份不可变。
  • 检查、替换或阻止工具结果tools/post-execute(waterfall)。accept(可替换内容/值、附 additionalContexts)或 block(把纠正反馈转为错误结果)。
  • 拦停或延长整个 turnagent/turn-stopping(serial)。想让它继续就 agent.steer();反向控制——让工具循环提前收尾——也是数据:工具结果携带 concludesTurn 即在当前 step 结束轮次。
  • 注入模型可见上下文而不打断当前请求 → 不用挂事件,调用 agent.inject();它落在 inbox,进入下一次获准的请求。
  • 只观察、不干预 → 监听 session/event,一切持久事实都经它广播(UI 与两个 SDK 就是这样渲染的)。
陷阱 · agent/request 改不了消息

想在 agent/request 里偷偷改用户消息或往历史里塞内容?做不到——这个 waterfall 只能替换调用配置。模型可见内容的唯一入口是已记录的通道agent/pre-step 的 enter 批次、agent.inject()、工具结果的 additionalContexts),因为「模型可见 ⟺ 已记录」:任何绕过日志的改写都会被运行时不变式当场抓住。

04 · Hands-On

动手试:在真实日志里把主线走一遍

headless profile 跑完任务后会把会话 flush 到 Harness home 的会话日志里(默认 ~/.dsh/sessions/<项目>/<会话>/session.v3.jsonl.zstd,带 checksum 的 zstd 压缩帧)。 为了肉眼可读,先用一条 --patch overlay 把本次运行的日志切成纯文本——注意 patch 会整份替换该条目的 config,所以 root 要原样保留:

# plain-jsonl.yml —— 仅供学习观察,正式部署请保留默认压缩 - id: session-persistence-jsonl config: root: !!js dshHomePath('sessions') compression: 'none'
动手试 · 5 分钟
  1. 跑一个必然触发工具调用的任务:
pnpm dsh --profile headless --patch ./plain-jsonl.yml "用 bash 工具执行 echo hello-turn,然后把输出复述给我"

2. 找到刚生成的会话日志,并按顺序列出所有事件类型:

LOG=$(ls -t ~/.dsh/sessions/*/*/session.v3.jsonl | head -1); grep -o '"type":"[^"]*"' "$LOG"

3. 对照图 7-1 逐行认领:turn/startstep/startuser/messagerequest/headerassistant/chunk×N → assistant/messagetool/calltool/resultstep/end → 第二轮 step/start → … → turn/endreason: completed)。注意体会:agent/pre-stepagent/request 与三段工具瀑布 不在日志里——它们是实时扩展点,想观察它们就得挂监听器,那正是 L8 的动手内容。

05 · Quiz · 10 分钟

课堂练习

2 单选 + 2 多选 + 2 问答。选择即时批改,问答先写下你的答案再对照。成绩保存在本地浏览器。
单选
在 dsh 的轮次流程里,一个 step(步骤)的准确定义是?

架构文档原文:「一个步骤是一次模型请求加上它调用的工具」。A 描述的更接近 turn(零到多个 step);C 只是 step 的后半段;D 漏掉了工具——正是欠下的工具请求让同一 turn 进入下一 step。

单选
agent/pre-step 把首轮提案的首批消息拒绝(或改写为空)后,会发生什么?

架构文档原文:「首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试」。A 错在 claim 是纯删除 splice,被拒批次保持已删除,不会退回;B 错在 turn 在认领首条输入之前就已开启(turn/start 已落日志);D 与正文无关,拒绝是正常决策路径。

多选
关于一次工具调用经过的事件与瀑布顺序,哪些说法正确?(选出所有正确项)

C 把 pre/post 说反了:决定执行与否的是 tools/pre-execute 的 allow / deny / ask(及其后的单调 guard);tools/post-execute 只能对已归一化的结果做接受、替换或阻止。A、B、D 就是正文工具流水线的原序:tool/call(durable)→ 三段瀑布 → tool/result(durable)。

多选
想在插件里 ① 拦截一次模型请求的配置、② 在整个 turn 即将关闭时拦停它,分别应该监听什么?(选出所有正确项)

agent/request 瀑布可替换冻结的调用配置(但不能改消息);agent/turn-stopping 是边界提交前被 await 的 serial 检查点,异议者用 agent.steer() 让机器再跑一步。B 只是持久事实的只读广播,D 只是 idle ⇄ running 的状态通知,都没有拦截能力。

问答完整复述一次 turn 从用户发出消息到收到回复的链路。写下答案后点击对照

参考要点:① followup() 把消息放进 inbox(agent/inbox/inserted),唤醒驱动器(agent/status → running);② turn/start 落日志后认领批次(纯删除 agent/inbox/spliced + 逐条 claimed);③ agent/pre-step 瀑布决定 reject 还是 enter(messages);④ enter 则 step/start、批次逐条追加为 user/message,组装提示词段落与工具 schema(request/header),deriveMessages() 从日志投影历史;⑤ agent/requestllm/streamassistant/chunk* 原样落日志并汇成 assistant/message;⑥ 有 tool-call 则 tool/calltools/pre-executetools/executetools/post-executetool/result;⑦ step/end:欠工具请求或有 next-step 输入就回到认领进入下一 step;⑧ 自然停止时经 agent/turn-stopping 串行检查点,写下 turn/end(reason: completed),agent/status 回到 idle,UI 从 session/event 渲染出回复。

问答为什么 agent/turn-stopping 用串行(serial)事件,而 agent/pre-step 用瀑布(waterfall)?两种语义差异带来的能力差异是什么?写下答案后点击对照

参考要点:waterfall 是围绕式中间件——监听器拿 (payload, next),调 next() 把决策委托给下游并拿到其返回值,不调 next() 直接返回即短路;决策值沿链流动,所以监听器能改写或拒绝 payload,且顺序会影响结果(每个监听器看到的是下游返回之后的值)。serial 没有 next()、返回 void——监听器只是被依次 await 的通知,不能用返回值否决什么,要表达异议只能采取副作用行动(agent.steer() 注入新输入),随后由机器重读 inbox 的数据决定:数据说了算,所以监听器顺序不能改变结局。agent/pre-step 需要产出一个类型化决策(reject / enter(messages)),天然是 waterfall;agent/turn-stopping 是边界提交前的检查点,要求所有监听器都跑完且结论不依赖注册顺序,天然是 serial。

本课小结

主线已经走完:step = 一次模型请求加它调用的工具,turn = 零到多个 step,在认领首条输入前开启、不再欠任何工作时关闭; 八个阶段里,durable 事件落日志,waterfall 给决策权,serial 做边界检查点。你也拿到了拦截点对照表—— 下一课就用它真动手:照 cookbook 写一个自己的工具,把它注册进 ctx.tools,并在三段瀑布上挂你的第一个策略。