Fold trace-only session facts into load-bearing events
将仅用于追踪的会话事实折叠进承载实际功能的事件
The session event vocabulary includes first-class events that are not part of replayable conversation history and have little or no production consumption. `usage` is already present as a model stream chunk before the loop also appends a separate `usage` event. `error` duplicates the `turn/end { kind: 'error', message, code }` reason for loop failures; ACP settlement reads the turn-end reason, while message and UI pr
English
Problem
The session event vocabulary includes first-class events that are not part of replayable conversation history and have little or no production consumption. usage is already present as a model stream chunk before the loop also appends a separate usage event. error duplicates the turn/end { kind: 'error', message, code } reason for loop failures; ACP settlement reads the turn-end reason, while message and UI projections skip the standalone error event.
These events make the canonical transcript look more useful as telemetry than it currently is. They add event variants, invariants, tests, snapshots, and persistence cases, but they are not load-bearing as separate records. The facts they carry can still be useful: token usage should remain available for accounting, and an error's step number should not silently disappear. The simplification is to fold those facts into nearby events consumers already must understand, not to record less information.
Decision
Standalone trace-only events are removed exactly where their information is preserved without a parallel record:
- Successful-step usage folds into the matching
assistant/message(assistant/message { turn, step, content, usage? }), so the assembled model output and its accounting travel together. - A failed or aborted step that has usage but no assistant content carries the usage on an empty-content
assistant/message { content: [], usage }— no persisted usage chunk goes unrepresented. The no-information-loss case is the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standaloneusage. To keep the empty-content event from injecting a spurious content-less assistant turn into the provider transcript,deriveMessages()skips empty-contentassistant/messageevents; a regression test asserts usage stays represented AND derived history stays uncorrupted. - The step number from the standalone
errorevent folds intoturn/end.reasonforkind: 'error'({ kind: 'error', step, message, code? }) —turn/endis the durable turn outcome ACP and resume already consume. agent/errorand logging stay for live diagnostics; there is no second session-log error record afterturn/end.
The user conversation log contains what is needed to render, resume, audit, and account for the interaction without consumers reconciling duplicate trace rows.
Alternatives considered
Keep the standalone rows as telemetry — the events made the canonical transcript look more useful as telemetry than it was, at the cost of event variants, invariants, tests, snapshots, and persistence cases nothing consumed. If analytics become real, the shape is a projection helper or a dedicated telemetry store with its own retention policy — not duplicate trace rows in the conversation log.
Verification
SessionEventMap carries no standalone usage or error; the loop appends no separate usage event and records durable failures through turn/end { kind: 'error', step, message, code? }; ACP snapshots and persistence tests assert no trace-only lines; recorded fixtures are on the new event shape with the session format version pinned at 0 (backends reject any non-0 stored log per the pre-release format policy); and the docs state where token usage and operational errors are observed.
Consequences
A consumer can no longer filter the canonical log for standalone usage or step-level error rows. It must read those facts from the assistant/failure events that carry them. That is a reasonable simplification because the same facts remain present, as the Verification section proves.
Implementation note
Format version. This changes persisted events, but the pre-release session format remains pinned at 0 and rejects any other version without migration. dsh-session owns the constant used by writers and load validation. Monotonic format versions begin at the first release.
Usage is now observed on assistant/message.usage; an operational error's step on turn/end.reason for kind: 'error'. agent/error + logging are unchanged for live diagnostics.
中文
问题
会话事件词汇中包含一些一等事件,它们不属于可回放的对话历史,在生产环境中几乎没有消费方。usage 已经作为模型流分片存在,之后循环又追加了一个独立的 usage 事件。error 与 turn/end { kind: 'error', message, code } 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取轮次结束原因,而消息投影和 UI 投影都会跳过独立的 error 事件。
这些事件让规范的 transcript(文本记录)看起来比实际更适合作为遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际功能。它们携带的事实仍然有用:token 用量应当保留以供核算,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本已必须理解的邻近事件,而非减少记录的信息量。
决策
仅在信息已被保留、无需并行记录的情况下,移除独立的、仅用于追踪的事件:
- 成功步骤的 usage 折叠进匹配的
assistant/message(assistant/message { turn, step, content, usage? }),使组装好的模型输出与其核算信息一同传递。 - 失败或中止的步骤如果有 usage 但没有助手内容,则将 usage 放在一个空内容的
assistant/message { content: [], usage }上——不会有已持久化的 usage 分片无处安放。必须确保信息不丢失的典型情形是 max-tokens 路径:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的usage。为防止空内容事件向提供方 transcript 注入一个多余的无内容的助手轮次,deriveMessages()跳过空内容的assistant/message事件;回归测试断言 usage 仍有记录,且派生历史未被破坏。 - 独立
error事件中的步骤编号折叠进turn/end.reason(当kind: 'error'时:{ kind: 'error', step, message, code? })——turn/end是 ACP 和恢复机制已经消费的持久轮次结果。 agent/error与日志保留用于实时诊断;turn/end之后不再有第二条会话日志错误记录。
用户对话日志包含渲染、恢复、审计和核算所需的全部信息,消费方无需协调重复的追踪行。
曾考虑的替代方案
保留独立行作为遥测——这些事件让规范 transcript 看起来比实际更适合作为遥测数据,代价是增加了事件变体、不变式、测试、快照和持久化用例,却没有任何消费方使用。如果分析需求真正出现,正确的形态是投影辅助工具或带有独立保留策略的专用遥测存储,而非对话日志中的重复追踪行。
验证
SessionEventMap 不再包含独立的 usage 或 error;agent loop(智能体循环)不再追加独立的 usage 事件,并通过 turn/end { kind: 'error', step, message, code? } 持久记录失败;ACP 快照和持久化测试断言不存在仅用于追踪的行;已录制的 fixture(测试前置数据)使用新事件形状,会话格式版本固定为 0(后端按预发布格式策略拒绝任何版本非 0 的已存储日志);文档说明了 token 用量和运行错误的观测位置。
后果
消费方不能再从规范日志中筛选独立的 usage 或步骤级 error 行,而必须从承载这些信息的助手消息或失败事件中读取这些事实。由于相同事实仍然存在——「验证」一节给出了证明——这是合理的简化。
实现说明
格式版本。 此变更影响已持久化的事件,但预发布会话格式仍固定为 0,拒绝任何其他版本且不做迁移。dsh-session 拥有写入方和加载校验使用的常量。单调递增的格式版本从首次正式发布开始。
Usage 现在通过 assistant/message.usage 观测;运行错误的步骤编号通过 turn/end.reason(当 kind: 'error' 时)观测。agent/error 与日志用于实时诊断,保持不变。