Преглед изворни кода

docs(session): define V3 system-head migration guarantees

Record order-preserving structural conversion and strict refusal instead of an identity edge; distinguish native writer layout and local references from historical delivery facts. Keep released generations frozen and one evolving unreleased V3 target, with canonical envelopes composing afterward.
Tianyi Cui пре 4 недеља
родитељ
комит
19fdc37448

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml

@@ -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-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: 9f749fe9acc3dc4ca530034791c71513a5e5d2d1
-2026-08-31-released-session-format-migrations.zh.md: 5818419c589d67f8169a06d54ee1b07b5443bfd6
+2026-08-31-released-session-format-migrations.md: 0acab910326317201329a3e9e19a012c5ce20f6c
+2026-08-31-released-session-format-migrations.zh.md: 3110d53b7e6cf993f03ecc9f591cb9fee30c6c45

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -68,11 +68,11 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
 
 V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored in V2 must not become an active V3 upload watermark merely because the header changes. Other non-current marker generations retain their ignored meaning. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
 
-V2→V3 provides an identity body stage and a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. Identity preserves accepted logical events and the inherited cut; the header version and successor filename change. Its admission retains installed ordinary event additions and unknown ignorable events, while unknown required events refuse. Unchanged sequence numbers, references, payloads, and ordering make that preservation safe for the identity edge; structural extensions must reassess it rather than inherit an unconditional opaque-event promise. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
+V2→V3 owns the [system-prompt structural conversion](2026-09-02-system-prompt-as-surface-node.md), a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. The edge preserves source event order and request meaning while inserting system-head events and remapping local sequence references and the inherited cut. Unknown events are not unconditionally safe to retain when cardinality changes; the edge refuses events it cannot transform safely. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
 
 A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. V2→V3 validates and derives its cut from markers; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
 
-A shared `release/*` base collects independent child changes in the one unreleased V2→V3 edge. Released generations and older edges retain their semantics; review order does not allocate extra format versions. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore use isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.
+All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.
 
 ### Physical codecs and packed runs
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -68,11 +68,11 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
 
 V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记不能仅因 header 变化就成为有效的 V3 上传水位。其他非当前代际标记仍保持被忽略的含义。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
 
-V2→V3 提供恒等正文 Stage,以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。恒等保留可接受的逻辑事件和继承截点;header 版本与后继文件名会变化。其准入保留已安装的普通事件新增项和未知可忽略事件,同时拒绝未知必需事件。序号、引用、payload 与顺序不变,使恒等迁移边可以安全保留这些数据;结构性扩展必须重新评估,而非继承无条件保留不透明事件的承诺。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
+V2→V3 负责[系统提示词结构转换](2026-09-02-system-prompt-as-surface-node.zh.md),以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。该迁移边在插入系统头节点事件、重映射本地序号引用与继承截点的同时,保留源事件顺序和请求含义。事件数量变化时,未知事件并非无条件可以安全保留;迁移边拒绝无法安全转换的事件。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
 
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
 
-共享 `release/*` 基线把独立子变更汇入唯一且尚未发布的 V2→V3 迁移边。已发布代际与旧迁移边保持原有语义;评审顺序不分配额外格式版本。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。
+所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。
 
 ### Physical codec 与 packed run
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml

@@ -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-09-02-system-prompt-as-surface-node.md
-2026-09-02-system-prompt-as-surface-node.md: 04cc0caf3fb8c4871a71d2bf015a8c3259e6270c
-2026-09-02-system-prompt-as-surface-node.zh.md: 740b6a8f3a53b20bba40df203ededebc9cddcd3b
+2026-09-02-system-prompt-as-surface-node.md: 4b2e0da1926116d84e875478f611373f4b7230f2
+2026-09-02-system-prompt-as-surface-node.zh.md: 57041c600cc9584503fbc9d5c047c391f9f3727c

+ 13 - 1
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md

