第 10 章:轨迹投影折叠引擎——把事件日志折叠成回合台账¶
对应 dsh 真实源码:
packages/client/ui-trajectory(Trajectory 是 web 专属视图;数据源 = 同一份事件溯源日志,经session/pageRPC 分页后在浏览器端投影)。 前置:第 1、4、7 章。本章产出:miniharness/client/trajectory.py;测试tests/test_trajectory.py。
在 system-map 中的位置¶
本章对应 system-map Fig S1 的 10 Trajectory 视图(体裁:实现)。浏览器端折叠在 webui/;本章为终端/headless 纯函数引擎。
10.1 这一章要做什么¶
事件溯源日志是给机器读的,人怎么看一次会话?Trajectory 给出的答案是投影:把同一份日志折叠成按 turn 组织的可检查台账。本章讲这个折叠怎么算——事件流进,TrajectorySnapshot 出。
Trajectory 是 web 专属的"Agent 的 DevTools":按 turn 组织的事件台账,带 Overview 时间线(TTFT 两色)、局部检查器、搜索、虚拟化滚动。设计上它不是独立数据系统——没有 session-timeline 包,不额外写入磁盘,就是同一份会话日志的浏览器端折叠(packages/client/ui-trajectory/README.md)。这样日志是唯一数据源,折叠永远可重放、可纠正。
flowchart LR
Log["Session 事件日志"]
Fold["fold_trajectory<br/>纯函数"]
Out["终端 / JSON / webui"]
Log --> Fold --> Out
上游折叠的三个要点:
- 折叠是纯函数:每个折叠 target 用独立 definition(
match/update/finalNode)物化,无副作用、可重入(trajectory-*-definition.ts); - 保留边界:设计笔记明确拒绝"把日志拍平成裸记录流"——Turn/Step/Request 边界保留因果结构(
.agents/notes/archived/feature/2026-07-27-trajectory-inspection-ledger.md); - 产物是快照:
TrajectorySnapshot含systemPrompts?、eventNodes、eventLocations、requests、callSchemas、partial、runningCalls(trajectory-contract.ts)。
10.2 mini 复现:fold_trajectory¶
折叠核心是一个纯函数:events → TrajectorySnapshot。节点保留 turn/step/seq 边界:
@dataclass
class TrajectoryNode:
id: str
kind: str # 'turn' | 'user' | 'assistant' | 'tool-call' | 'tool-result'
turn: int
step: int
seq: int
started_at: int
ended_at: int | None = None
text: str = ""
call_id: str | None = None
parent_id: str | None = None
children: list["TrajectoryNode"] = field(default_factory=list)
折叠算法¶
fold_trajectory(events, first_seq=0) 按事件顺序扫一遍,边扫边建节点,收尾时再汇总 turn 摘要:
- 窗口:
seq < first_seq的事件整条跳过。只关心某段区间时靠这个参数(headless 的summarize是同构的另一份实现,见 §10.4)。 turn/start:新建一个turn节点(id由mkid("turn")生成,形如turn-1),记为当前打开的 turn,并登记该 turn 的摘要框架(user_texts/assistant_texts/tool_calls/ttft_ms/ 起止时间)。user/message:建user节点,text取data.content里所有 text 块拼接;turn/step从事件 data 取。assistant/message:建assistant节点,text取data.message.content里的 text 块拼接;tool-call 块不并进消息文本。tool/call:建tool-call节点,call_id取data.callId。tool/result:call_id取data.message.source.callId,反向扫描已建节点栈,找最近一个call_id相同的tool-call作父节点,把本节点追加进parent.children并记parent_id。父子树按 callId 关联装配,与节点位置无关。request/header:追加进requests元数据(seq / turn / step / model / provider / reason)。turn/end:回填当前打开 turn 节点的ended_at。
扫完后做两件事:
- 把消息节点按 turn 归并进摘要(user/assistant 文本累积、tool-call 计数),并补每个 turn 的
ended_at; - 判定
partial:折叠结束时如果还有一个打开的 turn(只有turn/start没有配对的turn/end),partial=True,该 turn 的ended_at为None,duration_ms返回None——这就是"崩溃尾部"。
节点顺序:snapshot.nodes 就是事件到达顺序。事件 seq 单调递增,所以它等价于按 seq 排序;上游是把 contributions 先按 anchorSeq 排序、最终节点再按 seq 排序(trajectory-snapshot-builder.ts),结果相同。assistant 与 user 节点的 id 前缀不同(assistant-1、user-1),计数器各算各的。
TTFT 是每个 turn 的独立量:取该 turn 内第一条带内嵌 stream 的 assistant/message,用其首条记录时间减去该 turn 的 turn/start 时间。stream_first_time 读 stream[0]:记录 type == 'chunk' 时取 time,否则取 time0;该 turn 没有内嵌流时 ttft_ms 为 None。上游 Overview 的 TTFT 两色投影就基于这个差值。
快照字段与上游的映射¶
mini 的字段名与上游不完全相同,对照如下:
| mini | 上游 | 说明 |
|---|---|---|
nodes |
eventNodes |
按 seq 排序的节点列表;mini 用 dataclass,上游用 ConversationNode |
turns |
无同名项 | 每 turn 摘要(user_texts / assistant_texts / tool_calls / ttft_ms / 起止);上游把这些信息分散在节点与 requests,Overview 从图重算 |
requests |
requests |
mini 只留 seq/turn/step/model/provider/reason 元数据 |
partial |
partial |
同名不同型:mini 是 bool(末尾有未闭合 turn),上游是 TrajectoryPartialAssistant \| null(流中的部分 assistant 投影) |
parent_id / children |
节点内 subCalls + callSchemas |
mini 用父子指针挂 tool-result;上游把工具 schema 单独放 callSchemas(callId → schema) |
| (无) | eventLocations / runningCalls / systemPrompts? |
mini 不做虚拟化定位、运行中调用清单与独立 system prompt 块 |
fold_events_json(events) 把 partial / turns / requests 序列化成 JSON,供终端或 CI 断言。
10.3 消费端:三个只读视图¶
snapshot.messages # 折叠后的消息序列(role + text,按 seq 序)
snapshot.last_assistant_text # 最后一条非空 assistant 文本(与 headless 语义呼应)
snapshot.format_text # 终端可读台账(turn 分组 + 缩进 + 耗时)
last_assistant_text 与 headless 的 summarize 语义一致——summarize 是最简投影(只拼 text 块),本模块是完整折叠(保留边界 + 工具树 + TTFT + partial 标记)。
10.4 与 headless 的关系¶
summarize(第 7 章)是"投影"的一次极简使用:firstSeq 起、turn/start 之后收集、最后一条非空文本胜出。折叠引擎是它的泛化:同一份事件流,可以只取最后文本(headless),也可以展开成完整台账(web Trajectory),还可以序列化成 JSON 给 CI 断言:
fold_events_json(events) # {"partial":…, "turns":[…], "requests":[…]}
10.5 硬性规定¶
- 折叠是纯函数:同一事件流两次折叠结果一致(无状态泄漏)。
- turn 摘要由 turn/start 驱动:
first_seq过滤掉 turn/start 后不产生 turn 摘要(消息节点仍在)。 - tool-call 节点唯一:只来自
tool/call事件;tool/result按 callId 挂为子节点(父子树)。 - 崩溃尾部 = partial:末尾仍有未闭合 turn 时
partial=True,duration_ms为 None。 - TTFT:turn/start 到该 turn 首条
assistant/message内嵌流首条记录时间的时间差;无内嵌流则为 None。
验证:python -m unittest tests.test_trajectory -v;真实 loop 回合折叠也覆盖(TestFoldFromRealLoop)。
10.6 检查点¶
- 说出"投影"设计的价值:日志唯一数据源,折叠可重放可纠正。
- 解释 turn 摘要为什么必须由 turn/start 驱动(而不是从消息推断)。
- 手动构造一个崩溃尾部事件流,观察 partial 标记与 duration=None。
- 说出 mini 折叠引擎(
client/trajectory.py)相对上游的简化:折叠语义一致,无 UI 层;nodes/eventNodes、turns等字段的映射见 §10.2 表。 - 解释 tool-result 为什么按 callId 挂父子树,而不是按事件先后简单串联。
10.7 回到 dsh:真实源码对照¶
阅读 packages/client/ui-trajectory 的折叠 definition 与 trajectory-contract.ts 快照字段。mini fold_trajectory 保留 turn/step 边界与 partial 语义;虚拟化、Overview、全文搜索在 webui/src/trajectory/(零 wire 改动)。