mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
feat(session)!: embed assistant streams in format v2
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.md
|
||||
2026-07-26-packed-chunk-rows-by-default.md: c230c1f1faf5e597321654ebd01d60fae725f518
|
||||
2026-07-26-packed-chunk-rows-by-default.zh.md: e354efdf6bb68c02f30dc17c8d4ba17a495b61bd
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Make packed chunk rows the default JSONL layout
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-01
|
||||
|
||||
English | [中文](2026-07-26-packed-chunk-rows-by-default.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 将打包分片行设为默认 JSONL 布局
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-01
|
||||
|
||||
[English](2026-07-26-packed-chunk-rows-by-default.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.md
|
||||
2026-08-15-packed-session-history-transport.md: 1fe8c78a89a0541475d10fae9ad643203f144c30
|
||||
2026-08-15-packed-session-history-transport.zh.md: b2aa5bf0bc0bc4952df29674f0b9ffe836368f73
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Carry packed chunk rows through session history
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-01
|
||||
|
||||
English | [中文](2026-08-15-packed-session-history-transport.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 在会话历史中传输打包分片行
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-01
|
||||
|
||||
[English](2026-08-15-packed-session-history-transport.md) | 中文
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml": "sha256:cf99eda0e58b49630d5f95792459d7095666fafbef61f614165d5cdd031b7118",
|
||||
"architecture/2026-07-24-dsh-commander-argument-adapter.md": "sha256:705654c8a43bcd199f72c21a77d24ca8bfa02447aff1c7f3e4e820be61dcd562",
|
||||
"architecture/2026-07-24-dsh-commander-argument-adapter.zh.md": "sha256:3844f02d7659d18caf5d39e1131ed775c789cbf92dc44b4a446c7d6468aa5d00",
|
||||
"architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml": "sha256:41aa86c65f78e125ca2178295d02d5994d5e0f2e9359b1465777db1aba4a105b",
|
||||
"architecture/2026-07-26-packed-chunk-rows-by-default.md": "sha256:2b4e14675d12a1fc07eb373ab8566c3e63c7c15e2b5a0b391e6acbc896c7359d",
|
||||
"architecture/2026-07-26-packed-chunk-rows-by-default.zh.md": "sha256:e0b0e8a4ab529a3461c22f434cbbe1e7b0ea381592871caf7e3fe5157ef844ba",
|
||||
"architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml": "sha256:7b9dbe8b4a340640610abe7e54fb29492d77a187c176996a53d0e1fc7c8e1945",
|
||||
"architecture/2026-07-27-tui-chat-channel-module-split.md": "sha256:3e2cd43f306a18b3eaf9bac23e6bdc3a5dbdc7388b7c399ce71e4f71b8f71d2a",
|
||||
"architecture/2026-07-27-tui-chat-channel-module-split.zh.md": "sha256:d6b84fdcd91a2693b72cf6884b3a0c39e56e571b2b694f630805d894a6ba292f",
|
||||
@@ -52,6 +55,9 @@
|
||||
"architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc",
|
||||
"architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf",
|
||||
"architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa",
|
||||
"architecture/2026-08-15-packed-session-history-transport.i18n.yaml": "sha256:547b89497b009593db5acfae2a3b989f17b8392f5df73ef631b38f8f68f629f1",
|
||||
"architecture/2026-08-15-packed-session-history-transport.md": "sha256:ec7f84d59eea95668a8cb6c92ae433a2b7e7b76446a27e57dd5dba05856f563f",
|
||||
"architecture/2026-08-15-packed-session-history-transport.zh.md": "sha256:0d8eb5444557a18f76c68ce5ae9f0779c580eaddb921ee8651464baea34e0e53",
|
||||
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml": "sha256:42bce930799cb511e9fb245dec5e26efd78bdab4c9b75f7393e37b40fbee4d10",
|
||||
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.md": "sha256:4fe241f1b272278d9f3ca1a4431971220e1fa54411df043826ef6f59225bf949",
|
||||
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md": "sha256:73178c9ec5abf571680d8facfb145cbadc1efbb2e67e3f039747c2f9cf4bb730",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
|
||||
2026-06-14-session-persistence.md: 989beb6f8cc65c8d033206a61b4408f3aecdbbc7
|
||||
2026-06-14-session-persistence.zh.md: 9dcdbffc7df89ce6fcd5341ef77f99e47643aa92
|
||||
2026-06-14-session-persistence.md: 8cca8a25a1795b50ad1b8282d0d3b0d1776fff24
|
||||
2026-06-14-session-persistence.zh.md: 2a4e8098625135c20d569c20bad37d1b36c55fe0
|
||||
|
||||
@@ -15,11 +15,11 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append
|
||||
Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic:
|
||||
|
||||
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
|
||||
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
|
||||
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Current v2 writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
|
||||
|
||||
Key durable, contested choices:
|
||||
|
||||
- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
|
||||
- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
|
||||
- **Ordinary writes append; a crashed turn is closed, never truncated.** Flushed current-generation events are never rewritten by normal persistence. A format migration leaves the exact physical source path, bytes, and inode unchanged, then publishes one re-encoded current successor at a previously absent canonical versioned filename after only edge-owned normalization and current crash repair. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
|
||||
- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service and coordinator remain available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut.
|
||||
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
|
||||
@@ -27,10 +27,10 @@ Key durable, contested choices:
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
|
||||
Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
|
||||
|
||||
Format versioning: the header carries a `version`; current Session and coordinator code accept only `SESSION_FORMAT_VERSION = 1`. JSONL event-body reads compose the static adjacent migration chain before constructing a Session, and the v0-to-v1 edge owns the former narrow import upgrades such as [pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md). V0 remains at suffixless `session.jsonl[.zstd]`, while v1 and later use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract.
|
||||
Format versioning: the header carries a `version`; current Session and coordinator code accept only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before constructing a Session; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(id?, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row.
|
||||
The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(id?, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every Assistant attempt retains its exact compact timed stream in one durable settlement.
|
||||
|
||||
@@ -15,11 +15,11 @@ Status: implemented
|
||||
持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑:
|
||||
|
||||
1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。
|
||||
2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
|
||||
2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前 v2 每行写入一个事件;冻结的 v0 与 v1 读取器保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
|
||||
|
||||
长期有效、存在争议的关键选择:
|
||||
|
||||
- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
|
||||
- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message` 或 `assistant/attempt` 会嵌入一次 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。丢弃嵌入式 stream 成员很有吸引力,但会丢失 replay、时间、usage、部分失败与诊断事实。移除完整事件同样需要密集重编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该重写,而不是过滤规范日志。
|
||||
- **普通写入仅追加;崩溃的轮次被关闭,而非截断。** 正常持久化绝不重写已刷入当前 generation 的事件。格式迁移保持精确物理源路径、字节与 inode 不变,再只经过迁移边拥有的归一化与当前崩溃修复,在此前不存在的规范具名版本文件下发布一个重新编码的当前后继。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
|
||||
- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
|
||||
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。)
|
||||
@@ -27,10 +27,10 @@ Status: implemented
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
|
||||
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
|
||||
|
||||
格式版本控制:header 携带一个 `version`;当前 Session 与协调器代码只接受 `SESSION_FORMAT_VERSION = 1`。JSONL 的事件正文读取会在构造 Session 前组合静态相邻迁移链,v0-to-v1 边拥有原有的范围受限导入升级,例如[消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)。V0 保留在无后缀 `session.jsonl[.zstd]`,v1 及后续版本使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的追加与 flush 能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。
|
||||
格式版本控制:header 携带一个 `version`;当前 Session 与 coordinator 代码只接受 `SESSION_FORMAT_VERSION = 2`。JSONL 事件正文读取会在构造 Session 前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条迁移边拥有有界旧格式规范化,第二条拥有 Assistant stream 嵌入与密集引用重映射。V0 保留在无后缀 `session.jsonl[.zstd]`,正版本使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的追加与 flush 能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。
|
||||
|
||||
## 后果
|
||||
|
||||
Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header`,`create(id?, options?)` 签名)带来持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。
|
||||
Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header`,`create(id?, options?)` 签名)带来持久恢复/fork、读取/replay 路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧 Session 访问。可复用 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整逻辑日志也确定了事件保真度:每个 Assistant attempt 都在一个持久 settlement 中保留其精确紧凑带时间 stream。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-session-surface.md
|
||||
2026-06-18-session-surface.md: e9331ca2f827edd291d6365ef1356520c8f1927a
|
||||
2026-06-18-session-surface.zh.md: 26b58afe6aec3a192da94b2c29d4ec0c95d88a6b
|
||||
2026-06-18-session-surface.md: 0139cc4beba766e4e8b936594899649304234eaa
|
||||
2026-06-18-session-surface.zh.md: 0596d2a0425890924276265dd9cc6c32fcffb974
|
||||
|
||||
@@ -16,7 +16,7 @@ Add a **surface** — a derived, cached order of event sequences (the subset of
|
||||
|
||||
Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`):
|
||||
|
||||
- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). A present `[]` is valid only on `assistant/message` and records a known empty provider stream; when the field is absent, a legacy or foreign event does not record which earlier events produced the message. Other surface events require a non-empty list when the field is present. Without these cited seqs, replay cannot validate that a replace-range operation names every event it removed.
|
||||
- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. V2 `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed.
|
||||
- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events.
|
||||
|
||||
### SurfaceOp: two operations
|
||||
@@ -27,7 +27,7 @@ export type SurfaceOp =
|
||||
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
|
||||
```
|
||||
|
||||
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source.
|
||||
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly.
|
||||
|
||||
2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
|
||||
|
||||
@@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
|
||||
|
||||
### Invariants
|
||||
|
||||
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty source-event list; references are unique, earlier, and known; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
|
||||
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: source lists are non-empty, unique, earlier, and known; `assistant/message` carries no source list; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
|
||||
|
||||
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Historical v0 validation and normalization belong to the v0-to-v1 edge rather than generic Session code.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级):
|
||||
|
||||
- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。出现的 `[]` 只在 `assistant/message` 上有效,表示已知为空的提供方流;旧格式或外部事件缺少该字段时,没有记录这条消息由哪些早期事件产生。其他 surface 事件一旦出现此字段,就必须是非空列表。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
|
||||
- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号,例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。V2 `assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
|
||||
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
|
||||
|
||||
### SurfaceOp:两种操作
|
||||
@@ -27,7 +27,7 @@ export type SurfaceOp =
|
||||
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
|
||||
```
|
||||
|
||||
1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:每个成功的 `assistant/message` 都记录完整的 `assistant/chunk` 来源集合(包括 `[]`),而 `tool/result` 记录其 `tool/call` 来源。
|
||||
1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:`tool/result` 记录其 `tool/call` 来源,`assistant/message` 则直接拥有其嵌入式 stream。
|
||||
|
||||
2. **Replace**:移除从 `start` 到 `end`(两端包含)的条目,并在其位置插入新事件的 seq。`start` 和 `end` 都必须存在于当前 surface;`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
|
||||
|
||||
@@ -49,7 +49,7 @@ export type SurfaceOp =
|
||||
|
||||
### 不变式
|
||||
|
||||
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:只有 `assistant/message` 可以使用空的源事件列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选的不变式服务提供的规则。
|
||||
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:source list 必须非空、唯一、更早且已知;`assistant/message` 不携带 source list;replacement endpoint 必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选 invariant service 提供的规则。
|
||||
|
||||
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。历史 v0 的校验与规范化属于 v0-to-v1 边,而不属于通用 Session 代码。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
|
||||
2026-06-21-bounded-llm-request-recovery.md: 42bf460e52133b2a5471479fa3d7647e70092b48
|
||||
2026-06-21-bounded-llm-request-recovery.zh.md: 2a13f0a740348a5f74bd3d90120a148b25f2e870
|
||||
2026-06-21-bounded-llm-request-recovery.md: 6ae3915462c00eadcc550c80165fab7247a9170c
|
||||
2026-06-21-bounded-llm-request-recovery.zh.md: 82e2e783fffaedcb5bfaabe72f56f7c4176d3784
|
||||
|
||||
@@ -10,7 +10,7 @@ The [per-provider request retry policy](../feature/2026-07-24-provider-retry-pol
|
||||
|
||||
Provider adapters can fail by throwing during dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary normalizes thrown values to that terminal finish protocol before `dsh-agent-loop` receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to `agent/request-error`. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract.
|
||||
|
||||
That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry reconstructs its next attempt from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
|
||||
That boundary is already safe for another request attempt. Each failed stream commits one log-only `assistant/attempt` with its exact compact stream, message derivation ignores it, tool calls are dispatched only after a successful terminal finish and assembled `assistant/message`, and a retry reconstructs its next attempt from the durable surface. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
|
||||
|
||||
The prior boundary left three narrower gaps.
|
||||
|
||||
@@ -38,7 +38,7 @@ interface LlmFailure {
|
||||
}
|
||||
```
|
||||
|
||||
`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events.
|
||||
`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is preserved by the failed attempt's embedded stream.
|
||||
|
||||
`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. The final adapter boundary detaches those facts from adapter-thrown values and emits the appropriate terminal finish; unknown SDK exceptions receive an `UNKNOWN` payload. Exact thrown-object identity does not cross the LLM stream seam.
|
||||
|
||||
@@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada
|
||||
|
||||
### Keep attempts separate in the existing log
|
||||
|
||||
A failed attempt may leave `assistant/chunk` events in its step, but it never appends `assistant/message` and never dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own chunks; only the final outcome closes the turn. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed attempt or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
|
||||
A failed attempt appends `assistant/attempt` with its embedded stream, but never appends a surface `assistant/message` or dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own settlement; only the final outcome closes the turn. UIs may render transient `assistant/live-chunk` updates while a step is open, then settle the failed attempt when `llm/retry` identifies it or `turn/end` records failure. Web validates the complete retry payload contract, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no surface Assistant node. Message derivation ignores `assistant/attempt`, and Web applies the same projection during history rebuild so refreshing cannot promote failed partials into model history or duplicate retry rows.
|
||||
|
||||
If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. While transient recovery continues, `llm/retry` is the durable home for each intermediate failure and delay; the terminal row exists only once `turn/end` records the error, and because exhausted recovery shares the failing turn, the turn's retry history never suppresses that row — the settled retry chain and the terminal error render side by side. No standalone final-error event or response-id vocabulary is added.
|
||||
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回约定。
|
||||
|
||||
该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志重建下一次尝试。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。
|
||||
该边界已能安全地再次发起请求。每个失败 stream 会提交一个包含精确紧凑 stream 的仅日志 `assistant/attempt`,message derivation 会忽略它;系统只会在 terminal finish 成功并组装 `assistant/message` 后分派工具调用,重试则从持久 surface 重建下一次 attempt。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次 attempt。
|
||||
|
||||
此前的边界还留有三个较窄的缺口。
|
||||
|
||||
@@ -38,7 +38,7 @@ interface LlmFailure {
|
||||
}
|
||||
```
|
||||
|
||||
`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。
|
||||
`code` 仍是 `HarnessError` 建立的 provider-neutral 机器路由分类;新字段是在 provider 边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后是 provider 发放的字符串。该 payload 有意不包含 `retryable`、`failover`、`partialOutput`、provider、model、phase 或 route id。是否可重试属于 policy,provider/model 已位于持久 request header 中,部分输出由失败 attempt 的嵌入式 stream 保留。
|
||||
|
||||
`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。最终适配器边界会从适配器抛出值中分离这些事实,并发出相应的终止 finish;未知 SDK 异常会获得 `UNKNOWN` 载荷。精确的抛出对象身份不会跨越 LLM 流 seam。
|
||||
|
||||
@@ -82,7 +82,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
|
||||
|
||||
### 在现有日志中分隔尝试
|
||||
|
||||
一次失败尝试可以在其步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试在失败的轮次与步骤内继续,从持久表层重建请求,并生成自己的分片;只有最终结果才会关闭该轮次。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败尝试,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将每条生产方关联的 `retryId` 重试链投影为稳定的一行,并用最新一次尝试更新该行,再从 `llm/retry-started` 与所属轮次、步骤边界的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。
|
||||
失败 attempt 会追加带嵌入式 stream 的 `assistant/attempt`,但绝不追加 surface `assistant/message` 或分派工具。重试在失败 turn 与 step 内继续,从持久 surface 重建请求,并产生自己的 settlement;只有最终结果才会关闭 turn。step 打开时,UI 可以渲染瞬态 `assistant/live-chunk` update;当 `llm/retry` 标识失败 attempt 或 `turn/end` 记录失败时,UI 再结算它。Web 会校验完整 retry payload contract,把每条 producer-correlated `retryId` chain 投影为稳定一行并更新到最新 attempt,再从 `llm/retry-started` 与所属 turn、step boundary 的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到 event 的时刻为计划延迟起点,而不是 Host event clock;它按向上取整且不低于 1 秒的秒数显示,只在未结算时动画,并把最新失败详情折叠在该行后。即使失败 attempt 没有 surface Assistant node,retry node 也会锚定自己的 trajectory turn。Message derivation 会忽略 `assistant/attempt`,Web 在历史重建时应用同一投影,因此刷新不会把失败 partial 提升进模型历史,也不会生成重复 retry row。
|
||||
|
||||
如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。暂时性恢复继续期间,`llm/retry` 是每次中间失败与延迟的持久归属位置;终态错误行只在 `turn/end` 记录错误后才存在,而由于耗尽的恢复与失败共享同一轮次,该轮次的重试历史绝不会抑制这一行——定格的重试链与终态错误并列渲染。本决策不增加独立的最终错误事件或响应 id 词汇。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
|
||||
2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc
|
||||
2026-06-30-event-domain-semantics.zh.md: c3b12a167da0a41b792914d82a675a98b3a0b860
|
||||
2026-06-30-event-domain-semantics.md: f3e4686a1e9e14c9284de5a4bff16501c50aa907
|
||||
2026-06-30-event-domain-semantics.zh.md: f4bc92111666b0c30586222e14ee1aeab49ebb3f
|
||||
|
||||
@@ -21,7 +21,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/
|
||||
**Three domains, one job each, with a single boundary rule.**
|
||||
|
||||
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
|
||||
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as are the token stream (`assistant/chunk`) and mid-turn steering (a `user/message`).
|
||||
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, errors, and process-local `agent/assistant-stream` frames. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`; Assistant stream evidence becomes durable only inside one `assistant/message` or `assistant/attempt` settlement, and mid-turn steering is a durable `user/message`.
|
||||
- **`tools/*` — the tool registry and execution pipeline.**
|
||||
|
||||
**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.
|
||||
|
||||
@@ -21,7 +21,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
|
||||
**三个域,各司其职,以一条边界规则统一。**
|
||||
|
||||
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。
|
||||
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 的插入、领取和丢弃,以及错误。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和轮次中途以 `user/message` 呈现的 steering(中途引导)同理。
|
||||
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入、领取与丢弃、错误,以及进程本地 `agent/assistant-stream` frame。轮次和步骤边界不在此处——它们是从 `session/event` 读取的持久 Session event;Assistant stream 证据只在一个 `assistant/message` 或 `assistant/attempt` settlement 内变为持久事实,轮次中途 steering 则是持久 `user/message`。
|
||||
- **`tools/*`——工具注册表与执行流水线。**
|
||||
|
||||
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: a79bc3907c4f6c02851ba1814f733684ce373898
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: 178420c126b68689983d5b01f4ffae29b17fe657
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: 471419749ad65c811d0d1914e329f4d94597a54d
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: 382ea5d5d8d80e7314f5a718dab6e2773fa3614f
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-19-zstandard-jsonl-session-logs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The JSONL persistence backend keeps every `SessionEvent` verbatim, including high-volume `assistant/chunk` records. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties.
|
||||
The JSONL persistence backend keeps every `SessionEvent` verbatim, including Assistant settlements with embedded model streams. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties.
|
||||
|
||||
The encoding also has to remain explicit at the deployment boundary. Snapshot fixtures and external line readers require raw JSONL, while a backend cannot safely guess between compressed and raw artifacts in one root or silently migrate pre-release session data.
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量庞大的 `assistant/chunk` 记录。原始文本便于检查,但重复的 JSON 键和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有的 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复以及仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。
|
||||
JSONL 持久化后端会逐字保留每个 `SessionEvent`,包括嵌入模型 stream 的 Assistant settlement。原始文本便于检查,但重复的 JSON key 和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复与仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。
|
||||
|
||||
编码还必须在部署边界上保持显式。快照 fixture(测试前置数据)与外部逐行读取器需要原始 JSONL,而后端无法在同一根目录中安全猜测压缩产物与原始产物,也不能静默迁移预发布会话数据。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
|
||||
2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42
|
||||
2026-07-29-projected-token-usage-and-request-context.md: edcf80659a5456ae557a1591a7f290f5dac0c5f8
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: 84ab782f23d08f6214767b17650a29d91885ce58
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Context occupancy needs a numerator and a denominator that no existing surface c
|
||||
|
||||
Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present.
|
||||
|
||||
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value replaces the earlier sample from the same model attempt instead of double-counting it. A matching `llm/retry-started` boundary ends that replacement scope, so a retry with the same `(turn, step)` contributes a new attempt. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
|
||||
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. It expands each `assistant/message` or `assistant/attempt` stream and takes the last usage sample; a message's top-level usage takes precedence over its embedded sample instead of double-counting it. `assistant/attempt` therefore preserves usage from failed requests. A matching `llm/retry-started` boundary opens a new attempt, so a retry with the same `(turn, step)` contributes separately. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
|
||||
|
||||
Token-meter also owns the shared pure attempt/Turn fold over durable events. It applies the same retry boundary while adding the stricter completeness and exact-total checks required by an exact per-Turn disclosure. A presentation consumer may select a complete Turn window and invoke that fold, but does not own or duplicate the accounting semantics.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗
|
||||
|
||||
这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
|
||||
|
||||
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;`assistant/message` 用量值会替换同一次模型 attempt 的先前样本,不会重复计数。匹配的 `llm/retry-started` 边界会结束该替换作用域,因此复用同一 `(turn, step)` 的重试会贡献一次新的 attempt。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
|
||||
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。它会展开每个 `assistant/message` 或 `assistant/attempt` stream 并采用最后一个 usage sample;message 顶层 usage 优先于其嵌入式 sample,因此不会重复计数。`assistant/attempt` 由此保留失败请求的 usage。匹配的 `llm/retry-started` 边界会打开新 attempt,因此复用同一 `(turn, step)` 的重试会单独贡献用量。推理(reasoning)仍是输出的细分项。compaction 和 surface replacement 不会抹除先前计费。
|
||||
|
||||
token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。它采用相同的重试边界,并增加精确单轮次 disclosure 所需的更严格完整性与精确总量检查。展示消费方可以选择完整 Turn 窗口并调用该 fold,但不拥有或复制记账语义。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
|
||||
2026-08-05-large-session-jsonl-restore-pipeline.md: 309d9dc6bdb5c3160f3e6e76a8318915df58fe59
|
||||
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 28acd3ebe804dca22a0619c257ff3ad9c09500a9
|
||||
2026-08-05-large-session-jsonl-restore-pipeline.md: e87777cc407e50a0e4808b77c3a7d554659d62e7
|
||||
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 32762bd19914423ef38c6472f0a8087e3e7e45fa
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ The scanner stops retaining events at the first unparsable row or sequence gap b
|
||||
|
||||
### Restore admission
|
||||
|
||||
Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
|
||||
Persistence transfers freshly materialized current JSON values to `Session.fromRestore`. These values are detached, acyclic trees; historical packed rows and adjacent migrations have already produced newly allocated v2 settlements. The restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
|
||||
|
||||
Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values.
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
|
||||
|
||||
### 恢复准入
|
||||
|
||||
持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。
|
||||
持久化把刚物化的当前 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的 tree;历史 packed row 与相邻 migration 已经生成新分配的 v2 settlement。restore-only path 使用一次 `for...in` 与 `switch` 校验固定 event envelope,按 event discriminant 执行当前表示检查,并通过显式 `pending` array 迭代冻结 owned object graph,不使用 cycle-tracking set。`surface` 校验记录一次 transition plan,并在同一个 candidate event 进入 log 时提交该 plan。
|
||||
|
||||
普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
|
||||
2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb
|
||||
2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3
|
||||
2026-08-08-bounded-session-persistence-write-batching.md: 97610093b9d80eded1d890f47f02b6b28bdd7f64
|
||||
2026-08-08-bounded-session-persistence-write-batching.zh.md: d076d5df3ba06021f73daa1a0d39b9f802cb7370
|
||||
|
||||
+7
-7
@@ -6,13 +6,13 @@ English | [中文](2026-08-08-bounded-session-persistence-write-batching.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a provider append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast provider could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix.
|
||||
One agent step can emit several durable events in a short interval: request metadata, one Assistant settlement, tool lifecycles, plugin facts, and execution boundaries. Scheduling a provider append as soon as an idle queue receives one event can therefore produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix.
|
||||
|
||||
Dropping chunk events or replacing them with assembled messages would reduce logical storage, but it would also change the event log, replay, sequence numbers, timestamps, and the chunk seqs cited by assistant messages. The write-amplification problem does not require that larger semantic change.
|
||||
Assistant stream embedding reduces one high-volume event family, but write cadence remains a provider-neutral lifecycle concern for every other burst and for historical generations. The batching decision does not change event semantics or storage encoding.
|
||||
|
||||
### Quantified baseline
|
||||
|
||||
Repository fixtures make the logical volume concrete. Decoding the current packed rows in [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) yields 2,098 events: 2,017 chunks (96.1%). Their unpacked JSONL lines occupy 332,647 of 379,225 event bytes (87.7%), while chunk packing reduces the committed file to 89,176 bytes and 182 storage rows, including 23 packed chunk rows. [`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) yields 813 events: 746 chunks (91.8%) and 118,935 of 184,821 unpacked event bytes (64.4%); its packed file is 84,917 bytes and 123 storage rows, including 14 packed rows. These are tracked deterministic fixtures, not a production workload distribution, but they demonstrate why deleting chunks would reduce logical volume and why the existing packed-row layout already removes much of their JSON envelope cost.
|
||||
Released-v1 repository fixtures established the original logical volume. Decoding the packed `goal-multi-turn-actions` generation yielded 2,098 events, including 2,017 chunks (96.1%); unpacked chunk lines occupied 332,647 of 379,225 event bytes, while the packed file used 89,176 bytes and 182 rows. The packed `permission-policy-context` generation yielded 813 events, including 746 chunks (91.8%); unpacked chunk lines occupied 118,935 of 184,821 event bytes, while the packed file used 84,917 bytes and 123 rows. These deterministic historical measurements explain why v2 embeds streams, but they are not a production workload distribution or a current-format size claim.
|
||||
|
||||
JSONL writes one Zstandard frame and fsync per durable append batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync counts.
|
||||
|
||||
@@ -28,7 +28,7 @@ Each live Session receives a package-private `SessionWriteBehind`. When its pend
|
||||
|
||||
`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
|
||||
|
||||
Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format.
|
||||
Every admitted event remains durable in its original order and representation. The controller copies each event on admission; batching removes or rewrites no sequence, timestamp, surface metadata, embedded Assistant stream, or storage record. JSONL can therefore encode more events in one append frame without changing the Session format.
|
||||
|
||||
A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
|
||||
|
||||
@@ -36,7 +36,7 @@ This decision supersedes only the immediate scheduling cadence in [Collapse live
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Do not persist streaming chunk events.** Rejected here: it changes the event-sourced authority and recovery semantics rather than only physical write cadence. The existing [assembled-message rejection](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) remains the guardrail until a no-information-loss replacement defines replay, fork, cited source-event links, sequence, and crash behavior independently. The [packed-row decision](2026-07-26-packed-chunk-rows-by-default.md) remains the complementary JSONL storage-size optimization.
|
||||
**Use one settlement per Assistant attempt instead of batching writes.** The [v2 Assistant stream decision](2026-09-01-v2-embedded-assistant-streams.md) provides that no-information-loss event model and reduces Assistant event cardinality. It does not replace bounded batching for other adjacent events, historical-generation publication, or providers with the same append interface.
|
||||
|
||||
**Write only at semantic checkpoints.** Rejected: it maximizes batching but makes the ordinary crash-loss window depend on a separately mounted policy. Bounded background writes preserve progress between checkpoints while mandatory flushes keep their stronger ordering contract.
|
||||
|
||||
@@ -50,10 +50,10 @@ The controller tests use a fake clock to prove the fixed, non-resetting 200 ms w
|
||||
|
||||
## Consequences
|
||||
|
||||
High-frequency event bursts normally produce fewer durable append operations while preserving the exact logical event count. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
|
||||
High-frequency event bursts normally produce fewer durable append operations while preserving the exact admitted event sequence. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
|
||||
|
||||
This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule.
|
||||
|
||||
An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait.
|
||||
|
||||
The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged.
|
||||
The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. Batching itself never changes `SESSION_FORMAT_VERSION`.
|
||||
|
||||
+7
-7
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次 provider 追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果 provider 速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
|
||||
一个 agent 步骤可以在短时间内发出多个持久事件:请求元数据、一个 Assistant settlement、工具生命周期、插件事实与执行边界。只要空闲队列收到一个事件就立即调度 provider 追加,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
|
||||
|
||||
丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳,以及助手消息引用的分片 seq。写放大问题不要求采取这项语义变化更大的方案。
|
||||
Assistant stream 嵌入会减少一个高频事件 family,但对于其他事件突发与历史 generation,写入节奏仍是 provider-neutral 生命周期问题。批处理决策不会改变事件语义或存储编码。
|
||||
|
||||
### 量化基线
|
||||
|
||||
仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片(96.1%)。这些分片解包后的 JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片(91.8%);这些分片解包后的 JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销。
|
||||
已发布 v1 仓库 fixture 建立了原始逻辑数据量。解码 packed `goal-multi-turn-actions` generation 得到 2,098 个事件,其中 2,017 个是 chunk(96.1%);解包的 chunk 行占 379,225 个事件字节中的 332,647 字节,而 packed 文件使用 89,176 字节与 182 行。packed `permission-policy-context` generation 得到 813 个事件,其中 746 个是 chunk(91.8%);解包的 chunk 行占 184,821 个事件字节中的 118,935 字节,而 packed 文件使用 84,917 字节与 123 行。这些确定性历史测量解释了 v2 为何嵌入 stream,但不代表生产工作负载分布或当前格式大小。
|
||||
|
||||
JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 次数。
|
||||
|
||||
@@ -28,7 +28,7 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
|
||||
|
||||
`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
|
||||
|
||||
每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。
|
||||
每个已接纳事件仍会按原有顺序和表示持久化。控制器会在接纳时复制每个事件;批处理不会删除或改写任何序号、时间戳、surface 元数据、嵌入式 Assistant stream 或存储记录。因此,JSONL 可以在一个追加 frame 中编码更多事件,而无需改变 Session 格式。
|
||||
|
||||
后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
|
||||
|
||||
@@ -36,7 +36,7 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
|
||||
|
||||
## 备选方案
|
||||
|
||||
**不持久化流式分片事件。** 这里不采纳:这会改变事件溯源的权威地位及恢复语义,而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、引用源事件的关联、序列和崩溃行为之前,现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.zh.md)仍是配套的 JSONL 存储体积优化。
|
||||
**使用每个 Assistant attempt 一个 settlement 代替批处理写入。** [v2 Assistant stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)提供该无信息损失事件模型,并减少 Assistant 事件基数。它不能替代其他相邻事件、历史 generation 发布或使用同一 append 接口的 provider 所需的有界批处理。
|
||||
|
||||
**仅在语义检查点写入。** 不采纳:此方案会最大化批处理,却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度,而强制 flush 继续提供更强的顺序约定。
|
||||
|
||||
@@ -50,10 +50,10 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
|
||||
|
||||
## 后果
|
||||
|
||||
高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
|
||||
高频事件突发通常会减少持久化追加操作,同时保持已接纳事件序列完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
|
||||
|
||||
本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。
|
||||
|
||||
接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。
|
||||
|
||||
deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变。
|
||||
deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。批处理本身绝不改变 `SESSION_FORMAT_VERSION`。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d
|
||||
2026-08-09-client-conversation-node-assembly.md: abf03b52c174156b650f5c88425991892cc7615f
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: f4c8c7e1675dcf5b2bc846cb0731cc914532090a
|
||||
|
||||
+4
-4
@@ -51,7 +51,7 @@ Each `(kind, id)` has at most one start Match. A second start fails immediately;
|
||||
|
||||
#### `match(event)`
|
||||
|
||||
`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A `chunkrow/*` event can only be an update; the Assembler rejects it as a start, and `start()` receives a `ConversationStartMatch` containing a standard `SessionEvent`.
|
||||
`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A Client-only `assistant/live-chunk` event can only be an update; the Assembler rejects every transient start, and `start()` receives a `ConversationStartMatch` containing a durable `SessionEvent`.
|
||||
|
||||
This restriction makes one scalar event or packed run's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
|
||||
|
||||
@@ -110,7 +110,7 @@ Dependencies point strictly from earlier starts to later starts, so transitive r
|
||||
|
||||
#### `update(context, match)`
|
||||
|
||||
`update()` handles a post-start scalar or packed Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. A Definition that consumes Assistant deltas folds each matching `chunkrow/*` value as one batch without constructing member events.
|
||||
`update()` handles a post-start durable or transient Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. An Assistant Definition folds each `assistant/live-chunk` update directly and expands an embedded `assistant/message` or `assistant/attempt` stream during history replay.
|
||||
|
||||
The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`.
|
||||
|
||||
@@ -262,7 +262,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
|
||||
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Append message IDs to persistent splice state; materialize once per claim and expose the shared current claimed batch to Message |
|
||||
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
|
||||
| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
|
||||
| Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
|
||||
| Assistant / `assistant-step` | `turn:step` | `step/start` | Live `assistant/live-chunk`, durable `assistant/message` or `assistant/attempt`, and same-step Retry | Aggregate blocks, usage, first-token time, settlement evidence, and retry-hidden state, then publish same-key Step data |
|
||||
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
|
||||
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
|
||||
| Automatic Compaction / `compaction` | Compaction ID | `compaction/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start |
|
||||
@@ -382,7 +382,7 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
|
||||
|
||||
**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning.
|
||||
|
||||
**Add a separate chunk-run matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. `ChunkRowEvent` uses the existing `match(event)` and `update(context, match)` lifecycle while making packed handling explicit through its `chunkrow/*` discriminant.
|
||||
**Add a separate live-stream matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. Client-only `assistant/live-chunk` and durable settlements use the existing `match(event)` and `update(context, match)` lifecycle; only the event discriminator and stream expansion differ.
|
||||
|
||||
**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together.
|
||||
|
||||
|
||||
+4
-4
@@ -51,7 +51,7 @@ Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同
|
||||
|
||||
#### `match(event)`
|
||||
|
||||
`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }` 或 `null`。它拿不到 Context、历史、Reader、Location 或 view envelope。`chunkrow/*` event 只能作为 update;Assembler 会拒绝 packed start,`start()` 接收的 `ConversationStartMatch` 只包含标准 `SessionEvent`。
|
||||
`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }` 或 `null`。它拿不到 Context、history、Reader、Location 或 view envelope。Client-only `assistant/live-chunk` event 只能作为 update;Assembler 会拒绝每个 transient start,`start()` 接收的 `ConversationStartMatch` 只包含持久 `SessionEvent`。
|
||||
|
||||
这项限制使单条 scalar event 或 packed run 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
|
||||
|
||||
@@ -110,7 +110,7 @@ Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前
|
||||
|
||||
#### `update(context, match)`
|
||||
|
||||
`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start scalar 或 packed Match。它不判断 input 属于哪个 Context。消费 Assistant delta 的 Definition 会把每个匹配的 `chunkrow/*` 值作为一个 batch fold,而不构造成员 event。
|
||||
`update()` 只处理已由 `match()` 精确路由到当前 `(kind, id)` 的 post-start durable 或 transient Match。它不判断 input 属于哪个 Context。Assistant Definition 会直接 fold 每个 `assistant/live-chunk` update,并在 history replay 期间展开嵌入式 `assistant/message` 或 `assistant/attempt` stream。
|
||||
|
||||
Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。
|
||||
|
||||
@@ -262,7 +262,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
|
||||
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 把消息 ID 追加到持久 splice state;每次 claim 只 materialize 一次,并向 Message 暴露共享的当前 claimed batch |
|
||||
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
|
||||
| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
|
||||
| Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
|
||||
| Assistant / `assistant-step` | `turn:step` | `step/start` | Live `assistant/live-chunk`、持久 `assistant/message` 或 `assistant/attempt`、同 step Retry | 聚合 block、usage、首 token 时间、settlement 证据与 retry-hidden state,再发布同 key Step data |
|
||||
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent Map;Dispatch Event 用 `rootCallId` 精确路由 |
|
||||
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
|
||||
| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compaction/start` | summary、end、replacement checkpoint | 聚合 summary/checkpoint;checkpoint 足够时可在缺 start 下 fallback |
|
||||
@@ -382,7 +382,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
|
||||
|
||||
**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。
|
||||
|
||||
**增加独立的 chunk-run matcher 与 update lifecycle。** 拒绝:第二条 Definition 路径会重复 dispatch、replay、publication 与 Context 类型。`ChunkRowEvent` 使用既有 `match(event)` 与 `update(context, match)` lifecycle,并通过 `chunkrow/*` discriminator 明确标记 packed 处理。
|
||||
**增加独立 live-stream matcher 与 update lifecycle。** 拒绝:第二条 Definition path 会重复 dispatch、replay、publication 与 Context type。Client-only `assistant/live-chunk` 与持久 settlement 使用既有 `match(event)` 和 `update(context, match)` lifecycle;只有 event discriminator 与 stream expansion 不同。
|
||||
|
||||
**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
|
||||
2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79
|
||||
2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234
|
||||
2026-08-10-cancelled-stream-prefix-finalize.md: 1e6fe59bcc1b323941b69d0e9232f0bcf9e56f53
|
||||
2026-08-10-cancelled-stream-prefix-finalize.zh.md: 7aa81f179b80925ef47a8820f8a80c1ef37ec5ea
|
||||
|
||||
+7
-7
@@ -6,23 +6,23 @@ English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
|
||||
A cancelled stream can leave transient chunks that clients have rendered while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
|
||||
|
||||
The model history must contain assistant content that remains visible to the user after cancellation.
|
||||
|
||||
## Decision
|
||||
|
||||
`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
|
||||
`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, compact stream accumulator, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and the exact embedded timed stream. The append precedes the committed `agent/assistant-stream` end frame, `step/end`, and the aborted `turn/end`.
|
||||
|
||||
`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request.
|
||||
`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends `assistant/attempt` instead of a surface message. Provider `error` and `aborted` finishes also commit `assistant/attempt` before `agent/request-error`, so their streams remain durable without contributing failed-request content to model history.
|
||||
|
||||
Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
|
||||
Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provider information. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user.
|
||||
|
||||
**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
|
||||
**Assemble the prefix from the embedded attempt during projection.** `deriveMessages()` and Client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative surface message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
|
||||
|
||||
**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result.
|
||||
|
||||
@@ -32,8 +32,8 @@ Chat and Trajectory Conversation Definitions read `interrupted` from the durable
|
||||
|
||||
Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason.
|
||||
|
||||
Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy.
|
||||
Terminal provider errors retain their stream in `assistant/attempt` but keep its content out of model history. Only the user's cancellation decision turns visible delivered text into an interrupted surface message.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.
|
||||
`packages/core/agent-loop/tests/cancel.spec.ts` covers content, embedded streams, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix attempt. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both Client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.
|
||||
|
||||
+7
-7
@@ -6,23 +6,23 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
|
||||
被取消的流可能留下 Client 已经渲染的瞬态 chunk,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
|
||||
|
||||
模型历史必须包含取消后仍对用户可见的 assistant 内容。
|
||||
|
||||
## Decision
|
||||
|
||||
`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。
|
||||
`ReactLoopAgent.step()` 在消费模型 stream 期间捕捉取消,此时 `BlockAssembler`、紧凑 stream accumulator 与 provider route 可以确定已送达前缀。loop 把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 与精确嵌入式带时间 stream。该追加先于 committed `agent/assistant-stream` end frame、`step/end` 和记录 aborted 的 `turn/end`。
|
||||
|
||||
`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。
|
||||
`BlockAssembler.interruptedBlocks()` 按 stream 顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` block。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空 block 和未闭合的未知 block 类型。返回结果为空时追加 `assistant/attempt`,而不是 surface message。Provider `error` 与 `aborted` finish 也会在 `agent/request-error` 前提交 `assistant/attempt`,因此其 stream 保持持久,但失败请求内容不会进入模型历史。
|
||||
|
||||
Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
|
||||
Chat 和 Trajectory Conversation Definition 从持久 message 读取 `interrupted`。Chat 渲染 Stopped marker,Trajectory 则在 `step/end` 后把 provider request 保持在 error 生命周期,并保留持久 result seq 与 provider 信息。工具执行期间的取消遵循工具调度器约定,因为 assistant message 已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。
|
||||
|
||||
**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。
|
||||
**在投影时从嵌入式 attempt 组装前缀。** `deriveMessages()` 与 Client Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 surface message。这还会让模型历史超出三类 `SurfaceEventType` 事件。
|
||||
|
||||
**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。
|
||||
|
||||
@@ -32,8 +32,8 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`
|
||||
|
||||
取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。
|
||||
|
||||
终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。
|
||||
终局 provider error 会在 `assistant/attempt` 中保留其 stream,但不让内容进入模型历史。只有用户的取消决策会把可见的已送达文本变成 interrupted surface message。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。
|
||||
`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖 content、嵌入式 stream、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀 attempt。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 与 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种 Client 投影。keyless `cancel` ACP snapshot 与 `goal-round-driver` goal snapshot 覆盖组装应用。
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
|
||||
2026-08-15-packed-session-history-transport.md: 01e36509b7ad2c878ae4ea04c3a10f029e1b8f3d
|
||||
2026-08-15-packed-session-history-transport.zh.md: 6ef847a14da1b4ec1bd59e5aaad9093162b42d84
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
|
||||
2026-08-21-deepseek-llm-api-request-extensions.md: 018b93115f5376affd86a4da3c76f0f367ba9ed0
|
||||
2026-08-21-deepseek-llm-api-request-extensions.zh.md: 4bc0f0c992445c5897069b68efa47fdba46dfdb4
|
||||
2026-08-21-deepseek-llm-api-request-extensions.md: eadbe2a5f17de446c345120f9a6aeeeb531c43b4
|
||||
2026-08-21-deepseek-llm-api-request-extensions.zh.md: f45210adbc075c30484754ce7f8c2b65b515be6c
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ The receiver would also need to traverse the tagged tree, resolve paths into the
|
||||
|
||||
### Why not omit assistant chunks or overlapping event data?
|
||||
|
||||
About 98% of the measured real-session events were `assistant/chunk`. Omitting chunks after reference encoding reduced the complete identity JSON by another 84.79% for late enable and 6.49% for steady state, but it prevents lossless canonical-log reconstruction and leaves `assistant/message.sourceEventSeqs` pointing to absent events. Fuzzy or normalized substitutions have the same reconstruction defect.
|
||||
About 98% of the measured v1 real-session events were `assistant/chunk`. Omitting them after reference encoding reduced the complete identity JSON by another 84.79% for late enable and 6.49% for steady state, but prevented lossless reconstruction and left message provenance dangling. V2 embeds compact streams in attempt settlements; `dsh_session_log` still sends every current canonical event whole and does not omit those embedded records. Fuzzy or normalized substitutions have the same reconstruction defect.
|
||||
|
||||
**Keep the upload cursor only in memory.** Rejected because a normal process restart would resend the entire Session. A canonical acceptance event makes restart recovery best-effort durable without another storage backend; the remaining crash window produces allowed duplicates.
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ Status: implemented
|
||||
|
||||
### 为什么不省略 assistant 分片或重叠事件数据?
|
||||
|
||||
实测真实会话事件中约 98% 为 `assistant/chunk`。在引用编码后省略分片,会让完整未压缩 JSON 在延迟启用场景进一步减少 84.79%,在稳态场景进一步减少 6.49%,但这会阻止权威日志的无损重建,并让 `assistant/message.sourceEventSeqs` 指向缺失事件。模糊替换或规范化替换也存在同一重建缺陷。
|
||||
实测 v1 真实 Session event 中约 98% 为 `assistant/chunk`。在引用编码后省略它们,会让完整 identity JSON 在延迟启用场景进一步减少 84.79%,在稳态场景进一步减少 6.49%,但会阻止无损重建并让 message provenance 悬空。V2 把紧凑 stream 嵌入 attempt settlement;`dsh_session_log` 仍会完整发送每个当前规范 event,且不会省略这些嵌入式 record。模糊或规范化替换也有相同重建缺陷。
|
||||
|
||||
**只在内存中保留上传游标。** 已否决,因为普通进程重启会重发完整会话。权威接受事件让重启恢复获得尽力而为的持久性,无需另一存储后端;剩余崩溃窗口只会产生允许的重复。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
|
||||
2026-08-31-live-assistant-stream-frames.md: 9e848cadbfa71f8281caf14207b36d05f1f95d99
|
||||
2026-08-31-live-assistant-stream-frames.zh.md: dc672d6d8af31f1b45694299125d3f91cb1874ee
|
||||
2026-08-31-live-assistant-stream-frames.md: b91dde47259c2e455a2a2068c644a66aef136836
|
||||
2026-08-31-live-assistant-stream-frames.zh.md: a32124f24db94c04360f36baca7be29f5c062b7d
|
||||
|
||||
@@ -6,19 +6,19 @@ English | [中文](2026-08-31-live-assistant-stream-frames.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The session log keeps every `assistant/chunk` so replay, cold reads, telemetry, and request reconstruction observe one durable v1 history. A live consumer also needs prompt frame-by-frame presentation while a request runs. Treating a transient presentation update as a new durable event would change persistence semantics and make a process-lifetime concern survive restart.
|
||||
The v2 session log keeps one `assistant/message` or `assistant/attempt` settlement with the complete compact timed stream, so replay, cold reads, telemetry, and request reconstruction observe one durable history. A live consumer also needs prompt frame-by-frame presentation while a request runs. Treating a transient presentation update as another durable event would restore token-level event cardinality and make a process-lifetime concern survive restart.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded process-local `LlmAttemptId`; every emitted frame advances one Session-local revision. The `start` frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, and `end.index` equals the next chunk position. Stream acquisition and its final cancellation check occur before `start`; a failure there emits no frame, while every started attempt emits a terminal `end`. The loop appends every v1 `assistant/chunk` before its matching live chunk frame, records that exact `legacyChunkSeq`, and appends the final `assistant/message` before a committed end frame. The existing authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. If the durable opening snapshot precedes the Assistant baseline, a baseline may already acknowledge a chunk whose durable event remains buffered; the Web Session publishes that event when it arrives because the baseline's exact `legacyChunkSeq` proves its matching frame. A final message arriving after an active opening remains staged until the matching `end.index` and ordered provenance arrive; an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or provenance gaps re-open follow and replace the baseline. The TypeScript and Python SDK protocols do not expose these frames. The durable log remains the source of replay and model history.
|
||||
`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded process-local `LlmAttemptId`; every frame advances one Session-local revision. The start frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, chunk timestamps are reused by the compact stream, and `end.index` equals the next chunk position. The loop appends the final `assistant/message` or `assistant/attempt` before a committed end frame names that event and seq; an abandoned end names no durable event. Authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt compact baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. An opening between a durable settlement and its end frame reconstructs the active Client-only `assistant/live-chunk` updates, stages only the settlement owned by that attempt's `startedTime`, Turn, and Step, and releases it after the matching end index, type, and seq; an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or settlement gaps reopen follow and replace the baseline. The TypeScript and Python SDK protocols do not expose these frames. Durable settlements remain the source of replay and model history; the [v2 stream decision](2026-09-01-v2-embedded-assistant-streams.md) owns their representation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Replace `assistant/chunk` with a live-only stream** — rejected because cold reads, replay, telemetry, and the completed assistant message's source references require the durable raw chunk history.
|
||||
- **Add a durable assistant-stream event type** — rejected because process-local attempts, revisions, and reconnect presentation are not facts that survive restart or affect model reconstruction.
|
||||
- **Keep only the live stream** — rejected because cold reads, replay, telemetry, usage accounting, and failed-attempt diagnostics require the durable embedded stream.
|
||||
- **Persist each live frame as its own event** — rejected because process-local attempt ids, revisions, and reconnect presentation do not survive restart or affect model reconstruction; one settlement owns the durable stream.
|
||||
- **Use an unbranded request string as the attempt key** — rejected because consumers need an opaque identity that cannot be confused with provider request IDs or durable Session IDs.
|
||||
- **Let UI Chat subscribe to a second live source** — rejected because the Session object owns stream reconciliation and UI Conversation is the sole event-source subscriber; a second source would make settlement order target-dependent.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Web client renders in-memory chunks before persistence flush while retaining one durable v1 history, without changing `SESSION_FORMAT_VERSION` or the chunk-row encoding. A process restart has no active assistant frames; reconnect and cold replay use durable records. Cursorless notifications never advance the journal cursor, and notifications observed during durable gap repair wait for the replacement page. That page has no Assistant baseline, so the Client clears transient attempts and lets the held notification reopen follow once for an atomically paired page and baseline. The frame declaration remains agent-scoped, so a listener observes only its owning Agent unless it explicitly registers globally.
|
||||
The Web client renders in-memory chunks before the attempt settles while retaining one durable v2 history. A process restart has no active Assistant frames; reconnect can restore only the baseline held by the current process, while cold replay expands durable settlements. Cursorless notifications never advance the journal cursor, and notifications observed during durable gap repair wait for the replacement page. The frame declaration remains agent-scoped, so a listener observes only its owning Agent unless it explicitly registers globally.
|
||||
|
||||
+5
-5
@@ -6,19 +6,19 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
Session log 保留每个 `assistant/chunk`,因此重放、冷读、遥测和请求重建都能观察同一份持久 v1 历史。实时消费方还需要在请求运行时逐帧呈现。把短暂的呈现更新当作新的持久事件会改变持久化语义,并让只属于进程生命周期的事实跨重启保留。
|
||||
v2 Session log 通过一个 `assistant/message` 或 `assistant/attempt` settlement 保留完整紧凑带时间 stream,因此 replay、冷读、遥测与请求重建都能观察同一份持久历史。实时消费方还需要在请求运行时逐帧呈现。把瞬态呈现 update 当作另一种持久事件,会恢复 token 粒度事件基数,并让只属于进程生命周期的事实跨重启保留。
|
||||
|
||||
## 决定
|
||||
|
||||
`dsh-agent-loop` 为每次模型尝试发出作用域内的 `agent/assistant-stream` 帧。`start`、`chunk` 和 `end` 带有带品牌的进程本地 `LlmAttemptId`;每个已发出的帧都会推进一次 Session 本地 revision。`start` 帧会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始连续递增,`end.index` 等于下一个 chunk 位置。循环会先取得 stream 并执行最终取消检查,再发出 `start`;这些步骤失败时不发出任何帧,而每个已开始的尝试都会发出终态 `end`。循环在匹配的实时 chunk 帧之前追加每个 v1 `assistant/chunk`,记录精确的 `legacyChunkSeq`,并在已提交的 end 帧之前追加最终 `assistant/message`。现有的已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃尝试 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor 的帧。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。如果持久 opening snapshot 早于 Assistant baseline,baseline 可能已经确认一个持久事件仍在 buffer 中的 chunk;该事件到达时,Web Session 会依据 baseline 中精确的 `legacyChunkSeq` 已证明其匹配帧而直接发布。活跃 opening 之后到达的最终 message 会保持暂存,直到匹配的 `end.index` 与有序来源到达;同一 Turn 和 Step 中更早的 retry 仍保持可见。revision、连续 index 或来源缺口会重新打开 follow 并替换 baseline。TypeScript 和 Python SDK 协议不公开这些帧。持久 log 仍然是重放和模型历史的真源。
|
||||
`dsh-agent-loop` 为每次模型 attempt 发出作用域内的 `agent/assistant-stream` frame。`start`、`chunk` 和 `end` 带有带品牌的进程本地 `LlmAttemptId`;每个 frame 都会推进一次 Session 本地 revision。start frame 会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始密集递增,chunk 时间戳会被紧凑 stream 复用,`end.index` 等于下一个 chunk 位置。loop 会在 committed end frame 命名事件与 seq 前追加最终 `assistant/message` 或 `assistant/attempt`;abandoned end 不命名持久事件。已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃 attempt 紧凑 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor frame。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。opening 位于持久 settlement 与对应 end frame 之间时,会重建活跃的 Client-only `assistant/live-chunk` update,只暂存由该 attempt 的 `startedTime`、Turn 与 Step 所有的 settlement,并在匹配的 end index、type 与 seq 到达后释放;同一 Turn 和 Step 中更早的 retry 仍保持可见。revision、密集 index 或 settlement 缺口会重新打开 follow 并替换 baseline。TypeScript 和 Python SDK 协议不公开这些 frame。持久 settlement 仍是 replay 与模型历史的真源;其表示由 [v2 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)负责。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用仅实时的流替换 `assistant/chunk`**:不采用,因为冷读、重放、遥测和已完成 assistant message 的来源引用都需要持久的原始 chunk 历史。
|
||||
- **添加持久的 assistant-stream 事件类型**:不采用,因为进程本地尝试、revision 和重连呈现不是会跨重启保留或影响模型重建的事实。
|
||||
- **只保留 live stream**:不采用,因为冷读、replay、遥测、usage 记账与失败 attempt 诊断需要持久嵌入式 stream。
|
||||
- **把每个 live frame 作为独立事件持久化**:不采用,因为进程本地 attempt id、revision 与重连呈现不会跨重启保留或影响模型重建;一个 settlement 拥有持久 stream。
|
||||
- **用未加品牌的请求字符串作为尝试键**:不采用,因为消费方需要一个不透明身份,不能把它与 provider request ID 或持久 Session ID 混淆。
|
||||
- **让 UI Chat 订阅第二个实时 source**:不采用,因为 Session 对象拥有 stream 对账,UI Conversation 是唯一的 event-source 订阅方;第二个 source 会使结算顺序依赖 target。
|
||||
|
||||
## 影响
|
||||
|
||||
Web client 可以在 persistence flush 前渲染内存 chunk,同时保留一份持久 v1 历史,而不改变 `SESSION_FORMAT_VERSION` 或 chunk-row 编码。进程重启后没有活跃 assistant 帧;重连和冷重放使用持久记录。无 cursor 的通知绝不推进 journal cursor,在持久缺口修复期间观察到的通知会等待 replacement page。该 page 不携带 Assistant baseline,因此 Client 会清空瞬态尝试,并让 held notification 重新打开 follow 一次,以取得原子配对的 page 与 baseline。帧声明保持 agent 作用域,因此监听器只观察所属 Agent,除非它显式全局注册。
|
||||
Web client 可以在 attempt settlement 前渲染内存 chunk,同时保留一份持久 v2 历史。进程重启后没有活跃 Assistant frame;重连只能恢复当前进程持有的 baseline,冷 replay 则展开持久 settlement。无 cursor 通知绝不推进 journal cursor,在持久缺口修复期间观察到的通知会等待 replacement page。frame 声明保持 agent 作用域,因此监听器只观察所属 Agent,除非它显式全局注册。
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md
|
||||
2026-07-26-packed-chunk-rows-by-default.md: bd4b3b9f773afbf6aa7e88d51b6e842d6634c222
|
||||
2026-07-26-packed-chunk-rows-by-default.zh.md: eafe6632150aadd74395f4d0f09d064fb703a03d
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
|
||||
2026-09-01-v2-embedded-assistant-streams.md: 2f01905ba844b4ef4bdbce6e2f193c77d61ff8e4
|
||||
2026-09-01-v2-embedded-assistant-streams.zh.md: d4e84463986ddb3113181d2920973fa54cacb678
|
||||
@@ -0,0 +1,68 @@
|
||||
# Agent Note: Embed Assistant streams in v2 attempt settlements
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-01-v2-embedded-assistant-streams.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Token-sized `assistant/chunk` events preserve exact stream order, timing, usage, terminal state, replay metadata, and partial failed output, but making each chunk a top-level Session event repeats envelopes throughout persistence, telemetry, history transport, indexing, and client assembly. Physical packed rows reduce JSONL bytes without reducing logical event count or the work of consumers that receive the canonical stream.
|
||||
|
||||
Storing only assembled successful messages would remove that overhead but lose failed and abandoned output, token boundaries, timestamps, and deterministic provider replay. The durable record needs one unit per model attempt without reducing the evidence that replay, diagnostics, cancellation recovery, usage accounting, snapshots, and UI history rely on.
|
||||
|
||||
Changing event cardinality also changes Session sequence numbers. A released migration must preserve the relative order of unrelated events, rewrite every declared same-Session reference, retain the exact fork cut, and refuse any relationship it cannot preserve semantically.
|
||||
|
||||
## Decision
|
||||
|
||||
Session format v2 has no top-level `assistant/chunk` event. Each model attempt commits one durable settlement containing `stream: AssistantStreamRecord[]`:
|
||||
|
||||
- `assistant/message` is the surface settlement for a successful response or a cancelled response with visible assembled content. It embeds the exact compact timed stream beside the assembled message, optional usage, and optional `interrupted: true` marker.
|
||||
- `assistant/attempt` is log-only. It preserves the stream for a failed, retried, cancelled, or crash-tail attempt that commits no surface message, so diagnostics and accounting do not fabricate model-visible history.
|
||||
|
||||
`AssistantStreamAccumulator` snapshots each chunk once. Consecutive text, reasoning, or tool-argument deltas for the same block become one compact run with its first timestamp, exact timestamp gaps, and one array member per original delta. Every other chunk remains a timestamped raw record. `expandAssistantStream()` strictly validates and reconstructs the exact timed sequence; compaction never joins delta boundaries.
|
||||
|
||||
The current v2 validator requires the embedded stream to reproduce a non-empty `assistant/message`'s content, usage, and replay state. An empty stream remains valid for a migrated legacy message that had no source chunks. `assistant/message` cannot carry obsolete chunk `sourceEventSeqs`; ordinary user and tool surface provenance remains available.
|
||||
|
||||
### Live presentation and durable replay
|
||||
|
||||
`agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop appends the complete `assistant/message` or `assistant/attempt` before a committed end frame names its type and sequence. An abandoned end has no settlement.
|
||||
|
||||
The Web follow adapter opts into these cursorless frames. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages the matching settlement until the committed end, and reopens follow on a revision gap. A reconnect baseline carries the active attempt's compact prefix. Paged history, replay, telemetry, token accounting, and cold UI assembly read the durable embedded stream rather than the live frames.
|
||||
|
||||
### Released v1 to v2 migration
|
||||
|
||||
The adjacent migration validates the complete frozen v1 artifact, groups chunks by turn, step, terminal boundary, and exact message provenance, and then substitutes one settlement per attempt. A successful group's chunks move into its message. An unclaimed group becomes `assistant/attempt` at the last consumed chunk's position. Unrelated interleaved events retain their relative order, and survivors receive dense v2 sequence numbers.
|
||||
|
||||
The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
|
||||
|
||||
The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded artifact marks its exact cut with `session/end-seed { inherited: true }`; decoding derives the cut from the last tagged marker. The v2 codec writes one durable event per physical row and range-encodes only `sourceEventSeqs`. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
|
||||
|
||||
Generation selection and publication follow the [released Session migration decision](2026-08-31-released-session-format-migrations.md): the source path, bytes, and inode remain unchanged, only the final version-named successor is published, and retained predecessors provide neither fallback nor downgrade support.
|
||||
|
||||
## Verification
|
||||
|
||||
The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, provenance ranges, raw and Zstandard publication, and no-write current reads.
|
||||
|
||||
The manual performance acceptance compares current v2 catalog dispatch with a direct-current read of the same physical input across three runs, 100 warmup pairs, and 600 measured pairs. It requires every pooled median and p95 regression to remain within 5%; the accepted run's worst p95 regression was 2.201%. `--smoke` reports a non-gating diagnostic sample.
|
||||
|
||||
Agent-loop tests pin durable-before-end ordering, interrupted visible prefixes, failed and retry attempts, abandonment, usage, and replay metadata. Session Controller and Conversation tests pin live transient display, reconnect baselines, committed settlement release, history replay, Chat and Trajectory parity, while TypeScript and Python SDK snapshots pin the external event representation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Persist only assembled successful messages.** This loses partial failed output, timing, token boundaries, usage from attempts without a message, and exact deterministic replay. `assistant/attempt` and the embedded compact stream preserve those facts without adding them to model history.
|
||||
|
||||
**Keep top-level chunks and pack only physical rows.** This preserves the v1 logical representation but leaves sequence density, telemetry volume, wire envelopes, Client entries, and consumer dispatch proportional to token count. Historical codecs still decode that representation; it is not the current event model.
|
||||
|
||||
**Carry packed chunk rows through the history API.** This reduces wire and Client work for v1 but gives the Client a second event vocabulary and keeps transport coupled to token-row cardinality. The current API carries scalar durable settlements plus a separate live transient stream.
|
||||
|
||||
**Store the stream in a sidecar or replay-only fixture.** This splits one attempt's message and evidence across durability owners and cannot give ordinary resumed sessions the same failed-output and timing facts. The settlement is the atomic owner.
|
||||
|
||||
**Redirect references from consumed chunks to their settlement.** A chunk and an attempt settlement are not interchangeable facts. Refusal prevents a migration from silently changing the meaning of plugin-owned references.
|
||||
|
||||
## Consequences
|
||||
|
||||
Current logs, telemetry, history pages, and cold Client assembly scale by model attempts rather than token chunks while retaining exact stream evidence inside each settlement. Live presentation remains incremental and intentionally process-local.
|
||||
|
||||
One settlement can be large, and v1-to-v2 migration materializes the whole artifact plus its sequence map. The closed alpha inventory refuses unknown v1 events and undeclared references instead of guessing. Consumers that need individual chunks call `expandAssistantStream()` and must not infer durability from `agent/assistant-stream`.
|
||||
|
||||
Migration changes sequence numbers after consumed v1 chunks, so every same-Session reference belongs to an explicit rewrite rule. This constraint makes future cardinality-changing migrations expensive by design and keeps silent semantic redirection out of the format chain.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Agent Note: 在 v2 attempt settlement 中嵌入 Assistant stream
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-01-v2-embedded-assistant-streams.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Token 粒度的 `assistant/chunk` 事件会保留精确的 stream 顺序、时间、usage、terminal state、replay metadata 与失败时的部分输出,但让每个 chunk 成为顶层 Session event 会在持久化、遥测、历史传输、索引和 Client 组装中重复信封。物理 packed row 可以减少 JSONL 字节,却不会减少逻辑事件数,也不会减少接收规范 stream 的消费方工作量。
|
||||
|
||||
只存储组装后的成功 message 可以消除这些开销,但会丢失失败与放弃的输出、token 边界、时间戳和确定性 provider replay。持久记录需要让每个模型 attempt 只占一个单位,同时不减少 replay、诊断、取消恢复、usage 记账、snapshot 与 UI 历史依赖的证据。
|
||||
|
||||
改变事件基数也会改变 Session 序号。已发布迁移必须保留无关事件的相对顺序、改写每个已声明的同 Session 引用、保留精确 fork 切点,并拒绝任何无法保持语义的关系。
|
||||
|
||||
## 决策
|
||||
|
||||
Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt 提交一个包含 `stream: AssistantStreamRecord[]` 的持久 settlement:
|
||||
|
||||
- `assistant/message` 是成功响应或具有可见组装内容的已取消响应所对应的 surface settlement。它在组装 message 旁嵌入精确的紧凑带时间 stream、可选 usage 与可选 `interrupted: true` marker。
|
||||
- `assistant/attempt` 只进入日志。它保留失败、重试、取消或崩溃尾部 attempt 的 stream;这些 attempt 没有提交 surface message,因此诊断与记账不会虚构模型可见历史。
|
||||
|
||||
`AssistantStreamAccumulator` 对每个 chunk 只快照一次。同一 block 的连续 text、reasoning 或 tool argument delta 会变成一个紧凑 run,包含首个时间戳、精确时间戳间隔和每个原始 delta 对应的一个数组成员。其他 chunk 保留为带时间戳的 raw record。`expandAssistantStream()` 会严格校验并重建精确的带时间序列;压缩绝不会合并 delta 边界。
|
||||
|
||||
当前 v2 校验器要求嵌入式 stream 能复现非空 `assistant/message` 的 content、usage 与 replay state。对于没有源 chunk 的已迁移旧 message,空 stream 仍然有效。`assistant/message` 不能携带已停用的 chunk `sourceEventSeqs`;普通 user 与 tool surface provenance 保持可用。
|
||||
|
||||
### 实时呈现与持久回放
|
||||
|
||||
`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 命名其类型和序号前追加完整的 `assistant/message` 或 `assistant/attempt`。abandoned end 没有 settlement。
|
||||
|
||||
Web follow adapter 显式选择接收这些无 cursor frame。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,把匹配的 settlement 暂存到 committed end,并在 revision 缺口时重新打开 follow。重连 baseline 携带活跃 attempt 的紧凑前缀。分页历史、replay、遥测、token 记账与冷 UI 组装读取持久嵌入式 stream,而不是 live frame。
|
||||
|
||||
### 已发布 v1 到 v2 迁移
|
||||
|
||||
相邻迁移会校验完整的冻结 v1 产物,按 turn、step、terminal boundary 与精确 message provenance 对 chunk 分组,再为每个 attempt 替换一个 settlement。成功分组的 chunk 移入其 message。未被认领的分组会在最后一个被消费 chunk 的位置变成 `assistant/attempt`。无关的交错事件保持相对顺序,存活事件获得密集 v2 序号。
|
||||
|
||||
该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
|
||||
|
||||
v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,并且只对 `sourceEventSeqs` 做范围编码。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
|
||||
|
||||
Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-released-session-format-migrations.zh.md):源路径、字节与 inode 保持不变,只发布最终具名版本 successor;保留 predecessor 不提供 fallback 或 downgrade 支持。
|
||||
|
||||
## 验证
|
||||
|
||||
紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
|
||||
|
||||
手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,把当前 v2 catalog dispatch 与同一物理输入的 direct-current 读取比较。它要求每个 pooled median 与 p95 regression 保持在 5% 以内;已接受运行的最差 p95 regression 为 2.201%。`--smoke` 报告不参与 gate 的诊断 sample。
|
||||
|
||||
Agent-loop 测试固定先持久后 end 的顺序、中断的可见前缀、失败与重试 attempt、abandonment、usage 与 replay metadata。Session Controller 与 Conversation 测试固定实时瞬态显示、重连 baseline、committed settlement 发布、历史回放以及 Chat 与 Trajectory 一致性;TypeScript 与 Python SDK snapshot 固定外部事件表示。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**只持久化组装后的成功 message。** 这会丢失部分失败输出、时间、token 边界、没有 message 的 attempt usage,以及精确确定性 replay。`assistant/attempt` 与嵌入式紧凑 stream 会保留这些事实,且不把它们加入模型历史。
|
||||
|
||||
**保留顶层 chunk,只打包物理行。** 这会保留 v1 逻辑表示,却让序号密度、遥测量、wire 信封、Client entry 与消费方 dispatch 继续与 token 数成正比。历史编解码器仍然解码该表示;它不是当前事件模型。
|
||||
|
||||
**通过历史 API 传递 packed chunk row。** 这会减少 v1 的 wire 与 Client 工作,却让 Client 拥有第二套事件词汇,并让传输继续与 token-row 基数耦合。当前 API 携带标量持久 settlement,并使用独立的实时瞬态 stream。
|
||||
|
||||
**把 stream 存在 sidecar 或 replay-only fixture 中。** 这会把一个 attempt 的 message 与证据拆给不同持久性 owner,也无法让普通恢复 Session 获得相同的失败输出与时间事实。settlement 是原子 owner。
|
||||
|
||||
**把被消费 chunk 的引用重定向到其 settlement。** Chunk 与 attempt settlement 不是可互换事实。拒绝可以防止迁移悄然改变插件自有引用的含义。
|
||||
|
||||
## 后果
|
||||
|
||||
当前日志、遥测、历史页与冷 Client 组装按模型 attempt 而非 token chunk 扩展,同时在每个 settlement 内保留精确 stream 证据。实时呈现保持增量,并且有意仅存在于进程内。
|
||||
|
||||
一个 settlement 可能很大,v1 到 v2 迁移会物化完整产物及其序号映射。封闭的 Alpha 清单会拒绝未知 v1 事件与未声明引用,而不会猜测。需要单独 chunk 的消费方调用 `expandAssistantStream()`,并且绝不能从 `agent/assistant-stream` 推断持久性。
|
||||
|
||||
迁移会改变被消费 v1 chunk 之后的序号,因此每个同 Session 引用都必须属于显式改写规则。该约束有意让未来的基数变化迁移保持昂贵,并防止格式链执行无声的语义重定向。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.md: 08ceb820ee415cbac9aded7a6b4f55933dc64cf3
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 2d796c8518c703d872ebcec4f548a0e52d91994b
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.md: a1066cd9925b37a05ea06c22b60b81d6107cd336
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 3f487f63250713570ccc7962ba0afab869bc656a
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th
|
||||
- **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache.
|
||||
- **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs.
|
||||
- **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse.
|
||||
- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — rejected: the durable `compaction/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream.
|
||||
- **A dedicated Agent-backed summarization sub-session for snapshot replay** — rejected: the durable `compaction/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream. Creating a sub-session solely to obtain an Assistant settlement adds an unrelated lifecycle.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Status: implemented
|
||||
- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。
|
||||
- **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。
|
||||
- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。
|
||||
- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——否决:持久的 `compaction/summary` 事件会记录成功本地调用的位置和完整输出,而显式调用标记可防止回放把模板或远程输出当作本地流。
|
||||
- **为 snapshot replay 专门建立 Agent-backed summarization sub-session**——否决:持久 `compaction/summary` event 会记录成功本地调用的位置与完整输出,显式 call marker 可防止 replay 把 template 或 remote output 当作本地 stream。仅为获得 Assistant settlement 而创建 sub-session 会增加无关 lifecycle。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
|
||||
2026-07-31-english-compaction-checkpoints.md: dc95ed187a6ba5b86800f06ebefb2776995f9421
|
||||
2026-07-31-english-compaction-checkpoints.zh.md: 1dc98b43e5bf2ca66cb3480b8124af6e7a9479b2
|
||||
2026-07-31-english-compaction-checkpoints.md: a1123b3d679a8bab2dbd9ecdc7b2079a78ffe28a
|
||||
2026-07-31-english-compaction-checkpoints.zh.md: 1543d87493a9cbfb28b18f00afa4ee4d3a074179
|
||||
|
||||
@@ -25,4 +25,4 @@ The requirement is integrated into the first sentence of the trailing compaction
|
||||
|
||||
- New checkpoints normalize narrative context into English while retaining the exact strings that future tool use and code work depend on.
|
||||
- Existing checkpoint structure, compaction routing, and cache alignment are unchanged; only the final user instruction is different.
|
||||
- The direct summarization call remains outside transcript snapshots because it emits no `assistant/chunk` events. The real-loop regression instead asserts the exact final instruction received by the summarization request.
|
||||
- The direct summarization call remains outside transcript snapshots because it emits no Agent-owned Assistant settlement. The real-loop regression instead asserts the exact final instruction received by the summarization request.
|
||||
|
||||
@@ -25,4 +25,4 @@ Status: implemented
|
||||
|
||||
- 新检查点会将叙述性上下文规范化为英语,同时保留未来工具使用和代码工作所依赖的精确字符串。
|
||||
- 既有检查点结构、压缩路由和缓存对齐保持不变;只有最后一条 user 指令不同。
|
||||
- 直接摘要调用仍不纳入 transcript(文本记录)快照,因为它不会发出 `assistant/chunk` 事件。真实循环回归改为断言摘要请求收到的精确最终指令。
|
||||
- 直接 summarization call 仍不纳入 transcript snapshot,因为它不会发出 Agent-owned Assistant settlement。真实 loop regression 改为断言 summarization request 收到的精确最终 instruction。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: 24bab01ad844a5b48e0bf6fe0fc54df6403f4bb7
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 28d8d85e316fe4770e55685e9d5641fda846c5c5
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: 6ecb0ef254bd9734664aac71fe63c1eb3caf45b7
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: bcc6cad60fb5c791c328da1057e836cc1267d11b
|
||||
|
||||
+2
-2
@@ -6,11 +6,11 @@ English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`.
|
||||
The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message retains its stream and usage but contributes no output blocks. Three consumers selected the child's output independently and treated that record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in an embedded Assistant stream reported `[]`.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
|
||||
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty Assistant message; without one, select accumulated `text-delta` content from embedded `assistant/message` and `assistant/attempt` streams or a chunk-only transport; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)`, `pushText(text)`, and `collect()`. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete Assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
|
||||
|
||||
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message presents the optional safe Provider diagnostic owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md) after the stop-reason headline and appends the child's partial text afterward. The parent model receives the failure, separate infrastructure detail, and available assistant output without conflating them.
|
||||
|
||||
|
||||
+2
-2
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
当 `max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。
|
||||
当 `max-tokens` step 只组装出 tool-call block 时,agent loop 会追加空 content `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的 tool call;该 message 保留 stream 与 usage,但不贡献 output block。三个消费方独立选取 child agent 输出,并把该 record 当成输出。进程内 driver 的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK backend observer 则让任何 `assistant/message` 优先于累计 text。在被 max-tokens 截断的多 step turn 中,最后的空 message 导致 `SubagentResult.output`、tool result、telemetry 与 `subagent/end.lastAssistantMessage` 漏掉真实 partial answer。进程内 driver 也缺少 streamed-text fallback,因此被取消 child 的唯一 text 若只存在于嵌入式 Assistant stream 中,也会报告 `[]`。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一规范选取规则:选取最后一条非空 Assistant message;没有时,从嵌入式 `assistant/message` 与 `assistant/attempt` stream 或 chunk-only transport 选取累计 `text-delta` content;忽略空 content message。增量 `AssistantOutputFold` 通过 `push(event)`、`pushText(text)` 与 `collect()` 实现该规则。`finalAssistantOutput(events)` 把规则应用于完整 event suffix,供进程内 `readResult` 与 Activation capture 使用。SDK backend 折叠 notification event;ACP backend 不公开完整 Assistant message,并折叠 raw chunk text。`SubagentResult.output` 定义 result contract,`subagent/end.lastAssistantMessage` 使用同一规则。child 不产生任一种输出时,一次性与 continuable run 的 lifecycle field 都缺省,而不是空 array。`max-tokens` 或 `aborted` result 保留实际 stop reason。
|
||||
|
||||
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后呈现由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的可选安全提供方诊断,再附上子 agent 的部分文本。父模型会同时收到失败、独立的基础设施说明与已有 assistant 输出,而且不会把它们混为一体。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
|
||||
2026-07-19-fresh-agent-ralph-workflow-tool.md: f374fcee7abc89c61c3ff6654093d1026cc8a715
|
||||
2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 1362e9ba528230b9247bb65e3bbfa2c61a72c568
|
||||
2026-07-19-fresh-agent-ralph-workflow-tool.md: 9eedc022f04f733feb5c4e5abb4b0528c0d10f60
|
||||
2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 23c614d5b5eadbe583469acff1efa01b398498db
|
||||
|
||||
@@ -46,7 +46,7 @@ Human-facing presentation uses a generic `ralph` card whose raw input is the obj
|
||||
|
||||
Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node.
|
||||
|
||||
A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. The keyless [`ralph-loop` headless snapshot](../../../../snapshots/session/ralph-loop/) invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while request-header snapshots pin the shipped schema and prompt-guidance transcript output.
|
||||
A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, `isSeeded: false` with cut zero, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. The keyless [`ralph-loop` headless snapshot](../../../../snapshots/session/ralph-loop/) invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while request-header snapshots pin the shipped schema and prompt-guidance transcript output.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要
|
||||
|
||||
单元测试覆盖配置与调用上限解析、提供方能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、dispose(资源释放)、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明提供方路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且提供方覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
|
||||
|
||||
一项无密钥真实栈集成测试通过实际工作线程引擎、spawn 提供方、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一 Round 的交接中、只产生一个阶段事件、终止完成以及两个子 agent 都已 dispose。同一真实栈还覆盖阻塞与 Round 上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后子 agent 完全停稳。无密钥 [`ralph-loop` headless 快照](../../../../snapshots/session/ralph-loop/)会调用 `ralph`、固定父级流式 transcript,并检查持久化日志中存在两个不同且无种子的子会话,且 Round 1 的交接只出现在 Round 2。工具测试固定通用调用/结果展示,请求头快照固定发布的 schema 与提示指导 transcript 表面。
|
||||
一项无 key 真实栈 integration 通过实际 worker-thread engine、spawn provider、structured-output runtime 与 agent loop 驱动固定 script。它证明 child agent identity 不同、`isSeeded: false` 且 cut 为零、继承 cwd、两个 child request 都不含 parent-history marker、上一份 report 只精确出现在下一 Round handoff 中、只产生一个 phase event、terminal completion,以及两个 child agent 都已 dispose。同一真实栈还覆盖 blocker 与 Round-limit outcome、未规范化及语义无效 report、过大 handoff、保留上一份有效 handoff 的普通 child failure,以及取消后 child 完全停稳。无 key [`ralph-loop` headless snapshot](../../../../snapshots/session/ralph-loop/)调用 `ralph`、固定 parent stream transcript,并检查持久 log 中存在两个不同且 unseeded 的 child Session,且 Round 1 handoff 只出现在 Round 2。Tool test 固定通用 call/result presentation,request-header snapshot 固定已发布 schema 与 prompt-guidance transcript。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
|
||||
2026-07-24-provider-retry-policies.md: 968f40272d3d3cb0efa362c97dcb8630888f80ec
|
||||
2026-07-24-provider-retry-policies.zh.md: 3274d0311bb79825ef9551549e4783a33c1cb6ee
|
||||
2026-07-24-provider-retry-policies.md: 996e7fcef2f323044761126d82dc53f73d3dde88
|
||||
2026-07-24-provider-retry-policies.zh.md: 8b3cb96ddb78f5bf0d680f8e9762622f1383286b
|
||||
|
||||
@@ -40,7 +40,7 @@ Always mode asks downstream recovery first so a specialized policy such as conte
|
||||
|
||||
Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff.
|
||||
|
||||
Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, canonical resolved-policy key, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface.
|
||||
Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, canonical resolved-policy key, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. Neither that event nor the failed attempt's `assistant/attempt` settlement contributes a surface message, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之
|
||||
|
||||
两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。
|
||||
|
||||
每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、已解析策略的规范键、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。
|
||||
每次安排 retry 都会追加一条不进入 surface 的 `llm/retry` event,其中包含失败 provider、policy mode、resolved policy 的规范 key、provider-policy retry number、delay 与 failure facts。normal event 包含有限 `maxRetries`;always event 省略该字段,UI 把上限渲染为 `∞`。该 event 与失败 attempt 的 `assistant/attempt` settlement 都不产生 surface message,因此除非其他 recovery policy 有意改变 surface,否则下一次请求包含与失败请求相同的派生 context。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
|
||||
2026-08-03-web-search-source-scroll.md: 6fe532e2a2989e834b926cf48d531ae60a32f58b
|
||||
2026-08-03-web-search-source-scroll.zh.md: 12bc6d3decb3c13ea759a9f25322b94e45db1322
|
||||
2026-08-03-web-search-source-scroll.md: 0609f45a198c5c9a35c2e2dd1f2ac8c3aaba7ccc
|
||||
2026-08-03-web-search-source-scroll.zh.md: 1649598dfc9198bdbbd36b82df6a4606af4c3e4d
|
||||
|
||||
@@ -38,7 +38,7 @@ Every source the tool returned is always in the DOM, so no source the view carri
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. `packages/web/tool-web` independently pins the single- and multi-query model-side caps.
|
||||
|
||||
jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`.
|
||||
jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's embedded Assistant settlements, and the search double is a separate local endpoint the provider reaches by `fetch`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Status: implemented
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 独立固定单查询与多查询的模型侧上限。
|
||||
|
||||
jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 为两个查询分别返回 6 条结果,每条带标题、引用摘录与日期。真实组合会观察两次提供方请求,并固定工具的轮询组合上限——出厂 `searchMaxResults` 保留代表两个查询的 8 条来源,面向模型的 render 文本不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,滚动主体高于容器。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点。
|
||||
jsdom 不解析 CSS Modules layout,对任何 element 都报告 `scrollHeight === clientHeight`,因此无法见证滚动。几何由 assembled browser 的 `apps/web/tests/web-search-round.e2e.ts` 固定:确定性 search double 为两个 query 分别返回 6 条 result,每条带 title、citation snippet 与 date。真实 composition 观察两次 provider request,并固定 tool 的 round-robin combined cap——出厂 `searchMaxResults` 保留代表两个 query 的 8 条 source,model-visible render text 不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。aria golden 后的 case 展开 `web_search` row,对 card 的 `<ol>` 断言:8 个 `<li>`、card 内没有 `<button>`、`来源列表已截断` indicator 可见,以及 computed style `max-height: 320px` 与 `overflow-y: auto`,scroll body 高于 container。后续 case 在 list 自身继承 font 下测量 `999. ` marker 宽度,要求 computed `padding-left` 不小于该宽度,从而把 scroll container 无法滚回的 marker space 固定在最宽 marker,而不是某个 fixture 的 source count。Replay 是对 fixture 嵌入式 Assistant settlement 的位置 cursor,search double 是 provider 通过 `fetch` 抵达的另一个 local endpoint。
|
||||
|
||||
## 相关文档
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
|
||||
2026-08-10-web-session-log-export.md: df54c0b042de14c94e78d22e066bc61a1768c76f
|
||||
2026-08-10-web-session-log-export.zh.md: 4ed9d3839dece876a45a9af18ef3bc4609f8e3a9
|
||||
2026-08-10-web-session-log-export.md: 82731cbd8f8acde228bdc636448879678c8e8706
|
||||
2026-08-10-web-session-log-export.zh.md: 7088d4f2f556f930abb43f951203bbf3540aede5
|
||||
|
||||
@@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
|
||||
|
||||
## Decision
|
||||
|
||||
- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a Session's **selected generation text verbatim**: `readRaw` on the persistence service reads the backend's numerically highest canonical generation after any required migration (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under the backend-reported logical basename. The root entry is `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation; descendants use `subagents/<id>/<filename>`. Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the selected durable generation and self-describing through its own header line.
|
||||
- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a Session's **selected generation text verbatim**: `readRaw` on the persistence service reads the backend's numerically highest canonical generation after any required migration (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so key order, line breaks, and any historical v0/v1 packed rows survive byte-for-byte — under the backend-reported logical basename. The root entry is `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation; descendants use `subagents/<id>/<filename>`. Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the selected durable generation and self-describing through its own header line.
|
||||
- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`.
|
||||
- **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation.
|
||||
- The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md).
|
||||
|
||||
@@ -10,7 +10,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
|
||||
|
||||
## 决策
|
||||
|
||||
- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是 Session **选定 generation 的逐字原文**:持久化服务的 `readRaw` 会在完成所需迁移后读取后端数值最高的规范 generation(JSONL 后端解码其物理 zstd frame,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、key 顺序与换行全部逐字节保留——并使用后端报告的逻辑 basename。根条目在 v0 下为 `session.jsonl`,正 generation 下为 `session.vN.jsonl`;后代使用 `subagents/<id>/<filename>`。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与 archive 大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个 archive 放进单个 buffer(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写 manifest:每个文件都与选定持久 generation 逐字节一致,并通过自身 header 行自描述。
|
||||
- **导出是 Host 侧 download,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP attachment。每个 file 都是 Session **选定 generation 的逐字原文**:persistence service 的 `readRaw` 会在完成所需 migration 后读取 backend 数值最高的规范 generation(JSONL backend 解码其物理 zstd frame,或直接返回 plaintext)——绝非从解析后 event 重建,因此 key 顺序、换行与任何历史 v0/v1 packed row 都逐字节保留——并使用 backend 报告的逻辑 basename。root entry 在 v0 下为 `session.jsonl`,正 generation 下为 `session.vN.jsonl`;descendant 使用 `subagents/<id>/<filename>`。压缩在 Host 侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已校验 `sessionExportCompressionLevel` 0–9(默认 6),使 deployment 可以在 CPU/latency 与 archive size 间取舍;每个 entry 按有界 block 边产出边 deflate,response 随生成分块写出,Host 从不把整个 archive 放进单个 buffer(除预载 root 外,最多同时持有一条 descendant artifact text)。到达 64 KiB response byte high-water mark 后,生产会等待 consumer pull 恢复容量;fflate 同步 callback 最多只会在该 queue bound 外再增加一次有界 input push。不写 manifest:每个 file 都与选定持久 generation 逐字节相同,并通过自身 header line 自描述。
|
||||
- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。
|
||||
- **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。
|
||||
- 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md
|
||||
2026-08-21-headless-reasoning-progress.md: 6c4a3574b63ef316fd456f24b473406054ed30f3
|
||||
2026-08-21-headless-reasoning-progress.zh.md: e19fffc33fb5a55432ba2b6cb50550a0cfbd00b8
|
||||
2026-08-21-headless-reasoning-progress.md: 74868410839a497317c6a35097a20f4514b36dba
|
||||
2026-08-21-headless-reasoning-progress.zh.md: a03a380b7d5a79354d52a5172076918958c4a88a
|
||||
|
||||
@@ -6,27 +6,27 @@ English | [中文](2026-08-21-headless-reasoning-progress.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The one-shot headless runner waits for complete Agent quiescence before printing the final assistant text. Reasoning-capable providers already expose their reasoning as durable `assistant/chunk` events, but a long reasoned response leaves the terminal silent until the run completes. The final answer must remain the only stdout payload so command substitution and other consumers keep a stable result channel.
|
||||
The one-shot headless runner waits for complete Agent quiescence before printing the final Assistant text. Reasoning-capable providers expose reasoning through live `agent/assistant-stream` chunk frames and the final durable settlement, but a long reasoned response leaves the terminal silent if the runner observes only settled history. The final answer must remain the only stdout payload so command substitution and other consumers keep a stable result channel.
|
||||
|
||||
The earlier [direct core entry-point decision](../architecture/2026-08-09-headless-direct-core-entry-point.md) required empty stderr on every successful run. That clause prevents live reasoning progress and is superseded by this note; its transport, durability, and completion decisions remain unchanged.
|
||||
|
||||
## Decision
|
||||
|
||||
`headless-runner` observes the exact Session it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `assistant/chunk.reasoning-delta` is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. Reasoning block boundaries and usage metadata keep that phase open; a later non-reasoning block or output delta, stream finish, new turn, or listener disposal terminates it with one newline when the provider supplied none.
|
||||
`headless-runner` observes `agent/assistant-stream` for the exact Agent it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `reasoning-delta` chunk frame is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. Reasoning block boundaries and usage metadata keep that phase open; a later non-reasoning block or output delta, stream end, new turn, or listener disposal terminates it with one newline when the provider supplied none.
|
||||
|
||||
This output is a transient projection of the existing durable Session event stream. The runner still derives final text and exit status from the flushed log rather than from progress-presentation state. The LLM adapter, agent loop, Session event types, persistence format, and SDK projections do not change.
|
||||
This output is a transient projection of the process-local Assistant stream. The runner still derives final text and exit status from the flushed durable Session settlements rather than from progress-presentation state. SDK projections do not expose the live frames.
|
||||
|
||||
Reasoning progress is not TTY-gated and has no separate flag. A redirected stderr stream and a supervisor receive the same provider-reported content as an attached terminal. A successful run without reasoning still writes nothing to stderr; terminal model and driver errors keep their existing `dsh:` diagnostics after any open reasoning phase is terminated.
|
||||
|
||||
## Verification
|
||||
|
||||
The package test holds the Agent active after a reasoning delta and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The owner-local product expectation drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Recorded-session replay reconstructs expected stderr from scalar and packed chunk rows, closes sections on packed text and tool-call output, and uses the raw run log before fixture path tokenization in record modes. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
|
||||
The package test holds the Agent active after a reasoning frame and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The owner-local product expectation drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Recorded-session replay reconstructs expected stderr by expanding embedded Assistant streams, closes sections on text and tool-call output, and uses the raw run log before fixture path tokenization in record modes. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Dump reasoning after quiescence.** Folding reasoning from the persisted log would preserve content but leave the terminal silent during the long-running interval that motivates the feature.
|
||||
|
||||
**Wrap the LLM stream.** Tapping `ctx.llm.stream()` would place a presentation concern in the request path and duplicate the authoritative chunks that the agent loop already appends to the Session.
|
||||
**Wrap the LLM stream.** Tapping `ctx.llm.stream()` would place a presentation concern in the request path and duplicate the scoped frames that the agent loop already publishes.
|
||||
|
||||
**Print a spinner or periodic heartbeat.** A timer reports process liveness rather than provider progress, adds an interval policy, and still hides reasoning that the provider already supplies. Time before the first reasoning delta remains silent and can be addressed separately if providers buffer their first token.
|
||||
|
||||
|
||||
@@ -6,27 +6,27 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
一次性 headless runner 会等待 Agent(智能体)完全停稳,再打印最终 assistant 文本。具备推理能力的提供方已经把推理作为持久化的 `assistant/chunk` 事件暴露,但耗时较长的推理响应会让终端在运行完成前始终保持静默。最终答案必须继续作为 stdout 中唯一的载荷,使命令替换和其他消费方保持稳定的结果通道。
|
||||
一次性 headless runner 会等待 Agent(智能体)完全停稳,再打印最终 Assistant 文本。具备推理能力的 provider 会通过实时 `agent/assistant-stream` chunk frame 与最终持久 settlement 暴露 reasoning,但如果 runner 只观察已结算历史,耗时较长的 reasoning response 会让终端始终静默。最终答案必须继续作为 stdout 中唯一 payload,使命令替换和其他消费方保持稳定结果通道。
|
||||
|
||||
此前的[直接使用核心服务入口决策](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md)要求每次成功运行都保持 stderr 为空。该条款会阻止实时推理进度,因此由本 Agent Note 取代;其中关于传输、持久性与完成状态的其他决策保持不变。
|
||||
|
||||
## 决策
|
||||
|
||||
`headless-runner` 在启动工作完全停稳后、提交任务前,观察其创建的精确 Session。自身持有的区间以 `turn/start` 打开后,每个非空的 `assistant/chunk.reasoning-delta` 都会立即写入 stderr。一段连续推理以独占一行的 `dsh: reasoning:` 开始;各分片保持提供方顺序,不添加 token 边界装饰。推理块边界与用量元数据会保持该段打开;之后出现非推理块或输出分片、流结束、新轮次或 listener dispose(资源释放)时,如果提供方没有输出末尾换行,runner 会用一个换行终止该段。
|
||||
`headless-runner` 在启动工作完全停稳后、提交任务前,为其创建的精确 Agent 观察 `agent/assistant-stream`。自身持有的区间以 `turn/start` 打开后,每个非空 `reasoning-delta` chunk frame 都会立即写入 stderr。一段连续 reasoning 以独占一行的 `dsh: reasoning:` 开始;各 delta 保持 provider 顺序,不添加 token 边界装饰。Reasoning block boundary 与 usage metadata 会保持该段打开;之后出现非 reasoning block 或 output delta、stream end、新 turn 或 listener dispose 时,如果 provider 没有输出末尾换行,runner 会用一个换行终止该段。
|
||||
|
||||
该输出是既有持久化会话事件流的瞬时投影。runner 仍从 flush 后的日志而不是进度呈现状态推导最终文本与退出状态。LLM(大语言模型)适配器、agent loop(智能体循环)、Session 事件类型、持久化格式与 SDK 投影均不改变。
|
||||
该输出是进程本地 Assistant stream 的瞬态投影。runner 仍从 flush 后的持久 Session settlement 而非进度呈现状态推导最终文本与退出状态。SDK 投影不公开 live frame。
|
||||
|
||||
推理进度不按 TTY 启用,也没有单独 flag。重定向的 stderr 流与监督进程会收到和已连接终端相同的提供方报告内容。没有推理内容的成功运行仍不会写入 stderr;终止态模型错误与驱动器错误继续在任何已打开推理段终止后输出既有的 `dsh:` 诊断。
|
||||
|
||||
## 验证
|
||||
|
||||
包测试在推理分片后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由提供方终止和未终止的推理段换行归属,以及终止态错误。产品自有期望通过包含推理与工具调用的轮次驱动随附 headless profile,并固定 stderr 与持久化 Session。录制会话回放从标量及压缩分片记录重建预期 stderr,在压缩文本或工具调用输出处关闭推理段,并在录制模式下于 fixture 路径标记化之前使用原始运行日志。构建后二进制验收通过原生 DeepSeek SSE(Server-Sent Events)适配器发送 `reasoning_content`,要求推理出现在 stderr,同时 stdout 仍只包含最终答案。
|
||||
包测试在 reasoning frame 后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由 provider 终止和未终止的 reasoning 段换行归属,以及 terminal error。产品自有期望通过包含 reasoning 与工具调用的 turn 驱动随附 headless profile,并固定 stderr 与持久 Session。录制 Session replay 通过展开嵌入式 Assistant stream 重建预期 stderr,在 text 与 tool-call output 处关闭 reasoning 段,并在 record mode 下于 fixture path tokenization 前使用原始 run log。构建后二进制 acceptance 通过原生 DeepSeek SSE adapter 发送 `reasoning_content`,要求 reasoning 出现在 stderr,同时 stdout 仍只包含最终答案。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**完全停稳后再输出推理。** 从持久化日志折叠推理能够保留内容,但在导致本功能产生的长时间运行区间内,终端仍会保持静默。
|
||||
|
||||
**包装 LLM 流。** 截取 `ctx.llm.stream()` 会把呈现职责放入请求路径,并重复处理 agent loop 已经追加到 Session 的权威分片。
|
||||
**包装 LLM stream。** 截取 `ctx.llm.stream()` 会把呈现职责放入请求路径,并重复处理 agent loop 已发布的 scoped frame。
|
||||
|
||||
**打印 spinner 或周期性心跳。** 定时器报告的是进程存活状态,而不是提供方进度;它还会新增间隔策略,并继续隐藏提供方已经给出的推理。首个推理分片前的时间仍保持静默;如果提供方会缓冲首个 token,可以另行处理。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md
|
||||
2026-08-06-buffer-free-feedback-telemetry.md: 387342c8282072d07957054fbb7e879a9874d23b
|
||||
2026-08-06-buffer-free-feedback-telemetry.zh.md: b0f5f4eadef35a6a3d59847d13029b98dae68f14
|
||||
2026-08-06-buffer-free-feedback-telemetry.md: d2b8c5908889bcea672d40e7a58cef373338c362
|
||||
2026-08-06-buffer-free-feedback-telemetry.zh.md: 4fc8ff5c69ef2b3f5f066359bbca61ec2719a627
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Feedback-only telemetry must upload the session-log prefix only after recorded f
|
||||
|
||||
## Decision
|
||||
|
||||
The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no copied records. `captureSession(session, throughSeq?)` reads the canonical session log after the same-object handoff cursor through an optional inclusive sequence boundary, deep-copies every event in order, runs the current `session-telemetry/record` waterfall, and hands one record per event to the backend, including every `assistant/chunk` with its complete body. A new Session object has no WeakMap entry, so its logical cursor is `-1` and capture starts at seq 0.
|
||||
The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no copied records. `captureSession(session, throughSeq?)` reads the canonical session log after the same-object handoff cursor through an optional inclusive sequence boundary, deep-copies every event in order, runs the current `session-telemetry/record` waterfall, and hands one record per event to the backend, including each `assistant/message` or `assistant/attempt` with its complete embedded stream. A new Session object has no WeakMap entry, so its logical cursor is `-1` and capture starts at seq 0.
|
||||
|
||||
`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The object-keyed handoff cursor distinguishes later replays without another pending-record index: repeated feedback on the same object releases only a suffix, while the first feedback on a new resumed or migrated object releases its complete current canonical prefix.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
遥测协调器提供 `live` 与 `on-demand` 捕获。按需捕获不注册会话、flush 或运维事件监听器,也不保留记录副本。`captureSession(session, throughSeq?)` 从同一对象 handoff 游标之后读取权威会话日志,直至可选的包含式序列号边界,按顺序深拷贝每个事件、运行当前的 `session-telemetry/record` waterfall(瀑布式事件),并为每个事件向后端交接一条记录,其中包括每条 `assistant/chunk` 及其完整 body。新 Session 对象没有 WeakMap 条目,因此逻辑游标为 `-1`,捕获从 seq 0 开始。
|
||||
遥测 coordinator 提供 `live` 与 `on-demand` capture。按需 capture 不注册 Session、flush 或 operational-event listener,也不保留 record 副本。`captureSession(session, throughSeq?)` 从同一对象 handoff cursor 之后读取规范 Session log,直至可选的包含式序号 boundary,按序 deep-copy 每个 event、运行当前 `session-telemetry/record` waterfall,并为每个 event 向 backend 交接一条 record,其中包括每个带完整嵌入式 stream 的 `assistant/message` 或 `assistant/attempt`。新 Session 对象没有 WeakMap entry,因此逻辑 cursor 为 `-1`,capture 从 seq 0 开始。
|
||||
|
||||
`FEEDBACK_ONLY` 以 `feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。以对象为键的 handoff 游标可区分后续回放,无需另一个待处理记录索引:同一对象上的重复反馈只释放后缀,而新的 resume 或迁移对象上的首次反馈会释放其完整当前权威前缀。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
|
||||
2026-06-19-acp-snapshot-tests.md: c69f4a66aedb72d1fb8b83c3ec05ec5bc069ebf9
|
||||
2026-06-19-acp-snapshot-tests.zh.md: 0cb64a95af0391a34a1903bdcaa0111e2195c9d7
|
||||
2026-06-19-acp-snapshot-tests.md: 49d6012a3bbe3a42daadd07030bca354f7897149
|
||||
2026-06-19-acp-snapshot-tests.zh.md: d4bf65ea425e5962edbf97acf70731d470be9800
|
||||
|
||||
@@ -18,13 +18,13 @@ The [session-log snapshot corpus decision](2026-08-24-session-log-snapshot-corpu
|
||||
|
||||
### The fixture projects the persisted session JSONL
|
||||
|
||||
Each scenario's selected highest parent generation is harvested from a real run: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary Session generation therefore serves as both replay source and behavioral expected output.
|
||||
Each scenario's selected highest parent generation is harvested from a real run: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation. The compact streams embedded in `assistant/message` and `assistant/attempt` reproduce model attempts; tool, message, and boundary events capture the harness behavior. One ordinary Session generation therefore serves as both replay source and behavioral expected output.
|
||||
|
||||
Every committed session-format fixture uses the canonical packed physical layout. The all-row-kinds scenario is mechanically derived from an independent real recording; its test requires every packed storage-row kind and exact event-for-event equality after both fixtures decode, then ordinary replay and log comparison prove that the assembled process consumes and reproduces the layout.
|
||||
Every current v2 session-format fixture uses one physical row per durable event. Retained v0 and v1 predecessor generations may contain their frozen packed-row representation and remain immutable. Ordinary replay and log comparison prove that the assembled process selects, migrates, consumes, and reproduces the current generation.
|
||||
|
||||
### Replay derives the model script from the log
|
||||
|
||||
`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` splits recorded `assistant/chunk` events at terminal `finish` chunks and uses `(turn, step)` changes to reject an unterminated prior call. A `compaction/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter.
|
||||
`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` expands each recorded `assistant/message` or `assistant/attempt` stream into one positional call and validates its terminal chunk. A `compaction/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter.
|
||||
|
||||
### The in-memory replay entry honors the full LLM contract
|
||||
|
||||
@@ -36,7 +36,7 @@ Every committed session-format fixture uses the canonical packed physical layout
|
||||
| { kind: 'hang' }
|
||||
```
|
||||
|
||||
Logs derive chunk entries from finished assistant streams and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone.
|
||||
Logs derive chunk or throw entries from durable Assistant settlements and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local stream representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone.
|
||||
|
||||
### Positional replay, one in-flight stream
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ Status: implemented
|
||||
|
||||
### fixture 投影持久化会话 JSONL
|
||||
|
||||
每个场景数值最高的选定 parent generation 都从真实运行中采集:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`。`assistant/chunk` 事件复现模型 stream;工具、消息与边界事件捕获 harness 行为。因此,一份普通 Session generation 同时充当 replay source 与行为预期输出。
|
||||
每个场景数值最高的选定 parent generation 都从真实运行中采集:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`。`assistant/message` 与 `assistant/attempt` 中嵌入的紧凑 stream 会复现模型 attempt;工具、message 与 boundary event 捕获 harness 行为。因此,一份普通 Session generation 同时充当 replay source 与行为预期输出。
|
||||
|
||||
每个签入仓库的会话格式 fixture 都使用规范的打包物理布局。覆盖所有行类型的场景从一份独立的真实录制机械派生;测试要求它包含每一种打包存储行类型,并在两份 fixture 解码后逐事件精确相等;随后,普通回放与日志比较会证明组装后的进程能够消费并复现该布局。
|
||||
每个当前 v2 Session-format fixture 都为每个持久事件使用一条物理行。保留的 v0 与 v1 predecessor generation 可以包含其冻结 packed-row 表示,并保持不可变。普通 replay 与 log 比较证明组装进程会选择、迁移、消费并复现当前 generation。
|
||||
|
||||
### 回放从日志推导模型脚本
|
||||
|
||||
`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 在终止的 `finish` 分片处切分已记录的 `assistant/chunk` 事件,并用 `(turn, step)` 变化拒绝前一条未终止的调用。携带 `llmStreamCall: true` 的 `compaction/summary` 会在其持久日志位置贡献一次调用:回放根据 `rawOutput` 重建规范块边界,保留已记录的 usage(如有),并提供终止的 `stop`。该标记将这次本地调用与模板摘要或远程摘要区分开;后两者即使保留了 `rawOutput`,也未使用此上下文的适配器。
|
||||
`llm-replay` 短路 provider-neutral `llm/stream` waterfall。`deriveReplayScript()` 把每个已记录 `assistant/message` 或 `assistant/attempt` stream 展开为一次位置式调用,并校验其 terminal chunk。携带 `llmStreamCall: true` 的 `compaction/summary` 会在其持久 log 位置贡献一次调用:replay 根据 `rawOutput` 重建规范 block boundary,保留已记录 usage(如有),并提供 terminal `stop`。该 marker 将这次本地调用与 template 或 remote summary 区分开;后两者即使保留 `rawOutput`,也未使用此 context 的 adapter。
|
||||
|
||||
### 内存中的回放条目遵守完整的 LLM 约定
|
||||
|
||||
@@ -36,7 +36,7 @@ Status: implemented
|
||||
| { kind: 'hang' }
|
||||
```
|
||||
|
||||
日志从已结束的 assistant 流和显式标记的压缩(compaction)调用推导分片条目。流开始前的抛出、挂起和外部摘要器调用没有可重建的本地分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因或单独的提供方输出推断适配器行为。
|
||||
Log 从持久 Assistant settlement 与显式标记的 compaction call 推导 chunk 或 throw entry。Stream 开始前的 throw、hang 与 external summarizer call 没有可重建的本地 stream 表示,因此这些场景提供 `replay.override.json`。throw entry 可以包含 prefix chunk 以模拟 stream 中途失败。显式 override 避免从有损 turn-end reason 或单独 provider output 推断 adapter 行为。
|
||||
|
||||
### 位置式回放,单个在途流
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md
|
||||
2026-06-22-fork-child-replay-seed-boundary.md: 1fefc3546ebe029ba95318dad3ac2d463a3fe497
|
||||
2026-06-22-fork-child-replay-seed-boundary.zh.md: bb43eacf075318dc0101fca2cacb45a03d847764
|
||||
2026-06-22-fork-child-replay-seed-boundary.md: 3c1148f4c73e9d1f6a518dadb6f20509a6d97bff
|
||||
2026-06-22-fork-child-replay-seed-boundary.zh.md: f7646132a29d14dc858ef6d693a681b5d799e870
|
||||
|
||||
@@ -8,9 +8,9 @@ English | [中文](2026-06-22-fork-child-replay-seed-boundary.zh.md)
|
||||
|
||||
The [per-session snapshot replay Agent Note](2026-06-22-subagent-snapshot-replay.md) made the snapshot tier express a nested-agent shape: a parent plus one recorded log per in-process subagent, each replayed as its own script keyed by calling session. It noted (§ Scope, final bullet) that a fork snapshot was "a trivial future addition, not a gap in the keying." That was wrong about a fork child specifically — not the keying, but the *script derivation*.
|
||||
|
||||
A subagent script is derived from a recorded session log by [`deriveReplayScript`](../../../../packages/test-support/llm-replay): it groups the log's `assistant/chunk` events by `(turn, step)` into one replay entry per `stream()` call. This is correct for a **spawn** child, whose log contains only its own model calls.
|
||||
A subagent script is derived from a recorded session log by [`deriveReplayScript`](../../../../packages/test-support/llm-replay): it expands each durable `assistant/message` or `assistant/attempt` settlement into one replay entry per `stream()` call. This is correct for a **spawn** child, whose log contains only its own model calls.
|
||||
|
||||
A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). So a fork child's `.jsonl` begins with the **parent's** events — including the parent's `assistant/chunk` events — and only then carries the child's own turn.
|
||||
A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). A fork child's `.jsonl` therefore begins with the **parent's** events — including its Assistant settlements — and only then carries the child's own turn.
|
||||
|
||||
Deriving the child script from the whole fork-child log therefore replays the **parent's** recorded responses as the **child's** model calls: the live fork child's first `stream()` would receive the parent's first recorded chunk sequence instead of its own. All recorded scenarios use spawn, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch.
|
||||
|
||||
@@ -26,11 +26,11 @@ Record where a session's **inherited** prefix ends, persist it, and have the rep
|
||||
|
||||
### 2. JSONL round-trips it
|
||||
|
||||
The v0 JSONL header keeps its optional numeric `seedLength` for byte compatibility. `toHeaderLine` / `fromHeaderLine` translate it to and from logical `isSeeded` plus the exact `inheritedEventCount`, which the shared body-bearing persistence values return separately.
|
||||
The v2 JSONL header carries `isSeeded`, while `session/end-seed { inherited: true }` marks the exact cut in the body; decoding uses the last tagged marker. The frozen v0 header keeps optional numeric `seedLength` for byte compatibility, and its codec translates that historical field into the same logical pair.
|
||||
|
||||
### 3. Replay derives a child script after the boundary
|
||||
|
||||
`dsh-llm-replay`'s private v0 parser reads physical `seedLength` into `inheritedEventCount` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(inheritedEventCount)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child the cut is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged.
|
||||
`dsh-llm-replay` parses the selected generation through the static format catalog and retains its decoded `inheritedEventCount`. `loadSessionScripts` derives a child's entries from `fixture.events.slice(inheritedEventCount)` — the events at or after the boundary, which are the child's own model calls. For a spawn child the cut is 0 and this is a no-op.
|
||||
|
||||
This closes the routing correctness gap, and two recorded fork scenarios exercise it end to end — see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md).
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ Status: implemented
|
||||
|
||||
[逐会话快照回放 Agent Note](2026-06-22-subagent-snapshot-replay.zh.md)使快照层能够表达嵌套 agent(智能体)形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本回放。它曾指出(§ 范围,最后一个项目符号),fork 快照「只是未来很容易添加的一项,并非键控缺口」。这一判断对 fork 子会话而言是错误的——问题不在键控,而在*脚本派生*。
|
||||
|
||||
subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。
|
||||
subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm-replay) 从已录制 Session log 推导:它把每个持久 `assistant/message` 或 `assistant/attempt` settlement 展开为每次 `stream()` 调用对应的一条 replay entry。对 **spawn** 子 Session 而言这是正确的,因为其 log 只包含自身模型调用。
|
||||
|
||||
**fork** 子会话不同。fork 后端用*父日志的一段平衡的已完成轮次前缀*([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver))来播种子会话,而该 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制进 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自身的轮次。
|
||||
**fork** 子 Session 不同。fork backend 用*父 log 的一段平衡已完成 turn 前缀*([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver))播种子 Session,该 seed 会成为子 Session 持久化的 `log`(`Session` 构造函数把 seed 复制进 `this.log`)。因此 fork 子 Session 的 `.jsonl` 以**父 Session** event 开头——包括其 Assistant settlement——之后才是子 Session 自己的 turn。
|
||||
|
||||
从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段分片序列而非自身的。所有已录制场景都使用 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
|
||||
|
||||
@@ -26,11 +26,11 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm-
|
||||
|
||||
### 2. JSONL 完整往返
|
||||
|
||||
v0 JSONL header 为保持字节兼容而继续携带可选数值 `seedLength`。`toHeaderLine`/`fromHeaderLine` 在它与 logical `isSeeded` 加精确 `inheritedEventCount` 之间转换,共享的含正文持久化值再单独返回该 cut。
|
||||
v2 JSONL header 携带 `isSeeded`,而 `session/end-seed { inherited: true }` 会在正文中标记精确 cut;解码使用最后一个 tagged marker。冻结的 v0 header 为保持字节兼容而继续携带可选数值 `seedLength`,其 codec 会把该历史字段转换为相同的逻辑值对。
|
||||
|
||||
### 3. 回放从边界之后推导子会话脚本
|
||||
|
||||
`dsh-llm-replay` 的私有 v0 parser 把物理 `seedLength` 读入 `inheritedEventCount`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(inheritedEventCount)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 cut 为 0,此操作是空操作,spawn 场景逐字节不变。
|
||||
`dsh-llm-replay` 通过静态格式 catalog 解析选定 generation,并保留解码后的 `inheritedEventCount`。`loadSessionScripts` 从 `fixture.events.slice(inheritedEventCount)` 推导子 Session entry——即 boundary 及之后的 event,也就是子 Session 自己的模型调用。对 spawn 子 Session 而言 cut 为 0,因此此操作为空操作。
|
||||
|
||||
这弥补了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
|
||||
2026-06-22-subagent-snapshot-replay.md: e3da6ed984e2fc446556ee9f30d7ab9a2eef82ce
|
||||
2026-06-22-subagent-snapshot-replay.zh.md: ee2271d5fc91cbf78c744a474764c1566ab5dc3b
|
||||
2026-06-22-subagent-snapshot-replay.md: 2cca3206363a5211f598563085bb0165025d1313
|
||||
2026-06-22-subagent-snapshot-replay.zh.md: c6edfa7fa7faba259da0c593cdcc0cb507e0ea0c
|
||||
|
||||
@@ -54,5 +54,5 @@ Both replay keyless in the default gate.
|
||||
|
||||
- The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape.
|
||||
- `GenerateOptions.sessionId` is a small, honest core API addition useful beyond replay (telemetry, request routing).
|
||||
- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios now exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)).
|
||||
- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* needed the fork cut because a fork child's log begins with the seeded parent prefix, including the parent's Assistant settlements; deriving from the whole log would replay the parent's responses as the child's. The persisted seed boundary closes that gap — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)).
|
||||
- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in `subagent-acp`.
|
||||
|
||||
@@ -54,5 +54,5 @@ Status: implemented
|
||||
|
||||
- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。
|
||||
- `GenerateOptions.sessionId` 是一个小而诚实的 core API 新增,在回放之外同样有用(遥测、请求路由)。
|
||||
- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见[持久化 seed 边界以确保 fork 子会话回放正确路由](2026-06-22-fork-child-replay-seed-boundary.zh.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。
|
||||
- `subagent` 工具绑定到单一 provider,因此 `subagent-multi` 中两个 child 都是 spawn。键控按 Session 而非 backend 路由,因此对 fork 同样正确。脚本*派生*需要 fork cut,因为 fork 子 Session log 以 seeded parent prefix 开头,其中包含 parent Assistant settlement;从完整 log 派生会把 parent response 当作 child response replay。持久 seed boundary 会关闭该缺口——见[持久化 seed 边界以确保 fork 子 Session replay 正确路由](2026-06-22-fork-child-replay-seed-boundary.zh.md)——录制的 fork 与混合 spawn+fork 场景通过一份 transcript 验证两种 transport(见[记录 fork 与混合 spawn+fork snapshot 场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。
|
||||
- 进程外(ACP(Agent Client Protocol))subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 `subagent-acp` 中。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: 6a73cced5e8f085ad5c6a7b9b280ce93ce2d1cf7
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: 7eb6a2e1facc1b38ca153eee3c649fbd66a7134e
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: 5a88c49b0811e80c19e3a39cf5604a74460fc3e2
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: 829b07b789c2cfe74bcaf3b9db03acffd156887b
|
||||
|
||||
@@ -26,7 +26,7 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f
|
||||
|
||||
The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state.
|
||||
|
||||
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
|
||||
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
|
||||
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障(NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询;`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list` 和 `workspace.list` 的初始响应,随后等待播种数据投影到 DOM;仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。
|
||||
|
||||
不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
|
||||
不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
|
||||
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
|
||||
2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 70c200c7ade6ef995c68b68ddc21e4c85edf0da8
|
||||
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 9fb4be27369d4489fc90a6151fcbd0861ef83f2c
|
||||
2026-08-03-opt-in-reasoning-chunk-browser-stress.md: bc5d31d80e5fb8aebcd9988dcb1f051d113cbf63
|
||||
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 105c8d69c37a18fd18aed3cc9f6f716e38b952d7
|
||||
|
||||
+4
-4
@@ -6,15 +6,15 @@ English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Long reasoning streams continuously produce large numbers of `assistant/chunk` events. Each raw event must be ordered, logged, and folded into `PartialAccumulator` to preserve replay fidelity and the completeness of the final content; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
|
||||
Long reasoning streams continuously produce large numbers of process-local `assistant/live-chunk` updates before one durable settlement. Each update must remain ordered and be folded into the Assistant Definition to preserve live completeness, while the settlement embeds the exact stream for replay; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
|
||||
|
||||
Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
|
||||
|
||||
## Decision
|
||||
|
||||
`Session.acceptLiveEvent()` appends every raw event immediately and synchronously updates the transcript, `PartialAccumulator`, and other session-derived state. Visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks publish through `Notifier.markFrameDirty()`: the first change schedules one `requestAnimationFrame`, later chunks only continue updating the accumulator, and the frame callback rebuilds one accumulated snapshot from the latest state and notifies subscribers once. `usage`, `finish`, and unknown invisible chunks remain in the event window but trigger no redundant React notifications. Session and history checks share the same visible-chunk classification.
|
||||
The Session Controller appends every Client-only live chunk to its event source, and Conversation immediately folds it into each matching Definition State. Chat and Trajectory Definitions request `animation-frame` publication for visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks; the first change schedules one `requestAnimationFrame`, later chunks continue updating State, and the frame callback materializes one accumulated snapshot from the latest State. `usage` and `finish` request no publication. The durable `assistant/message` or `assistant/attempt` settlement publishes immediately and reproduces the same final stream during history replay.
|
||||
|
||||
`Notifier` tracks pending publication work with a scheduling kind and generation marker. Ordinary structural events continue to publish in a microtask through `markDirty()`; if a finalized message, tool event, or error arrives while a frame publication is pending, the microtask supersedes it and the old frame callback is invalidated by its generation mismatch. `notifyNow()` likewise invalidates the old schedule to preserve synchronous echo for controlled inputs. Environments without `requestAnimationFrame` fall back to microtask batching. A finalization event may skip one intermediate partial that has not yet appeared, while the published final content and raw event sequence remain complete.
|
||||
`BoundConversation` owns one pending frame per Session. Ordinary structural events and durable settlements request immediate publication, flush the latest assembled State, and make a later frame callback harmless because no dirty Context remains. Environments without `requestAnimationFrame` publish immediately. A settlement may skip one intermediate partial that has not yet appeared, while the published final content and durable embedded stream remain complete.
|
||||
|
||||
Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
|
||||
|
||||
@@ -26,7 +26,7 @@ Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption
|
||||
|
||||
**React transitions, deferred values, or component throttling applied to snapshots.** Rejected: the session source would still notify `useSyncExternalStore` for every chunk, the React render has already occurred before a component decides to defer display, and multiple components consuming the same snapshot would each need to implement the strategy. Visual tail-following throttling for the Think summary occurs after snapshot publication and only reduces the frequency of synchronous layout; it does not implement the data-publication policy.
|
||||
|
||||
**Dropping, sampling, or concatenating raw chunks at the ingestion or logging layer.** Rejected: raw `assistant/chunk` events are replayable session facts; changing them would reduce diagnostic and UI fidelity and mix display-frequency policy into the authoritative data layer.
|
||||
**Dropping or sampling live chunks before Definition folding.** Rejected: the live accumulated state would diverge from the durable embedded stream and could omit visible intermediate content. Compact durable storage and frame-coalesced React publication solve different costs.
|
||||
|
||||
**Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk.
|
||||
|
||||
|
||||
+4
-4
@@ -6,15 +6,15 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
长推理流会连续产生大量 `assistant/chunk`。这些原始事件必须逐个完成排序、日志记录和 `PartialAccumulator` 折叠,以保持重放保真度和最终内容完整;但 React 只需要看到当前累计结果,不需要观察同一浏览器帧内的每个中间态。
|
||||
长 reasoning stream 会在一个持久 settlement 前连续产生大量进程本地 `assistant/live-chunk` update。每个 update 都必须保持有序并折叠进 Assistant Definition,以保留实时完整性;settlement 则嵌入精确 stream 供 replay。React 只需要看到当前累计结果,不需要观察同一浏览器帧内每个中间态。
|
||||
|
||||
异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。
|
||||
|
||||
## 决策
|
||||
|
||||
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript(文本记录)、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta` 和 `block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage`、`finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
|
||||
Session Controller 把每个 Client-only live chunk 追加到 event source,Conversation 会立即把它折叠进每个匹配 Definition State。Chat 与 Trajectory Definition 为可见 `block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta` 与 `block-end` chunk 请求 `animation-frame` publication;第一项变化调度一次 `requestAnimationFrame`,后续 chunk 继续更新 State,frame callback 再从最新 State materialize 一个累计 snapshot。`usage` 与 `finish` 不请求 publication。持久 `assistant/message` 或 `assistant/attempt` settlement 会立即发布,并在历史 replay 中复现同一最终 stream。
|
||||
|
||||
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务中发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial,但发布的定稿内容和原始事件序列保持完整。
|
||||
`BoundConversation` 为每个 Session 拥有一个 pending frame。普通结构 event 与持久 settlement 请求 immediate publication,flush 最新 assembled State,并让之后的 frame callback 因没有 dirty Context 而不产生影响。没有 `requestAnimationFrame` 的环境会立即发布。Settlement 可以跳过一个尚未显示的中间 partial,但发布的最终 content 与持久嵌入式 stream 保持完整。
|
||||
|
||||
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。
|
||||
|
||||
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
**在 React 内对快照使用 transition、deferred value 或组件节流。** 不予采纳:会话源仍会逐分片通知 `useSyncExternalStore`,React render 在组件决定延后展示之前已经发生,且多个消费同一快照的组件需要重复实现策略。Think 摘要的视觉跟尾节流位于快照发布之后,只减少同步布局频率,不承担数据发布策略。
|
||||
|
||||
**在接收或日志层丢弃、抽样或拼接原始分片。** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层。
|
||||
**在 Definition fold 前丢弃或抽样 live chunk。** 不予采纳:实时累计 State 会与持久嵌入式 stream 分歧,并可能省略可见中间内容。紧凑持久存储与逐 frame 合并 React publication 解决的是不同成本。
|
||||
|
||||
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使微任务合批近似退化为每个分片通知一次。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md
|
||||
2026-07-08-interactive-side-sessions.md: 653f77b931fe1149aad9e15b82f40fe3272f5469
|
||||
2026-07-08-interactive-side-sessions.zh.md: 55300fbad297b27e18a9e0213d4f45bc7fc08070
|
||||
2026-07-08-interactive-side-sessions.md: d1867b7088d89e20683f54300728e19d9ae9e5e5
|
||||
2026-07-08-interactive-side-sessions.zh.md: bd2772e1d44eb7e5cc324b2e4600fe6c1bc9169f
|
||||
|
||||
@@ -12,7 +12,7 @@ A user may want to explore a question from a live session without changing its m
|
||||
|
||||
A **side session** is an ordinary live session forked at the source's last completed turn, attached to its own agent, framed as a read-only advisor, and able to **merge back** one condensed note.
|
||||
|
||||
- **Fork and attach:** create the child with the parent's balanced completed-turn prefix and stamp `parentSession` and `seedLength` in its metadata. This composes `ctx.agents.create({ seed, meta })`; it adds no core service or session-store method.
|
||||
- **Fork and attach:** create the child with the parent's balanced completed-turn prefix, `parentSession`, `meta.isSeeded: true`, and the exact sibling `inheritedEventCount`. This composes `ctx.agents.create({ seed, inheritedEventCount, meta })`; it adds no core service or session-store method.
|
||||
- **Advisor framing:** inject one plugin-sourced `context/message` after creation that tells the child to explain without mutating or continuing the task. Keeping the system prompt byte-identical preserves the provider prefix cache over inherited history.
|
||||
- **Merge-back:** ask the child for a length-capped handback, then inject one plugin-sourced `context/message` into the parent. The next parent request sees it at its logged position, preserving replay and [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) without a new session event.
|
||||
- **Presentation:** invocation, session switching, and handback rendering belong to the first client UI. This Agent Note specifies only the client-independent mechanics.
|
||||
@@ -28,7 +28,7 @@ Rewind productization, session-tree views, a model-facing side-session tool, and
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `seedLength`, and a byte-identical system prompt.
|
||||
- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `isSeeded: true`, the exact `inheritedEventCount`, and a byte-identical system prompt.
|
||||
- Advisor framing adds exactly one plugin-sourced `context/message` at the head of the child's appended history, rather than changing its system prompt.
|
||||
- Merge-back adds exactly one length-capped `context/message` with source `plugin: sidechat`; the next parent request and replay see it at the same position.
|
||||
- Parent and child run concurrently without log or stream cross-talk.
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: proposed
|
||||
|
||||
**侧会话(side session)**是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent(智能体),定位为只读顾问,并能**合并回写**一条精简笔记。
|
||||
|
||||
- **Fork 并绑定:**以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或会话存储方法。
|
||||
- **Fork 并绑定:**用 parent 的平衡 completed-turn prefix、`parentSession`、`meta.isSeeded: true` 与精确 sibling `inheritedEventCount` 创建 child。这组合 `ctx.agents.create({ seed, inheritedEventCount, meta })`;不新增 core service 或 Session-store method。
|
||||
- **顾问定位:**创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。
|
||||
- **合并回写:**向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在日志所记录的位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md),无需新增会话事件。
|
||||
- **呈现:**调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。
|
||||
@@ -28,7 +28,7 @@ Status: proposed
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Fork 不改变源会话,创建的子会话具有平衡的已完成轮次前缀、`parentSession`、`seedLength`,以及逐字节一致的系统提示词。
|
||||
- Fork 不改变 source Session,创建的 child 带有平衡 completed-turn prefix、`parentSession`、`isSeeded: true`、精确 `inheritedEventCount` 与逐字节相同 system prompt。
|
||||
- 顾问定位在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。
|
||||
- 合并回写恰好添加一条有长度上限的 `context/message`,来源为 `plugin: sidechat`;父会话的下一次请求与回放在相同位置看到它。
|
||||
- 父会话与子会话并发运行,日志和流之间无串扰。
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
|
||||
2026-06-20-assembled-assistant-messages-only.md: edc756f71271065ec579a2becb8597190b34cdc8
|
||||
2026-06-20-assembled-assistant-messages-only.zh.md: 98b833a5d0cbca5f2295076e2ef50f450c9b93be
|
||||
@@ -1,36 +0,0 @@
|
||||
# Agent Note: Persist assembled assistant messages, not stream chunks
|
||||
|
||||
Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement.
|
||||
|
||||
English | [中文](2026-06-20-assembled-assistant-messages-only.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The [session persistence Agent Note](../../implemented/architecture/2026-06-14-session-persistence.md) chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace.
|
||||
|
||||
For successful steps that assemble completed content, the loop already appends an `assistant/message`. That is the event `deriveMessages()` uses for the next model request. In other words, the normal resumable conversation state is already present without the chunks; chunks are a live rendering and deterministic-test artifact, not required conversation history. Failed or aborted streams are different: partial assistant output may exist only as chunks, and empty max-token steps may produce no `assistant/message` at all.
|
||||
|
||||
## Proposal
|
||||
|
||||
Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. Scenarios that need partial failed-stream output must record that output in the replay fixture.
|
||||
|
||||
ACP `session/load` can replay prior assistant messages as complete content blocks instead of simulating the original token stream. A loaded transcript need not reproduce every historical delta; it must show the same completed assistant content and resume with a valid provider history.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed.
|
||||
- [Session persistence docs](../../../../packages/session/session-persistence/README.md) no longer require every stream chunk to be stored verbatim.
|
||||
- `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks.
|
||||
- `session/load` renders completed assistant messages from `assistant/message`.
|
||||
- Stored logs get much smaller and remain `seq`-contiguous without chunk holes.
|
||||
- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy.
|
||||
|
||||
## What we give up
|
||||
|
||||
The canonical user session no longer reconstructs the exact token stream of an old turn. It also loses partial assistant output from failed or aborted streams unless another event or fixture records it. That is too much information loss for the current resume, load, and snapshot contracts. Tests that need exact deterministic streams should own that fixture directly only if the production session log keeps enough fidelity for user-visible recovery.
|
||||
|
||||
## Related
|
||||
|
||||
This supersedes the chunk-persistence choice in [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and affects [ACP snapshot tests](../../implemented/testing/2026-06-19-acp-snapshot-tests.md), whose current replay plugin derives its script from `assistant/chunk` events.
|
||||
|
||||
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# Agent Note: 仅持久化组装后的 assistant 消息,不存储流式分片
|
||||
|
||||
Status: rejected — 高保真分片回放、失败流的部分输出与快照回放目前依赖持久化的 `assistant/chunk` 事件。只有具备无信息损失的回放或产物替代方案后,才能删除分片。
|
||||
|
||||
[English](2026-06-20-assembled-assistant-messages-only.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 Agent Note](../../implemented/architecture/2026-06-14-session-persistence.zh.md)选择这一方案是为了 token 级回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的增量记录占据,快照场景通过对分片事件分组来回放模型,ACP(Agent Client Protocol)加载时从分片重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级追踪。
|
||||
|
||||
对于成功组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复会话状态无需分片即已具备;分片是实时渲染和确定性测试的产物,不是必需的会话历史。失败或中止的流则不同:部分 assistant 输出可能仅以分片形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。
|
||||
|
||||
## 提案
|
||||
|
||||
停止在规范会话日志中存储 `assistant/chunk`。持久日志保留 `assistant/message`、`tool/call`、`tool/result`、`usage`(如保留)以及轮次边界。实时 UI 仍可通过一个明确设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从记录的适配器产物中派生,而非将规范的用户会话当作 token 磁带。需要失败流部分输出的场景必须在回放 fixture 中记录该输出。
|
||||
|
||||
ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并基于有效的提供方历史继续运行。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `SessionEventMap` 移除 `assistant/chunk`,或在需要过渡性实时事件时将其标记为非持久化。
|
||||
- [会话持久化文档](../../../../packages/session/session-persistence/README.zh.md)不再要求逐字存储每个流式分片。
|
||||
- `llm-replay` 和 ACP 快照使用显式的回放 fixture 格式或伴随文件来存储模型分片。
|
||||
- `session/load` 从 `assistant/message` 渲染已完成的 assistant 消息。
|
||||
- 存储的日志大幅缩小,且删除分片后仍保持 `seq` 连续,不留下序号缺口。
|
||||
- 会话格式版本与已记录的 fixture 一并刷新;按预发布格式策略拒绝非当前版本的存储日志。
|
||||
|
||||
## 放弃了什么
|
||||
|
||||
规范的用户会话不再能重建旧轮次的精确 token 流。它也会丢失失败或中止流的部分 assistant 输出,除非另有事件或 fixture 记录。对于当前的恢复、加载和快照约定而言,这是过大的信息损失。需要精确确定性流的测试应当直接拥有该 fixture,前提是生产会话日志为用户可见的恢复保留了足够的保真度。
|
||||
|
||||
## 相关
|
||||
|
||||
本 Agent Note 取代 [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.zh.md) 中关于分片持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.zh.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。
|
||||
|
||||
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
|
||||
@@ -11,7 +11,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
@@ -60,10 +59,7 @@ interface WorkspaceBaseline {
|
||||
}
|
||||
|
||||
interface HistoryPage {
|
||||
records: Array<
|
||||
| { type: 'event'; event: HistoryEvent }
|
||||
| { type: 'chunks'; event: HistoryChunkEvent }
|
||||
>
|
||||
records: Array<{ type: 'event'; event: HistoryEvent }>
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
@@ -72,11 +68,6 @@ interface HistoryEvent {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
interface HistoryChunkEvent extends HistoryEvent {
|
||||
seq: number
|
||||
time: number
|
||||
}
|
||||
|
||||
interface ProcessObservation {
|
||||
readonly ready: Promise<string>
|
||||
readonly text: () => string
|
||||
@@ -314,16 +305,9 @@ function assistantText(page: HistoryPage): string {
|
||||
return text.join('\n')
|
||||
}
|
||||
|
||||
/** Expand lossless history records for assertions over the public event stream. */
|
||||
/** Read scalar v2 history records for assertions over the public event stream. */
|
||||
function historyEvents(page: HistoryPage): HistoryEvent[] {
|
||||
return page.records.flatMap(record => record.type === 'event'
|
||||
? [record.event]
|
||||
: decodeStorageRecord({
|
||||
type: record.event.type.replace(/^chunkrow\//u, ''),
|
||||
seq0: record.event.seq,
|
||||
time0: record.event.time,
|
||||
data: record.event.data,
|
||||
}))
|
||||
return page.records.map(record => record.event)
|
||||
}
|
||||
|
||||
/** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */
|
||||
|
||||
@@ -2,7 +2,9 @@ import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
fixtureContext,
|
||||
normalizeSessionSnapshot,
|
||||
normalizeSessionSnapshots,
|
||||
normalizeStdout,
|
||||
runScenario,
|
||||
type AgentUnderTest,
|
||||
@@ -62,6 +64,13 @@ function normalizeGoalLog(content: string, context: NormalizeContext): string {
|
||||
return normalizeGoalTimestamps(normalizeSessionSnapshot(content, context)) as string
|
||||
}
|
||||
|
||||
/** Compare one current normalized goal log with its generation-aware committed fixture. */
|
||||
async function expectGoalLog(actual: string, expectedPath: string): Promise<void> {
|
||||
const expected = await readFile(expectedPath, 'utf8')
|
||||
expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parseJsonl))
|
||||
.toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parseJsonl))
|
||||
}
|
||||
|
||||
describe('same-session goal snapshot through the ACP automation driver', () => {
|
||||
it('runs exact automatic rounds in the shipped application and persists cancellation', async () => {
|
||||
const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as InputScript
|
||||
@@ -109,7 +118,7 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
|
||||
])
|
||||
}
|
||||
expect(stdout).toBe(await readFile(stdoutExpected, 'utf8'))
|
||||
expect(session).toBe(await readFile(sessionExpected, 'utf8'))
|
||||
await expectGoalLog(session, sessionExpected)
|
||||
})
|
||||
|
||||
it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => {
|
||||
@@ -168,6 +177,6 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
|
||||
])
|
||||
}
|
||||
expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8'))
|
||||
expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8'))
|
||||
await expectGoalLog(session, wrapupSessionExpected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,12 @@ import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
normalizeSessionLog,
|
||||
normalizeSessionSnapshot,
|
||||
normalizeSessionSnapshots,
|
||||
normalizeStdout,
|
||||
scrubRequestHeaders,
|
||||
type NormalizeContext,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import { prepareSessionEventNotificationsForComparison } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import {
|
||||
decompressZstdFrame,
|
||||
@@ -61,6 +63,24 @@ interface DeepSeekDefaultsServer {
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** Compare one current Session with an older committed generation in memory. */
|
||||
async function expectSessionSnapshot(
|
||||
actual: string,
|
||||
context: NormalizeContext,
|
||||
expectedPath: string,
|
||||
): Promise<void> {
|
||||
const [normalizedActual] = normalizeSessionSnapshots([actual], context)
|
||||
const expected = await readFile(expectedPath, 'utf8')
|
||||
const [normalizedExpected] = normalizeSessionSnapshots([expected], context, { sourcePaths: [expectedPath] })
|
||||
expect(parseJsonl(normalizedActual ?? '')).toEqual(parseJsonl(normalizedExpected ?? ''))
|
||||
}
|
||||
|
||||
/** Compare current headless session-event wrappers with a committed v1 stream. */
|
||||
async function expectHeadlessStream(normalized: string, expectedPath: string): Promise<void> {
|
||||
const expected = prepareSessionEventNotificationsForComparison(await readFile(expectedPath, 'utf8'))
|
||||
expect(parseJsonl(normalized)).toEqual(parseJsonl(expected))
|
||||
}
|
||||
|
||||
/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
|
||||
async function deepseekDefaultsServer(): Promise<DeepSeekDefaultsServer> {
|
||||
const requests: JsonObject[] = []
|
||||
@@ -218,7 +238,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const context = contextFromLogs([actual.content])
|
||||
const session = normalizeSessionSnapshot(actual.content, context)
|
||||
if (refreshing) await writeFile(headlessSessionExpected, session)
|
||||
await expect(session).toMatchFileSnapshot(headlessSessionExpected)
|
||||
await expectSessionSnapshot(session, context, headlessSessionExpected)
|
||||
expect(session).toContain(task)
|
||||
expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP')
|
||||
},
|
||||
@@ -303,7 +323,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(result.stderr).toBe('')
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
await expectHeadlessStream(normalized, streamExpected)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('logs actionable missing-credential guidance through the one-shot app', async () => {
|
||||
@@ -332,7 +352,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(result.stderr).toBe('')
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
await expectHeadlessStream(normalized, streamExpected)
|
||||
// The durable failure leads with the credential store — the path that
|
||||
// keeps the secret out of configuration files — then names the launching
|
||||
// environment, and stops there: configuration carries the reference, so
|
||||
@@ -369,7 +389,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(result.stderr).toBe('')
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
await expectHeadlessStream(normalized, streamExpected)
|
||||
// The durable failure names the reference to correct and the writer that
|
||||
// usually owns it, and stays true in a composition that mounts no Models
|
||||
// page at all.
|
||||
@@ -663,7 +683,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(result.stderr).toBe('')
|
||||
const normalized = normalizeGoalStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
await expectHeadlessStream(normalized, streamExpected)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('delivers a continuable child result without parent polling', async () => {
|
||||
@@ -720,7 +740,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const context = contextFromLogs([parent.content, child.content])
|
||||
const normalizedChild = normalizeSessionSnapshot(child.content, context)
|
||||
if (refreshing) await writeFile(childExpected, normalizedChild)
|
||||
await expect(normalizedChild).toMatchFileSnapshot(childExpected)
|
||||
await expectSessionSnapshot(normalizedChild, context, childExpected)
|
||||
expect(normalizedChild).toContain('CHILD_RESULT')
|
||||
expect(normalizedChild).not.toContain('"name":"report"')
|
||||
},
|
||||
@@ -734,6 +754,6 @@ describe('headless stream-json snapshots', () => {
|
||||
})
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
||||
await expectHeadlessStream(normalized, streamExpected)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot'
|
||||
import {
|
||||
fixtureContext,
|
||||
normalizeSessionSnapshot,
|
||||
normalizeSessionSnapshots,
|
||||
type NormalizeContext,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
@@ -20,6 +25,16 @@ const sessionId = SessionId('semantic-checkpoint-unknown-outcome')
|
||||
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
const task = 'Continue safely from the interrupted operation.'
|
||||
|
||||
/** Compare one current normalized Session with its generation-aware committed fixture. */
|
||||
async function expectSession(actual: string, expectedPath: string): Promise<void> {
|
||||
const expected = await readFile(expectedPath, 'utf8')
|
||||
const parse = (content: string): Record<string, unknown>[] => content.split('\n')
|
||||
.filter(line => line.trim().length > 0)
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse))
|
||||
.toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse))
|
||||
}
|
||||
|
||||
async function seedInterruptedSession(root: string, cwd: string): Promise<string> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -45,6 +60,7 @@ async function seedInterruptedSession(root: string, cwd: string): Promise<string
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
stream: [],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: ToolCallId('unknown-outcome-call'), name: 'write_remote', arguments: '{"value":1}' }],
|
||||
@@ -104,7 +120,7 @@ describe('semantic checkpoint recovery snapshot', () => {
|
||||
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
|
||||
const session = normalizeSessionSnapshot(await readFile(sessionPath, 'utf8'), normalization)
|
||||
if (refreshing) await writeFile(sessionExpected, session)
|
||||
expect(session).toBe(await readFile(sessionExpected, 'utf8'))
|
||||
await expectSession(session, sessionExpected)
|
||||
expect(session).toContain('TOOL_OUTCOME_UNKNOWN')
|
||||
expect(session).toContain('Do not retry blindly.')
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Assembled-app regressions for Session-format lifecycle behavior: a physical
|
||||
* v0 log migrates through the real Loader composition, remains byte-for-byte
|
||||
* intact beside v1, and accepts the next append through v1; a future format or unknown
|
||||
* intact beside v2, and accepts the next append through v2; a future format or unknown
|
||||
* required current event refuses with the direction and raw log path.
|
||||
* @module session-format-guard-snapshot
|
||||
*/
|
||||
@@ -46,7 +46,11 @@ async function seedSession(root: string, cwd: string, version: number, events: S
|
||||
const location = ctx.sessionPersistence.locate(meta)
|
||||
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
|
||||
const content = [
|
||||
{ type: 'session', version, id: sessionId, createdAt: 1, cwd, delegationDepth: 0 },
|
||||
{
|
||||
type: 'session', version, id: sessionId, createdAt: 1, cwd,
|
||||
...(version >= 2 ? { isSeeded: false } : {}),
|
||||
delegationDepth: 0,
|
||||
},
|
||||
...events,
|
||||
].map(record => JSON.stringify(record)).join('\n') + '\n'
|
||||
const path = join(dirname(location.path), generationLogFilename(version, 'none'))
|
||||
@@ -66,7 +70,7 @@ function closedTurn(): SessionEvent[] {
|
||||
}
|
||||
|
||||
describe('session format guard through the assembled app', () => {
|
||||
it('migrates a raw v0 log before resume, preserves exact source bytes, and appends only to v1', async () => {
|
||||
it('migrates a raw v0 log before resume, preserves exact source bytes, and appends only to v2', async () => {
|
||||
let v0Path = ''
|
||||
let v0 = ''
|
||||
let v0Identity: { readonly dev: bigint; readonly ino: bigint } | undefined
|
||||
@@ -97,7 +101,7 @@ describe('session format guard through the assembled app', () => {
|
||||
.toBe(SESSION_FORMAT_VERSION)
|
||||
expect(current).not.toBe(v0)
|
||||
expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1)
|
||||
expect((await readdir(dirname(v0Path))).sort()).toEqual(['session.jsonl', 'session.v1.jsonl'])
|
||||
expect((await readdir(dirname(v0Path))).sort()).toEqual(['session.jsonl', 'session.v2.jsonl'])
|
||||
},
|
||||
})
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user