@@ -28,7 +28,7 @@ The system prompt lives on the surface. It is an ordinary surface event, `system
 | A `system/message` survives and the rendered prompt differs from its text (including a prompt that becomes empty) | replace exactly that node: `surfaceOp: { op: 'replace', start: <seq of the node>, end: <same> }`, `sourceEventSeqs: [<seq of the node>]`; an empty prompt produces an empty-content node that projects to no message |
 | The rendered prompt equals the surviving node's text | no operation |
 
-The loop reserves an empty system head before the initial admitted user messages so a prompt that first becomes non-empty later still replaces node 0. Omitting that empty node would append the later prompt behind user history, where pi-ai converts it to a user message rather than its `systemPrompt`. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`.
+When the initial rendered prompt is empty, the loop reserves an empty system head before the initial admitted user messages so a prompt that first becomes non-empty later still replaces node 0. Omitting that empty node would append the later prompt behind user history, where pi-ai converts it to a user message rather than its `systemPrompt`. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`.
 
 `packages/core/session/src/surface.ts` enforces the head invariant in `assertSystemHeadRewrite`: a replacement whose range covers surface node 0 while node 0 is a `system/message` is rejected unless the replacing event is itself a `system/message` covering exactly that node. System nodes at later positions carry no such protection; a compaction range may shadow them.
 
@@ -56,6 +56,18 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r
 
 `RuntimeContextProjection` and `SystemPromptProjection` are symmetric: both watch owned surface nodes and their shadowing through `sourceEventSeqs`, and both hand the loop an uncommitted message that `turn()` commits. The difference is the role and the operation set — runtime context appends user-role snapshots only, the system prompt appends once and then replaces.
 
+### V2-to-V3 structural conversion
+
+The [V2-to-V3 migration](../../../../packages/session/session-format-v2-to-v3/README.md) converts each V2 `request/header.system` into the protected `system/message` head and removes the retired header field. It inserts an empty head immediately after the first `step/start`, then replaces that head immediately before a header whose prompt differs. Inserted messages have deterministic IDs. The transform preserves source event order, reconstructed request meaning, and the exact IDs and content of every other message. A native V3 writer emits its normal prompt head; the migrated event layout is semantically equivalent, not byte-identical to a native recording.
+
+Inserted events shift local sequence positions. The transform remaps local sequence references, replacement ranges, and the inherited cut; historical delivery/version facts and captured references to other sessions retain their source values. These historical coordinates must not be relabeled as acknowledgements of the transformed V3 log.
+
+Strict migration refuses unknown events whose payloads cannot be safely transformed, unsupported surface history before the first step, and a prompt transition outside an open step. Such a V2 source can be valid while having no order-preserving conversion under the current core step invariant. Refusal leaves the original files unchanged; it does not reorder source events or relax the invariant to force a conversion. The V3 reader rejects the retired `header.system` field and validates system-message payloads and protected-head rewrites rather than relying on TypeScript field omission.
+
+The [released-format policy](2026-08-31-released-session-format-migrations.md) keeps V0, V1, and V2 generations byte-frozen and publishes only V3 successors. V3 is one unreleased target, not a new version per feature; it can evolve before release, so integration requires disposable homes. An existing V3 generation does not rerun V2-to-V3. Projection-cache version 4 is independent of the Session format and does not imply Session V4.
+
+[Canonical-envelope work](https://github.com/deepseek-ai/deepseek-harness/pull/3636) is separate and not integrated here. Its composition order is after this structural transform within the same V2-to-V3 edge, so it canonicalizes the transformed events rather than replacing the conversion.
+
 ## Alternatives considered
 
 **Keep `header.system` and add `system/message` only for updates.** Two homes for one fact: every consumer above would read the header for message 0 and the surface for later messages, and the loop would need a special case that ignores `system` in `headerEquals` while a surface system node exists. Rejected because the point of the change is one representation.

+ 13 - 1
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md

@@ -28,7 +28,7 @@ Status: implemented
 | 有存活的 `system/message` 且渲染后的提示词与其文本不同(包括提示词变为空) | 恰好替换该节点:`surfaceOp: { op: 'replace', start: <该节点的 seq>, end: <同一值> }`,`sourceEventSeqs: [<该节点的 seq>]`;空提示词产生一个投影为无消息的空内容节点 |
 | 渲染后的提示词与存活节点的文本相同 | 无操作 |
 
-循环在初始接纳的用户消息之前预留空系统头部,使稍后首次变为非空的提示词仍替换第 0 号节点。省略该空节点会让后来的提示词追加在用户历史之后,pi-ai 会将其转换为用户消息,而不是 `systemPrompt`。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。
+当初始渲染的提示词为空时,循环在初始接纳的用户消息之前预留空系统头部,使稍后首次变为非空的提示词仍替换第 0 号节点。省略该空节点会让后来的提示词追加在用户历史之后,pi-ai 会将其转换为用户消息,而不是 `systemPrompt`。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。
 
 `packages/core/session/src/surface.ts` 在 `assertSystemHeadRewrite` 中强制头部不变量:当第 0 号节点是 `system/message` 时,范围覆盖第 0 号节点的替换会被拒绝,除非替换事件本身是恰好覆盖该节点的 `system/message`。位于更后位置的系统节点没有此类保护;压缩范围可以遮蔽它们。
 
@@ -56,6 +56,18 @@ Status: implemented
 
 `RuntimeContextProjection` 与 `SystemPromptProjection` 是对称的:两者都通过 `sourceEventSeqs` 观察自己拥有的 surface 节点及其被遮蔽的情况,都把一条未提交的消息交给循环由 `turn()` 提交。区别在于角色与操作集——运行时上下文只追加 user 角色快照,系统提示词追加一次之后只做替换。
 
+### V2-to-V3 结构转换
+
+[V2-to-V3 迁移](../../../../packages/session/session-format-v2-to-v3/README.zh.md)把每个 V2 `request/header.system` 转为受保护的 `system/message` 头节点,并删除已退役的 header 字段。它紧接首个 `step/start` 插入空头节点,再在提示词不同的 header 之前立即替换该节点。插入消息的 ID 是确定性的。转换保留源事件顺序、重建请求的含义,以及所有其他消息的精确 ID 和内容。原生 V3 writer 正常写出提示词头节点;迁移事件布局与原生记录语义等价,但并非逐字节相同。
+
+插入事件会移动本地序列位置。转换重映射本地序号引用、替换范围与继承截点;历史投递/版本事实,以及捕获的其他会话引用保留源值。这些历史坐标不得被重新标记为对转换后 V3 日志的确认。
+
+严格迁移拒绝无法安全转换载荷的未知事件、首个步骤前不受支持的 surface 历史,以及开放步骤之外的提示词转换。这样的 V2 源可以有效,但在当前核心步骤不变量下没有保持顺序的转换方式。拒绝不会改动原始文件;它不会重排源事件,也不会放宽不变量来强行转换。V3 读取器拒绝已退役的 `header.system` 字段,并校验系统消息载荷与受保护头节点的重写,而非依赖 TypeScript 省略字段。
+
+[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保持 V0、V1、V2 代际字节冻结,并且只发布 V3 后继代际。V3 是一个尚未发布的目标,而不是每个功能一个新版本;它在发布前可以演化,因此集成必须使用可丢弃的 home。已有 V3 代际不会重跑 V2-to-V3。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。
+
+[规范信封工作](https://github.com/deepseek-ai/deepseek-harness/pull/3636)独立开展,此处尚未集成。它在同一 V2-to-V3 迁移边中的组合顺序位于该结构转换之后,因此规范化的是转换后的事件,而不是替代该转换。
+
 ## Alternatives considered
 
 **保留 `header.system`,只为更新添加 `system/message`。** 一个事实两个归属:上述每个消费方都要从 header 读消息 0、从 surface 读后续消息,循环还需要一个在 surface 存在系统节点时让 `headerEquals` 忽略 `system` 的特例。被否决,因为本次变更的目的就是单一表示。

+ 2 - 2
packages/core/session/README.i18n.yaml

@@ -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 packages/core/session/README.md
-README.md: 17d2bea80eaef582878871652a3f60d885817534
-README.zh.md: 9969ba8c05084c089fd62df14381ef1b07954e64
+README.md: 2886dd900ed20b240d6d183785e6bf4aeb1628a0
+README.zh.md: 7b93d3a7b5b9b0bd9599369d00364530b70e4e40

+ 1 - 1
packages/core/session/README.md

@@ -177,7 +177,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
 These limits define when the session store needs special care. They are current package constraints, not a task backlog.
 
 - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API.
-- **`SESSION_FORMAT_VERSION` names only the current logical representation** — historical headers and events remain in adjacent format packages, while persistence publishes a complete supported chain before constructing `Session`; equal-version unknown events still require the envelope's explicit `ignorable` marker ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
+- **`SESSION_FORMAT_VERSION` names the current V3 logical representation** — the V3 reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the V2→V3 edge converts supported history before constructing `Session`, and write open publishes only the V3 successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
 - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
 - **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
 

+ 1 - 1
packages/core/session/README.zh.md

@@ -177,7 +177,7 @@ session.deriveMessages()         // the derived model history
 这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
 
 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;fork API 不支持对已持久化但未加载的会话进行 fork。
-- **`SESSION_FORMAT_VERSION` 只命名当前逻辑表示**——历史 header 与事件位于相邻格式包中,持久化会在构造 `Session` 前发布完整受支持链;同版本未知事件仍要求信封显式带有 `ignorable` 标记([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
+- **`SESSION_FORMAT_VERSION` 命名当前 V3 逻辑表示**——V3 读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;V2→V3 迁移边在构造 `Session` 前转换受支持的历史,写打开只发布 V3 后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
 - **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。