主线精读:一次 turn 的
完整生命周期。
前六课分别拿下了 Cordis 内核、四种事件派发和事件溯源会话日志。这一课把它们串成一条主线:
从用户按下回车,到回复出现在屏幕上,agent loop 到底走了哪几步、产生了哪些事件、
在哪些地方给你留了下手的钩子。走完这条线,packages/core 的代码你就能顺着读了。
- 用一句话分别定义 step 与 turn,说清 turn 何时打开、何时关闭(包括零 step 的 turn)
- 按顺序走完一次 turn 的八个阶段,指出每个事件的类型:durable / waterfall / serial
- 面对「改写消息、拒绝请求、拦工具、拦停 turn」四类需求,立刻说出该挂哪个事件
- 跑一次带工具调用的 headless 任务,在会话日志里按
seq找出本课每个持久事件
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/start 与 turn/end 照常写入日志,
这次尝试被完整记录,只是没有花费任何一次模型请求。被拒绝的批次保持已删除状态,不会退回 inbox。
这条主线上出现的事件分两类,分错类是读代码时最常见的迷路原因:
- 持久会话事件(durable):
turn/*、step/*、user/message、assistant/*、tool/*。它们是追加到会话日志、并经session/event广播的事实,重载后仍在。 - 实时扩展点:
agent/*、tools/*、llm/stream。它们只在进程内短暂存在,是给插件观察与拦截用的钩子,不进日志。
抵达模型请求的一切都必须能从日志重建(deriveMessages() 投影),一项运行时不变式(dsh-invariants,见 docs/subsystems/invariants.zh.md)在运行时断言这一点。推论:新增一种模型可见输入,就必须新增一个会话事件——反过来,凡是模型看到的,日志里一定找得到。
全流程逐阶段精讲
下图是一次 turn 的纵向 pipeline:八个阶段,每个节点标注了事件名与类型。
注意 step/end 左侧的虚线——只要还欠着工具请求、或 inbox 又到了 next-step 输入,
循环就回到认领阶段进入下一 step,直到谁也不欠谁,才走向 turn/end。
对照图 7-1,逐阶段走一遍(事件签名均见 docs/subsystems/core.zh.md 的 Cordis API 目录):
- turn/start(durable)。排队的输入唤醒驱动器(
agent/status变为running),它做的第一件事不是读消息,而是在日志上开启轮次。 - inbox 认领。
claim以一次纯删除agent/inbox/spliced取走全部 next-step 输入,外加轮次边界上的一条 next-turn 消息,再逐条发出agent/inbox/claimed。agent.inject()注入的上下文不唤醒驱动器,就留在 inbox 里等某次认领把它带走。 - agent/pre-step(waterfall)。payload 携带独占的已领取批次
messages、坐标turn/step与取消signal;每个监听器拿到(payload, next)——调next()委托下去并保留当前消息,不调next()直接返回即短路。决策类型是PreStepDecision:reject不开启 step;enter(messages)以改写后的批次进入 step,还可带startsRequestSeries: true开启新的 request 系列(对应一条request/header日志)。包装next()的监听器要改写下游决策时,必须{ ...decision, messages }展开保留原有字段,除非有意替换。 - step/start(durable)。enter 批次逐条追加为
user/message;每个 step 都重新读取插件注册的提示词段落与工具 schema(组装结果以request/header落日志),并用deriveMessages()从日志投影出模型历史——历史不单独存储,永远从日志派生。 - agent/request → llm/stream(两个 waterfall)。
agent/request可以替换冻结的调用配置LlmCallConfig(provider / model / maxTokens),但不能改消息;随后llm/stream流出 StreamChunk,驱动器把assistant/chunk分片原样落日志,流结束后汇成一条assistant/message(durable),并用sourceEventSeqs精确列出它由哪些分片汇聚而成。请求失败时,失败的 step 先正常关闭,再由agent/request-error瀑布决定是重试还是保持终态。 - 工具流水线。模型以
tool-calls结束、请求里带 tool-call 时:tool/call先落持久日志,然后调用依次经过tools/pre-execute(allow / deny / ask 的审批瀑布)→ 单调 guard →tools/execute(环绕分派的包装层,超时/重试/指标挂这里)→tools/post-execute(接受、替换或阻止已归一化的结果)→tools/result(实时、冻结的权威结果)→tool/result(durable)落日志。 - step/end(durable)。本步收尾后清算欠账:工具结果欠模型一次回答,或 inbox 又到了 next-step 输入——回到阶段 ② 认领,进入下一 step。一个 turn 因此可以有任意多个 step。
- agent/turn-stopping(serial)→ turn/end(durable)。自然停止且 inbox 排空时,循环在边界提交前依次 await 每个监听器;有异议的监听器不能用返回值否决,只能
agent.steer()注入新输入——机器随后重读 inbox:有新 steering 就再跑一个 step,没有就写下turn/end(reason: 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-step、agent/request、tools/*——实时扩展点不落日志。这也反过来帮你定位代码:凡是日志里的事件,去 agent-loop 的 driver 里搜事件名,就能找到产生它的那一行。
拦截点全景:四类需求各挂哪个事件
「一切皆插件」落到这条主线上,就是一组位置精确的钩子。想动手之前先背下这张对照表—— 它同时也回答了「为什么不需要改 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(把纠正反馈转为错误结果)。 - 拦停或延长整个 turn →
agent/turn-stopping(serial)。想让它继续就agent.steer();反向控制——让工具循环提前收尾——也是数据:工具结果携带concludesTurn即在当前 step 结束轮次。 - 注入模型可见上下文而不打断当前请求 → 不用挂事件,调用
agent.inject();它落在 inbox,进入下一次获准的请求。 - 只观察、不干预 → 监听
session/event,一切持久事实都经它广播(UI 与两个 SDK 就是这样渲染的)。
想在 agent/request 里偷偷改用户消息或往历史里塞内容?做不到——这个 waterfall 只能替换调用配置。模型可见内容的唯一入口是已记录的通道(agent/pre-step 的 enter 批次、agent.inject()、工具结果的 additionalContexts),因为「模型可见 ⟺ 已记录」:任何绕过日志的改写都会被运行时不变式当场抓住。
动手试:在真实日志里把主线走一遍
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'- 跑一个必然触发工具调用的任务:
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/start → step/start → user/message →
request/header → assistant/chunk×N → assistant/message →
tool/call → tool/result → step/end → 第二轮 step/start → … →
turn/end(reason: completed)。注意体会:agent/pre-step、agent/request 与三段工具瀑布
不在日志里——它们是实时扩展点,想观察它们就得挂监听器,那正是 L8 的动手内容。
课堂练习
架构文档原文:「一个步骤是一次模型请求加上它调用的工具」。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)。
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/request → llm/stream,assistant/chunk* 原样落日志并汇成 assistant/message;⑥ 有 tool-call 则 tool/call → tools/pre-execute → tools/execute → tools/post-execute → tool/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,并在三段瀑布上挂你的第一个策略。