diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index f92ea2f66d..faf4d69b5b 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md README.md: ae8e4724d610c97d74910d7dec5c95af69e93281 -README.zh.md: a16403ae87cd16d758b8303f42f60ad34b9c1eb3 +README.zh.md: 8cecc7068d9efc5e2ebb78d2301d03633f70f137 diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index a16403ae87..8cecc7068d 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -16,13 +16,13 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 -活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 +活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 ## 分类 -每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。 +每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.zh.md)。 | 类别 | 覆盖范围 | |---|---| @@ -41,7 +41,9 @@ 归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 -封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。 +封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。 + + ## 何时需要写一份 @@ -57,7 +59,7 @@ ## 文件格式 -每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。归档记录保留封存时的格式,并增加上述归档日期行。 +每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.zh.md)。归档记录保留封存时的格式,并增加上述归档日期行。 ### 头部块 @@ -126,4 +128,4 @@ Status: ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 +`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.zh.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0c0e629d26..dac9ce6b5c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md 2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b -2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f +2026-06-11-content-block-vocabulary.zh.md: da387b179816cda64791e71ca7affa1fbdfd195b diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index 5ac882e9de..da387b1798 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -12,7 +12,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。 -会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 +会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index e5c589dbf9..44272f7ca1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md 2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 0d694ea84e1b7c265491e9adb64757378b4185f1 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 0d694ea84e..576e53e0b2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -32,7 +32,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 包拥有的不变式配套插件检查关系 -`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。 +`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md))。 当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 760c3fd099..868d215253 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md 2026-06-11-event-sourced-sessions.md: b6d17d2db1d9b489f3d4224683b61e62be485014 -2026-06-11-event-sourced-sessions.zh.md: a78349ea2385b3c9d32870757bb607f15996196d +2026-06-11-event-sourced-sessions.zh.md: 11e5740dc2636fc002ed7158167b1dfbb39a971d diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index a78349ea23..11e5740dc2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -24,5 +24,5 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 - 回放、追踪与遥测在结构上得到保证,而非事后附加。 - 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。 -- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志具备持久性后固定了其结构。 +- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.zh.md)在日志具备持久性后固定了其结构。 - 派生成本随日志长度增长,压缩(dsh-compaction)是预期的缓解手段,而不是改写日志。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index 8e97d86ccf..57843018c6 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md 2026-06-11-microkernel-event-taxonomy.md: 6cacbc30f29c863de0f8ddfa4df7251a64bc80ca -2026-06-11-microkernel-event-taxonomy.zh.md: 2e43b57d62d8fca692ffab379d1841e6a3e61fad +2026-06-11-microkernel-event-taxonomy.zh.md: 1c7919392b835c491b9dc606d46f638a708d2948 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 2e43b57d62..1c7919392b 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -25,7 +25,7 @@ Status: implemented ## 后果 -- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 +- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.zh.md#the-feature--mechanism-map)是证明义务,保持更新)。 - HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。 - waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。 - 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何扩展点的 steering(中途引导)永远不会被搁置(有回归测试保障)。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 2e6f1ad2df..821a220d9a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md 2026-06-11-runtime-arg-validation.md: 520823c76f147205805d306e97b5461f38b84c35 -2026-06-11-runtime-arg-validation.zh.md: 0a6ef40c93ec3550d6e991836950be849e86ede7 +2026-06-11-runtime-arg-validation.zh.md: bc2f743300be78cc2a01f027d5aca81fcb18b468 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index 0a6ef40c93..bc2f743300 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。 +`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.zh.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。 ## 决策 @@ -17,8 +17,8 @@ Status: implemented ## 后果 - 模型会收到有关自身畸形调用的可操作反馈,而不是遭遇不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 -- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。 -- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 +- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.zh.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。 +- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.zh.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 - 校验开销相对于一次模型调用可忽略不计。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index 106164a71c..b7a0ea7192 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-capability-seams.md 2026-06-13-capability-seams.md: 2a166278ea454895177fa12b58f5493276f19cd1 -2026-06-13-capability-seams.zh.md: 0874af5826960ab9e718eb07b00c12b446edfd78 +2026-06-13-capability-seams.zh.md: 28b45cbbc7f65a0b783db3d91a2e559132b5779f diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index 0874af5826..28b45cbbc7 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -26,7 +26,7 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh ## 术语:seam 指三者组合,而非接口 -一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.md#capability-seam)是规范条目。 +一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.zh.md#capability-seam)是规范条目。 ## 曾考虑的替代方案 @@ -35,4 +35,4 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh ## 后果 -分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。 +分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.zh.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index d44279eb42..3af4789414 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md 2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57 -2026-06-13-twin-llm-adapters.zh.md: 2af7e7cca4d4c40e18cece1a4dd5c9cdb11901c8 +2026-06-13-twin-llm-adapters.zh.md: 36996750a16cc95393d727cffee3bcc53573eaf2 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 2af7e7cca4..36996750a1 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 +`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.zh.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 61f9754431..8f9aa62e49 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -3,4 +3,4 @@ # 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: 62228bd2f5b25b13880a563818d08f3a2d52d956 -2026-06-14-session-persistence.zh.md: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee +2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index b10ceebd95..ebf004333c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -8,28 +8,28 @@ Status: implemented 会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose(资源释放)时采用 fire-and-forget 方式排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。 -[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 +[事件溯源模型](2026-06-11-event-sourced-sessions.zh.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 ## 决策 -持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-shell` 模板),而非循环或核心逻辑: +持久化是一个具有抽象 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.md)是默认物理编码,也可通过配置使用原始行。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 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` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 约定的事务中),读取使用 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上采用的正是这种接口形态),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该约定以相同的语义约束两个后端(惰性物化、逻辑关闭中断轮次、修复只提交一次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 -- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 9898684b9e..c3984a9fa9 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md 2026-06-17-filesystem-capability-seam.md: 6265aebf5e7ffdd4ec4dc0083aa55adb34ee78a1 -2026-06-17-filesystem-capability-seam.zh.md: 024421ed59fa1e6c29bcc12ab51f69ac19a0c231 +2026-06-17-filesystem-capability-seam.zh.md: f0f1cb132fe06ebf669f0310f0066f4fdd36ad0b diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 024421ed59..f0f1cb132f 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -20,7 +20,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local ## 决策 -文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): +文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 @@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 做出。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 @@ -36,7 +36,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但路径包含约束策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 -读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.md) Agent Note。 +读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.zh.md) Agent Note。 ## 包拓扑 @@ -84,7 +84,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)说明这些事实为何属于文件系统 seam。 +即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)说明这些事实为何属于文件系统 seam。 读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-observation-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 @@ -140,7 +140,9 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 - **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的 Service Definition / Service Provider / Consumer 拆分理由否决,且合并名称从未成为公开 API。 -- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 +- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 + + ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml index 0ecda2484f..03a0440ca1 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml @@ -3,4 +3,4 @@ # 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-agent-lifecycle-and-ownership-contracts.md 2026-06-18-agent-lifecycle-and-ownership-contracts.md: 9bc558bfce75892b0ebdb80e9f8735d440cabaf4 -2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: 65e0018c3f2ba56043898050c8e0b49929f8e7e7 +2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: bbd0a7219f902e5ead4d190c58e9a5d4da2131fd diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md index 65e0018c3f..bbd0a7219f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个所有权 ### 1. 队列感知的 `Agent.cancel(cause?)` -`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)规定了当前的 cause、signal 生命周期与协作式结算约定。 +`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.zh.md)规定了当前的 cause、signal 生命周期与协作式结算约定。 ### 2. `AgentHandle` 异步释放器 @@ -43,7 +43,7 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen - **公开的 `BashTask.owner` 字段**而非 `ShellExecutor.ownerOf(id)` Service Definition 方法:否决。一条读取路径即可,无需冗余 API。 - **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 -- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.md))。 +- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.zh.md))。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..8097b8cbb0 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.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-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd +2026-06-18-shared-persistence-write-coordinator.zh.md: 70db616b0a71826c648072228fff936ad423ad8f diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 93b6cd1bd0..286bbb7d5c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. +Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary. + +Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing. + The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..70db616b0a 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -14,7 +14,11 @@ Status: implemented 组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。协调器让非常规后端与继承层级作斗争的风险由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 -协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.zh.md)定义。 + +创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.zh.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。 + +已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 @@ -44,4 +48,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index b7e2678928..ed11425f4b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md 2026-06-20-branded-ids.md: 29b258b21240c92e74051339f0939a8e70933099 -2026-06-20-branded-ids.zh.md: 288125764d4b279c79b7469080bb0c9f2efdc709 +2026-06-20-branded-ids.zh.md: 2f960bac3172f1e83161f1af8f4e9d2cc0cda7bd diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 288125764d..2f960bac31 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 +harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 -bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 +bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)覆盖的共享 id 别名。 **缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 @@ -64,6 +64,6 @@ export function OwnerToken(id: string): OwnerToken { ## 后果 -- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 - **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本决策不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 - **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 477c6df3ae..7d4ccbd4e1 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md 2026-06-20-generic-long-running-tool-runtime.md: 7db43323dd83f8e99698a7163412c9b33a607dfb -2026-06-20-generic-long-running-tool-runtime.zh.md: efd306cc8d3b82749635dde302af45235c27b431 +2026-06-20-generic-long-running-tool-runtime.zh.md: 040ea6a01e97c9dc1d93e8f3a0fd9c1f4b0f31fc diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index efd306cc8d..040ea6a01e 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -19,13 +19,13 @@ Status: implemented 长时间运行工具是生产方。`dsh-tool-bash` 将 `ShellProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。bash 与 subagent 能力 seam 保持独立,不依赖会话或任务注册表。 -`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)中)。 +`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)中)。 ## 运行时约定 -字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。 +字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.zh.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。 -进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 +进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.zh.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 `outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 @@ -79,7 +79,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 流式读取共享一个任务作用域内的消费游标,因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API;共享该游标会让读取方彼此消费对方的输出。 -系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md)负责。 +系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。 当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 @@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ### 立即抽象任务运行时后端 -当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 +当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 ### 由消费方负责授权或清理事件 @@ -131,7 +131,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ## 后果 -bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 +bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)将生产方指向本约定。 单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index f9b830c3b7..dcaeb8c34b 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 3a91d77fe9c08fbcc3afdce4dad9a38e288b4723 -2026-06-21-bounded-llm-request-recovery.zh.md: a6b342b19236251e638242c1e9df6b3d6ed557c0 +2026-06-21-bounded-llm-request-recovery.md: e725a025f2d8b0d5e8eaf4137f07d8eab4448bf4 +2026-06-21-bounded-llm-request-recovery.zh.md: 9e2263b05888797eaaaeb82859730d1c4a728cea diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 3a91d77fe9..e725a025f2 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -4,13 +4,13 @@ Status: implemented English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) -The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism. +The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the failed-attempt recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism. ## Problem 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 opens a new numbered turn 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. 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. The prior boundary left three narrower gaps. @@ -52,7 +52,7 @@ The shared transient-code set is intentionally small: adapter mappings for `RATE `@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. -The `agent/request-error` waterfall carries the current `LlmFailure`, an immutable list of prior failures that authorized retry turns in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal `dsh-llm-retry` policy counts durable retry records scheduled by the same exact-provider policy, while `dsh-compaction-basic` keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets. +The `agent/request-error` waterfall carries the current `LlmFailure`, an immutable list of prior failures that authorized retries in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal `dsh-llm-retry` policy counts durable retry records scheduled by the same exact-provider policy, while `dsh-compaction-basic` keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets. The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) owns the current configuration shape. Provider adapters register their nested `retryPolicy`; omission uses normal defaults: two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). @@ -68,7 +68,7 @@ The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot C ### Make one layer own visible attempts -Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`. +Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by its recorded failed attempt plus `llm/retry`. `ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper. @@ -82,9 +82,9 @@ 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 closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. 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 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. -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. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay, so its failed turn does not also gain a terminal error row. No standalone final-error event or response-id vocabulary is added. +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. ## Out of scope @@ -114,15 +114,15 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Each provider adapter validates its nested retry policy at Loader startup, and `ctx.llm` captures it with the route; normal mode delegates ineligible paths and makes at most `maxRetries + 1` provider requests when no other policy applies. - HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random hooks, and abort during backoff. -- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery. +- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success inside the same turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry records its own chunk seqs and provider/model route. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, real Web composition tests cover partial transport failure through recovery and exhausted recovery's terminal error row beside the settled retry chain, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. ## Consequences -- Every retry attempt is visible as a closed failed turn plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success. +- Every retry attempt is visible inside its owning turn as the failed attempt's chunks plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success. - Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. - Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. - Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. @@ -136,3 +136,4 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - [Timeout deadline library](../../implemented/architecture/2026-07-06-timeout-deadline-library.md) separates shared deadline classification from capability-owned termination. - [After-call compaction pressure and context-overflow recovery](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) owns the current closed-step request-recovery extension point and bounded overflow retry. - [Provider-routed LLM adapters](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) owns explicit provider/model routing and the one-adapter-per-provider invariant. +- [Terminal turn errors survive same-turn retry history](../bug-fix/2026-08-20-turn-error-survives-same-turn-retry-history.md) owns the removal of the Web retry-history suppression that hid exhausted recovery's terminal error row. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index a6b342b192..9e2263b058 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -4,13 +4,13 @@ Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 -[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)取代了其中关于抛出错误身份和流 sidecar 的机制。 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.zh.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、失败尝试的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)取代了其中关于抛出错误身份和流 sidecar 的机制。 ## 问题 -提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回约定。 +提供方适配器可能在分发或迭代时抛出异常,也可能以 `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 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志重建下一次尝试。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 此前的边界还留有三个较窄的缺口。 @@ -46,15 +46,15 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen 适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 -共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 +共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 ### 将重试策略放在现有失败步骤扩展点上 `@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 -`agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试轮次的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 +`agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 -当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。 +当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.zh.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。 对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 @@ -68,7 +68,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 由单一层负责可见的尝试 -适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。 +适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由其记录在案的失败尝试加 `llm/retry` 表示。 `ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩(compaction)摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。 @@ -82,9 +82,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 +一次失败尝试可以在其步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试在失败的轮次与步骤内继续,从持久表层重建请求,并生成自己的分片;只有最终结果才会关闭该轮次。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败尝试,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将每条生产方关联的 `retryId` 重试链投影为稳定的一行,并用最新一次尝试更新该行,再从 `llm/retry-started` 与所属轮次、步骤边界的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 -如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置,因此该失败轮次不会再获得终态错误行。本决策不增加独立的最终错误事件或响应 id 词汇。 +如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。暂时性恢复继续期间,`llm/retry` 是每次中间失败与延迟的持久归属位置;终态错误行只在 `turn/end` 记录错误后才存在,而由于耗尽的恢复与失败共享同一轮次,该轮次的重试历史绝不会抑制这一行——定格的重试链与终态错误并列渲染。本决策不增加独立的最终错误事件或响应 id 词汇。 ## 不在范围内 @@ -100,7 +100,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。 - **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。 - **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。 -- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 +- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.zh.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 - **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 - **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 @@ -114,15 +114,15 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 每个提供方适配器都在 Loader 启动时验证其嵌套重试策略,`ctx.llm` 则将该策略与路由一同捕获;normal mode 会委托不合格路径,而且在没有其他策略时最多发起 `maxRetries + 1` 次提供方请求。 - 退避期间执行 HMR 的测试证明:dispose 过程会注销监听器、中止并等待其捕获的回调,dispose 后不发出重试决策,也不留下存活的定时器或 promise。 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数钩子,以及退避期间中止。 -- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。 +- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在同一轮次内重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试会记录自己的分片 seq 和提供方/模型路由。 -- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,以及耗尽后终态错误行与定格重试链并列的画面,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 - `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 ## 后果 -- 每次重试尝试都以一个已关闭失败轮次加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。 +- 每次重试尝试都在其所属轮次内以失败尝试的分片加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。 - 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 - 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 - 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 @@ -131,8 +131,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 相关资料 -- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。 -- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。 -- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 -- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。 -- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 +- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md)负责稳定、可供机器路由的 code 与 cause chaining。 +- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md)使提供方/模型和完整请求输入在分发前持久化。 +- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.zh.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 +- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。 +- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 +- [Terminal turn errors survive same-turn retry history](../bug-fix/2026-08-20-turn-error-survives-same-turn-retry-history.zh.md)负责移除曾藏掉耗尽恢复终态错误行的 Web 重试历史抑制。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 687743c0a4..1b5420b35a 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -3,4 +3,4 @@ # 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-mandatory-app-attribution-headers.md 2026-06-21-mandatory-app-attribution-headers.md: 479d3a46dc41c5cc9ae9b77b81dbef3d6524370b -2026-06-21-mandatory-app-attribution-headers.zh.md: 75162604623099d50ca87aafd5d45567e2121789 +2026-06-21-mandatory-app-attribution-headers.zh.md: 1b11cb6ef1e96609c6777134a85de298ca979c58 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 7516260462..1b11cb6ef1 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。 +LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.zh.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。 直接触发因素来自 OpenRouter 的[应用归属](https://openrouter.ai/docs/app-attribution)文档。OpenRouter 根据 `HTTP-Referer` 加上用于展示和分类的头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 @@ -24,7 +24,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 ## 决策 -在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.md)另行负责其用户与会话头部。 +在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)另行负责其用户与会话头部。 OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index bc92549f44..f0e60bbc20 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md 2026-06-24-web-capability-seam.md: 7e7b09f19864bd2ad8ad9d69579c1d5c79600cde -2026-06-24-web-capability-seam.zh.md: d6051eec498edb640773ba367038581cebd1f654 +2026-06-24-web-capability-seam.zh.md: dbb41ee42d2c7503955ead2df32abe80b3a4f641 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index d6051eec49..dbb41ee42d 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -16,7 +16,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某 ## 决策 -Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): +Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。 @@ -280,7 +280,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 ## 测试 -每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 @@ -329,10 +329,10 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 - `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 - `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。 +- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。 - `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 ## 开放问题 - 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出? -- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? +- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index c49cd144b9..421739cb57 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md 2026-06-26-file-context-as-event-gate.md: a8316741c3ce8548db5b5def431425589994995c -2026-06-26-file-context-as-event-gate.zh.md: 78b1b759edc0340fd7be103fc9bfec0b125e0800 +2026-06-26-file-context-as-event-gate.zh.md: 618985f33073cf9fd69cf2c7fda7112d9752468f diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index 78b1b759ed..618985f330 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 +[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: @@ -33,7 +33,7 @@ provider dsh-fs-local local implementation of ctx.fs 该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-observation-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent(智能体)配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。 -[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。 +[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。 `dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。 @@ -152,7 +152,7 @@ interface Events { ## 取代关系 -本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 +本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 ## 验证 @@ -160,7 +160,7 @@ interface Events { ## 曾考虑的替代方案 -- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 +- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 - **策略侧版本检查**(`dsh-fs-observation-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 - **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml index 7a5d35f882..04eec8e0e1 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml @@ -3,4 +3,4 @@ # 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-bash-stdin-env-trusted-plugin-api.md 2026-06-30-bash-stdin-env-trusted-plugin-api.md: 41be63fff598587ee9b873cf7edf51da788bc02a -2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 4f0338cbb6a265ff629a81e9c9a12c4456941ba0 +2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 60721539d9d37857e145e289bb262482588139bf diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md index 4f0338cbb6..60721539d9 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md @@ -8,7 +8,7 @@ Status: implemented 钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.shell` 能力 seam 后面有一个完善的命令执行器([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)),具备进程组终止、输出截断/spill 处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程底层机制——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。 -`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。 +`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.zh.md)。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented 三个有意为之的选择: -1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 +1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.zh.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策负责管理 `DSH_*`:这类环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.md)。 +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 171f33fe17..feb512101f 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -3,4 +3,4 @@ # 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: 6795e1f6f583d6fe7d34efc5e1619da38ab1c89f +2026-06-30-event-domain-semantics.zh.md: c3b12a167da0a41b792914d82a675a98b3a0b860 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index 6795e1f6f5..c3b12a167d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: +harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.zh.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: - `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 @@ -26,14 +26,14 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;事件接纳失败或内部校验失败仍会在边界进入日志之前向外抛出。 - 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所锁定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 -- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 +- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 - 生成的 Cordis 事件表面(`docs/subsystems/` 各页)不再列出镜像事件。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index fa8401fdc8..933b3d2826 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md 2026-07-02-tool-render-intent-union.md: 52626d3d9200146df95aee7836d37bbaf7e6a9ec -2026-07-02-tool-render-intent-union.zh.md: 43fda366694aecda07b276d34c882e75d6b4aa95 +2026-07-02-tool-render-intent-union.zh.md: bc91b89c53c3c43b6c483dc0d4876a00f5a936b7 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index 43fda36669..bc91b89c53 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-02-tool-render-intent-union.md) | 中文 -> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。 +> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 ## 问题 @@ -78,5 +78,5 @@ terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命 ## 相关 - 取代早先被否决的折叠工具自有呈现提案(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 -- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)拥有。 +- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)拥有。 - 将 `ToolTerminal` 折入当前 UI 传输层使用的带标签 `terminal` 视图。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 800449486f..13871d1cff 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md 2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 9a53619d9510e3f4fa561f8420b2da3bedbbf4bb -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6174f277f0a4e8c46ff8d732411f28b4ea97728c +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: dc1164198df0f92b843c75b618f140d8aef86e4f diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 6174f277f0..dc1164198d 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -40,7 +40,7 @@ Status: implemented ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)。 ## 曾考虑的替代方案 @@ -49,7 +49,7 @@ Status: implemented - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 -- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 +- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)中被否决。 ## 不在范围内 diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 336d9669ed..a66c6f6065 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md 2026-07-05-subagent-provider-lifecycle-events.md: 503c0d638785e3a4944c8903b8d7d469c68b1881 -2026-07-05-subagent-provider-lifecycle-events.zh.md: e7d1d8c398a6d77efb2dbccc365e0040f38f2d33 +2026-07-05-subagent-provider-lifecycle-events.zh.md: d9d15b4ff688506b84b81c8649e1de4ce50e086d diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index e7d1d8c398..d9d15b4ff6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 -如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。 +如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.zh.md))。 ## 决策 @@ -31,6 +31,6 @@ Status: implemented ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.zh.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.zh.md)。 - **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表发出的 `tools/change` 事件会使提示词组装保持最新状态。 - **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例分别指定了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml index cf8eaf225c..cc278292d9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md 2026-07-05-windows-jsonl-durable-publish.md: 546cde6086f3c84e22b4d4de144a48bb3425cd4e -2026-07-05-windows-jsonl-durable-publish.zh.md: 205460bcd374bd351cfdf541eb4041c09461229d +2026-07-05-windows-jsonl-durable-publish.zh.md: b99032e9d7e72e2028f39c5af91659278fd81343 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md index 205460bcd3..b99032e9d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md @@ -28,7 +28,7 @@ Windows 通过持久的暂存发布来创建缺失目录:创建一个以固定 ## 影响 -该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。 +该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.zh.md)会先作用于不透明字节,然后才由任一平台执行发布。 Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 约定属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index e7e49aa115..580e74f217 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md 2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a -2026-07-06-timeout-deadline-library.zh.md: e8b0ab62b19d931c33027d5e6c9919804e80aa99 +2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index e8b0ab62b1..c8d189c2a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -103,7 +103,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 -以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.zh.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml index 5e943fed2b..398eaab528 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md 2026-07-06-tool-result-retention-library.md: 8d938db8f5fa78398a39e97cc877308200f7d60f -2026-07-06-tool-result-retention-library.zh.md: 0b2fe841beed21511c4088732713d3fd9fd9048f +2026-07-06-tool-result-retention-library.zh.md: 747b545ee8c900c13d80c7aef6caecc8ae8dc0ad diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md index 0b2fe841be..747b545ee8 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md @@ -103,7 +103,7 @@ type TextRetentionStrategy = `grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过 spill seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。 -`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。 +`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.zh.md)相互独立。 `web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。 @@ -138,7 +138,7 @@ const formatGrepNotice = (notice: RetentionNotice): string => **已交付内容。** `@deepseek-ai/dsh-output-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。 -**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 +**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.zh.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 **该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash spill 路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。 @@ -152,6 +152,6 @@ const formatGrepNotice = (notice: RetentionNotice): string => **把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 -**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。 +**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.zh.md)可以保留完整的已采集结果。 **在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index 10a29ee499..373b1293ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md 2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8 -2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2 +2026-07-07-tool-call-timeout-policy.zh.md: 0303fadd134c6eb8c41449823b62f1458f4523ba diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index cc633ceaa3..0303fadd13 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 +[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.zh.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.shell` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子复用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 @@ -16,7 +16,7 @@ Status: implemented - `@deepseek-ai/dsh-timeout` 仍是拥有 `deadline()` 和 `timeoutOf()` 的共享库。 - `@deepseek-ai/dsh-tools` 在 `tools/pre-execute` 和 `tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`。 -- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 +- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 执行流水线如下: @@ -52,7 +52,7 @@ catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重 searchTimeoutMs: 30000 ``` -超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 +超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.zh.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的截止信号。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index efed9ca5ea..20d0e4f85c 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md 2026-07-08-agent-scope-contexts.md: eb3f6f247bac1a1d81aa2644132c7b9cc04d602c -2026-07-08-agent-scope-contexts.zh.md: fe82ac18d07de97330e86461e6bdf86edc37d2ae +2026-07-08-agent-scope-contexts.zh.md: a0f4ffb0ef80dd2fc1ee61c9ab3f4730c28c687e diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index fe82ac18d0..a0f4ffb0ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -16,7 +16,7 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 -Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。 +Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。 对大多数贡献者而言,完整约定是四条规则: @@ -45,7 +45,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 @@ -104,7 +104,7 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 在 Cordis 层面,`Scoped` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 -以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。 +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.zh.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。 ### 创建最后发布,dispose 最后撤销 @@ -138,6 +138,8 @@ flowchart TB detach --> revoke["Dispose the agent scope"] ``` + + ## 安全与权限是非目标 agent 作用域组合的是受信的同进程注册。它不沙箱化插件、不定义父到子的权限格、不在创建时冻结授权、也不保证子级不能做超出父级的事。 @@ -146,6 +148,8 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父级子集授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 + + ## 曾考虑的替代方案 被否决的设计要么将可见性与清理分离,要么只覆盖一类注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 5f9f8ba421..15abdc91f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md 2026-07-08-tool-output-spill-files.md: 4d1d4b7b665f34b362df2f8c8aeb06d96bf2668f -2026-07-08-tool-output-spill-files.zh.md: 1a3387830861c6d05318024870325523b8eeee36 +2026-07-08-tool-output-spill-files.zh.md: 3e7ce4f57a0078c6a4b919436946be8e172fa7cb diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index 1a33878308..3e7ce4f57a 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -8,9 +8,9 @@ Status: implemented 工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。 -这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 +这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.zh.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 -其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 +其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)规定了这项区分。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 0321d494a0..ec2f5318d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md 2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 89c8917484c11057ff2e6b55e2311e83f724f7ed -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 96f7aa1b0ea4e44ca6318caca2c785d898b1d00d +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 48f954f2c4720894548ecab387965d3a3d1799b0 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 96f7aa1b0e..48f954f2c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -22,9 +22,9 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 ### 请求恢复只覆盖最终模型边界 -`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)规定这一规范化边界。 +`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)规定这一规范化边界。 -恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 +恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回边界。 如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 @@ -58,4 +58,4 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复不可剪枝的剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 -[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 +[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 21c607072c..b8bcc4246e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md 2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 54030fa4b0..cff72ae10e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -27,8 +27,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: -- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 @@ -44,11 +44,11 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 02aeec5ad6..7d8d2db1cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md 2026-07-12-agent-scope-runtime-design.md: 9d70b8048b1d2bb50290d34158d9deb329d5e15e -2026-07-12-agent-scope-runtime-design.zh.md: a505f5337559294795c1080937c6079cee235baa +2026-07-12-agent-scope-runtime-design.zh.md: 278bcede47fee9f67d3d2d2d7135e5357c120161 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index a505f53375..278bcede47 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。 +[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.zh.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。 主要的设计风险是为每个竞态条件引入第二套机制。独立的预留、就绪哨兵、取消中继、快照层和保护注册表可能镜像同一个事实,直到没有读者能分辨哪个才是权威的。这些机制还会诱使运行时把可信的类型化调用当作敌对的序列化边界来处理。 @@ -30,7 +30,7 @@ Status: implemented 本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与提示词、subagent 与工作流,最后是可执行检查。 -[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 ## Cordis 模型:上下文、fiber、effect、receiver 与 waterfall @@ -58,6 +58,8 @@ Cordis 使用 dispatch receiver(`this`)过滤监听器,而 harness 的监 Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则短路或替换下游结果。Waterfall 驱动提示词组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。 + + ## 作用域路由:一个不透明键选择一层 scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露会等待作用域 fiber 完全停稳的 disposer。 @@ -72,7 +74,7 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent ### 注册表读取叠加一个精确 layer -作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。 +作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.zh.md))。 每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 @@ -158,6 +160,8 @@ sequenceDiagram 此顺序让最终的 agent 和会话事件能使用匹配的作用域监听器,并使持久化观察者在最终刷新完成前保持附加。作用域 dispose 放在最后,因为注册撤销是外部可见的生命期边界。 + + ## 会话追加:物化、验证、提交、通知 会话事件跨越持久化边界,因此追加操作拥有其数据。算法的其余部分使用一条已附加的注册表条目和一个提交点。 @@ -208,7 +212,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 -[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 +[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 `ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。 @@ -230,6 +234,8 @@ SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为 Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 + + ### 结构化输出仅提交权威结果 结构化输出将子作用域组合与两阶段执行提交相结合。子级在发布前注册其 `structured_output` 工具和指令;可信的 assembly 监听器可以变换这些普通贡献,并有责任在期望子级完成时保持协议。工具体验证候选值并按当前 `ToolExecution` 暂存,但成功捕获仅由不可变的 `tools/result` 观察决定。 @@ -242,6 +248,8 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 + + ### 三个执行边界有意设为单向 提示词组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算: @@ -266,7 +274,7 @@ subagent 启动有一次所有权转移。提供方拥有未发布资源,直 ### 服务约定有一个取消通道 -`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)所要求的约定。 +`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)所要求的约定。 `SubagentStartRequest.signal` 是必需的。中止它会在启动期间,以及已发布 run 的剩余就绪或轮次工作中请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 @@ -292,6 +300,8 @@ Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn Worker 和子进程桥接比同进程注册表需要更多状态,因为消息、进程死亡和清理可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。 + + ### 工作流子级是待定 start 或已发布记录 工作流宿主保持待定的提供方 start promise 和已发布的子级记录。子级仅在异步 `SubagentRuntime.start()` 兑现时才从待定变为已发布;被拒绝的 start 清理其部分提供方工作且不产生子级生命周期对。 @@ -308,7 +318,7 @@ Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲 ### ACP 提示词结算不依赖更新投递 -[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。 +[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。 即使已提交消息的更新无法送达客户端,会话事件监听器也会从匹配的 `turn/end` 结算关联。因此更新投递不能让会话永久处于进行中状态。ACP 创建由服务器分配 id 的全新会话,并拥有由此产生的每个 agent 句柄,直到连接拆除。 @@ -330,13 +340,13 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 生成的产物使公开约定保持对齐 -事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 +事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.zh.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 -[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。 ### 使用透明代理作为作用域载体 @@ -389,4 +399,4 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化 该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 约定的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 -[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 +[安全与权限非目标](2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml index 86871f2bcd..205c3c4e39 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md 2026-07-12-scoped-layers-store.md: 221711748007d5f909aec3fdfcf3919884d11cdb -2026-07-12-scoped-layers-store.zh.md: 7f3ad11e23f02eeafe955efe08525bda8a9e9996 +2026-07-12-scoped-layers-store.zh.md: 97ad637b91a2b9b2cad9847d8b4cdbe23ff14a48 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md index 7f3ad11e23..97ad637b91 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。 +agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.zh.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。 如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 3f4683f480..34523f2723 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md 2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4 -2026-07-14-provider-routed-llm-adapters.zh.md: 5e73cab5f1f2b1296c9a486d1c833e95bb5674a0 +2026-07-14-provider-routed-llm-adapters.zh.md: af8bc4fe27a50d47d7b49b51eada67afe889fc13 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 5e73cab5f1..af8bc4fe27 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -20,7 +20,7 @@ Status: implemented `GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。 -`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 +`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。 @@ -40,11 +40,11 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定 助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 -成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 +成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充该结构:一个响应半区(源 API/提供方/模型、响应 ID/模型、停止原因),以及逐块的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。持久化内容保持权威:适配器收到无法使用的回放状态——未知 kind 或版本、格式错误的元数据、或与内容不再匹配的块结构——会把该消息降级为提供方无关转换并带出诊断;其他适配器只能收到提供方无关的内容以及提供方/模型字段。 -该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 +该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.zh.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 ### 在所有请求生产方中传播目标 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 458999f905..570b68263c 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md 2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277 -2026-07-15-agent-initiator-scope.zh.md: a0e3638081d875adc2412191829ab84c8dcd697c +2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index a0e3638081..3ea893aa5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -12,7 +12,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 决策 -必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.md#initiating-agent)标明了所携带的类型。 +必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.zh.md#initiating-agent)标明了所携带的类型。 `currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。 @@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 -本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 +本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 69af08605c..c9fe4938a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md 2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 145fd0bc379ff8132d09ec628b6381a18f755cfa +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: b93b333c6f25a779b1d307b6141bea9f5eb1e407 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index 145fd0bc37..b93b333c6f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文 -> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。 +> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index aee9c76d55..277e0b097d 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md 2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc -2026-07-15-lsp-capability-seam.zh.md: b85b478b25a375b04e1301d893e53198554641e3 +2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index b85b478b25..bdb5e812e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -79,7 +79,7 @@ interface LspService { 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。seam 中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方的规范工作区 URI,使消费方在执行世界的命名空间内相对化文件 URI。seam 不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 -`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 +`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 ## 面向模型的约定 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index 47e7156ab8..6ad1773f6f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md 2026-07-15-replay-token-meter-service.md: 10261189fca621b305b3ec6347b54471d81c8ba3 -2026-07-15-replay-token-meter-service.zh.md: 4e460b3c32630f205f447a16205bd4c9b1d89faf +2026-07-15-replay-token-meter-service.zh.md: 370d3cf932d37702c5298bc79e9dc8f5a37835b9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4e460b3c32..370d3cf932 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -16,7 +16,7 @@ Status: implemented `@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeter` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。 +服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.zh.md)。 ### 逐会话回放折叠 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 342f2073ea..fcca895513 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md 2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954 -2026-07-16-explicit-turn-cancellation.zh.md: 5397b368628c196efc2b35c00855267246ac6e85 +2026-07-16-explicit-turn-cancellation.zh.md: acf56e0629668a227324045ffd0521619dc45f33 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 5397b36862..acf56e0629 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -8,7 +8,7 @@ Status: implemented 取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法完整区分所有调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。 -[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 +[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.zh.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 ## 决策 @@ -18,9 +18,9 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.zh.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index e772ff9036..29807cfb7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # 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-cooperative-tool-cancellation.md 2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8 -2026-07-19-cooperative-tool-cancellation.zh.md: a5ce2e671e92f4758ddeb3d3556d8af574467f7f +2026-07-19-cooperative-tool-cancellation.zh.md: ec35734eef91c5c774d1be814b221e9fdb8f65fa diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index a5ce2e671e..ec35734eef 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -42,7 +42,7 @@ Status: implemented 工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。 -这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 +这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md)。 ## 验证 @@ -62,7 +62,7 @@ Status: implemented **禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 -**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。 +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index b864b41f76..1ed087f38c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -3,4 +3,4 @@ # 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-gui-layering-and-rpc-protocol.md 2026-07-19-gui-layering-and-rpc-protocol.md: 620803668e88f5a462ab2a75e6e916a85d433ed6 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 8145b6e5af149f79b45e03c80aeea90da90b3729 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 137cbce3e5dbc0be2736472f6e26d58112422697 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 8145b6e5af..137cbce3e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 -> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.zh.md)。 ## Problem @@ -23,13 +23,13 @@ Status: implemented 目录按照如下分层: - `packages/host/*`:包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 -- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): +- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有): - **纯库**(`ui-slots`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;两个客户端库播种进模块表。 - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-web-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.zh.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -50,9 +50,9 @@ harness core packages ──────────────────┘ - `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 - client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 -- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。 +- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有)。 -TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 +TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md))。 协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 @@ -167,7 +167,7 @@ export type ResponseValue = ### 帧(server→client,具名 union) -两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: +两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| @@ -214,7 +214,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 2d5aaa3a25..9ee365f446 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -3,4 +3,4 @@ # 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-gui-web-client-architecture.md 2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2 -2026-07-19-gui-web-client-architecture.zh.md: a86f9d6f71bed0ce147ffee39bd921037d6c1c4c +2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index a86f9d6f71..705b1337dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-web-client-architecture.md) | 中文 -> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 +> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 ## Problem @@ -30,21 +30,21 @@ Status: implemented ## client cordis 树与装载链 -装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双阶段 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一阶段预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);全局样式与 CSS Modules 都内联在其持有插件的 bundle 中,物化时注入为 `` } + case 'html': + return { placement: row.placement, markup: row.html } + default: + return assertNever(row) + } +} + +/** Insert `markup` into `html` at `at`. */ +function splice(html: string, at: number, markup: string): string { + return `${html.slice(0, at)}${markup}${html.slice(at)}` +} + +/** + * Render rows into an index.html body: head rows immediately after the + * opening head tag, body rows immediately after the opening body tag, each + * group in table order. + * @param html - the raw index.html body. + * @param rows - the collected injection table. + * @returns the html with every row rendered. + */ +export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string { + let head = '' + let body = '' + for (const row of rows) { + const rendered = renderRow(row) + if (rendered.placement === 'head') head += rendered.markup + else body += rendered.markup + } + let out = html + if (head !== '') { + const open = /]*)?>/i.exec(out) + // Headless fixture pages may lack ; prepending keeps the rows ahead + // of every document script. + out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head) + } + if (body !== '') { + const open = /]*)?>/i.exec(out) + // Body-less fragments receive the rows at the end, where the HTML parser + // has already synthesized a body. + out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body) + } + return out +} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 2cbd285856..ffe5b4648d 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import HttpServer from '../src/index.ts' +import HttpServer, { renderIndexInjections } from '../src/index.ts' let root: string | undefined let context: Context | undefined @@ -200,6 +200,54 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) + it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const server = loaded.webServer + let flag = 'dark' + loaded.on('webserver/index-inject', (table) => { + table.push( + { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, + { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, + { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } }, + { kind: 'style', text: 'body{margin:0}' }, + { kind: 'html', placement: 'head', html: '' }, + { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` }, + ) + }) + + const html = server.renderIndex('shell') + // Head rows land right after the opening head tag in table order; the body + // row lands right after the opening body tag. + const order = [ + '', + '', + '', + 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}', + '', + '', + '', + '', + 'shell', + ].map(part => html.indexOf(part)) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every(at => at !== -1)).toBe(true) + + // Fresh collection per render: the listener reads live state at emit time. + flag = 'light' + expect(server.renderIndex('')).toContain('window.__P__="light"') + + // Raw taps still run, over the already-rendered rows. + const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) + expect(server.renderIndex('')).toContain('window.__Q__=2') + untap() + + // Tag-less fragments: head rows prepend, body rows append. + expect(renderIndexInjections('
x
', [ + { kind: 'script', placement: 'head', text: 'H' }, + { kind: 'script', placement: 'body', text: 'B' }, + ])).toBe('
x
') + }) + it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { const first = await loadComposition() const takenPort = first.webServer.port diff --git a/packages/identity/README.i18n.yaml b/packages/identity/README.i18n.yaml index 653704a83c..f1f3b0c384 100644 --- a/packages/identity/README.i18n.yaml +++ b/packages/identity/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/identity/README.md README.md: ebbc7d937dfa793edf9a8617d86a1820e87866da -README.zh.md: 3d29fc3dee7599e366cb91c09d6a73c8acddab96 +README.zh.md: cd5fa06a0e9c6c277292b5904a1617acaa8dcfc3 diff --git a/packages/identity/README.zh.md b/packages/identity/README.zh.md index 3d29fc3dee..cd5fa06a0e 100644 --- a/packages/identity/README.zh.md +++ b/packages/identity/README.zh.md @@ -6,4 +6,4 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`anonymous-user-id/`](anonymous-user-id/README.md) | 为遥测、反馈和 DeepSeek 请求持久化一个限定于 Harness home 的匿名关联 id | — | +| [`anonymous-user-id/`](anonymous-user-id/README.zh.md) | 为遥测、反馈和 DeepSeek 请求持久化一个限定于 Harness home 的匿名关联 id | — | diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index f967e48d2b..c596482b22 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/README.i18n.yaml b/packages/interaction/README.i18n.yaml index 116db6b260..7086f59493 100644 --- a/packages/interaction/README.i18n.yaml +++ b/packages/interaction/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/README.md README.md: a7842e40aa708ee9ec159dd51b8a94a6b2b18539 -README.zh.md: ec329365331efd2769078078d1da4a8e1b3b57e0 +README.zh.md: 1a760484f887a459348612c867213d95e7257420 diff --git a/packages/interaction/README.zh.md b/packages/interaction/README.zh.md index ec32936533..1a760484f8 100644 --- a/packages/interaction/README.zh.md +++ b/packages/interaction/README.zh.md @@ -6,12 +6,12 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`commands/`](commands/README.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` | -| [`user-approval/`](user-approval/README.md) | 协调一次性审批决策。 | `ctx.approval` | -| [`permission/`](permission-presets/README.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permissionPresets` | -| [`user-questions/`](user-questions/README.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userQuestions` | -| [`tool-ask-user/`](tool-ask-user/README.md) | 向模型提供用户问题。 | (注册到 `ctx.tools`) | +| [`commands/`](commands/README.zh.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` | +| [`user-approval/`](user-approval/README.zh.md) | 协调一次性审批决策。 | `ctx.approval` | +| [`permission/`](permission-presets/README.zh.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permissionPresets` | +| [`user-questions/`](user-questions/README.zh.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userQuestions` | +| [`tool-ask-user/`](tool-ask-user/README.zh.md) | 向模型提供用户问题。 | (注册到 `ctx.tools`) | -这些包通过现有的 agent 和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)直接组合这些包。 +这些包通过现有的 agent 和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.zh.md),可运行的演示组合包位于 [`examples/`](../examples/README.zh.md)。产品 [`dsh`](../../apps/cli/README.zh.md) CLI(命令行界面)直接组合这些包。 -子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission-presets.md](../../docs/subsystems/permission-presets.md)、[user-questions.md](../../docs/subsystems/user-questions.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。 +子系统参考:[approval.md](../../docs/subsystems/approval.zh.md)、[permission-presets.md](../../docs/subsystems/permission-presets.zh.md)、[user-questions.md](../../docs/subsystems/user-questions.zh.md)与 [commands.md](../../docs/subsystems/commands.zh.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.zh.md),SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.zh.md),共享 bin 启动胶水是 [`boot/`](../boot/README.zh.md)。 diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index fa8ce400f3..cde56d4ef9 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/commands/README.md README.md: 4a4cb2a70b56ba1a18e9f4719541a50a9683510c -README.zh.md: f89ccd1a5cc9189b2810026481d4c57855481111 +README.zh.md: d331e3e2f359572e4953831ea0c4dbe8d2fd64c3 diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index f89ccd1a5c..d331e3e2f3 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发约定。 +由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md)定义了其边界与分发约定。 ## 服务约定 @@ -24,7 +24,7 @@ #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 #### Token 影响 diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 84e2d41b88..746d2aabb9 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 7e7359526c..d547571951 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/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/interaction/permission-presets/README.md -README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6 -README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621 +README.md: 7e993ece949aa54d0810d8a7434d3e74d86f4797 +README.zh.md: 2946f340b2023f707d4d00c9b8161a71b4d05853 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index 2b671f9e6e..7e993ece94 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -4,17 +4,17 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. +The service owns the `permission` Settings namespace. Its `defaultPreset` initializes fresh sessions and default-origin Workspace blanks that Web explicitly confirms for New Session reuse: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. Session creation reads the current setting and pins `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records whether it came from the default, an explicit selection, or legacy-knob inference. A committed setting change does not scan or rewrite existing sessions. When the Web workspace runtime selects a member blank with matching cwd that is not archived, the host rechecks those facts, adopts that exact session, including a cold persisted one, and advances it only when it has not started a turn, its latest selection is default-origin, and its effective knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, independently changed knobs, and ordinary seeded resumes remain pinned. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). -Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. +Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. ## Model Experience -Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only. +Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. #### KV Cache effect @@ -25,4 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. -- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset. +- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset. diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 686138c1f2..2946f340b2 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -4,17 +4,17 @@ 通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 -该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 +该服务拥有 `permission` Settings namespace。其 `defaultPreset` 会初始化新建会话,以及 Web 明确确认为新会话复用目标、且权限来自默认值的 Workspace 空白会话:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。创建会话时会读取当前设置,并固定 `permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实还会记录它来自默认值、显式选择还是旧旋钮推断。已提交的设置变更不会扫描或改写现有会话。当 Web workspace runtime 选中 cwd 匹配、属于该 Workspace 且未归档的空白会话时,host 会重新检查这些事实,并接纳这个确切会话(包括冷存储中的持久会话),而且只在它尚未开始轮次、最近选择来自默认值且有效旋钮仍匹配该选择时推进默认值。显式选择、由旧旋钮推断或没有来源标记的旧选择、独立变更的旋钮,以及普通 seed 恢复都会继续固定原权限。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 -该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。 -两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 +两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 ## 模型体验 -间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。 +间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 #### KV Cache 影响 @@ -25,4 +25,4 @@ - **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。 - **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。 -- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。 +- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。 diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 59e7f4abeb..79243339f4 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index fee29c2644..333edb808c 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -42,12 +42,15 @@ declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** - * Records the selected preset as durable, log-only user intent. The knob + * Records the selected preset and whether it came from the session + * default, an explicit selection, or legacy-knob inference. The knob * events follow in the same turn and control execution; this event stays * out of the model transcript and lets {@link effectivePermissionPreset} - * preserve a selection when bundles match. + * preserve a selection when bundles match. `origin` is optional so logs + * written before origin tracking remain readable but are never mistaken + * for refreshable defaults. */ - 'permission/preset': { preset: string } + 'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' } } } @@ -69,7 +72,7 @@ export interface PresetSpec { */ export const CUSTOM_PRESET = 'custom' -/** Settings namespace carrying the default for future sessions. */ +/** Settings namespace carrying the default for fresh sessions and confirmed reusable blanks. */ export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') /** @@ -100,6 +103,22 @@ export interface KnobState { approval: ApprovalPolicy | null } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + permissions: KnobState + } +} + +const knobStateSchema: zod.ZodType = zod.object({ + preset: zod.string().nullable(), + sandbox: zod.union([ + zod.literal('read-only'), + zod.literal('workspace-write'), + zod.literal('danger-full-access'), + ]).nullable(), + approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(), +}).strict() + /** State for the empty log: every knob at its composition default. */ const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null } @@ -130,9 +149,18 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState { return state } +/** Last recorded permission selection, including its durable origin. */ +function latestPermissionSelection(events: readonly SessionEvent[]): Extract | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'permission/preset') return event + } + return undefined +} + /** User setting resolved when a new session receives its initial permission. */ export interface PermissionSettings { - /** Preset pinned into a newly created session. */ + /** Preset pinned into a fresh session or an eligible confirmed blank reuse. */ defaultPreset: string } @@ -145,8 +173,9 @@ export interface Config { */ presets?: Record /** - * Default for new sessions. When omitted, the preset matching the composed - * sandbox and approval defaults is used. + * Default for fresh sessions and eligible confirmed blank reuse. When + * omitted, the preset matching the composed sandbox and approval defaults + * is used. */ defaultPreset?: string } @@ -212,8 +241,6 @@ export class PermissionPresetService extends Service { setSource: (current) => { this.defaultSettings = current }, - // The source thunk reads the latest scope snapshot at session creation; - // no process-level registration needs replacement on change. onChange: () => {}, }) @@ -243,10 +270,10 @@ export class PermissionPresetService extends Service { ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register<'permissions', KnobState>({ key: 'permissions', - schema: selectSchema, + stateSchema: knobStateSchema, init: () => EMPTY_KNOBS, apply: applyKnobEvent, - view: state => this.selectFor(state), + wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, stateVersion: 1, }) }) @@ -270,7 +297,9 @@ export class PermissionPresetService extends Service { if (!this.names.includes(name)) { return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` } } - this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) }) + this.apply(agent.session, name, (policy) => { + this.ctx.approval.setPolicy(agent, policy) + }, 'selection') return { kind: 'success', text: `preset ${name}` } }, }) @@ -286,7 +315,7 @@ export class PermissionPresetService extends Service { } /** - * The preset currently selected as the default for future sessions. + * The preset currently selected for fresh sessions and confirmed blank reuse. * @returns the resolved settings value, or the composition default without * a mounted settings provider. */ @@ -305,6 +334,26 @@ export class PermissionPresetService extends Service { return this.derive(foldKnobs(events)) } + /** + * Advance one blank session after the host has confirmed it as the exact + * Web New Session reuse target. Only a still-effective + * default-origin selection advances; a started session, an explicit pick, + * legacy origin-less data, or independently changed knobs remain pinned. + * This is the permission-side half of the Web candidate selection and the + * host's blankness, membership, cwd, and archive verification. + * @param session - the live session selected for Workspace blank reuse. + */ + refreshDefaultForReuse(session: Session): void { + const events = session.events + if (events.some(event => event.type === 'turn/start')) return + const selected = latestPermissionSelection(events) + if (selected?.data.origin !== 'default') return + if (this.current(events) !== selected.data.preset) return + this.apply(session, this.defaultPreset, (policy) => { + setApprovalPolicy(session, policy) + }, 'default') + } + /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ private derive(state: KnobState): string { const sandbox = state.sandbox ?? this.ctx.shell.sandboxMode @@ -373,14 +422,21 @@ export class PermissionPresetService extends Service { * @param name - the preset to switch to; unknown names throw. */ set(session: Session, name: string): void { - this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) }) + this.apply(session, name, (policy) => { + setApprovalPolicy(session, policy) + }, 'selection') } /** Apply one preset with the caller-selected live or initialization policy writer. */ - private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void { + private apply( + session: Session, + name: string, + setApproval: (policy: ApprovalPolicy) => void, + origin: 'default' | 'selection' | 'inferred', + ): void { const spec = this.resolve(name) if (this.current(session.events) !== name) { - session.append('permission/preset', { preset: name }) + session.append('permission/preset', { preset: name, origin }) } const events = session.events if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) { @@ -406,7 +462,7 @@ export class PermissionPresetService extends Service { if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { const name = this.defaultPreset const spec = this.resolve(name) - session.append('permission/preset', { preset: name }) + session.append('permission/preset', { preset: name, origin: 'default' }) setSandboxMode(session, spec.sandbox) setApprovalPolicy(session, spec.approval) return @@ -419,7 +475,7 @@ export class PermissionPresetService extends Service { } const effective = this.derive(state) if (selected === undefined && effective !== CUSTOM_PRESET) { - session.append('permission/preset', { preset: effective }) + session.append('permission/preset', { preset: effective, origin: 'inferred' }) } if (sandbox === undefined) { setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode) diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 1f0e8760b8..222e609ce0 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -131,7 +131,7 @@ describe('PermissionPresetService', () => { const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') expect(session.events.map(e => [e.type, e.data])).toEqual([ - ['permission/preset', { preset: 'danger-full-access' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], ]) @@ -154,7 +154,7 @@ describe('PermissionPresetService', () => { ctx.permissionPresets.set(session, 'danger-full-access') const tail = session.events.slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ - ['permission/preset', { preset: 'danger-full-access' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }], ['sandbox/mode', { mode: 'danger-full-access' }], ]) }) @@ -197,14 +197,16 @@ describe('new-session default', () => { const ctx = await mountedStore() const first = ctx.sessions.create(SessionId('first')) expect(first.events.map(event => [event.type, event.data])).toEqual([ - ['permission/preset', { preset: 'workspace-write' }], + ['permission/preset', { preset: 'workspace-write', origin: 'default' }], ['sandbox/mode', { mode: 'workspace-write' }], ['approval/policy', { policy: 'ask' }], ]) + first.append('turn/start', { turn: 1 }) await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { defaultPreset: 'danger-full-access', }) + ctx.permissionPresets.refreshDefaultForReuse(first) expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access') const second = ctx.sessions.create(SessionId('second')) expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write') @@ -214,6 +216,89 @@ describe('new-session default', () => { ]) }) + it('advances a confirmed reusable blank session that still carries its default', async () => { + const ctx = await mountedStore() + const blank = ctx.sessions.create(SessionId('blank-placeholder')) + expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write') + + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write') + ctx.permissionPresets.refreshDefaultForReuse(blank) + + expect(ctx.permissionPresets.current(blank.events)).toBe('danger-full-access') + expect(blank.events.map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'workspace-write', origin: 'default' }], + ['sandbox/mode', { mode: 'workspace-write' }], + ['approval/policy', { policy: 'ask' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'default' }], + ['sandbox/mode', { mode: 'danger-full-access' }], + ['approval/policy', { policy: 'never' }], + ]) + }) + + it('leaves explicit, inferred, legacy, absent, and independently changed selections unchanged', async () => { + const ctx = await mountedStore() + const picked = ctx.sessions.create(SessionId('blank-explicit-pick')) + ctx.permissionPresets.set(picked, 'danger-full-access') + const pickedEvents = [...picked.events] + + const restored = ctx.sessions.create(SessionId('blank-restored'), { seed: [] }) + expect(ctx.permissionPresets.current(restored.events)).toBe('workspace-write') + const restoredEvents = [...restored.events] + + const drifted = ctx.sessions.create(SessionId('blank-drifted-knob')) + drifted.append('sandbox/mode', { mode: 'read-only' }) + const driftedEvents = [...drifted.events] + + const legacy = freshSession('blank-originless-selection') + legacy.append('permission/preset', { preset: 'workspace-write' }) + legacy.append('sandbox/mode', { mode: 'workspace-write' }) + legacy.append('approval/policy', { policy: 'ask' }) + const legacyEvents = [...legacy.events] + + const absent = freshSession('blank-without-selection') + + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'workspace-write', + }) + ctx.permissionPresets.refreshDefaultForReuse(picked) + ctx.permissionPresets.refreshDefaultForReuse(restored) + ctx.permissionPresets.refreshDefaultForReuse(drifted) + ctx.permissionPresets.refreshDefaultForReuse(legacy) + ctx.permissionPresets.refreshDefaultForReuse(absent) + + expect(picked.events).toEqual(pickedEvents) + expect(restored.events).toEqual(restoredEvents) + expect(drifted.events).toEqual(driftedEvents) + expect(legacy.events).toEqual(legacyEvents) + expect(absent.events).toEqual([]) + }) + + it('refreshes a cold default-origin placeholder after resume', async () => { + const ctx = await mountedStore() + const source = ctx.sessions.create(SessionId('cold-placeholder-source')) + const stored = [...source.events] + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const resumed = ctx.sessions.create(SessionId('cold-placeholder-resumed'), { seed: stored }) + expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write') + + ctx.permissionPresets.refreshDefaultForReuse(resumed) + + expect(ctx.permissionPresets.current(resumed.events)).toBe('danger-full-access') + expect(resumed.events.slice(-3).map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'danger-full-access', origin: 'default' }], + ['sandbox/mode', { mode: 'danger-full-access' }], + ['approval/policy', { policy: 'never' }], + ]) + }) + it('preserves a seeded legacy session instead of applying the latest user default', async () => { const ctx = await mountedStore() await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { diff --git a/packages/interaction/tool-ask-user/README.i18n.yaml b/packages/interaction/tool-ask-user/README.i18n.yaml index ed6cd6a35f..dd69a79586 100644 --- a/packages/interaction/tool-ask-user/README.i18n.yaml +++ b/packages/interaction/tool-ask-user/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/tool-ask-user/README.md README.md: bf2ba369fca36c462c6155ed2354ab3d0e930dd0 -README.zh.md: 671bea0132093b890de4ce27095fa60dac396276 +README.zh.md: 067235d84d70d2f3278fd39f5c01f130373d3ac2 diff --git a/packages/interaction/tool-ask-user/README.zh.md b/packages/interaction/tool-ask-user/README.zh.md index 671bea0132..067235d84d 100644 --- a/packages/interaction/tool-ask-user/README.zh.md +++ b/packages/interaction/tool-ask-user/README.zh.md @@ -27,7 +27,7 @@ #### 模型看到的内容 -模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。 +模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。 #### Token 影响 diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 0e11850943..8629460671 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/README.i18n.yaml b/packages/interaction/user-approval/README.i18n.yaml index 07843b5f58..ba340c5273 100644 --- a/packages/interaction/user-approval/README.i18n.yaml +++ b/packages/interaction/user-approval/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/user-approval/README.md README.md: 0cf5d458863194e29f8c84168a6f089baabbf3d2 -README.zh.md: ccd8641e30d8a78611eabf602fa46d2705f24487 +README.zh.md: a93f9c17c89ea50622e354eb7729547660e877e2 diff --git a/packages/interaction/user-approval/README.zh.md b/packages/interaction/user-approval/README.zh.md index ccd8641e30..a93f9c17c8 100644 --- a/packages/interaction/user-approval/README.zh.md +++ b/packages/interaction/user-approval/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见 [approval.md](../../../docs/subsystems/approval.md#cordis-surface) 的生成区块。 +与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见 [approval.md](../../../docs/subsystems/approval.zh.md#cordis-surface) 的生成区块。 每个请求都必须属于一个尚未结束的 agent(智能体)轮次。服务会追加一对 `approval/asked` 与 `approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会解析为 `cancelled`;如果审计记录的追加在提交前失败,Promise 会被拒绝,而不会返回一项未记录的决定。 @@ -10,7 +10,7 @@ `ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。 -工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。 ## 模型体验 diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 06f72c1e2f..86dccb7ef7 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 30709a00f3..ae3f841363 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/README.i18n.yaml b/packages/jobs/README.i18n.yaml index 6c032dcbf0..121481d6ab 100644 --- a/packages/jobs/README.i18n.yaml +++ b/packages/jobs/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/README.md README.md: daad96ae561f8f943759332b6b1ea1e20fcaf40e -README.zh.md: 4e733b1da09656c9d779c43a0ee77703e1c10bfa +README.zh.md: 586f195f2603cd1b8e4cd5e23174841cb1a39910 diff --git a/packages/jobs/README.zh.md b/packages/jobs/README.zh.md index 4e733b1da0..586f195f26 100644 --- a/packages/jobs/README.zh.md +++ b/packages/jobs/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`jobs/`](jobs/README.md) | 定义任务注册表和生命周期约定 | `ctx.jobs` | -| [`jobs-local/`](jobs-local/README.md) | 实现进程本地任务注册表 | 注册到 `ctx.jobs` | -| [`tool-jobs/`](tool-jobs/README.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` | +| [`jobs/`](jobs/README.zh.md) | 定义任务注册表和生命周期约定 | `ctx.jobs` | +| [`jobs-local/`](jobs-local/README.zh.md) | 实现进程本地任务注册表 | 注册到 `ctx.jobs` | +| [`tool-jobs/`](tool-jobs/README.zh.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` | -参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)决策。 +参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)决策。 -子系统参考文档——id 方案、所有者隔离约定、快照——见 [docs/subsystems/jobs.md](../../docs/subsystems/jobs.md);设计见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与[任务注册表约定](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)两篇 Agent Note。 +子系统参考文档——id 方案、所有者隔离约定、快照——见 [docs/subsystems/jobs.md](../../docs/subsystems/jobs.zh.md);设计见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与[任务注册表约定](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)两篇 Agent Note。 diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 7d7f6d2c9b..8aa1a4ea4b 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md README.md: e55d9d1747e0dbb12d22f2528527312334f352e6 -README.zh.md: 78979d9518b1b588c689ce5184b33d79177a1ec0 +README.zh.md: 86fd3e8b9db9d0073ce5e6999f5a1fbce12059d2 diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 78979d9518..86fd3e8b9d 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-jobs`](../jobs/README.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。 +[`@deepseek-ai/dsh-jobs`](../jobs/README.zh.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。 ## 准入 @@ -22,7 +22,7 @@ ## 模型体验 -通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。 +通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.zh.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。 #### KV Cache 影响 diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index a615cded8b..e33cf78715 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 0473d974f8..8129e170ed 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md README.md: f4469286603f687be5789bd016ab462dba26e664 -README.zh.md: 30d8e4cb1e298bb4a3a0a3e65bb0858b2458a277 +README.zh.md: 6fd97aa3fe4cbbd555c2475f4132ec00c6a97390 diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 30d8e4cb1e..6fd97aa3fe 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -后台任务注册表约定(`ctx.jobs`)。抽象的 `JobRegistry` 及其词汇类型在同一份约定下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-jobs-local`](../jobs-local/README.md)。生产方插件使用其不透明 id namespace 扩展 `JobKindMap`。 +后台任务注册表约定(`ctx.jobs`)。抽象的 `JobRegistry` 及其词汇类型在同一份约定下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-jobs-local`](../jobs-local/README.zh.md)。生产方插件使用其不透明 id namespace 扩展 `JobKindMap`。 ## 服务约定 @@ -23,11 +23,11 @@ 实现还必须兑现约定的生命周期语义:注册的存续期长于生产方 fiber 与控制器 fiber,owner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。 -参见[任务类型目录](../../../docs/subsystems/jobs.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)。 +参见[任务类型目录](../../../docs/subsystems/jobs.zh.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)。 ## 模型体验 -通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会渲染 job id、输出、状态、取消和完成通知。 +通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.zh.md) 间接影响;它们会渲染 job id、输出、状态、取消和完成通知。 #### KV Cache 影响 diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 6734d1ea37..333ca7deac 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index e1a51a2cbe..6b5cc133dc 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-jobs/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/tool-jobs/README.md README.md: 8d6a00651f69258d764904ac99d000c2055982dc -README.zh.md: 15903b6f263fe9d225c48bf567c46f587d23cb00 +README.zh.md: bd1045ecf6f2b7c9ed1f8a8cdfe4f965af418a37 diff --git a/packages/jobs/tool-jobs/README.zh.md b/packages/jobs/tool-jobs/README.zh.md index 15903b6f26..bd1045ecf6 100644 --- a/packages/jobs/tool-jobs/README.zh.md +++ b/packages/jobs/tool-jobs/README.zh.md @@ -63,7 +63,7 @@ Track every background job id you start. You are notified in-session when a job #### 模型看到的内容 -该工具集可见时,会看到生成的 [`job_output`、`job_list` 和 `job_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-jobs)。 +该工具集可见时,会看到生成的 [`job_output`、`job_list` 和 `job_kill` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-jobs)。 #### Token 影响 diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 57f585ff50..b2efe581cd 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index a655518047..8dbce74a9d 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/README.md README.md: 15f90024abf3256560d67099af79be7d4e0ee310 -README.zh.md: 6be4123e2e5de5c41937c4b966580278c78e5a42 +README.zh.md: 31ccfd0c8d979f1dac1ec2c0d6a377c9277c3271 diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index 6be4123e2e..31ccfd0c8d 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -6,12 +6,12 @@ LLM(大语言模型)seam 及其提供方适配器。`llm` 包同时承担 Se | 包 | 职责 | ctx key | |---|---|---| -| [`llm/`](llm/README.md) | LLM 服务和共享流式词汇 | `ctx.llm` | -| [`token-meter/`](token-meter/README.md) | 可感知回放的 token 测量 | `ctx.tokenMeter` | -| [`llm-retry/`](llm-retry/README.md) | 提供方作用域的重试策略 | 监听 `agent/request-error` | -| [`llm-deepseek/`](llm-deepseek/README.md) | 直接 DeepSeek 适配器 | 注册到 `ctx.llm` | -| [`llm-pi-ai/`](llm-pi-ai/README.md) | 多提供方 pi-ai 适配器 | 注册到 `ctx.llm` | +| [`llm/`](llm/README.zh.md) | LLM 服务和共享流式词汇 | `ctx.llm` | +| [`token-meter/`](token-meter/README.zh.md) | 可感知回放的 token 测量 | `ctx.tokenMeter` | +| [`llm-retry/`](llm-retry/README.zh.md) | 提供方作用域的重试策略 | 监听 `agent/request-error` | +| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 直接 DeepSeek 适配器 | 注册到 `ctx.llm` | +| [`llm-pi-ai/`](llm-pi-ai/README.zh.md) | 多提供方 pi-ai 适配器 | 注册到 `ctx.llm` | -适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)说明设计原理。 +适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md)说明设计原理。 -子系统参考——消息与内容块、模型请求、`StreamChunk` 协议、适配器约定(adapter contract)——见 [docs/subsystems/llm-streaming.md](../../docs/subsystems/llm-streaming.md)(token 计量:[token-meter.md](../../docs/subsystems/token-meter.md));另见[孪生适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)、[回放 token 计量](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md)与[按路由模型上下文](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) Agent Note。 +子系统参考——消息与内容块、模型请求、`StreamChunk` 协议、适配器约定(adapter contract)——见 [docs/subsystems/llm-streaming.md](../../docs/subsystems/llm-streaming.zh.md)(token 计量:[token-meter.md](../../docs/subsystems/token-meter.zh.md));另见[孪生适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md)、[回放 token 计量](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md)与[按路由模型上下文](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md) Agent Note。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 695a8add1d..82da16e4f0 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 18303cfe36d80b9fcb76782d8829bcd76c4cbb0d -README.zh.md: 22321f2d2b026428d16654b0f9304fa72774c523 +README.md: bae9011135a9cbc14467086e4b6ebc6f052ed230 +README.zh.md: 0a5f0224dbebd62766775822260585825579f4a7 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 18303cfe36..bae9011135 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -78,7 +78,7 @@ DeepSeek request identity is separate from app attribution. After credential res - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. - The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). -- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). +- **Reasoning passback rule**: every assistant turn that carried reasoning serializes `reasoning_content` back in history. Thinking mode requires it on tool-call turns; DeepSeek ignores it elsewhere, while a gateway re-encoding the conversation for another vendor recovers that turn's upstream thinking signature by hashing the replayed text. - Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`. - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. @@ -92,15 +92,15 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect -Provider tokenization governs exact text and image-token input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning and over-budget images avoids paying those tokens again; cache-read usage is reported when available. +Provider tokenization governs exact text and image-token input. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again; cache-read usage is reported when available. #### KV Cache effect -An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. +An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends on every reasoned turn. ### DeepSeek response diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 22321f2d2b..0a5f0224db 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -69,16 +69,16 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 应用归因 -每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 +每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 -DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提供方请求都会通过 `x-deepseek-harness-user-id` 携带来自 [`@deepseek-ai/dsh-anonymous-user-id`](../../identity/anonymous-user-id/README.md) 的稳定匿名 id;携带 `GenerateOptions.sessionId` 的请求还会通过 `x-deepseek-harness-session-id` 发送该确切值,缺少会话的直接调用则省略会话标头。两个标头都会发送至解析后的 `baseURL`(包括已配置的 gateway),且不会进入请求正文或模型可见内容。 +DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提供方请求都会通过 `x-deepseek-harness-user-id` 携带来自 [`@deepseek-ai/dsh-anonymous-user-id`](../../identity/anonymous-user-id/README.zh.md) 的稳定匿名 id;携带 `GenerateOptions.sessionId` 的请求还会通过 `x-deepseek-harness-session-id` 发送该确切值,缺少会话的直接调用则省略会话标头。两个标头都会发送至解析后的 `baseURL`(包括已配置的 gateway),且不会进入请求正文或模型可见内容。 ## 协议格式说明 - 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish 分片上,也可能作为尾随的纯 usage 分片到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。 - 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。 - 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。 -- **推理回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(思考模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。 +- **推理回传规则**:每个携带推理内容的 assistant 轮次都会将 `reasoning_content` 序列化回历史。思考模式在工具调用轮次上必需它;DeepSeek 在其他轮次上会忽略它,而将该对话重新编码转发给其他厂商的网关,要靠对回传原文取哈希来恢复该轮次上游的思考签名。 - 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。 - Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。 @@ -92,15 +92,15 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 -精确文本与图片 token 输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理和超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。 +精确文本与图片 token 输入取决于提供方 tokenization。推理回传会把每个含推理轮次的思维链带入后续请求,丢弃超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。 #### KV Cache 影响 -未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 +未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在每个含推理的轮次上追加。 ### DeepSeek 响应 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 52d022806a..766ba80fd1 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index da643aa599..498b3fb2f7 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -182,10 +182,12 @@ function serializeAssistant(message: Message): WireMessage { // the message sits durably in the session log, a null here bricks every // later turn of that session. content: text, - // Official passback rule (guides/thinking_mode.mdx): reasoning_content - // must return on tool-call turns; it is ignored on plain turns, so we - // drop it there to save tokens. - ...toolCalls.length > 0 && reasoning.length > 0 ? { reasoning_content: reasoning } : {}, + // CoT passback on every reasoning-carrying turn. The official rule + // (guides/thinking_mode.mdx) requires it on tool-call turns and ignores it + // elsewhere; a gateway re-encoding the conversation for another vendor + // recovers that turn's upstream thinking signature by hashing this exact + // text, which a tool-call-free turn carries nowhere else. + ...reasoning.length > 0 ? { reasoning_content: reasoning } : {}, ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}, } } diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 1781ed8646..93c7f48a36 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -79,9 +79,11 @@ export interface WireAssistantMessage { role: 'assistant' content: string | null /** - * CoT passback. REQUIRED on assistant turns that carried tool calls - * (thinking mode); ignored on tool-call-free turns (we omit it there to - * save tokens). See guides/thinking_mode.mdx § Tool Calls. + * CoT passback, present on every turn whose assistant content carried + * reasoning. REQUIRED on tool-call turns in thinking mode (see + * guides/thinking_mode.mdx § Tool Calls); DeepSeek ignores it elsewhere, + * while a gateway re-encoding for another vendor recovers that turn's + * thinking signature by hashing it. */ reasoning_content?: string tool_calls?: WireToolCall[] diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 973fd63f4b..506bfc657d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -138,7 +138,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () try { // JSON.stringify quotes the value: YAML is a JSON superset, so a real // key survives whatever characters it happens to carry. - await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) // Scrub the ambient variable so only the credential seam can supply the // key: this request proves the per-request resolution path end to end. vi.stubEnv('DEEPSEEK_API_KEY', '') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index b397efdba7..ac2043170a 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -100,7 +100,7 @@ describe('request-level dynamic configuration', () => { it('routes the next request with the freshly resolved base URL and credential', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: serverA.url }) @@ -255,7 +255,7 @@ describe('request-level dynamic configuration', () => { it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 1398d36110..ec83345b61 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -53,7 +53,7 @@ async function loadComposition( const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) + await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) } const configPath = join(root, 'cordis.yml') @@ -124,7 +124,7 @@ describe('llm-deepseek real dynamic composition', () => { await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) + await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) await vi.waitFor(async () => { expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) }, { timeout: 5000 }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index d503a8b5c7..742efc863d 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -54,7 +54,7 @@ describe('serializeMessages', () => { expect(wire).toEqual([{ role: 'system', content: 'be brief' }]) }) - it('maps plain assistant text without reasoning_content', () => { + it('passes reasoning_content back on tool-call-free turns', () => { const wire = serializeMessages([ createMessage({ role: 'assistant', @@ -65,8 +65,10 @@ describe('serializeMessages', () => { source: { kind: 'plugin', plugin: 'test' }, }), ]) - // Tool-call-free turn: reasoning is dropped (ignored by the API anyway). - expect(wire).toEqual([{ role: 'assistant', content: 'answer' }]) + // A gateway that re-encodes the conversation for another vendor recovers + // the upstream thinking signature by hashing this exact text, and a turn + // that called no tool carries it nowhere else. + expect(wire).toEqual([{ role: 'assistant', content: 'answer', reasoning_content: 'thinking…' }]) }) it('passes reasoning_content back on tool-call turns (official passback rule)', () => { @@ -589,16 +591,17 @@ describe('review fixes: assistant content shapes', () => { expect(wire).toEqual([{ role: 'assistant', content: '' }]) }) - it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => { + it('serializes a reasoning-ONLY assistant message as "" content beside its reasoning', () => { // The model can answer entirely in the reasoning channel (a v4-flash - // greeting did, live). The passback rule keeps reasoning_content off - // plain turns, and content must still be SET — a null here poisoned the - // session log and bricked every later turn of that session. + // greeting did, live). Content must still be SET — a null here poisoned + // the session log and bricked every later turn of that session. const wire = serializeMessages([createMessage({ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }], source: { kind: 'plugin', plugin: 'test' }, })]) - expect(wire).toEqual([{ role: 'assistant', content: '' }]) + expect(wire).toEqual([{ + role: 'assistant', content: '', reasoning_content: '你好!有什么我可以帮你的吗?', + }]) }) it('serializes tool-call turns with empty string content, not null', () => { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 694da1fb3c..034382822f 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: f696b6bee50b844bfbc6bab7f9def0e785d450c9 -README.zh.md: cace9d1fdfd85a559674b121b79b3dbb86b4337b +README.md: 19dbcfa90dbefbe322800c83da6d75c70c849f05 +README.zh.md: 334c6c3166f35f5dc7d7659b9de7cff404c6b56d diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f696b6bee5..19dbcfa90d 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -51,9 +51,12 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,13 @@ A profile's `models` list *replaces* the route's installed catalog rather than e The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. -### Reasoning-dispatch compat switches +### Wire-compatibility switches -How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. +pi-ai shapes each request from the provider id and baseURL: which role carries the system prompt, which field caps output, how a thinking level travels. A private gateway's URL says nothing, and for an endpoint pi-ai does not recognize the detection answers as though it were OpenAI itself — a reasoning model's system prompt goes out as `developer`, the output cap as `max_completion_tokens`, the thinking level as a bare `reasoning_effort` — and most OpenAI-compatible gateways reject at least one of those. `compat` is therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's own detection; a route-level switch shadows the catalog entry's value for every model that reads it, and there is no spelling for handing a field back to the catalog short of restating its value. + +Each switch belongs to the protocols whose pi-ai compat type declares it, and grouping follows the compat *type* rather than the protocol name: the three Responses protocols (`openai-responses`, `azure-openai-responses`, `openai-codex-responses`) share one compat type, so a switch settable on one is settable on all three. `supportsDeveloperRole` is settable on `openai-completions` and on those three; `thinkingFormat` only on `openai-completions`; `supportsTemperature` only on `anthropic-messages`; `supportsStrictMode` also reaches `bedrock-converse-stream`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all. + +Three kinds of key are refused rather than dropped: one no protocol declares (a misspelling), one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such — and one written with no value at all (`supportsDeveloperRole:`), which schemastery passes through as null and which would otherwise replace the installed catalog's value with nothing. The offered set is pinned to pi-ai's four compat types by drift gates, the protocols carrying them are derived from `Model.compat` itself, and each field's type is derived from upstream rather than restated, so an upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. @@ -133,7 +140,7 @@ Most listings disclose an id and nothing else; `context_window`/`context_length` Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. -Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. +A route's `apiKeyEnv` key still resolves through the harness seam before the request reaches pi-ai and rides as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override — the fail-loud reference semantics stay the harness's. Beneath that override the collection carries this plugin's credential store and ambient auth context: a stored sign-in (an OAuth grant, or a key typed into pi-ai's own login prompt) authenticates its route through them and refreshes itself under the store's cross-process lock. The store addresses records as `llm-pi-ai/`, and a hand-declared route key outside that record grammar (uppercase, dots, underscores) reads as holding nothing stored rather than as an addressing error; such a route cannot be signed into — a record write for it refuses with `LlmError('UNSTORABLE_PROVIDER_ID')` — and authenticates through `apiKeyEnv` or ambient provider settings. A route naming no credential at all resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. @@ -190,8 +197,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design work. -- **A provider that authenticates through OAuth alone is not offered** — pi-ai resolves OAuth from a *stored* OAuth credential, and this adapter builds its `Models` collection with no credential store and runs no login flow, so every request on such a route fails `Provider is not configured` before it goes out. The configurable-provider directory withholds them; `openai-codex` is the only one the installed catalog ships. A route a settings document already names keeps its entry so a configuration surface can edit or delete it, and `apiKeyEnv` still authenticates it with that key — which for Codex is a token that expires with nothing here to refresh it. -- **Provider-native discovery reads the process environment only** — a route naming no credential defers to the catalog provider's own resolution, which interrogates environment variables (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, and each provider's own set). It reads no local credential directory, so `~/.aws/credentials` without an exported `AWS_PROFILE` resolves as unconfigured, and a value held by the harness credential seam is invisible to it unless the process environment carries it too. +- **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer. +- **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam. - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index cace9d1fdf..334c6c3166 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -51,9 +51,12 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,13 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 -### 推理分派的 compat 开关 +### 协议兼容开关 -思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输。私有网关的 URL 什么也说明不了,而对于 pi-ai 无法识别的端点,其检测会当作 OpenAI 本身来回答——推理模型的系统提示词以 `developer` 发出、输出上限写作 `max_completion_tokens`、思考级别只发一个裸的 `reasoning_effort`——而多数 OpenAI 兼容网关至少会拒绝其中之一。因此 `compat` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 自身的检测;路由级开关会为每个读取它的模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。 + +每个开关归属于其 pi-ai compat 类型声明了它的那些协议,且归组依据是 compat **类型**而非协议名:三个 Responses 协议(`openai-responses`、`azure-openai-responses`、`openai-codex-responses`)共用同一个 compat 类型,因此可设在其中之一的开关,三者皆可设。`supportsDeveloperRole` 可设在 `openai-completions` 与这三者上;`thinkingFormat` 只能设在 `openai-completions`;`supportsTemperature` 只能设在 `anthropic-messages`;`supportsStrictMode` 还可达 `bedrock-converse-stream`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。 + +三类键会被拒绝而非丢弃:没有任何协议声明的键(笔误);pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由;以及完全没有写值的键(`supportsDeveloperRole:`),schemastery 会把它放行为 null,若照单收下就会用空值替换已安装 catalog 的值。开放集由漂移门禁钉在 pi-ai 的四个 compat 类型上,承载它们的协议集派生自 `Model.compat` 本身,每个字段的类型也派生自上游而非重述,因此上游新增字段、给别的协议加上 compat 类型、或拓宽某个值并集,都会使构建失败,直到有人为它做出分类。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。 @@ -134,7 +141,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 -凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己明确失败的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 +路由的 `apiKeyEnv` 密钥仍在请求抵达 pi-ai 之前经 harness 自身 seam 解析,并作为请求的 `apiKey` 选项传入——pi-ai 将其视为优先级最高的 auth 覆盖,harness 因此保住自己明确失败的引用语义。在该覆盖之下,集合携带本插件的凭据存储与 ambient auth context:已存储的登录(OAuth grant,或在 pi-ai 自己的登录提示里键入的密钥)经由它们为路由完成认证,并在存储的跨进程锁下自行刷新。存储以 `llm-pi-ai/` 为记录地址;手写路由键若落在记录文法之外(大写、点、下划线),读取时视为「没有存储任何东西」而不是寻址错误——这样的路由无法登录(对它的记录写入会以 `LlmError('UNSTORABLE_PROVIDER_ID')` 拒绝),只能经 `apiKeyEnv` 或提供方 ambient 设置认证。完全没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 @@ -152,7 +159,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 ## 应用归因 -每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,并通过 pi-ai `headers` 流选项合并。不会合成提供方特定应用归因标头。详见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts)。 +每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,并通过 pi-ai `headers` 流选项合并。不会合成提供方特定应用归因标头。详见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts)。 ## 依赖体量 @@ -191,11 +198,11 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。 -- **仅以 OAuth 认证的提供方不予提供**:pi-ai 的 OAuth 只从*已存储*的 OAuth 凭据解析,而本适配器构造 `Models` 集合时不注入凭据存储、也不运行登录流程,因此这类路由的每个请求都会在发出之前以 `Provider is not configured` 失败。可配置提供方目录因此不列出它们;已安装 catalog 中只有 `openai-codex` 属于此类。settings 文档已经写过的路由仍保留目录条目,配置界面据此可以编辑或删除;`apiKeyEnv` 也仍能用该密钥完成认证——对 Codex 而言那是一个会过期、且这里没有任何环节会去刷新的 token。 -- **提供方自带的凭据发现只读进程环境**:不指定凭据的路由交由 catalog 提供方自行解析,而它探测的是环境变量(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE`、`AWS_ACCESS_KEY_ID` 以及各提供方自己的那一组)。它不读任何本地凭据目录,因此只有 `~/.aws/credentials` 而未导出 `AWS_PROFILE` 会被解析为未配置;由 harness 凭据 seam 保管的值,除非进程环境里也有,否则对它不可见。 +- **一次登录只存活于发起它的进程中**:授权尝试不可持久,登录途中刷新页面会丢弃它,人需要重来。登出即对已存储记录执行 `deleteRecord`,它只在本地遗忘而不通知签发方。 +- **提供方自带的凭据发现经由本插件的 ambient context 作答**:不指定凭据的路由交由 catalog 提供方自行解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 以及各提供方自己的那一组)与本地凭据文件是否存在。两类问题都在这里作答:先查凭据 seam 再查进程环境,文件存在性则按宿主进程的文件系统判断并展开 `~`。它做不到的是*读取*凭据文件的内容——自行解析 `~/.aws/credentials` 的提供方是直接读盘的,不经过 seam。 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以 dormant 方式挂载该适配器),且 `models` 列表是数组、整体替换,这是带内的解决办法。 -- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 +- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.zh.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 - **模态声明不经验证,且多声明的后果超出本轮**:没有任何环节会去询问端点接受什么,因此声明了网关并不提供的 `image` 的模型不会在这里被拦下,而是由提供方在轮次中途拒绝。prompt 准入在构造请求之前就把用户消息持久化提交,于是被拒绝的图片留在会话日志里:该模型会不断重发它,而模型选择拒绝切换到任何纯文本模型。恢复途径是换一个确实支持图片的模型、fork 到图片之前,或开启新会话;发送失败时把尚未消费的图片消息从日志中回滚出去这件事已暂缓。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 3575d6672f..ab286f006f 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, @@ -33,6 +33,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -47,6 +48,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 5ecec593da..3c7ecd4a91 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,10 +13,15 @@ * way down: switching models mid-reply takes effect on the next step, never * inside the one in flight. * - * Credentials stay outside that collection. The harness resolves a route's key - * through its own seam and passes it as the request's `apiKey` option, which - * pi-ai treats as the highest-priority auth override — so `Models` never holds - * a credential store and the harness keeps its fail-loud reference semantics. + * A route naming a credential reference still resolves it through the harness + * seam and passes it as the request's `apiKey` option, which pi-ai treats as + * the highest-priority auth override — that is what keeps the fail-loud + * reference semantics. Everything that override does not cover reaches pi-ai + * through the collection's own auth: the credential store holds the records a + * login wrote and a refresh rotates, and the auth context answers the ambient + * questions a provider asks while resolving. Both are stable across snapshots, + * so a configuration change rebuilds the collection without forgetting who is + * signed in. * * @module dsh-llm-pi-ai/adapter */ @@ -24,6 +29,8 @@ import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, + AuthContext, + CredentialStore, Model, Models, ModelThinkingLevel, @@ -74,6 +81,15 @@ export interface PiAiAdapterOptions { * `MISSING_CREDENTIAL` rather than falling back. */ resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise + /** + * How every collection this adapter builds resolves auth the request-level + * `apiKey` override does not cover. Required rather than optional: a + * collection built without them gets pi-ai's in-memory default store, which + * is empty at every boot and discarded on every configuration change, so a + * route whose only method is a login would report itself unconfigured on + * every request no matter how often the human signed in. + */ + auth: PiAiAuthInjection /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined /** @@ -83,6 +99,14 @@ export interface PiAiAdapterOptions { onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void } +/** The two auth injectables a pi-ai collection is built with. */ +export interface PiAiAuthInjection { + /** Durable storage for credentials pi-ai itself writes: logins, and the refreshes it runs under its own lock. */ + credentials: CredentialStore + /** Ambient lookups a provider performs while resolving its own auth. */ + authContext: AuthContext +} + /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( profile: ResolvedPiAiProviderProfile, @@ -204,7 +228,7 @@ export class PiAiAdapter extends LlmAdapter { private current(): PiAiSnapshot { const profiles = this.config.profiles() if (this.snapshot?.profiles === profiles) return this.snapshot - const models: MutableModels = createModels() + const models: MutableModels = createModels(this.config.auth) for (const profile of profiles.values()) models.setProvider(profile.piProvider) this.snapshot = { profiles, models } return this.snapshot diff --git a/packages/llm/llm-pi-ai/src/auth.ts b/packages/llm/llm-pi-ai/src/auth.ts new file mode 100644 index 0000000000..f729a77f85 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/auth.ts @@ -0,0 +1,207 @@ +/** + * The three adapters between pi-ai's auth model and the harness credential + * plane. Every pi-ai-specific concept stays on this side of them: the harness + * seams they consume — `ctx.credentials` records and `ctx.authorization` flows — + * name nothing from this library, so another adapter family can arrive with a + * different auth model and share the same two seams. + * + * @module dsh-llm-pi-ai/auth + */ + +import { homedir } from 'node:os' +import { access } from 'node:fs/promises' +import { resolve as resolvePath } from 'node:path' +import type { AuthContext, Credential, CredentialInfo, CredentialStore } from '@earendil-works/pi-ai' +import type { Context } from '@deepseek-ai/cordis' +import { + credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName, +} from '@deepseek-ai/dsh-credentials' +import type { CredentialKey, CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { LlmError } from '@deepseek-ai/dsh-llm' + +/** + * The record scope every credential this adapter family stores is written + * under. It is the plugin's registered name, which is what tells a later + * reader — a configuration UI, or a second adapter family serving the same + * provider name — that this plugin owns the format inside the record. + */ +export const RECORD_SCOPE = 'llm-pi-ai' + +/** + * The record address for one pi-ai provider id. + * @param providerId - pi-ai's own provider id, which is also the harness route key. + * @returns the scoped credential key this adapter family reads and writes. + */ +export function recordKeyFor(providerId: string): CredentialKey { + return credentialKey(RECORD_SCOPE, providerId) +} + +/** + * Translate a stored record into the credential pi-ai expects. + * + * An `api-key` record is structural on both sides, so it is rebuilt field by + * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim: + * the seam treats it as opaque JSON precisely so a library that owns a token + * format keeps owning it, refresh fields and all. + * @param record - the stored record, or undefined when nothing is stored. + * @returns the pi-ai credential, or undefined for an absent record. + */ +function toPiCredential(record: CredentialRecord | undefined): Credential | undefined { + if (record === undefined) return undefined + if (record.kind === 'api-key') { + return { + type: 'api_key', + ...record.key === undefined ? {} : { key: record.key }, + ...record.env === undefined ? {} : { env: { ...record.env } }, + } + } + return record.payload as Credential +} + +/** + * Translate a pi-ai credential into the record to store. + * @param credential - what a login or refresh produced. + * @returns the record to commit, in the union the credential seam stores. + */ +function toRecord(credential: Credential): CredentialRecord { + if (credential.type === 'api_key') { + return { + kind: 'api-key', + ...credential.key === undefined ? {} : { key: credential.key }, + ...credential.env === undefined ? {} : { env: { ...credential.env } }, + } + } + return { kind: 'grant', payload: credential } +} + +/** + * The credential service, or the failure that names what is missing. Reads + * answer "nothing stored" without a service, because a composition with no + * credential plane genuinely holds no credential; writes refuse, because a + * login whose grant silently evaporated would report success and then fail + * every request. + * @param ctx - the plugin context. + * @returns the live service. + * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted. + */ +function writableStore(ctx: Context): CredentialProvider { + const credentials = ctx.get('credentials') + if (credentials === undefined) { + throw new LlmError( + 'llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the' + + ' credential a sign-in produces; mount one (dsh-credentials-local) to sign in', + 'NO_CREDENTIAL_STORE', + ) + } + return credentials +} + +/** + * A pi-ai `CredentialStore` over the harness credential records. + * + * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has + * to cover a network round trip rather than a file rename — which is why the + * record write path takes a wait limit of its own rather than the short one a + * local write would need. + * + * pi-ai asks this store about every provider in the collection, hand-declared + * routes included, and a route key is an arbitrary settings dict key while a + * record id is not. An id outside the record grammar can never have stored a + * record, so reads answer "nothing stored" and a delete has nothing to remove; + * only `modify` refuses it, because a write that cannot land must not report + * that it did. + * @param ctx - the plugin context carrying the optional `ctx.credentials`. + * @returns the store to hand `createModels()`. + */ +export function credentialStoreFrom(ctx: Context): CredentialStore { + return { + async read(providerId) { + const credentials = ctx.get('credentials') + if (credentials === undefined) return undefined + if (!isCredentialKeySegment(providerId)) return undefined + return toPiCredential(await credentials.readRecord(recordKeyFor(providerId))) + }, + async list(): Promise { + const stored = await ctx.get('credentials')?.listRecords() ?? [] + const mine: CredentialInfo[] = [] + for (const entry of stored) { + // Records another plugin owns are not this collection's to report: + // their payloads are written in a format pi-ai never agreed to. + if (credentialKeyScope(entry.key) !== RECORD_SCOPE) continue + mine.push({ + providerId: credentialKeyId(entry.key), + type: entry.kind === 'api-key' ? 'api_key' : 'oauth', + }) + } + return mine + }, + async modify(providerId, mutate) { + if (!isCredentialKeySegment(providerId)) { + throw new LlmError( + `llm-pi-ai: provider id "${providerId}" cannot address a stored credential record (a record id is a` + + ' lowercase hyphenated identifier); authenticate this route through apiKeyEnv instead of a stored' + + ' credential', + 'UNSTORABLE_PROVIDER_ID', + ) + } + const stored = await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => { + const next = await mutate(toPiCredential(current)) + return next === undefined ? undefined : toRecord(next) + }) + return toPiCredential(stored) + }, + // `async` so a missing service reaches the caller as a rejection: pi-ai's + // store contract is promise-returning, and a synchronous throw would + // escape the `ModelsError` wrapper every other storage failure gets. + async delete(providerId) { + if (!isCredentialKeySegment(providerId)) return + await writableStore(ctx).deleteRecord(recordKeyFor(providerId)) + }, + } +} + +/** + * A pi-ai `AuthContext` over the harness credential plane and the host + * filesystem. + * + * `env()` answers from the credential seam first, so a value a deployment + * stored through the harness is found by a provider's own ambient discovery — + * without this, that discovery reads only the process environment and a stored + * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host + * process's own filesystem rather than the workspace `ctx.fs` seam, because the + * paths it is asked about (`~/.aws/credentials`, application-default + * credentials) are facts about where this process runs, not about the project + * under edit. + * @param ctx - the plugin context carrying the optional `ctx.credentials`. + * @returns the auth context to hand `createModels()`. + */ +export function authContextFrom(ctx: Context): AuthContext { + return { + async env(name) { + // pi-ai asks about arbitrary provider-declared names; one that is not a + // POSIX identifier can never have been stored as a reference, and asking + // the seam would throw instead of answering "not set". + if (isCredentialRefName(name)) { + const credentials = ctx.get('credentials') + const hit = await credentials?.resolve(credentialRef(name)) + if (hit !== undefined) return hit.value + } + return launchEnvironmentOf(ctx).get(name)?.value + }, + async fileExists(path) { + const expanded = path.startsWith('~/') || path === '~' + ? resolvePath(homedir(), path.slice(1).replace(/^\//, '')) + : path + try { + await access(expanded) + return true + } catch { + // Absent, unreadable, or a broken symlink — every one of which means + // this ambient credential source cannot be used, which is the only + // distinction the caller makes. + return false + } + }, + } +} diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 7a4ff7e8a2..1e9b126dd5 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -15,11 +15,16 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' import type { + AnthropicMessagesCompat, Api, + BedrockCompat, + ChatTemplateKwargValue, + KnownApi, Model, ModelCost, ModelThinkingLevel, OpenAICompletionsCompat, + OpenAIResponsesCompat, Provider, ThinkingLevelMap, } from '@earendil-works/pi-ai' @@ -79,23 +84,16 @@ const THINKING_LEVEL_GATE: Record = { /** Every pi-ai thinking level a profile may declare, in escalation order. */ export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' - /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude +export type PiAiThinkingFormat = NonNullable /** * The nameable reasoning-dispatch formats, most-reached first. The `Record` * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added - * `baseten`) fails compilation here until the format is classified as offered - * here or withheld above, so the offer never silently lags the upstream set. + * `baseten`) fails compilation here until the new format is named, so the + * offer never silently lags the upstream set. The two `chat-template` variants + * are nameable because {@link PiAiCompatProfile.chatTemplateKwargs} carries + * the kwargs they dispatch through. */ const THINKING_FORMAT_GATE: Record = { 'openai': true, @@ -104,6 +102,8 @@ const THINKING_FORMAT_GATE: Record = { 'together': true, 'zai': true, 'qwen': true, + 'chat-template': true, + 'qwen-chat-template': true, 'string-thinking': true, 'ant-ling': true, } @@ -111,6 +111,41 @@ const THINKING_FORMAT_GATE: Record = { /** Reasoning-dispatch wire formats a profile may name, most-reached first. */ export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[] +/** The output-cap field spellings pi-ai accepts. */ +export type PiAiMaxTokensField = NonNullable + +/** Drift gate over {@link PiAiMaxTokensField}; an upstream spelling added here fails compilation until named. */ +const MAX_TOKENS_FIELD_GATE: Record = { + max_completion_tokens: true, + max_tokens: true, +} + +/** The output-cap field spellings a profile may name. */ +export const MAX_TOKENS_FIELDS = Object.keys(MAX_TOKENS_FIELD_GATE) as readonly PiAiMaxTokensField[] + +/** The prompt-cache marker conventions pi-ai accepts. */ +export type PiAiCacheControlFormat = NonNullable + +/** Drift gate over {@link PiAiCacheControlFormat}; a new upstream convention fails compilation until named. */ +const CACHE_CONTROL_FORMAT_GATE: Record = { + anthropic: true, +} + +/** The prompt-cache marker conventions a profile may name. */ +export const CACHE_CONTROL_FORMATS = Object.keys(CACHE_CONTROL_FORMAT_GATE) as readonly PiAiCacheControlFormat[] + +/** The request-state placeholders a `chat_template_kwargs` value may name. */ +export type PiAiChatTemplateVar = Extract['$var'] + +/** Drift gate over {@link PiAiChatTemplateVar}; a new upstream placeholder fails compilation until named. */ +const CHAT_TEMPLATE_VAR_GATE: Record = { + 'thinking.enabled': true, + 'thinking.effort': true, +} + +/** The request-state placeholders a profile may name. */ +export const CHAT_TEMPLATE_VARS = Object.keys(CHAT_TEMPLATE_VAR_GATE) as readonly PiAiChatTemplateVar[] + let providerIndex: Map | undefined /** @@ -141,26 +176,6 @@ export function catalogProviderIds(): readonly string[] { return getBuiltinProviders() } -/** - * Whether the installed catalog provider for one route declares an api-key - * method — the only authentication this adapter obtains on its own. - * - * A key is what the harness resolves through its own credential seam and hands - * pi-ai per request. pi-ai's other method, OAuth, resolves from a *stored* - * OAuth credential alone: `resolveProviderAuth` has no ambient path for it, - * this adapter builds its `Models` collection with no credential store, and - * nothing here runs a login flow. So a provider offering OAuth by itself - * leaves nothing for this adapter to authenticate with, and the posture such a - * provider invites — no key configured, credentials discovered by the provider - * — fails every request with `Provider is not configured`. - * @param provider - provider route key. - * @returns whether the catalog provider takes an api key; false for a route - * pi-ai does not ship, which the caller answers for separately. - */ -export function catalogProviderTakesApiKey(provider: string): boolean { - return catalogProvider(provider)?.auth.apiKey !== undefined -} - /** * The installed catalog models for one route, indexed by model id. * @param provider - provider route key. @@ -183,19 +198,336 @@ export function catalogModels(provider: string): Map> { export type PiAiReasoningEfforts = Partial> /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * Whether one pi-ai compat field is configurable on a profile. + * + * `withhold` is the disposition for a field pi-ai's installed catalog already + * sets for a named vendor. Reaching for one of those on a hand-declared route + * means configuring a provider that should have been named as a catalog route + * instead, where the installed entry carries the right value already. + */ +type CompatDisposition = 'offer' | 'withhold' + +/** + * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type + * is a drift gate: a pi-ai upgrade that adds a field fails compilation here + * until it is classified, so the offer never silently lags the upstream set. + */ +const COMPLETIONS_COMPAT_GATE = { + supportsStore: 'offer', + supportsDeveloperRole: 'offer', + supportsReasoningEffort: 'offer', + supportsUsageInStreaming: 'offer', + maxTokensField: 'offer', + requiresToolResultName: 'offer', + requiresAssistantAfterToolResult: 'offer', + requiresThinkingAsText: 'offer', + requiresReasoningContentOnAssistantMessages: 'offer', + thinkingFormat: 'offer', + chatTemplateKwargs: 'offer', + supportsStrictMode: 'offer', + cacheControlFormat: 'offer', + supportsLongCacheRetention: 'offer', + openRouterRouting: 'withhold', + vercelGatewayRouting: 'withhold', + zaiToolStream: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + sendSessionAffinityHeaders: 'withhold', + deferredToolsMode: 'withhold', + sessionAffinityFormat: 'withhold', +} as const satisfies Record + +/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */ +const RESPONSES_COMPAT_GATE = { + supportsDeveloperRole: 'offer', + supportsStrictMode: 'offer', + supportsLongCacheRetention: 'offer', + sessionAffinityFormat: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + supportsToolSearch: 'withhold', + supportsExplicitPromptCacheMode: 'withhold', +} as const satisfies Record + +/** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */ +const ANTHROPIC_COMPAT_GATE = { + supportsEagerToolInputStreaming: 'offer', + supportsLongCacheRetention: 'offer', + supportsCacheControlOnTools: 'offer', + supportsTemperature: 'offer', + forceAdaptiveThinking: 'offer', + allowEmptySignature: 'offer', + supportsStrictTools: 'offer', + sendSessionAffinityHeaders: 'withhold', + supportsToolReferences: 'withhold', +} as const satisfies Record + +/** Disposition of every `BedrockCompat` field; a drift gate like the one above. */ +const BEDROCK_COMPAT_GATE = { + supportsStrictMode: 'offer', +} as const satisfies Record + +/** + * Every wire protocol pi-ai gives a compat type. Derived from `Model.compat`'s + * own conditional rather than listed by hand, so a pi-ai release that gives a + * further protocol a compat type fails the {@link COMPAT_GATES} entry list + * until someone classifies its fields. A protocol pi-ai gives no compat type + * resolves away here and takes no configured compat at all. + */ +type ApiWithCompat = { [K in KnownApi]: NonNullable['compat']> extends never ? never : K }[KnownApi] + +/** + * The compat gate of every wire protocol a profile may configure. + * + * Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses + * protocols share `OpenAIResponsesCompat`, so a switch settable on one is + * settable on all three. Keying by protocol alone would refuse + * `azure-openai-responses` and `openai-codex-responses` the fields their own + * models declare. + */ +const COMPAT_GATES: Readonly>>> = { + 'openai-completions': COMPLETIONS_COMPAT_GATE, + 'openai-responses': RESPONSES_COMPAT_GATE, + 'azure-openai-responses': RESPONSES_COMPAT_GATE, + 'openai-codex-responses': RESPONSES_COMPAT_GATE, + 'anthropic-messages': ANTHROPIC_COMPAT_GATE, + 'bedrock-converse-stream': BEDROCK_COMPAT_GATE, +} + +/** + * The compat gate of one resolved protocol. A `string` lookup rather than a + * keyed read: a route's `api` is configuration, so it may name a protocol + * pi-ai gives no compat type — or none at all. + * @param api - resolved wire protocol. + * @returns that protocol's field gate, or `undefined` when it takes no compat. + */ +function compatGate(api: string): Readonly> | undefined { + return (COMPAT_GATES as Readonly>>>)[api] +} + +/** The field names one gate offers. */ +type OfferedIn = { [K in keyof G]: G[K] extends 'offer' ? K : never }[keyof G] + +/** Every compat field name a profile may set, on whichever protocol takes it. */ +type OfferedCompatField = + | OfferedIn + | OfferedIn + | OfferedIn + | OfferedIn + +/** + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. "The three Responses + * protocols" below means `openai-responses`, `azure-openai-responses`, and + * `openai-codex-responses`, which pi-ai gives one shared compat type, so a + * switch settable on one is settable on all three. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions` and the three Responses protocols. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: NonNullable + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable + /** + * Whether the endpoint accepts `strict` in tool definitions; + * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. + */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: NonNullable + /** + * Whether the endpoint accepts long prompt-cache retention; + * `openai-completions`, the three Responses protocols, `anthropic-messages`. + */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean +} + +/** Compile-time constraint that `T` is `never`. */ +type AssertNever = T + +/** + * Proof that every documented field is one a gate offers. A field the profile + * declares past the gates fails compilation with its own name in the error. + */ +export type EveryProfileFieldIsOffered = AssertNever> + +/** + * Proof that every offered field is documented. A gate entry flipped to + * `offer` without a profile field fails compilation with its own name in the + * error, which is the half a schema alone cannot catch. + */ +export type EveryOfferedFieldIsDocumented = AssertNever> + +/** Compile-time constraint that `T` is `true`. */ +type AssertTrue = T + +/** Every compat type a gate classifies, merged so one `Pick` reaches all offered fields. */ +type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat + +/** + * Proof that each documented field carries its upstream type, not a hand-copied + * restatement of it. The name gates above pin *which* fields exist; this pins + * their types, in both directions because each catches a different drift. A + * profile field wider than upstream accepts a value the provider rejects, and + * `resolveModelCompat`'s cast to `ModelCompat` would hide it; a narrower one + * refuses a value the provider accepts, which is how an upgrade that widens a + * union would otherwise leave configuration silently behind. + */ +export type EveryProfileFieldMatchesUpstream = AssertTrue< + PiAiCompatProfile extends Partial> + ? Partial> extends PiAiCompatProfile ? true : false + : false +> + +/** + * The compat entries a profile actually set. + * + * schemastery materializes an absent dict as `{}` — the behavior + * `reasoningEfforts` works around with a union — so every parsed profile + * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty + * one states nothing here: it would send no kwargs, which is exactly what + * leaving the field out does, so absent and empty are the same request and + * neither may make a route look like it configured a switch. A valueless + * scalar is the other thing schemastery lets through, and it is refused by + * {@link assertOfferedCompatFields} before this runs rather than filtered. + * @param compat - the configured switches, when any. + * @returns the entries carrying a value, in declaration order. + */ +function configuredCompatEntries(compat: PiAiCompatProfile | undefined): readonly (readonly [string, unknown])[] { + return Object.entries(compat ?? {}).flatMap(([field, value]) => { + const empty = typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value as object).length === 0 + return empty ? [] : [[field, value] as const] + }) +} + +/** + * The protocols offering one compat field, in {@link COMPAT_GATES} order. + * @param field - configured compat field name. + * @returns the protocols whose compat takes it; empty when none does, which + * is either a withheld field or a name no upstream compat type declares. + */ +function compatProtocols(field: string): readonly string[] { + return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === 'offer' ? [api] : []) +} + +/** + * The compat fields one protocol offers, for a diagnostic that has to show + * what was available instead of the name that missed. + * @param api - wire protocol. + * @returns the offered field names, or an empty list for a protocol taking no compat. + */ +function offeredCompatFields(api: string): readonly string[] { + return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : []) +} + +/** + * Every offered field name, deduplicated, for the one diagnostic that cannot + * narrow by protocol: the vocabulary check runs before any protocol resolves, + * which is what lets it refuse a misspelling on a route whose models would + * never have reached the protocol that declares the intended field. + * @returns the offered field names across every protocol, in gate order. + */ +function allOfferedCompatFields(): readonly string[] { + const fields = new Set() + for (const api of Object.keys(COMPAT_GATES)) { + for (const field of offeredCompatFields(api)) fields.add(field) + } + return [...fields] +} + +/** + * Reject a compat key no protocol offers. Runs before any protocol is + * resolved, so a withheld field or a misspelling fails even on a route whose + * models never reach the protocol that would have taken it — the alternative + * being the silent drop that let an unreadable switch look applied. + * @param provider - provider route key, for diagnostics. + * @param site - the configuration site, for diagnostics. + * @param compat - the configured switches, when any. + * @throws Error naming the offending key. + */ +function assertOfferedCompatFields( + provider: string, + site: string, + compat: PiAiCompatProfile | undefined, +): void { + // Every key, not only the ones carrying a value: a withheld or undeclared + // name is never in the schema, so schemastery cannot have materialized it — + // whatever its value, a person wrote it and expects it to do something. + for (const [field, value] of Object.entries(compat ?? {})) { + // The name is judged before the value, so a withheld or misspelled key + // written bare is refused for being that name rather than for being empty: + // the other order sends someone to supply a value the key would be refused + // with anyway. + if (compatProtocols(field).length === 0) { + const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined) + if (declared) { + invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed` + + ' catalog sets it for the vendors that need it, so name that provider as the route instead') + } + invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable` + + ` switches are ${allOfferedCompatFields().join(', ')}`) + } + // A valueless key (`supportsDeveloperRole:`) survives schemastery, which + // passes nullable data through before any member schema runs — the same + // behavior `reasoningEfforts` documents — and a `cordis.yml` entry may + // reach the same state through `!!js undefined`. Either way the key is + // kept, so carrying it forward writes nothing over whatever the next layer + // resolved, leaving pi-ai's `??` at its baseURL detection: the "written but + // not applied" outcome this surface exists to refuse. + if (value == null) { + invalid(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to` + + ' leave the field to the next layer — the installed catalog entry, then pi-ai\'s own detection') + } + } } /** One configured model entry: an id plus the catalog fields it overrides. */ @@ -233,7 +565,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -258,7 +590,7 @@ export interface RouteCatalogRequest { models?: readonly PiAiModelProfile[] /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ modelOverrides?: Readonly> - /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + /** Route-level wire-compatibility switches, landing on each model whose protocol declares them; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number @@ -368,16 +700,20 @@ function resolveModelReasoning( return { reasoning: true, thinkingLevelMap: map } } +/** The compat block a materialized model carries, whichever protocol it speaks. */ +type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | BedrockCompat + /** - * Resolve one model's compat block from the profile's reasoning switches. + * Resolve one model's compat block from the profile's switches. * - * A model switch wins over the route switch; whatever neither sets keeps the - * installed entry's value, and a field no layer decides falls through to - * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes - * the switches at all: a model-level switch on any other protocol fails - * resolution, while a route-level default skips past such models — the same - * posture as the route-level `reasoning` default, which also must not fail - * models it does not fit. + * A model switch wins over the route switch field by field; whatever neither + * sets keeps the installed entry's value, and a field no layer decides falls + * through to pi-ai's own detection. A model-level switch its protocol does not + * take fails resolution — about one named model it can only be a mistake — + * while a route-level one skips past such models, since a route default must + * stay settable on a route whose models do not all speak one protocol. Every + * field reaching here is offered by some protocol; {@link + * assertOfferedCompatFields} has already refused the rest. * @param provider - provider route key, for diagnostics. * @param entry - the configured model entry. * @param route - the route-level switches, when any. @@ -391,31 +727,31 @@ function resolveModelCompat( route: PiAiCompatProfile | undefined, base: Model | undefined, api: string, -): { compat: OpenAICompletionsCompat } | Record { - const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat - const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort - if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} - if (api !== 'openai-completions') { - if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { - invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` - + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') - } - return {} +): { compat: ModelCompat } | Record { + const gate = compatGate(api) + const configured: Record = {} + for (const [field, value] of configuredCompatEntries(route)) { + if (gate?.[field] !== 'offer') continue + configured[field] = value } + for (const [field, value] of configuredCompatEntries(entry.compat)) { + if (gate?.[field] !== 'offer') { + const offered = offeredCompatFields(api) + invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not` + + ` take it; that switch exists on ${compatProtocols(field).join(', ')}, and "${api}" offers` + + ` ${offered.length === 0 ? 'no configurable compat' : offered.join(', ')}`) + } + configured[field] = value + } + if (Object.keys(configured).length === 0) return {} // The installed entry's compat matches the entry's OWN api — a route-level // `api` repoint (an anthropic catalog served through an OpenAI-compatible // gateway) leaves `base.compat` in the other protocol's shape, so it is // inherited only while the resolved api still is the entry's. A repointed // model starts from pi-ai's baseURL-derived detection instead, which is // what a protocol change means for every other compat field too. - const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined - return { - compat: { - ...inherited, - ...thinkingFormat === undefined ? {} : { thinkingFormat }, - ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, - }, - } + const inherited = base?.api === api ? base.compat : undefined + return { compat: { ...inherited, ...configured } as ModelCompat } } /** One route's materialized catalog, plus the request caps its profile chose. */ @@ -485,8 +821,13 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) - const routeCompatDefined = request.compat?.thinkingFormat !== undefined - || request.compat?.supportsReasoningEffort !== undefined + // Vocabulary before protocols: a withheld or undeclared switch is refused + // wherever it is written, so it cannot look applied on a route whose models + // never reach the protocol that would have taken it. + assertOfferedCompatFields(provider, 'route', request.compat) + for (const entry of entries) { + assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat) + } const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -538,9 +879,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { ...resolveModelCompat(provider, entry, request.compat, base, api), } }) - if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { - invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' - + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + // Per field, not per block: a route may default a switch its completions + // models take beside one only its anthropic models do, and neither should + // fail for the other's sake. What is refused is a route default no model on + // the route could ever read, which is a route that will not behave as written. + for (const [field] of configuredCompatEntries(request.compat)) { + const takers = compatProtocols(field) + if (models.some(model => takers.includes(model.api))) continue + invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;` + + ` it exists on ${takers.join(', ')}`) } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index d1e1f697a9..62d49a58ee 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -14,14 +14,22 @@ * @module dsh-llm-pi-ai/config */ -import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { MODALITIES, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' +import { + CACHE_CONTROL_FORMATS, + CHAT_TEMPLATE_VARS, + MAX_TOKENS_FIELDS, + MODALITIES, + resolveRouteModels, + SUPPORTED_THINKING_FORMATS, + THINKING_LEVELS, +} from './catalog.ts' import type { PiAiCompatProfile, PiAiModality, @@ -102,10 +110,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -205,9 +214,43 @@ const thinkingBudgets = z.object({ high: z.number(), }) +/** + * One `chat_template_kwargs` value. The `$var` member is pi-ai's placeholder + * for a value dispatch fills from the request's thinking state, which is what + * makes a chat-template gateway configurable without restating its template. + */ +const chatTemplateKwarg: z = z.union([ + z.string(), + z.number(), + z.boolean(), + z.const(null), + z.object({ + $var: z.union(CHAT_TEMPLATE_VARS).required(), + omitWhenOff: z.boolean(), + }), +]) + const compatProfile: z = z.object({ - thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsStore: z.boolean(), + supportsDeveloperRole: z.boolean(), supportsReasoningEffort: z.boolean(), + supportsUsageInStreaming: z.boolean(), + maxTokensField: z.union(MAX_TOKENS_FIELDS), + requiresToolResultName: z.boolean(), + requiresAssistantAfterToolResult: z.boolean(), + requiresThinkingAsText: z.boolean(), + requiresReasoningContentOnAssistantMessages: z.boolean(), + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + chatTemplateKwargs: z.dict(chatTemplateKwarg), + supportsStrictMode: z.boolean(), + cacheControlFormat: z.union(CACHE_CONTROL_FORMATS), + supportsLongCacheRetention: z.boolean(), + supportsEagerToolInputStreaming: z.boolean(), + supportsCacheControlOnTools: z.boolean(), + supportsTemperature: z.boolean(), + forceAdaptiveThinking: z.boolean(), + allowEmptySignature: z.boolean(), + supportsStrictTools: z.boolean(), }) /** diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 1bbeec79db..846509f8c6 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -61,10 +61,12 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' -import { catalogProviderIds, catalogProviderTakesApiKey } from './catalog.ts' +import { authContextFrom, credentialStoreFrom } from './auth.ts' +import { catalogProviderIds } from './catalog.ts' import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' import { discoverModels } from './discovery.ts' +import { registerPiAiFlows } from './login.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' @@ -79,6 +81,7 @@ export type { PiAiThinkingFormat, ResolvedPiAiProviderProfile, } from './config.ts' +export { recordKeyFor } from './auth.ts' export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' @@ -105,15 +108,10 @@ function registrationFacts(profiles: ReadonlyMap ctx.get('attachments'), onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn( @@ -208,6 +204,12 @@ export function apply(ctx: Context, config: Config): void { ) }, }) + // Independent of the route set: signing in is what makes a route worth + // adding, so the flows are offered before any profile names their provider. + // Scoped to the authorization seam rather than injected outright, because a + // composition without it (headless, ACP) simply has no surface to sign in + // from, while everything else this plugin does still works. + ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every // pi-ai provider before any route exists. Hand-declared routes join it as diff --git a/packages/llm/llm-pi-ai/src/login.ts b/packages/llm/llm-pi-ai/src/login.ts new file mode 100644 index 0000000000..578489afb4 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/login.ts @@ -0,0 +1,161 @@ +/** + * Authorization flows for the pi-ai providers that ship a login. This is the + * whole of the translation between the harness's neutral notice/prompt + * vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which + * library ran the conversation. + * + * @module dsh-llm-pi-ai/login + */ + +import { createModels } from '@earendil-works/pi-ai' +import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai' +import type { Context } from '@deepseek-ai/cordis' +import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { isCredentialKeySegment } from '@deepseek-ai/dsh-credentials' +import { catalogProvider, catalogProviderIds } from './catalog.ts' +import { recordKeyFor } from './auth.ts' +import type { PiAiAuthInjection } from './adapter.ts' + +/** + * The login methods one catalog provider offers. + * + * A method appears only when pi-ai can actually run it: `oauth` always carries + * a `login`, while an api-key method has one only when the provider collects + * its key interactively — which every installed one currently does, so a key is + * typed into pi-ai's own prompt rather than into the settings form. + * @param provider - the installed catalog provider, if pi-ai ships one. + * @returns its methods, most preferred first; empty when it offers no login. + */ +function loginMethods(provider: Provider | undefined): AuthorizationMethod[] { + const methods: AuthorizationMethod[] = [] + const oauth = provider?.auth.oauth + if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name }) + const apiKey = provider?.auth.apiKey + if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name }) + return methods +} + +/** + * Restate one pi-ai login event in the seam's vocabulary. + * + * A device-code grant is the one event carrying two things the human needs at + * once — where to go and what to type there — which is why the neutral notice + * has a `code` beside its `url` rather than folding the code into the message. + * @param event - what pi-ai reported. + * @param session - the attempt to report it to. + */ +function relay(event: AuthEvent, session: AuthorizationSession): void { + switch (event.type) { + case 'info': { + const link = event.links?.[0] + session.notify({ message: event.message, ...link === undefined ? {} : { url: link.url } }) + return + } + case 'auth_url': + session.notify({ + message: event.instructions ?? 'Open this page to continue signing in.', + url: event.url, + }) + return + case 'device_code': + session.notify({ + message: 'Enter this code on the verification page to finish signing in.', + url: event.verificationUri, + code: event.userCode, + }) + return + case 'progress': + session.notify({ message: event.message }) + return + default: + // pi-ai's event union is open to new members: a build that meets one it + // does not know still shows the human that something is happening rather + // than going silent mid-login. + session.notify({ message: 'Signing in…' }) + } +} + +/** + * Restate one pi-ai prompt in the seam's vocabulary. + * + * `manual_code` becomes a plain text question because the difference pi-ai + * draws — a code the human copies from a browser rather than a value they know + * — changes nothing a surface renders. Its own `signal` is carried through, and + * that is the part which matters: it is how a flow racing a typed code against + * a browser callback withdraws the losing question. + * @param prompt - what pi-ai asked. + * @returns the neutral prompt to put to the human. + */ +function restate(prompt: AuthPrompt): AuthorizationPrompt { + const signal = prompt.signal === undefined ? {} : { signal: prompt.signal } + switch (prompt.type) { + case 'select': + return { ...signal, kind: 'select', message: prompt.message, options: prompt.options } + case 'secret': + return { + ...signal, + kind: 'secret', + message: prompt.message, + ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }, + } + default: + return { + ...signal, + kind: 'text', + message: prompt.message, + ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }, + } + } +} + +/** + * Register one authorization flow per installed provider that ships a login. + * + * Registration is unconditional on configuration: a provider has to be signed + * into before a route for it is worth adding, so the flow exists from the + * moment the plugin mounts rather than appearing once a profile does. + * @param ctx - the plugin context carrying `ctx.authorization`. + * @param auth - the injectables every collection here is built with. + */ +export function registerPiAiFlows(ctx: Context, auth: PiAiAuthInjection): void { + for (const providerId of catalogProviderIds()) { + const provider = catalogProvider(providerId) + const [first, ...rest] = loginMethods(provider) + /* v8 ignore next 3 -- every id here names an installed provider and every + installed provider ships a login, so nothing is skipped today; the guard + is what keeps that from becoming a crash if either stops being true. */ + if (provider === undefined || first === undefined) continue + /* v8 ignore next 7 -- every installed catalog id today is a lowercase + hyphenated identifier; the guard keeps a future upstream id outside the + record grammar (dotted or uppercase, as vendor ids elsewhere already + are) from throwing in `recordKeyFor` and failing the whole mount. */ + if (!isCredentialKeySegment(providerId)) { + ctx.logger.warn( + 'llm-pi-ai: catalog provider "%s" cannot address a credential record; its sign-in is not offered', + providerId) + continue + } + ctx.authorization.registerFlow({ + key: recordKeyFor(providerId), + label: provider.name, + methods: [first, ...rest], + async run(session) { + // A collection of its own, holding only the provider being signed + // into: login is not serving requests, and the credential it produces + // lands in the shared store either way. + const models = createModels(auth) + models.setProvider(provider) + // Total over the two ids declared above, and the seam only ever hands + // back one a flow declared. + const type: AuthType = session.method === 'oauth' ? 'oauth' : 'api_key' + // pi-ai persists what the login returns through that same store, which + // is what makes it the single writer of this record. + await models.login(providerId, type, { + signal: session.signal, + notify: (event) => { relay(event, session) }, + prompt: prompt => session.prompt(restate(prompt)), + }) + }, + }) + } +} diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index d45a2e3089..a36a180f7d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -13,6 +13,7 @@ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { DEFAULT_MAX_REQUEST_IMAGE_BYTES, resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -47,6 +48,7 @@ function adapterOf( return new PiAiAdapter({ profiles: () => resolveProfiles(providers), resolveApiKey: () => Promise.resolve(apiKey), + auth: memoryAuth(), }) } @@ -597,6 +599,46 @@ describe('provider profile lifecycle', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') }) + it('keeps the system role on a declared route whose gateway rejects the developer one', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [ + // pi-ai sends the system prompt as `developer` to a reasoning + // model whenever its URL detection says the endpoint is OpenAI — + // which is what an unrecognized private URL resolves to. Most + // OpenAI-compatible gateways reject that role. + { id: 'acme-think', reasoningEfforts: { off: null, high: 'high' }, compat: { supportsDeveloperRole: false } }, + { id: 'acme-guess', reasoningEfforts: { off: null, high: 'high' } }, + ], + }, + }, + }) + const roles = async (model: string): Promise => { + await assemble(ctx, { + provider: 'acme-gateway', + model, + reasoningEffort: ReasoningEffortId('high'), + system: 'you are a harness', + messages: [], + }) + const request = server.requests.at(-1) as { messages: { role: string }[] } + return request.messages.map(message => message.role) + } + + expect(await roles('acme-think')).toEqual(['system']) + // The switch is the only thing that changes it: the same route, same + // endpoint, same reasoning declaration still gets pi-ai's guess. + expect(await roles('acme-guess')).toEqual(['developer']) + }) + it('sends a declared off value as the effort parameter instead of omitting it', async () => { vi.stubEnv('PI_TEST_KEY', 'test-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/auth-double.ts b/packages/llm/llm-pi-ai/tests/auth-double.ts new file mode 100644 index 0000000000..a05c017d04 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/auth-double.ts @@ -0,0 +1,39 @@ +import type { Credential } from '@earendil-works/pi-ai' +import type { PiAiAuthInjection } from '../src/adapter.ts' + +/** + * The auth injectables for tests that exercise streaming rather than + * authentication: an in-process credential store and an ambient context that + * finds nothing. A test needing real records builds the store over + * `ctx.credentials` instead, through `credentialStoreFrom`. + * @param seed - credentials to start with, by pi-ai provider id. + * @returns the injection to hand `PiAiAdapter`, with its store readable. + */ +export function memoryAuth(seed: Record = {}): PiAiAuthInjection & { + stored: Map +} { + const stored = new Map(Object.entries(seed)) + return { + stored, + credentials: { + read: id => Promise.resolve(stored.get(id)), + list: () => Promise.resolve([...stored].map(([providerId, credential]) => ({ + providerId, + type: credential.type, + }))), + async modify(id, mutate) { + const next = await mutate(stored.get(id)) + if (next !== undefined) stored.set(id, next) + return stored.get(id) + }, + delete: (id) => { + stored.delete(id) + return Promise.resolve() + }, + }, + authContext: { + env: () => Promise.resolve(undefined), + fileExists: () => Promise.resolve(false), + }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/auth.spec.ts b/packages/llm/llm-pi-ai/tests/auth.spec.ts new file mode 100644 index 0000000000..27a496a2c3 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/auth.spec.ts @@ -0,0 +1,172 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' +import { authContextFrom, credentialStoreFrom, recordKeyFor } from '../src/auth.ts' + +const CODEX = recordKeyFor('openai-codex') + +const dirs: string[] = [] + +/** A context whose credential records live in a throwaway `$DSH_HOME`. */ +async function stored(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-auth-')) + dirs.push(dir) + const ctx = new Context() + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + return ctx +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('pi-ai credential store over harness records', () => { + it('reads nothing for a provider with no record', async () => { + const store = credentialStoreFrom(await stored()) + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + }) + + it('round-trips an api-key credential field by field', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + + await store.modify('cloudflare', () => + Promise.resolve({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })) + + await expect(store.read('cloudflare')) + .resolves.toEqual({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }) + await expect(ctx.credentials.readRecord(recordKeyFor('cloudflare'))) + .resolves.toEqual({ kind: 'api-key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }) + }) + + it('stores an api-key credential carrying neither a key nor env', async () => { + const store = credentialStoreFrom(await stored()) + + await store.modify('bedrock', () => Promise.resolve({ type: 'api_key' })) + + await expect(store.read('bedrock')).resolves.toEqual({ type: 'api_key' }) + }) + + it('keeps an OAuth credential verbatim, refresh fields and all', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, accountId: 'acc' } + + await store.modify('openai-codex', () => Promise.resolve(granted)) + + await expect(store.read('openai-codex')).resolves.toEqual(granted) + await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted }) + }) + + it('shows the mutation the current credential and leaves it alone when declined', async () => { + const store = credentialStoreFrom(await stored()) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 })) + const seen: unknown[] = [] + + const unchanged = await store.modify('openai-codex', (current) => { + seen.push(current) + return Promise.resolve(undefined) + }) + + expect(seen).toEqual([{ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }]) + expect(unchanged).toEqual({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }) + }) + + it('lists only the records this adapter family owns', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 })) + await store.modify('cloudflare', () => Promise.resolve({ type: 'api_key', key: 'k' })) + // Another plugin's record for a provider name this one also serves: its + // payload is written in a format pi-ai never agreed to. + await ctx.credentials.modifyRecord(credentialKey('llm-kimi', 'openai-codex'), () => + Promise.resolve({ kind: 'grant', payload: { theirs: true } })) + + await expect(store.list()).resolves.toEqual([ + { providerId: 'openai-codex', type: 'oauth' }, + { providerId: 'cloudflare', type: 'api_key' }, + ]) + }) + + it('forgets a credential on delete, and stays quiet when there was none', async () => { + const store = credentialStoreFrom(await stored()) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 })) + + await store.delete('openai-codex') + await store.delete('openai-codex') + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + }) + + it('reads empty but refuses to write without a credentials service', async () => { + const store = credentialStoreFrom(new Context()) + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + await expect(store.list()).resolves.toEqual([]) + await expect(store.modify('openai-codex', () => Promise.resolve({ type: 'api_key', key: 'k' }))) + .rejects.toThrow(/mounts no credentials service/) + await expect(store.delete('openai-codex')).rejects.toThrow(/mounts no credentials service/) + }) + + it('treats a provider id outside the record grammar as holding nothing', async () => { + const store = credentialStoreFrom(await stored()) + + // A hand-declared route key is an arbitrary settings dict key, and pi-ai + // reads it during auth resolution: the answer is "not signed in", never a + // thrown address error… + await expect(store.read('My_Proxy')).resolves.toBeUndefined() + // …nothing can ever be stored under it, so a logout has nothing to remove… + await expect(store.delete('My_Proxy')).resolves.toBeUndefined() + // …while a write that cannot land must refuse rather than report success. + await expect(store.modify('My_Proxy', () => Promise.resolve({ type: 'api_key', key: 'k' }))) + .rejects.toThrow(/cannot address a stored credential record/) + }) +}) + +describe('pi-ai ambient auth context', () => { + beforeEach(() => { + vi.stubEnv('PI_AUTH_AMBIENT', 'from-environment') + }) + + it('answers an environment name from the credential seam first', async () => { + const ctx = await stored() + await ctx.credentials.set(credentialRef('PI_AUTH_SEAM'), 'from-seam') + + await expect(authContextFrom(ctx).env('PI_AUTH_SEAM')).resolves.toBe('from-seam') + }) + + it('falls back to the launch environment when nothing is stored', async () => { + await expect(authContextFrom(await stored()).env('PI_AUTH_AMBIENT')).resolves.toBe('from-environment') + }) + + it('answers "not set" for a name no reference could ever address', async () => { + // pi-ai asks about provider-declared names; one outside the reference + // grammar has no reference to miss, and must not throw. + await expect(authContextFrom(await stored()).env('not a var')).resolves.toBeUndefined() + }) + + it('answers about the host filesystem, expanding a leading ~', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-home-')) + dirs.push(dir) + await writeFile(join(dir, 'creds'), 'x') + // Both spellings of "home": os.homedir() reads HOME on POSIX and + // USERPROFILE on Windows, and the expansion under test goes through it. + vi.stubEnv('HOME', dir) + vi.stubEnv('USERPROFILE', dir) + const context = authContextFrom(await stored()) + + await expect(context.fileExists('~/creds')).resolves.toBe(true) + await expect(context.fileExists('~/missing')).resolves.toBe(false) + await expect(context.fileExists(join(dir, 'creds'))).resolves.toBe(true) + await expect(context.fileExists('~')).resolves.toBe(true) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index eb4ba511d4..99886cdbaf 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -15,6 +15,7 @@ import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-wo import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' +import { memoryAuth } from './auth-double.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const homes: string[] = [] @@ -755,7 +756,7 @@ describe('modelOverrides', () => { }) }) -describe('reasoning-dispatch compat switches', () => { +describe('compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> { const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] @@ -813,18 +814,244 @@ describe('reasoning-dispatch compat switches', () => { expect(models.get(responses.id)?.compat).toEqual(responses.compat) }) - it('rejects a model-level switch on a protocol that has no such field', () => { + it('rejects a model-level switch on a protocol that has no such field, naming what it offers', () => { expect(() => resolveProfiles({ anthropic: { models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], }, - })).toThrow(/exist only on openai-completions/) + })).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s) }) it('rejects route switches no model on the route can take', () => { expect(() => resolveProfiles({ anthropic: { compat: { thinkingFormat: 'openai' } }, - })).toThrow(/no model on the route speaks openai-completions/) + })).toThrow(/no model on the route speaks a protocol that takes it/) + }) + + it('carries the developer-role switch onto a hand-declared reasoning model', () => { + // pi-ai reads this switch only for a reasoning model, and detects it from + // the endpoint URL — which for a private gateway answers as though it were + // OpenAI itself, so the route must be able to say otherwise. + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' }, + models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-gateway') + + expect(models.get('acme-think')?.compat).toEqual({ + supportsDeveloperRole: false, + maxTokensField: 'max_tokens', + }) + }) + + it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => { + const models = modelsOf({ + 'acme-responses': { + api: 'openai-responses', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false }, + models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-responses') + + expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false }) + }) + + it('carries an anthropic-only switch onto an anthropic-messages route', () => { + const models = modelsOf({ + 'acme-claude': { + api: 'anthropic-messages', + baseURL: 'https://acme.test', + compat: { supportsTemperature: false, supportsCacheControlOnTools: false }, + models: [{ id: 'acme-opus' }], + }, + }, 'acme-claude') + + expect(models.get('acme-opus')?.compat).toEqual({ + supportsTemperature: false, + supportsCacheControlOnTools: false, + }) + }) + + it('lands each route switch only on the models whose protocol declares it', () => { + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + // Both protocols take the first switch; only completions takes the second. + compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat + expect(onCompletions.supportsDeveloperRole).toBe(false) + expect(onCompletions.thinkingFormat).toBe('openai') + const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string } + expect(onResponses.supportsDeveloperRole).toBe(false) + expect(onResponses.thinkingFormat).toBeUndefined() + }) + + it('carries chat-template kwargs beside the thinking format that dispatches through them', () => { + const models = modelsOf({ + 'acme-qwen': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ + id: 'qwen-local', + reasoningEfforts: { off: null, medium: 'medium' }, + compat: { + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }, + }], + }, + }, 'acme-qwen') + + expect(models.get('qwen-local')?.compat).toEqual({ + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }) + }) + + it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'acme-chat', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { supportsStore: false } }], + }, + })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s) + }) + + it('refuses a valueless compat key written through the composed settings path', async () => { + // The write path an operator reaches: a section resolved by schemastery, + // judged by this adapter's section validator before it is stored. + // schemastery keeps the null, so nothing but that check stands between it + // and `Model.compat`. + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + await expect(ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + compat: { supportsDeveloperRole: null }, + models: [{ id: 'acme-a' }], + }, + }, + })).rejects.toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('carries a compat switch from a written settings section onto the wire', async () => { + // End to end for the reported gap: the switch enters as configuration and + // changes the request the provider receives, not merely the resolved model. + vi.stubEnv(KEY_ENV, 'test-key') + const server = await mockServer([{ events: textEvents }]) + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + apiKeyEnv: KEY_ENV, + api: 'openai-completions', + baseURL: `${server.url}/v1`, + compat: { supportsDeveloperRole: false }, + models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + system: 'you are a harness', + messages: [], + }) + + const request = server.requests[0] as { messages: { role: string }[] } + expect(request.messages.map(message => message.role)).toEqual(['system']) + }) + + it('refuses a valueless compat key rather than writing null over the catalog', () => { + // schemastery passes a YAML bare key through as null. Carried forward it + // would replace the installed entry's value, and pi-ai's `??` would then + // reach for its baseURL detection — the "written but not applied" outcome. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: null } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('refuses a compat key whose value is undefined, as a cordis.yml entry can write', () => { + // `!!js undefined` reaches the same state as a YAML bare key, and + // schemastery keeps the key either way, so both are refused together. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: undefined } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('refuses a valueless compat key on a model entry too', () => { + expect(() => resolveProfiles({ + deepseek: { + modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never }, + }, + })).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/) + }) + + it('serves the Responses compat type on every protocol pi-ai gives it to', () => { + // pi-ai types azure-openai-responses and openai-codex-responses with the + // same OpenAIResponsesCompat, so a switch settable on one is settable on all. + for (const route of ['azure-openai-responses', 'openai-codex']) { + const models = modelsOf({ [route]: { compat: { supportsDeveloperRole: false } } }, route) + const [first] = [...models.values()] + expect((first?.compat as { supportsDeveloperRole?: boolean }).supportsDeveloperRole).toBe(false) + } + }) + + it('serves the Bedrock compat type on its own protocol', () => { + const models = modelsOf({ 'amazon-bedrock': { compat: { supportsStrictMode: false } } }, 'amazon-bedrock') + const [first] = [...models.values()] + expect((first?.compat as { supportsStrictMode?: boolean }).supportsStrictMode).toBe(false) + }) + + it('refuses a compat key no wire protocol declares instead of dropping it', () => { + // The silent drop is what let an unreadable switch look applied: schemastery + // passes unknown keys through, and resolution used to read only two fields. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDevelperRole: false } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/) + }) + + it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }], + }, + })).toThrow(/compat "openRouterRouting", which is not configurable here/) }) }) @@ -839,6 +1066,7 @@ describe('resolution snapshots', () => { // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. resolveApiKey: async () => { await held; return 'k' }, + auth: memoryAuth(), }) const chunks: StreamChunk[] = [] @@ -867,7 +1095,11 @@ describe('resolution snapshots', () => { const first = await mockServer([{ events: textEvents }]) const second = await mockServer([{ events: textEvents }]) let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } }) - const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') }) + const adapter = new PiAiAdapter({ + profiles: () => current, + resolveApiKey: () => Promise.resolve('k'), + auth: memoryAuth(), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], @@ -934,30 +1166,22 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) }) - it('withholds a catalog route this adapter cannot authenticate', async () => { + it('offers every installed catalog route, including one that only signs in', async () => { const ctx = await harness({}) const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider) // `openai-codex` is the one installed provider that authenticates through - // OAuth alone. pi-ai resolves OAuth only from a *stored* credential, this - // adapter constructs its collection with no credential store, and nothing - // here runs a login flow — so every request on such a route fails with - // `Provider is not configured` before it goes out. Offering it would put a - // provider on the settings page that no amount of configuration can make - // work. - expect(offered).not.toContain('openai-codex') - // A provider that offers OAuth *beside* an api-key method keeps its entry: - // the key is a path this adapter can serve. + // OAuth alone. It is offered like any other because the collection now + // carries a durable credential store and a login flow writes into it, so + // the route has a posture that works rather than only one that fails. + expect(offered).toContain('openai-codex') expect(offered).toContain('anthropic') expect(offered).toContain('openai') }) - it('still lists a withheld route a stored profile names, as a catalog route', async () => { - // Withholding the offer must not strand a profile someone already stored: - // the route keeps its entry so a configuration surface can edit or delete - // it, and `declared` still answers catalog membership rather than the - // offer, so the page does not mislabel it as a route this deployment - // invented. + it('lists a route a stored profile names as a catalog route, not a declared one', async () => { + // `declared` answers catalog membership, so a profile stored against a + // route pi-ai ships is not mislabelled as one this deployment invented. const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } }) expect(ctx.llm.listConfigurableProviders()).toContainEqual({ diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 1019354156..3decff3749 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -9,6 +9,7 @@ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import AuthorizationService from '@deepseek-ai/dsh-authorization' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -37,7 +38,11 @@ async function home(): Promise { } /** Real dynamic composition mirroring the deepseek twin's harness. */ -async function boot(dir: string, config: LlmPiAi.Config): Promise { +async function boot( + dir: string, + config: LlmPiAi.Config, + options: { authorization?: boolean } = {}, +): Promise { const ctx = new Context() cleanups.push(async () => { await ctx.fiber.dispose() @@ -45,17 +50,38 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + if (options.authorization === true) await ctx.plugin(AuthorizationService) await ctx.plugin(LlmPiAi, config) return ctx } +describe('login flows in a real composition', () => { + it('offers a sign-in for a provider no route names, once the seam is mounted', async () => { + const ctx = await boot(await home(), {}, { authorization: true }) + + // Zero routes configured: signing in is what makes a route worth adding, + // so the offer cannot wait for a profile to name the provider. + const codex = ctx.authorization.describe(LlmPiAi.recordKeyFor('openai-codex')) + expect(codex?.methods.map(method => method.id)).toEqual(['oauth']) + }) + + it('mounts without the seam, and simply offers no sign-in', async () => { + const ctx = await boot(await home(), {}) + + // A headless or ACP composition has no surface to sign in from; everything + // else this plugin does still works. + expect(ctx.get('authorization')).toBeUndefined() + expect(ctx.llm.listConfigurableProviders().length).toBeGreaterThan(0) + }) +}) + describe('request-level dynamic profiles', () => { it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-from-settings\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }]) @@ -93,7 +119,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }]) @@ -122,7 +148,7 @@ describe('request-level dynamic profiles', () => { it('rotates the per-request credential referenced by apiKeyEnv', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await boot(dir, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, @@ -173,7 +199,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index a4e89424f6..6ca02f281a 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -49,7 +49,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) const settingsPath = join(root, 'settings.yaml') await writeFile(settingsPath, '# personal settings\n') - await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) + await writeFile(join(root, '.credentials.yaml'), 'version: 1\nrefs:\n PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ diff --git a/packages/llm/llm-pi-ai/tests/login.spec.ts b/packages/llm/llm-pi-ai/tests/login.spec.ts new file mode 100644 index 0000000000..beb65ce188 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/login.spec.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AuthorizationService from '@deepseek-ai/dsh-authorization' +import type { AuthorizationInteraction, AuthorizationNotice, AuthorizationPrompt } from '@deepseek-ai/dsh-authorization' +import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import type { CredentialKey } from '@deepseek-ai/dsh-credentials' +import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType, Credential } from '@earendil-works/pi-ai' + +const login = vi.hoisted(() => vi.fn()) + +// The whole of what this module does with pi-ai is run one provider's login +// against a collection built with the harness store, so the collection is the +// boundary worth observing; a real login would open a browser. +vi.mock('@earendil-works/pi-ai', async importOriginal => ({ + ...await importOriginal(), + createModels: () => ({ setProvider: () => {}, login }), +})) + +const { credentialStoreFrom, authContextFrom, recordKeyFor } = await import('../src/auth.ts') +const { registerPiAiFlows } = await import('../src/login.ts') + +const CODEX = recordKeyFor('openai-codex') +const dirs: string[] = [] + +/** A context with the record store, the seam, and every pi-ai login flow. */ +async function harness(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-login-')) + dirs.push(dir) + const ctx = new Context() + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + await ctx.plugin(AuthorizationService) + registerPiAiFlows(ctx, { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) }) + return ctx +} + +/** An interaction recording everything a flow says, answering every question. */ +function surface(answer = 'typed'): AuthorizationInteraction & { + notices: AuthorizationNotice[] + prompts: AuthorizationPrompt[] +} { + const notices: AuthorizationNotice[] = [] + const prompts: AuthorizationPrompt[] = [] + return { + notices, + prompts, + notify: (notice) => { notices.push(notice) }, + prompt: (prompt) => { + prompts.push(prompt) + return Promise.resolve(answer) + }, + } +} + +/** Drive one attempt, letting the mocked login talk back through `converse`. */ +async function attempt( + ctx: Context, + converse: (interaction: AuthInteraction) => Promise, + request: { key?: CredentialKey; method?: string } = {}, +): Promise> { + const ui = surface() + login.mockImplementation(async (providerId: string, _type: AuthType, interaction: AuthInteraction) => { + await converse(interaction) + const granted: Credential = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 } + await credentialStoreFrom(ctx).modify(providerId, () => Promise.resolve(granted)) + return granted + }) + await expect(ctx.authorization.begin({ + key: request.key ?? CODEX, + interaction: ui, + ...request.method === undefined ? {} : { method: request.method }, + })).resolves.toEqual({ status: 'authorized' }) + return ui +} + +afterEach(async () => { + login.mockReset() + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('pi-ai login flows', () => { + it('offers one flow per installed provider, with the methods that provider ships', async () => { + const ctx = await harness() + const offered = ctx.authorization.list() + + // The OAuth-only provider is exactly the case this exists for: nothing + // else could ever configure it. + expect(offered.find(entry => entry.key === CODEX)?.methods) + .toEqual([{ id: 'oauth', label: expect.stringContaining('ChatGPT') as string }]) + // A provider offering both keeps both, the subscription login first. + expect(offered.find(entry => entry.key === recordKeyFor('anthropic'))?.methods.map(one => one.id)) + .toEqual(['oauth', 'api-key']) + // A key-only provider still gets a flow, because pi-ai collects the key + // through its own prompt rather than leaving it to the settings form. + expect(offered.find(entry => entry.key === recordKeyFor('deepseek'))?.methods.map(one => one.id)) + .toEqual(['api-key']) + }) + + it('runs the pi-ai auth type the chosen method names', async () => { + const ctx = await harness() + + await attempt(ctx, () => Promise.resolve()) + expect(login).toHaveBeenLastCalledWith('openai-codex', 'oauth', expect.anything()) + + await attempt(ctx, () => Promise.resolve(), { key: recordKeyFor('anthropic'), method: 'api-key' }) + expect(login).toHaveBeenLastCalledWith('anthropic', 'api_key', expect.anything()) + }) + + it('commits what the login produced, where the adapter reads it back', async () => { + const ctx = await harness() + + await attempt(ctx, () => Promise.resolve()) + + await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ + kind: 'grant', + payload: { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }, + }) + }) + + it('restates every pi-ai login event in the neutral vocabulary', async () => { + const ctx = await harness() + const events: AuthEvent[] = [ + { type: 'info', message: 'Read this first', links: [{ url: 'https://help.example' }] }, + { type: 'info', message: 'Nothing to open' }, + { type: 'auth_url', url: 'https://auth.example/start', instructions: 'Approve in the tab' }, + { type: 'auth_url', url: 'https://auth.example/plain' }, + { type: 'device_code', userCode: 'WXYZ-1234', verificationUri: 'https://device.example' }, + { type: 'progress', message: 'Exchanging the code' }, + // pi-ai's event union is open; an unrecognised member must still show + // the human that something is happening. + { type: 'quantum-handshake' } as unknown as AuthEvent, + ] + + const ui = await attempt(ctx, (interaction) => { + for (const event of events) interaction.notify(event) + return Promise.resolve() + }) + + expect(ui.notices).toEqual([ + { message: 'Read this first', url: 'https://help.example' }, + { message: 'Nothing to open' }, + { message: 'Approve in the tab', url: 'https://auth.example/start' }, + { message: 'Open this page to continue signing in.', url: 'https://auth.example/plain' }, + { + message: 'Enter this code on the verification page to finish signing in.', + url: 'https://device.example', + code: 'WXYZ-1234', + }, + { message: 'Exchanging the code' }, + { message: 'Signing in…' }, + ]) + }) + + it('restates every pi-ai prompt, carrying the per-prompt withdrawal signal', async () => { + const ctx = await harness() + const withdraw = new AbortController() + const prompts: AuthPrompt[] = [ + { type: 'text', message: 'Your workspace', placeholder: 'acme' }, + { type: 'secret', message: 'Paste the key' }, + { type: 'secret', message: 'Paste the token', placeholder: 'sk-…' }, + { type: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] }, + // The manual-code question a browser callback can win the race against. + { type: 'manual_code', message: 'Paste the code', signal: withdraw.signal }, + ] + + const ui = await attempt(ctx, async (interaction) => { + for (const prompt of prompts) await interaction.prompt(prompt) + }) + + expect(ui.prompts).toEqual([ + { kind: 'text', message: 'Your workspace', placeholder: 'acme' }, + { kind: 'secret', message: 'Paste the key' }, + { kind: 'secret', message: 'Paste the token', placeholder: 'sk-…' }, + { kind: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] }, + { kind: 'text', message: 'Paste the code', signal: withdraw.signal }, + ]) + }) + + it('hands the flow the attempt-wide cancellation signal', async () => { + const ctx = await harness() + let seen: AbortSignal | undefined + const controller = new AbortController() + login.mockImplementation((_id: string, _type: AuthType, interaction: AuthInteraction) => { + seen = interaction.signal + controller.abort() + return new Promise(() => {}) + }) + + await expect(ctx.authorization.begin({ + key: CODEX, + interaction: surface(), + signal: controller.signal, + })).resolves.toEqual({ status: 'cancelled' }) + expect(seen?.aborted).toBe(true) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 317b803f0c..2f409c6d9d 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -13,6 +13,7 @@ vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({ import { PiAiAdapter } from '../src/adapter.ts' import { resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' afterEach(() => { streamSimple.mockReset() }) @@ -27,6 +28,7 @@ function gatewayAdapter(): PiAiAdapter { }, }), resolveApiKey: () => Promise.resolve('test-key'), + auth: memoryAuth(), }) } diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 6f0b821d36..172dbe8a6e 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../credentials/authorization" + }, { "path": "../../settings/settings" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 44386ce78b..4773344586 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 0700b14362..2621de89af 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377 -README.zh.md: 51f034af632c19ca91c1d2f91a7c9c22714870e0 +README.zh.md: 91f1c6ede2b800671b35a90d02a23a6d79e96e0e diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 51f034af63..91f1c6ede2 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -63,9 +63,11 @@ `LlmCallConfig` 记录一个会话的模型请求所使用的提供方、模型、由适配器定义的可选推理强度,以及采样参数(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,分别与同名 `GenerateOptions` 字段一一对应)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值以及标明哪些字段由适配器默认值填入的标记,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 使用的请求所有权辅助函数:每个构造完成的请求都会在分发前深度冻结;`llm/stream` 监听器和适配器只能读取,绝不能改写。`markAgentLoopRequest()` 将该精确对象标记为由进程本地 agent loop 创建,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 会对已记录的辅助压缩和会话标题调用进行分类,使适配器可以按调用目的应用不同的传输策略,而不改变普通会话请求。 + + ### 应用归因(`attribution.ts`) -每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md)。 ### API 密钥校验(`api-key.ts`) @@ -85,7 +87,7 @@ ### 真实适配器 -两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现在内部可以抛出异常或发出失败 finish;`LlmRuntime` 会将两者都暴露为终止失败 finish。适配器理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)。 +两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现在内部可以抛出异常或发出失败 finish;`LlmRuntime` 会将两者都暴露为终止失败 finish。适配器理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md)。 ## 模型体验 @@ -95,6 +97,8 @@ 透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。 + + ## 已知限制与暂缓事项 - **本服务不执行重试、缓存或速率限制**:提供方注册会存储重试策略,但 `llm/stream` 仍是单次尝试调用包装层。agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败;`@deepseek-ai/dsh-llm-retry` 是共享示例主干加载的可选执行器。 diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 8f491173d2..f8e2e40224 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 5eb3668915..7dc5221c9b 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -27,7 +27,8 @@ interface PartialBlock { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -157,6 +158,25 @@ export class BlockAssembler { return this.assembled().blocks } + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with non-whitespace content, in stream order. + * Tool calls are omitted because interruption precedes dispatch; retaining + * one would require a fabricated result. Open unknown blocks are also omitted. + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[] { + return this.order + .map((index) => { + const partial = this.mustGet(index) + const type = partial.block?.type ?? partial.blockType + if (type !== 'text' && type !== 'reasoning') return undefined + return this.assemble(partial, index) + }) + .filter((block): block is ContentBlock => + (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '') + } + /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined { return this._usage diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 9f73ee3f96..2ecfcfd860 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -224,3 +224,41 @@ describe('BlockAssembler duplicate-close contract', () => { expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) + +describe('BlockAssembler.interruptedBlocks', () => { + it('keeps closed and open text/reasoning blocks with streamed content, in order', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'reasoning' }) + assembler.push({ type: 'reasoning-delta', index: 0, text: 'planning' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'planning' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 1, text: 'half an ans' }) + expect(assembler.interruptedBlocks()).toEqual([ + { type: 'reasoning', text: 'planning' }, + { type: 'text', text: 'half an ans' }, + ]) + }) + + it('drops tool calls whether open or closed — interruption precedes dispatch', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'calling' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'calling' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"a":1}' }) + assembler.push({ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"a":1}' } }) + assembler.push({ type: 'block-start', index: 2, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 2, id: CallId('c2'), name: 'read', argumentsDelta: '{"pa' }) + expect(assembler.interruptedBlocks()).toEqual([{ type: 'text', text: 'calling' }]) + }) + + it('drops empty and whitespace-only text/reasoning blocks and unknown open block types', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: ' \n' }) + // A merge-extended block kind this build does not know how to assemble. + assembler.push({ type: 'block-start', index: 1, blockType: 'mystery' } as unknown as StreamChunk) + assembler.push({ type: 'block-start', index: 2, blockType: 'reasoning' }) + expect(assembler.interruptedBlocks()).toEqual([]) + }) +}) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 44db6530cd..98b96e634d 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md README.md: a2deab11a31285ba598b8864d3a734ecf7c56620 -README.zh.md: e0c3a8aa123ca0acc374277cd92b415e0b0b3cb5 +README.zh.md: d14cded74691f88db7267ea470f536db85a39218 diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index e0c3a8aa12..d14cded746 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -41,7 +41,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。 -[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md) 记录了否决「让这对值保持原子」方案的那次对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。 +[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md) 记录了否决「让这对值保持原子」方案的那次对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。 ## 组合 diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index c5236fbbf8..be479e4cf1 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index c83879c63a..e0c980e843 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -10,22 +10,35 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts' import { foldSurfaceProjection } from './surface-projection.ts' -import type { ShadowPriceClaim } from './surface-projection.ts' -// Import for the `contextBreakdown` SessionProjectionMap key merge. +// Import for the `contextBreakdown` SessionProjectionStateMap key merge. import type {} from './projection.ts' -interface ContextBreakdownState { - systemTokens: number - toolsTokens: number - messageTokens: number - /** Shadow price armed by the immediately preceding metering event. */ - claim?: ShadowPriceClaim +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + contextBreakdown: ContextBreakdownState + } } +/** Non-negative integer token count (the shared figure shape). */ +const tokenCount = z.number().int().nonnegative() + +/** The context-breakdown state schema and source of its inferred type. */ +const contextBreakdownStateSchema = z.object({ + systemTokens: tokenCount, + toolsTokens: tokenCount, + messageTokens: tokenCount, + claim: z.object({ + start: tokenCount, + end: tokenCount, + tokens: tokenCount, + }).optional(), +}).strict() +type ContextBreakdownState = z.infer + const breakdownSchema = z.object({ - systemTokens: z.number().int().nonnegative(), - toolsTokens: z.number().int().nonnegative(), - messageTokens: z.number().int().nonnegative(), + systemTokens: tokenCount, + toolsTokens: tokenCount, + messageTokens: tokenCount, }).strict() /** @@ -39,10 +52,10 @@ const breakdownSchema = z.object({ * state is a fixed handful of numbers, so the persisted checkpoint stays * O(1) over the session's life. */ -export const contextBreakdownProjectionDefinition: -ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { +export const contextBreakdownProjectionDefinition = { key: 'contextBreakdown', - schema: breakdownSchema, + stateVersion: 2, + stateSchema: contextBreakdownStateSchema, init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }), apply: (state, event) => { const fold = foldSurfaceProjection(state.claim, event) @@ -65,6 +78,8 @@ ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { ...fold.claim === undefined ? {} : { claim: fold.claim }, } }, - view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }), - stateVersion: 2, -} + wire: { + viewSchema: breakdownSchema, + view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }), + }, +} satisfies ProjectionDefinition<'contextBreakdown', ContextBreakdownState> diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index a7fc9debf0..864b1669ce 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -8,18 +8,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts' import { foldSurfaceProjection } from './surface-projection.ts' -import type { ShadowPriceClaim } from './surface-projection.ts' - -interface UsageSample { - turn: number - step: number - buckets: TokenUsageProjection -} - -interface TokenUsageState { - totals: TokenUsageProjection - last: UsageSample | null -} const zeroBuckets = (): TokenUsageProjection => ({ uncachedInputTokens: 0, @@ -59,13 +47,30 @@ const projectionSchema = z.object({ cacheWriteTokens: z.number().int().nonnegative(), }).strict() -// Cast for the optional values: under exactOptionalPropertyTypes zod infers -// `number | undefined` where the interface declares absent-or-number fields. -const pressureSchema = z.object({ +/** + * The token-usage unit's state schema — the one definition of the state + * shape; the state type is inferred from it. + */ +const tokenUsageStateSchema = z.object({ + totals: projectionSchema, + last: z.object({ + turn: z.number().int().nonnegative(), + step: z.number().int().nonnegative(), + buckets: projectionSchema, + }).nullable(), +}).strict() + +type TokenUsageState = z.infer + +const pressureSchema: z.ZodType = z.object({ pressureTokens: z.number().int().nonnegative().optional(), projectedTokens: z.number().int().nonnegative().optional(), contextWindow: z.number().int().positive().optional(), -}).strict() as unknown as z.ZodType +}).strict().transform(({ pressureTokens, projectedTokens, contextWindow }) => ({ + ...pressureTokens === undefined ? {} : { pressureTokens }, + ...projectedTokens === undefined ? {} : { projectedTokens }, + ...contextWindow === undefined ? {} : { contextWindow }, +})) /** Prompt-side pressure of one request: input plus cache traffic, no output. */ const pressureFrom = (usage: TokenUsage): number => @@ -79,21 +84,28 @@ const usageOf = (event: SessionEvent): TokenUsage | undefined => ? event.data.usage : undefined -/** - * Context-occupancy state: the two independent last-wins records plus the - * O(1) running surface total needed to carry the newest sample forward. - */ -interface ContextPressureState { - contextWindow?: number - pressureTokens?: number - /** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */ - surfaceTokens: number - /** {@link surfaceTokens} at the newest usage sample; absent until one lands. */ - sampledSurfaceTokens?: number - /** Shadow price armed by the immediately preceding metering event. */ - claim?: ShadowPriceClaim +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + tokenUsage: TokenUsageState + contextPressure: ContextPressureState + } } +/** The context-pressure state schema and source of its inferred type. */ +const contextPressureStateSchema = z.object({ + contextWindow: z.number().int().positive().optional(), + pressureTokens: z.number().int().nonnegative().optional(), + surfaceTokens: z.number().int().nonnegative(), + sampledSurfaceTokens: z.number().int().nonnegative().optional(), + claim: z.object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + tokens: z.number().int().nonnegative(), + }).optional(), +}).strict() + +type ContextPressureState = z.infer + /** * Token-meter's session projection unit. * @@ -104,10 +116,10 @@ interface ContextPressureState { * that usage reports for one turn/step are adjacent: once a later step begins, * a legal log never reports usage for an earlier step again. */ -export const tokenUsageProjectionDefinition: -ProjectionDefinition<'tokenUsage', TokenUsageState> = { +export const tokenUsageProjectionDefinition = { key: 'tokenUsage', - schema: projectionSchema, + stateVersion: 1, + stateSchema: tokenUsageStateSchema, init: () => ({ totals: zeroBuckets(), last: null }), apply: (state, event) => { let turn: number @@ -135,9 +147,8 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { last: { turn, step, buckets }, } }, - view: state => state.totals, - stateVersion: 1, -} + wire: { viewSchema: projectionSchema, view: state => state.totals }, +} satisfies ProjectionDefinition<'tokenUsage', TokenUsageState> /** * Token-meter's context-occupancy projection unit. @@ -160,10 +171,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { * BEFORE the same event joins the surface, so an `assistant/message` anchors * against the surface its own request saw. */ -export const contextPressureProjectionDefinition: -ProjectionDefinition<'contextPressure', ContextPressureState> = { +export const contextPressureProjectionDefinition = { key: 'contextPressure', - schema: pressureSchema, + stateVersion: 4, + stateSchema: contextPressureStateSchema, init: () => ({ surfaceTokens: 0 }), apply: (state, event) => { const fold = foldSurfaceProjection(state.claim, event) @@ -195,12 +206,14 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = { const { claim: _expired, ...withoutClaim } = next return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim } }, - view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({ - ...contextWindow === undefined ? {} : { contextWindow }, - ...pressureTokens === undefined ? {} : { pressureTokens }, - ...pressureTokens === undefined || sampledSurfaceTokens === undefined - ? {} - : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }, - }), - stateVersion: 4, -} + wire: { + viewSchema: pressureSchema, + view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({ + ...contextWindow === undefined ? {} : { contextWindow }, + ...pressureTokens === undefined ? {} : { pressureTokens }, + ...pressureTokens === undefined || sampledSurfaceTokens === undefined + ? {} + : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }, + }), + }, +} satisfies ProjectionDefinition<'contextPressure', ContextPressureState> diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 350ed335c9..841b7d6aef 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -209,20 +209,20 @@ describe('contextBreakdown session projection', () => { state = definition.apply(state, append(1)) state = definition.apply(state, append(3)) // No metering event: the replacement contributes zero instead of throwing. - expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens) - .toBe(definition.view(state).messageTokens) + expect(definition.wire.view(definition.apply(state, replace(1, 3))).messageTokens) + .toBe(definition.wire.view(state).messageTokens) // An adjacent claim for another range contradicts the replacement. const mismatched = definition.apply(state, meter(1, 1, 8)) expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') // A claim expires after one intervening event, so replacement delta is zero. let expired = definition.apply(state, meter(1, 3, 8)) expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) - expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens) - .toBe(definition.view(state).messageTokens) + expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens) + .toBe(definition.wire.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. const armed = definition.apply(state, meter(1, 3, 8)) - expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) - .toBe(definition.view(state).messageTokens - 5 + estimateMessage( + expect(definition.wire.view(definition.apply(armed, replace(1, 3))).messageTokens) + .toBe(definition.wire.view(state).messageTokens - 5 + estimateMessage( createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), )) }) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 7805d39278..074f18fb76 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -4,6 +4,7 @@ import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' @@ -90,7 +91,11 @@ function appendSuccessfulCall( } function meter(config: TokenMeterConfig = {}): TokenMeter { - return new TokenMeter(new Context(), config) + const ctx = new Context() + // The registry is a required injection of the service (its three projection + // units register in the constructor); mount it synchronously. + new SessionProjectionRegistry(ctx) + return new TokenMeter(ctx, config) } function expectSurfaceTotal(measurement: TokenMeasurement): void { @@ -115,6 +120,7 @@ describe('TokenMeter configuration and registration', () => { it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TokenMeter) expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter) await fiber.dispose() @@ -660,6 +666,7 @@ describe('malformed replay and listener lifecycle', () => { it('handles earlier-reader catch-up, eager observation, and service reload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) let activeMeter: TokenMeter | undefined const revisions: number[] = [] ctx.on('session/event', (session) => { diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index d72ba7f1f6..56ba67ee80 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -8,6 +8,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' import { CompactionId } from '@deepseek-ai/dsh-compaction' +import type {} from '../src/usage-projection.ts' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/lsp/README.i18n.yaml b/packages/lsp/README.i18n.yaml index 195967e3a2..2670636cb5 100644 --- a/packages/lsp/README.i18n.yaml +++ b/packages/lsp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/lsp/README.md README.md: 414311fd3f537634651daf6f313baf33daede26b -README.zh.md: 8791b0ea9c01ed637586cee5c131690a73263a6a +README.zh.md: 8128bd410daeaa12186191a2dccddddc2a0e5b37 diff --git a/packages/lsp/README.zh.md b/packages/lsp/README.zh.md index 8791b0ea9c..8128bd410d 100644 --- a/packages/lsp/README.zh.md +++ b/packages/lsp/README.zh.md @@ -12,6 +12,6 @@ Service Definition 位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型约定。提供方注册的是**能力**而非工具;`tool-lsp` 是面向模型的名称、schema、提示词指引和呈现的唯一 owner。 -设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。 +设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md),其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。 -子系统参考——操作、坐标、请求/结果、`LspError`——见 [docs/subsystems/lsp.md](../../docs/subsystems/lsp.md);设计依据见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)。 +子系统参考——操作、坐标、请求/结果、`LspError`——见 [docs/subsystems/lsp.md](../../docs/subsystems/lsp.zh.md);设计依据见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md)。 diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 23d6113ea2..75fbd7afcd 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/README.i18n.yaml b/packages/lsp/lsp/README.i18n.yaml index 1beb022f57..0fb8bcab4e 100644 --- a/packages/lsp/lsp/README.i18n.yaml +++ b/packages/lsp/lsp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/lsp/lsp/README.md README.md: cf32f9cc60a346def48d9720bf0695db36553b0e -README.zh.md: 6f19d1f649eb1483782b6ebb4c82f05da7a42a80 +README.zh.md: eba25a7494f8c624b0e9d0aa5b5c7dd484d1f0f9 diff --git a/packages/lsp/lsp/README.zh.md b/packages/lsp/lsp/README.zh.md index 6f19d1f649..eba25a7494 100644 --- a/packages/lsp/lsp/README.zh.md +++ b/packages/lsp/lsp/README.zh.md @@ -39,6 +39,6 @@ ## 已知限制与暂缓事项 -- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 +- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md))。 - **仅四种操作**:symbol 与 call hierarchy 暂缓(它们需要不同 schema);diagnostics 需要独立的新鲜度/累积规则;修改操作(rename、code action、formatting)需要独立工具,并集成预览、权限和写入策略。 - **没有观测表层**:可用性只能通过运行 `query()` 并按抛出的 `LspError` code 路由来观测;没有提供方变更事件或能力状态查询。 diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 04d107cedb..a4a935bd1b 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml index 2eb5cb2f17..8287d4b40b 100644 --- a/packages/lsp/tool-lsp/README.i18n.yaml +++ b/packages/lsp/tool-lsp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/lsp/tool-lsp/README.md README.md: e0fd5738cf114314a8483125d20142d5f1ffac40 -README.zh.md: 8c908c1e8766ab47e9cefbc9d1f72f05e8c2709f +README.zh.md: 71d6a4b5479b2da187b78a388d617afe7214e78c diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md index 8c908c1e87..71d6a4b547 100644 --- a/packages/lsp/tool-lsp/README.zh.md +++ b/packages/lsp/tool-lsp/README.zh.md @@ -46,7 +46,7 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu #### 模型看到的内容 -模型会看到生成的 [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp)。 +模型会看到生成的 [`lsp` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-lsp)。 #### Token 影响 @@ -86,5 +86,5 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu ## 已知限制与暂缓事项 -- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;未落在符号上的位置可能返回空结果,因此提示词解释了该约定,但不鼓励广泛使用 LSP(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 +- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;未落在符号上的位置可能返回空结果,因此提示词解释了该约定,但不鼓励广泛使用 LSP(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md))。 - **不承诺跨服务器完整性**:受支持的服务器仍可能根据索引就绪情况返回空或部分结果;该工具不承诺跨语言或服务器的完整性。 diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index d6efa136a8..d0bbf867fa 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/README.i18n.yaml b/packages/mcp/README.i18n.yaml index 3ce6f13ac3..93fa4aa4aa 100644 --- a/packages/mcp/README.i18n.yaml +++ b/packages/mcp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/README.md README.md: cc3440f6cb2e507277e6141393ccf39fc7022d7d -README.zh.md: 0a74f3e9016be95f76104303396dc690888d645d +README.zh.md: 9cdc01b80dce63e06bb36fe92cfaf62ed5adce51 diff --git a/packages/mcp/README.zh.md b/packages/mcp/README.zh.md index 0a74f3e901..9cdc01b80d 100644 --- a/packages/mcp/README.zh.md +++ b/packages/mcp/README.zh.md @@ -6,4 +6,4 @@ | 包 | 职责 | |---|---| -| [`mcp-client/`](mcp-client/README.md) | MCP 客户端桥接,将外部服务器工具注册到 `ctx.tools` | +| [`mcp-client/`](mcp-client/README.zh.md) | MCP 客户端桥接,将外部服务器工具注册到 `ctx.tools` | diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index e3cc3e04c8..4fe83ee574 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/plan/README.i18n.yaml b/packages/plan/README.i18n.yaml index c2a60d7d2d..51a2fb09eb 100644 --- a/packages/plan/README.i18n.yaml +++ b/packages/plan/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/README.md README.md: 535a2b02e297d7c3bc68538fa1d03226089372e0 -README.zh.md: 0ee1c658ee0a7863eb16b99d956e882f320a3717 +README.zh.md: 288daf7a7b19a14372ab9396363bf558f1bcc240 diff --git a/packages/plan/README.zh.md b/packages/plan/README.zh.md index 0ee1c658ee..288daf7a7b 100644 --- a/packages/plan/README.zh.md +++ b/packages/plan/README.zh.md @@ -6,8 +6,8 @@ Plan mode 是按 agent(智能体)记录的协作状态,而不是通用模 | 包 | 职责 | ctx 键 | |---|---|---| -| [`plan-mode/`](plan-mode/README.md) | 负责 plan mode 状态、指引、命令和评审流程 | `ctx.planMode` | +| [`plan-mode/`](plan-mode/README.zh.md) | 负责 plan mode 状态、指引、命令和评审流程 | `ctx.planMode` | -[plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)决策记录了该家族的设计。 +[plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md)决策记录了该家族的设计。 -子系统参考——`plan/mode` 折叠、步骤边界刷写、配置、退出工具——见 [docs/subsystems/plan.md](../../docs/subsystems/plan.md);设计见[计划专属协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。 +子系统参考——`plan/mode` 折叠、步骤边界刷写、配置、退出工具——见 [docs/subsystems/plan.md](../../docs/subsystems/plan.zh.md);设计见[计划专属协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md)。 diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c12c5c248f..b1c7671e8b 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e -README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf +README.zh.md: b3548da880af5d0cacfebba4486294d9d5085626 diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index f7d6a1f8e9..b3548da880 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -10,6 +10,8 @@ `ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个提示词之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 + + ## 模型与人类交互 激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userQuestions` 获得用户明确批准后才退出。 @@ -22,7 +24,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -37,7 +39,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接 `section` 必填且非空。出现未知键时,插件会加载失败。该包不接受任意命名的 mode、工具过滤器、沙箱设置或批准策略。 -设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。 +设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md)。 ## 模型体验 @@ -79,7 +81,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。 +[`exit_plan_mode` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。 #### Token 影响 @@ -89,6 +91,8 @@ You are in plan mode. Explore and design before presenting the complete plan thr mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。 + + ## 已知限制与暂缓事项 - Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。 diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 72295f7328..91319304d1 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index b3d0933256..d81865932c 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -33,8 +33,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' // Type-only edge: resolves `ctx.commands` for the optional command child. -import type {} from '@deepseek-ai/dsh-commands' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CommandId } from '@deepseek-ai/dsh-commands' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import type { PlanProjection } from './types.ts' @@ -152,6 +151,21 @@ interface PlanUnitState { running: { commandId: CommandId; wanted: boolean } | null } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + plan: PlanUnitState + } +} + +const planUnitStateSchema: ZodType = zod.object({ + active: zod.boolean(), + wanted: zod.boolean().nullable(), + running: zod.object({ + commandId: zod.string() as unknown as ZodType, + wanted: zod.boolean(), + }).strict().nullable(), +}).strict() + /** Wire payload schema of the `plan` projection. */ const planProjectionSchema: ZodType = zod.object({ active: zod.boolean(), @@ -247,7 +261,7 @@ export class PlanModeController extends Service { ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register<'plan', PlanUnitState>({ key: 'plan', - schema: planProjectionSchema, + stateSchema: planUnitStateSchema, init: () => ({ active: false, wanted: null, running: null }), apply: (state, event) => { if (event.type === 'command/run' && event.data.name === 'plan') { @@ -266,9 +280,12 @@ export class PlanModeController extends Service { } return state }, - view: (state) => { - const wanted = state.running?.wanted ?? state.wanted - return { active: state.active, pending: wanted !== null && wanted !== state.active } + wire: { + viewSchema: planProjectionSchema, + view: (state) => { + const wanted = state.running?.wanted ?? state.wanted + return { active: state.active, pending: wanted !== null && wanted !== state.active } + }, }, stateVersion: 2, }) diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index b8d5dd62d9..bf3583068a 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/README.md README.md: 5e7805eaa303b78c4d8a1a8972e1d031ee304fae -README.zh.md: 9afb0a4f585fc445977f81839695f15cf8b21327 +README.zh.md: 217d807f40abc0908fb0c67fa0c94dc4d0806592 diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index 9afb0a4f58..217d807f40 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -13,4 +13,4 @@ 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 -设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。 +设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md)。 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index f3fef85bbd..25ebbe1b15 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md README.md: 2438d751f000297cc4ede7b2a34736a62cc39dda -README.zh.md: c8ab8e167d624f639f6657d4c0ade7205b23821b +README.zh.md: a786afbc3790595edbd64ec16686717ef3d8140f diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index c8ab8e167d..a786afbc37 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -4,7 +4,7 @@ 按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。 -其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。 +其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.zh.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.zh.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。 ## 服务:`AgentPresets`(ctx 键:`agentPresets`) @@ -36,7 +36,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装, 按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。 -子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。 +子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.zh.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。 ### 会话实际运行的是哪个 preset @@ -48,7 +48,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装, `recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。 -"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。 +"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.zh.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。 ## 创作 @@ -93,7 +93,7 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen ### 可写根目录属于本包,随附根目录属于 app -`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 +`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.zh.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a8f374d765..08c035c953 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index 0323741aed..560a90256a 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/persona/README.md README.md: 978d01ddbd52095bc2486d27d08fde2d4a8fa79b -README.zh.md: d36af6c028f1258d044608535c6da4eaf2fb92e8 +README.zh.md: a6e682711c0d6bba742cd1bb192fb9f38bf32cd0 diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index d36af6c028..a6e682711c 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -4,7 +4,7 @@ 把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。 -[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 +[`dsh-system-prompt`](../../core/system-prompt/README.zh.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.zh.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 ## 仅限 scope 内使用 diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 0453ca0897..24f6569815 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 537381af9c..59e6983bb0 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/README.i18n.yaml b/packages/sandbox/README.i18n.yaml index ab79d37170..cc93658129 100644 --- a/packages/sandbox/README.i18n.yaml +++ b/packages/sandbox/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/README.md README.md: 61127118c895cce728ab0b4eed387f51054a83f1 -README.zh.md: 984a677aad6ab97579abf861ea8b99f149c07d08 +README.zh.md: c5b8f12c544e2b6b2c7ce1ec3fc63d274df18609 diff --git a/packages/sandbox/README.zh.md b/packages/sandbox/README.zh.md index 984a677aad..c5b8f12c54 100644 --- a/packages/sandbox/README.zh.md +++ b/packages/sandbox/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`sandbox/`](sandbox/README.md) | 定义进程沙箱服务和共享升权词汇 | `ctx.sandbox` | -| [`sandbox-local/`](sandbox-local/README.md) | 提供本地平台限制后端 | 注册到 `ctx.sandbox` | -| [`sandbox-policy/`](sandbox-policy/README.md) | 解析持久的逐会话沙箱策略 | `ctx.sandboxPolicy` | +| [`sandbox/`](sandbox/README.zh.md) | 定义进程沙箱服务和共享升权词汇 | `ctx.sandbox` | +| [`sandbox-local/`](sandbox-local/README.zh.md) | 提供本地平台限制后端 | 注册到 `ctx.sandbox` | +| [`sandbox-policy/`](sandbox-policy/README.zh.md) | 解析持久的逐会话沙箱策略 | `ctx.sandboxPolicy` | -[沙箱决策](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)记录了能力边界,[文件系统集成决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了跨家族策略的使用方式。 +[沙箱决策](../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)记录了能力边界,[文件系统集成决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md)记录了跨家族策略的使用方式。 -子系统参考——模式与强制执行、按调用策略、包装 argv 方言、故障关闭错误——见 [docs/subsystems/sandbox.md](../../docs/subsystems/sandbox.md);边界与跨家族阶段见[沙箱](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)与[跨家族 fs 沙箱](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) Agent Note。 +子系统参考——模式与强制执行、按调用策略、包装 argv 方言、故障关闭错误——见 [docs/subsystems/sandbox.md](../../docs/subsystems/sandbox.zh.md);边界与跨家族阶段见[沙箱](../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)与[跨家族 fs 沙箱](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md) Agent Note。 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index d8155add50..8c63ddc717 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/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/sandbox/sandbox-local/README.md -README.md: e1b59385a1380e0918e69c06dd86834c00cf2395 -README.zh.md: f4e31e220f3f69612243066a3183ebf79ed6f73a +README.md: 7f96ebd725280cc5b767c40dcf52e9353aaafe79 +README.zh.md: 8cb14128ae4b97c3089d72bb6126bc4fd835ac31 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index e1b59385a1..7f96ebd725 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -6,10 +6,12 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal. -Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. +Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. +The bwrap profile combines a read-only host root, fresh `/dev`, and private-PID `/proc`. Commands manage descendants but cannot see host processes; hiding host `/proc/` entries prevents magic links such as `root` and `fd` from bypassing its mounts. `workspace-write` adds ephemeral `/tmp` and a writable workspace bind. The [private-PID Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.md) records the boundary. + The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index f4e31e220f..8cb14128ae 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -6,9 +6,11 @@ 包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。 -不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 负责说明选择依据与 profile 差异。 +不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。 -策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且仅排除完全匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开约定均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限定功能探测的时长。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 负责说明选择与失败语义。 +策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且仅排除完全匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开约定均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限定功能探测的时长。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md) 负责说明选择与失败语义。 + +bwrap profile 将只读宿主根目录、全新的 `/dev` 与使用私有 PID 命名空间的 `/proc` 组合起来。命令可管理后代进程,但看不到宿主进程;隐藏宿主的 `/proc/` 条目,可以防止 `root`、`fd` 等魔法链接绕过其挂载约束。`workspace-write` 另加临时的 `/tmp` 与可写工作区绑定挂载。[私有 PID Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md)记录该边界。 Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 @@ -25,7 +27,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但 ## 模型体验 -通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.md) 间接影响;它们渲染该提供方的强制执行与拒绝事实,而 [`dsh-sandbox`](../sandbox/README.md) seam 负责定义 `SANDBOX_UNAVAILABLE` 文本,runner 选择与 profile 则不进入上下文。 +通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.zh.md) 间接影响;它们渲染该提供方的强制执行与拒绝事实,而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 负责定义 `SANDBOX_UNAVAILABLE` 文本,runner 选择与 profile 则不进入上下文。 #### KV Cache 影响 diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 1866c8ddd7..8256e98ed7 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 8880da8103..5965d86d32 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -66,7 +66,7 @@ export interface Config { /** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */ function defaultProbeBwrap(timeoutMs: number): boolean { - const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { + const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: timeoutMs, stdio: 'ignore', }) diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index 5b76390319..4d647580c5 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -14,7 +14,7 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' * @returns profile arguments before the trailing separator and command argv. */ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { - const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'] + const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent'] if (policy.mode === 'workspace-write') { args.push('--tmpfs', '/tmp') args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts index 3da6787049..9a8289b95a 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, readlinkSync, rmSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -78,6 +78,44 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => { expect(result.stdout).toBe('dev-ok\n') }) + it.each(['read-only', 'workspace-write'] as const)( + '%s runs in a private PID namespace and blocks writes through procfs root magic links', + async (mode) => { + const workdir = await tempDir(homedir()) + const outside = await tempDir(homedir()) + const target = join(outside, 'escaped.txt') + const sandbox = await provider() + // Compare PID-namespace identity, not PID numbers: numeric /proc entries + // recur inside a private namespace, and the /proc/1/root write below is + // denied even in a shared namespace (host init is root-owned), so this + // comparison is the assertion that fails when --unshare-pid is lost. + const hostPidNamespace = readlinkSync('/proc/self/ns/pid') + const visibility = runConfined(sandbox, 'readlink /proc/self/ns/pid', { mode, workspaceRoot: workdir }) + expect(visibility.result.status).toBe(0) + expect(visibility.result.stdout.trim()).not.toBe('') + expect(visibility.result.stdout.trim()).not.toBe(hostPidNamespace) + + const escape = runConfined( + sandbox, + `printf escaped > /proc/1/root${target}`, + { mode, workspaceRoot: workdir }, + ) + expect(escape.result.status).not.toBe(0) + expect(existsSync(target)).toBe(false) + }, + ) + + it('keeps descendants observable and controllable inside the private PID namespace', async () => { + const workdir = await tempDir(homedir()) + const sandbox = await provider() + const { result } = runConfined( + sandbox, + 'sleep 30 & child=$!; kill -0 "$child" && kill "$child"; wait "$child"; status=$?; test "$status" -ge 128', + { mode: 'read-only', workspaceRoot: workdir }, + ) + expect(result.status).toBe(0) + }) + it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => { const workdir = await tempDir(homedir()) const outside = await tempDir(homedir()) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 389f23a011..285afb865b 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -62,13 +62,13 @@ function fakeSeatbeltExec(status: number): string { const SEATBELT_RO_PROFILE = '(version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))' describe('profile dialects', () => { - it('bwrap read-only: whole tree read-only with fresh /dev and /proc, no writable mounts', () => { - expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']) + it('bwrap read-only: whole tree read-only with fresh /dev and private PID-scoped /proc, no writable mounts', () => { + expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']) }) it('bwrap workspace-write: adds an ephemeral /tmp and rebinds the workspace root', () => { expect(bwrapProfileArgs(WW)).toEqual([ - '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', + '--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent', '--tmpfs', '/tmp', '--bind', '/ws', '/ws', ]) }) diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index f12249398a..31ef984c5e 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md README.md: c85420019b490d311360f310c04212ac6cd737d1 -README.zh.md: 58354adf55a55951cad1ea9adb88aea2457c7f7b +README.zh.md: 46e1deea680122622e58490064dcc4ea6699d1d3 diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 58354adf55..46e1deea68 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每项负责强制执行的能力在每次调用时都会收到一项解析完成的模式与根目录策略;模型在每次请求前会收到当前策略,而不会另收一份能力清单。 +沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.zh.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每项负责强制执行的能力在每次调用时都会收到一项解析完成的模式与根目录策略;模型在每次请求前会收到当前策略,而不会另收一份能力清单。 ## 为何需要共享归属位置 -文件系统工具、一次性 bash 命令和终端会话可以用不同组合强制执行同一套模式词汇。如果各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界,正是[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。每个强制执行后端都会消费归属方解析出的完整策略,而当前上下文只说明该策略对于任何受 DSH 文件沙箱强制执行的可用操作有何含义。[跨家族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。 +文件系统工具、一次性 bash 命令和终端会话可以用不同组合强制执行同一套模式词汇。如果各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界,正是[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)所警告的情况。每个强制执行后端都会消费归属方解析出的完整策略,而当前上下文只说明该策略对于任何受 DSH 文件沙箱强制执行的可用操作有何含义。[跨家族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md)记录了共享策略决策。 ## 配置 diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 3038e99718..6f4532b376 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index a394957f49..ef0522e661 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md README.md: a78f334342196ec1848a4a360e5c60b375a28057 -README.zh.md: 8962653b69b23b92fe763e4fcc90bf45911865f7 +README.zh.md: dc7f4d4c080d4e2204103afb8b1a2defcfb306c7 diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 8962653b69..dc7f4d4c08 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -6,7 +6,7 @@ 一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。这些 SID 是主要白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 -直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 +直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 ## 用法 @@ -86,7 +86,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## 模型体验 -间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.zh.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 #### KV Cache 影响 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 817d52e48f..21e42e961a 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 7e7ed765b2..d92c0552ea 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md README.md: 3ebbffd623eee51dd6fcb588eec374e6d09be2f0 -README.zh.md: bde83955a85cbe2d60c521e3677b5b305a29ecb1 +README.zh.md: b9215b59d5ed5314275e91261c5541be1e5586fe diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index bde83955a8..b9215b59d5 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -2,23 +2,25 @@ [English](README.md) | 中文 -进程沙箱 Service Definition。负责定义 `ctx.sandbox` 服务约定([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。作为[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)中的 Service Definition 角色,它只依赖 cordis(及 harness 错误基类),绝不依赖后端。 +进程沙箱 Service Definition。负责定义 `ctx.sandbox` 服务约定([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。作为[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)中的 Service Definition 角色,它只依赖 cordis(及 harness 错误基类),绝不依赖后端。 -用一句话概括约定:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/subsystems/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。 +用一句话概括约定:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/subsystems/sandbox.zh.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。 策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。 -**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析工作区所指的目录,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力 seam 的 Service Provider(`ctx.shell`、`ctx.fs`)。边界及其设计理由见[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析工作区所指的目录,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力 seam 的 Service Provider(`ctx.shell`、`ctx.fs`)。边界及其设计理由见[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。 实现:[`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)(Linux:`bwrap`,否则使用相应平台的 Landlock launcher;macOS:`sandbox-exec`/Seatbelt)。消费方:[`@deepseek-ai/dsh-bash-sandbox`](../../shell/bash-sandbox/)(包装 `['bash', '-c', command]`)。 ## 模型体验 + + ### 间接的限制错误 #### 模型看到的内容 -通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.md),无法强制执行所请求模式时会产生错误码 `SANDBOX_UNAVAILABLE` 及以下精确错误。执行期 runner 失败会追加 ` Runner failure: `。 +通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.zh.md),无法强制执行所请求模式时会产生错误码 `SANDBOX_UNAVAILABLE` 及以下精确错误。执行期 runner 失败会追加 ` Runner failure: `。 ##### 精确错误 diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 7581e4a7ff..b2506fa10c 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/README.i18n.yaml b/packages/schedule/README.i18n.yaml index 4d04edf194..90aa318ed0 100644 --- a/packages/schedule/README.i18n.yaml +++ b/packages/schedule/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/README.md README.md: 2fac190cc19f40e5d5e87acacaddce970e4b8474 -README.zh.md: 3db72e7e6650e38ab51fd75087d95cb2faf121d4 +README.zh.md: af4cd96dcb5f095a7ea83556e5f73eca532d0dee diff --git a/packages/schedule/README.zh.md b/packages/schedule/README.zh.md index 3db72e7e66..af4cd96dcb 100644 --- a/packages/schedule/README.zh.md +++ b/packages/schedule/README.zh.md @@ -10,4 +10,4 @@ Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志 本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。 -有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.md)。 +有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.zh.md)。 diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 76577697c4..8dc406becf 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/schedule/README.md README.md: f56c689198b7e17a85f43a1ecb6e5f1527da6a91 -README.zh.md: 75425c77d2b43c6511c400a55bb5f8f898f978cc +README.zh.md: 7180565370d83a7207c73aa9a2539219974dace0 diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 75425c77d2..7180565370 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -26,7 +26,7 @@ Schedule 负责确定性的日历规范化。落在夏令时缺口内的本地 ## 管理工具 -生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。 +生成的[工具目录](../../../docs/tool-catalog.zh.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。 一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。`schedule_create` 要求 `after_seconds`、`at` 与 `every_seconds` 有且只有一项;它会在进入队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。`schedule_list` 按创建顺序返回活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在进入队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight 后返回 `{ id, deleted: false, code: "schedule_not_found" }`。 diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 70910080e5..a40830b3b1 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml index c9fc6e2600..b78b628c94 100644 --- a/packages/sdk/README.i18n.yaml +++ b/packages/sdk/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/README.md README.md: 052ac933defae8766e99d79b79bc4cdc6c6d74db -README.zh.md: 364f752fcbed06cae8b0675cae852be2760bd3f4 +README.zh.md: e420c5178de7233e9939147e6b674d83e29902b8 diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md index 364f752fcb..e420c5178d 100644 --- a/packages/sdk/README.zh.md +++ b/packages/sdk/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -本组包含用于从另一进程驱动 Harness 运行时的协议栈。调用方提供运行时可执行文件及其 `cordis.yml`;本组不创建、配置、构建或启动开发者项目。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md)负责产品边界。 +本组包含用于从另一进程驱动 Harness 运行时的协议栈。调用方提供运行时可执行文件及其 `cordis.yml`;本组不创建、配置、构建或启动开发者项目。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)负责产品边界。 | 包 | 职责 | |---|---| -| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 | -| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | -| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 | +| [`protocol/`](protocol/README.zh.md) | 定义 SDK 运行时通信协议 | +| [`client/`](client/README.zh.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | +| [`server/`](server/README.zh.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 | diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index da2067692b..ab49e47455 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/client/README.md README.md: b33457875f81d11d09bab2e5aa5ce730e233c78a -README.zh.md: 271f07ffb0f97abe005971962beb517acfdc05a4 +README.zh.md: b89e56629fefe572394751bc1bee38aaba6f3300 diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index 271f07ffb0..b89e56629f 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 +以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.zh.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 -与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 +与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.zh.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 ## DeepSeekHarness @@ -29,7 +29,7 @@ console.log(result.finalResponse) 自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 -`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。 +`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.zh.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。 `HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。 @@ -44,6 +44,6 @@ console.log(result.finalResponse) ## 已知限制与暂缓事项 - **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。 -- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../protocol/README.md))。 +- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../protocol/README.zh.md))。 - **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。 - **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。 diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 523223f102..a1270ffe8a 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 262a608875..63a7e665ac 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/protocol/README.md README.md: 9024f9ca34a5467aff1b83cb9cd864c1ec06e56b -README.zh.md: ffd67552a770561320f919eec95679681fadbe0d +README.zh.md: 28372bf3dcd57a817225a2769ef969d6f0c10936 diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index ffd67552a7..28372bf3dc 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-sdk-jsonrpc-server`](../server/README.md) 插件;客户端是 [`dsh-sdk-client`](../client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。 +DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-sdk-jsonrpc-server`](../server/README.zh.md) 插件;客户端是 [`dsh-sdk-client`](../client/README.zh.md)(TypeScript)与 [Python SDK](../../../python/README.zh.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。 ## 传输 @@ -26,7 +26,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 ## 模型体验 -无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-sdk-jsonrpc-server`](../server/README.md) 后方的运行时插件。 +无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-sdk-jsonrpc-server`](../server/README.zh.md) 后方的运行时插件。 #### KV Cache 影响 @@ -35,5 +35,5 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 ## 已知限制与暂缓事项 - **无协议版本协商**——握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);处于预发布阶段,无兼容承诺。 -- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-sdk-jsonrpc-server` README](../server/README.md)。 +- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-sdk-jsonrpc-server` README](../server/README.zh.md)。 - **server→client 请求是未使用的功能**——传输层支持,但服务器从不发送;Python SDK 的应答接口为未来审批流程预留。 diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index beda9e4982..b506b5d45e 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 4f277446d3..bd7b0b22c4 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/server/README.md README.md: 29ad5840b9d70c9c22ecd387730ba21ce89cbe07 -README.zh.md: f5afb255c83fa62fa4c2891cf725ffbf4c93e77d +README.zh.md: c5e170beb70e3cd887865d653f49f0810bc07d48 diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index f5afb255c8..c5e170beb7 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。 +`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.zh.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.zh.md) 提供外围的 `cordis.yml` 应用。 ## 组装 diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index ad55c29dd8..855254e97a 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/README.i18n.yaml b/packages/session-query/README.i18n.yaml index 544fef197b..ba11f33c04 100644 --- a/packages/session-query/README.i18n.yaml +++ b/packages/session-query/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/README.md README.md: b955a7f6e19f5a49e48e564523cc343e32816dd2 -README.zh.md: d1e6c645050f4e58f9409e328552b5f9209bc3a5 +README.zh.md: ebf3bd5c5f18e00b0beec4855fb99907019664ed diff --git a/packages/session-query/README.zh.md b/packages/session-query/README.zh.md index d1e6c64505..ebf3bd5c5f 100644 --- a/packages/session-query/README.zh.md +++ b/packages/session-query/README.zh.md @@ -6,9 +6,9 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`session-query/`](session-query/README.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` | -| [`session-query-sqlite/`](session-query-sqlite/README.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` | -| [`session-log-export/`](session-log-export/README.md) | 在 Host ZIP 端点之上增加 Web `/export` 命令、共享浏览器下载状态和结果弹窗 | `ctx.sessionLogDownload` | -| [`tool-session-query/`](tool-session-query/README.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` | +| [`session-query/`](session-query/README.zh.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` | +| [`session-query-sqlite/`](session-query-sqlite/README.zh.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` | +| [`session-log-export/`](session-log-export/README.zh.md) | 在 Host ZIP 端点之上增加 Web `/export` 命令、共享浏览器下载状态和结果弹窗 | `ctx.sessionLogDownload` | +| [`tool-session-query/`](tool-session-query/README.zh.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` | -子系统参考——逻辑记录、有界读取、追踪、筛选器、结果页——见 [docs/subsystems/session-query.md](../../docs/subsystems/session-query.md)。 +子系统参考——逻辑记录、有界读取、追踪、筛选器、结果页——见 [docs/subsystems/session-query.md](../../docs/subsystems/session-query.zh.md)。 diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index d333d5ed90..0d158164ee 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md README.md: f3625338e85a38ab279c5fa4700a7a9e1124cfb3 -README.zh.md: 5a2fa86d126f6bda808d3036bddc0e26fd310690 +README.zh.md: 101f0bb41c7296916b72a82186dae74b1b576e7f diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 5a2fa86d12..101f0bb41c 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流式 ZIP 端点。Host 半包注册 `/export`;浏览器半包在 Session Header 中提供 111×32 的 `Session log` 操作,以及一个供该按钮与斜杠命令共用的下载控制器和弹窗。ZIP 生成、原始 JSONL/zstd 读取、子 Session、附件、背压和 HTTP 错误语义仍由 [ApiProxy 下载实现](../../host/apiproxy/README.md)负责。 +Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流式 ZIP 端点。Host 半包注册 `/export`;浏览器半包在 Session Header 中提供 111×32 的 `Session log` 操作,以及一个供该按钮与斜杠命令共用的下载控制器和弹窗。ZIP 生成、原始 JSONL/zstd 读取、子 Session、附件、背压和 HTTP 错误语义仍由 [ApiProxy 下载实现](../../host/apiproxy/README.zh.md)负责。 ## 命令约定 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 5092374444..f4c12768f1 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, "repository": { "type": "git", diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4bab825e49..6b507c1d1a 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index 783b6d9e6f..b668d26d4b 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md README.md: 02640bee4b3eb7f6877756a2ce84b26f698c9f58 -README.zh.md: 794c0e6e86606d92ed82ec286844647487df3295 +README.zh.md: dbac518b2c295825459475dba5a9c6c38f53afc8 diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 794c0e6e86..dbac518b2c 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -29,7 +29,7 @@ `SessionQueryEngine.searchSessions(request, exec?)` 按匹配最强的事件对逻辑语料库分组;`searchEvents(request, exec?)` 搜索一个逻辑会话。这两个是服务仅有的抽象方法。两者都返回分页结果,其延续信息是由服务持有的带品牌 `SessionSearchCursor`;接受可选取消,并在不使用提供方专用数值分数的情况下提供摘录。事件搜索分页结果还携带来自与命中相同索引世代的克隆目标 header,使授权消费方可将策略绑定到此次载荷观察。搜索请求只接受事件元数据过滤器,因为字面文本过滤使用上文所述扫描路径。 -该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。 +该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.zh.md)。 `SessionQueryError.code` 是一个封闭联合,覆盖请求验证、缺失目标、格式错误的表层、来源冲突、持久化/索引失败、取消,以及无效或陈旧游标;精确字面值在 [`src/config.ts`](src/config.ts) 中定义。 @@ -53,4 +53,4 @@ ## 已知限制与暂缓事项 - **无调用方授权**:这是上下文范围内的可信基础设施;未来的模型工具或 UI 必须限制调用方可检查的会话。 -- **无注册表或面向模型工具**:尚未提供提取器和搜索提供方注册表、递归遍历所引用的源事件的能力,以及面向模型的工具。[跟踪决策](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) 负责关系语义;SQLite 归属和 tokenizer 决策位于[已实现搜索记录](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md)。 +- **无注册表或面向模型工具**:尚未提供提取器和搜索提供方注册表、递归遍历所引用的源事件的能力,以及面向模型的工具。[跟踪决策](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md) 负责关系语义;SQLite 归属和 tokenizer 决策位于[已实现搜索记录](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md)。 diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 8a2f49c053..06863b8a37 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml index 4bc40ec6a2..33ba5b3460 100644 --- a/packages/session-query/tool-session-query/README.i18n.yaml +++ b/packages/session-query/tool-session-query/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md README.md: d973daf1124c4be05f7335b18661d431d45be39f -README.zh.md: 18ff18520ba8bb9d3d3dba5091d6281a0a47b78a +README.zh.md: 71c1c74addf8cb2b29773933a36f3368b1c81bf5 diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md index 18ff18520b..71c1c74add 100644 --- a/packages/session-query/tool-session-query/README.zh.md +++ b/packages/session-query/tool-session-query/README.zh.md @@ -45,7 +45,7 @@ Use session_search to find relevant work from prior sessions, or session_event_s #### 模型看到的内容 -模型会看到生成的 [`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query)。搜索过滤器会增加固定 schema token,而游标、工作区路径、输出分页和模型可控结果上限仍不存在。 +模型会看到生成的 [`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-session-query)。搜索过滤器会增加固定 schema token,而游标、工作区路径、输出分页和模型可控结果上限仍不存在。 #### Token 影响 diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 8c069a5907..e271fd1645 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml index 2939d73342..69cbe17e10 100644 --- a/packages/session/README.i18n.yaml +++ b/packages/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/session/README.md -README.md: 2680bc3c6aea519427da2f31ab2526b5115cac96 -README.zh.md: b446a6a1d17da39c2a3d29c10fbb8dddf573bf80 +README.md: 78fbc0ab6f0a3b9a77c9e3b1a538dd227c532296 +README.zh.md: e1c04337931a42a1751edb2d3d616b4b53d2af1b diff --git a/packages/session/README.md b/packages/session/README.md index 2680bc3c6a..78fbc0ab6f 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -13,7 +13,7 @@ Durable session persistence, semantic checkpoint policy, and the shipped storage | [`session-persistence/`](session-persistence/README.md) | Defines the persistence service and shared write coordination | `ctx.sessionPersistence` | | [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | Applies semantic durability checkpoints | wraps `ctx.llm` and `ctx.tools` | | [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Persists sessions in JSONL files | registers on `ctx.sessionPersistence` | -| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Persists sessions in SQLite | registers on `ctx.sessionPersistence` | +| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Opt-in SQLite backend with packed physical chunk rows | registers on `ctx.sessionPersistence` | The [session-persistence decision](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) records the persistence design. diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md index b446a6a1d1..e1c0433793 100644 --- a/packages/session/README.zh.md +++ b/packages/session/README.zh.md @@ -10,12 +10,12 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`session-persistence/`](session-persistence/README.md) | 定义持久化服务和共享写入协调机制 | `ctx.sessionPersistence` | -| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | 应用语义持久性检查点 | 包装 `ctx.llm` 和 `ctx.tools` | -| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | 将会话持久化到 JSONL 文件 | 注册到 `ctx.sessionPersistence` | -| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | 将会话持久化到 SQLite | 注册到 `ctx.sessionPersistence` | +| [`session-persistence/`](session-persistence/README.zh.md) | 定义持久化服务和共享写入协调机制 | `ctx.sessionPersistence` | +| [`session-checkpoint-policy/`](session-checkpoint-policy/README.zh.md) | 应用语义持久性检查点 | 包装 `ctx.llm` 和 `ctx.tools` | +| [`session-persistence-jsonl/`](session-persistence-jsonl/README.zh.md) | 将会话持久化到 JSONL 文件 | 注册到 `ctx.sessionPersistence` | +| [`session-persistence-sqlite/`](session-persistence-sqlite/README.zh.md) | 使用物理分片打包行的可选 SQLite 后端 | 注册到 `ctx.sessionPersistence` | -[会话持久化决策](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)记录了持久化设计。 +[会话持久化决策](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md)记录了持久化设计。 ## 投影 @@ -23,9 +23,9 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`session-projection/`](session-projection/README.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` | -| [`session-projection-cache/`](session-projection-cache/README.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` | -| [`session-stats/`](session-stats/README.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` | +| [`session-projection/`](session-projection/README.zh.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` | +| [`session-projection-cache/`](session-projection-cache/README.zh.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` | +| [`session-stats/`](session-stats/README.zh.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` | ## 标题 @@ -33,20 +33,20 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`session-title/`](session-title/README.md) | 负责标题状态、回退行为、提供方注册与刷新 | `ctx.sessionTitle` | -| [`session-title-llm/`](session-title-llm/README.md) | 提供共享的模型标题生成能力 | — | -| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` | -| [`session-title-all-prompts-llm/`](session-title-all-prompts-llm/README.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` | +| [`session-title/`](session-title/README.zh.md) | 负责标题状态、回退行为、提供方注册与刷新 | `ctx.sessionTitle` | +| [`session-title-llm/`](session-title-llm/README.zh.md) | 提供共享的模型标题生成能力 | — | +| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.zh.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` | +| [`session-title-all-prompts-llm/`](session-title-all-prompts-llm/README.zh.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` | 部署可以注册一个模型驱动提供方;未注册时,服务仍保留确定性回退机制。 ## 遥测 -将会话活动投影为外发遥测,并将投递委派给配置的上报后端。[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录上报边界;[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)记录即时、反馈门控与禁用投递。 +将会话活动投影为外发遥测,并将投递委派给配置的上报后端。[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)记录上报边界;[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md)记录即时、反馈门控与禁用投递。 | 包 | 职责 | |---|---| -| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递。 | -| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志以 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 模式投递遥测。 | +| [`session-telemetry/`](session-telemetry/README.zh.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递。 | +| [`session-telemetry-otel/`](session-telemetry-otel/README.zh.md) | 通过 OpenTelemetry 日志以 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 模式投递遥测。 | -子系统参考:[persistence.md](../../docs/subsystems/persistence.md)、[session-projection.md](../../docs/subsystems/session-projection.md)、[session-title.md](../../docs/subsystems/session-title.md) 与 [session-telemetry.md](../../docs/subsystems/session-telemetry.md)。同一时间只允许一个标题提供方注册;demo 主干挂载回退服务,两个模型提供方都留在默认组合之外。 +子系统参考:[persistence.md](../../docs/subsystems/persistence.zh.md)、[session-projection.md](../../docs/subsystems/session-projection.zh.md)、[session-title.md](../../docs/subsystems/session-title.zh.md) 与 [session-telemetry.md](../../docs/subsystems/session-telemetry.zh.md)。同一时间只允许一个标题提供方注册;demo 主干挂载回退服务,两个模型提供方都留在默认组合之外。 diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 1273bf3ce3..396669044d 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 2184f491c1..f9ff2ed252 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md README.md: 4cff3215cdb083d2fdb7c4a8f1b60e8c4028ba84 -README.zh.md: 38b41cb9f7fa0f144923feced06152a185254c0a +README.zh.md: 1675f14a6b9fd2fedf50cca0484aff68ab06f975 diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 38b41cb9f7..1675f14a6b 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -16,7 +16,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d - 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`agentPreset` 必须持久化,因为它决定了被恢复会话的工具与提示词——恢复成另一套组装,就会回放模型已无法据以行动的历史。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和各成员的 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 -- 项目目录保留规范化 cwd 的可读形式,便于导航,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 +- 项目目录保留规范化 cwd 的可读形式,便于导航,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md) 记录这项取舍。 - 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 ## 配置 @@ -33,7 +33,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d ## 物理编码 -默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 +默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。不提供迁移、混合根回退或双写。 @@ -42,7 +42,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d - **绑定存储身份。** 查找要求可读项目目录中只有一个匹配会话目录,然后验证 header id 等于请求 id,且 header id/cwd 派生所选 transcript 路径。列表应用同一路径检查,并拒绝重复 id。身份失败发生在修复或 append 前。 - **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。 - **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。 -- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。 +- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。 - **非修改式检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。 - **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝无法 JSON 序列化的 `event.data`,同时命名违规事件类型。 - **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation,而不加载日志。快照列表通过产物发现原样转发该信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。 diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 0b27dfa1d3..4044e46022 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml index 8fd46fa101..b331013806 100644 --- a/packages/session/session-persistence-sqlite/README.i18n.yaml +++ b/packages/session/session-persistence-sqlite/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/session/session-persistence-sqlite/README.md -README.md: ec42419a132a26c1f23ab99ab3da1db97a5483b0 -README.zh.md: 67c6bcfbec92f8f5b91150fb108c65a287906db9 +README.md: ba005d79771bcbc2c0c1632da77d694aa3a18c07 +README.zh.md: 96a396d237a8abf263c50c46c8c7b23054d6e7aa diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md index ec42419a13..ba005d7977 100644 --- a/packages/session/session-persistence-sqlite/README.md +++ b/packages/session/session-persistence-sqlite/README.md @@ -2,40 +2,39 @@ English | [中文](README.zh.md) -A SQLite durable session-persistence backend — a second `SessionPersistence` provider ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)) satisfying the same contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. +An opt-in SQLite `SessionPersistence` provider. It stores eligible `assistant/chunk` runs in packed physical rows, selectively Zstandard-compresses large payloads, and delta-encodes provenance sequences while restoring the exact logical `SessionEvent[]`. No shipped composition selects it; deployments mount this package explicitly and provide its database path. -`locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path. +`locate(meta)` returns `undefined` because every session shares one database. The provider exposes no per-session raw artifact. ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows store one logical event. Packed rows use `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` as the physical `type`; `seq` and `time` identify the first represented event, and `data` holds the shared packed-chunk payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. These tags are storage records, not `SessionEventMap` members. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations. +Schema 17 owns its codec locally rather than importing another persistence format's mutable implementation. Only exact, consecutive same-block text, reasoning, or tool-call delta forms pack. Unknown fields, surface metadata, sequence gaps, incompatible block/call identity, and unsafe timestamps remain scalar. A packed row represents at most 1,024 events and at most 1 MiB of uncompressed UTF-8 `data`; longer runs are partitioned without changing logical events. Reads reconstruct every original sequence number, timestamp, token boundary, argument fragment, and payload before returning data to the persistence coordinator. -On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. +Serialized `data` smaller than 4 KiB stays as SQLite `TEXT`. At or above that threshold, the writer uses Zstandard level 3 and stores a `BLOB` only when the frame is smaller than the original text; the reader decompresses it before UTF-8 validation and JSON parsing. `source_event_seqs` remains the complete ordered provenance array. Its first sequence is an unsigned varint and each subsequent sequence is a signed delta encoded with ZigZag varints, stored as a `BLOB`; no source is omitted or converted to a range. -## Contract semantics over rows +Each append holds `BEGIN IMMEDIATE`, validates the bounded physical tail, packs only the new durable batch, inserts those records, and increments the session revision once. Normal appends never delete or replace an earlier event row. The default 200 ms write-behind window therefore compresses high-frequency streams while the physical write volume stays proportional to newly durable batches rather than repeatedly rewriting a growing packed value. A storage-level logical-tail check rejects a stale writer before mutation. -- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) -- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). -- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. -- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision. -- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible. +Full reads scan physical rows in first-logical-sequence order. A reverse pass finds the last valid `turn/end` without retaining decoded copies of every physical row; the forward pass decodes and validates one physical row at a time into the returned logical event array. `readFrom(id, fromSeq)` examines packed predecessors only within the maximum row span and anchors the suffix at the earliest one that may contain `fromSeq`; this includes an event range that starts inside a packed row, detects overlapping physical corruption, and does not parse unrelated earlier scalar rows. A malformed packed row is all-or-nothing: committed corruption rejects, while a torn final row is deleted from its physical base during mutating recovery. Repair re-reads the tail under the write lock and rejects a stale marker before deleting anything. Packed `data` that exceeds the schema byte limit rejects before JSON parsing. + +## Schema compatibility + +A pristine database initializes directly at schema 17. Older schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; this pre-release provider supplies no migration. Every statement and fixed pragma lives in a packaged `.sql` resource; values use SQLite parameters and runtime code never assembles query text. ## Configuration (schemastery) ```ts interface Config { - path: string // SQLite database file path, or ':memory:' for an in-process DB - journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' - preparedSessionCacheSize?: number // positive integer; default 5 - writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647 + path: string + journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' + busyTimeoutMs?: number + preparedSessionCacheSize?: number + writeBatchMaxDelayMs?: number } ``` -## Write path - -Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session. The first pending event starts the configured fixed batching window, and later events join without resetting it. Expiry starts one transaction; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains current and pending batches. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. Every event remains a separate SQLite row; batching only groups more INSERTs into one transaction and revision increment. +`journalMode` defaults to `wal`, `busyTimeoutMs` defaults to `5,000`, `preparedSessionCacheSize` defaults to `5`, and `writeBatchMaxDelayMs` defaults to `200`. The timeout bounds each synchronous SQLite lock wait. Because SQLite may return `SQLITE_BUSY` immediately while changing journal mode, cold open yields between attempts and starts no further attempt after an open-relative retry cutoff. An in-progress synchronous SQLite call may finish after that cutoff. The provider disables trusted schemas and memory-mapped I/O on every connection, then reads both settings back. The selected journal mode is also read back and must match; in-memory databases explicitly accept SQLite's `memory` result. After selecting the journal, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. On POSIX, the database parent and file must be owned by the current user, the parent must not be group/world-writable, and the file must have no group/world permissions. Symbolic links and non-regular files reject. Windows also rejects symbolic links and non-regular files, but deployments remain responsible for restricting the directory and file ACLs to the harness user. Path and ownership failures reject plugin initialization. Node SQLite loads lazily on the first persistence operation; the import suppresses only Node 22's exact SQLite `ExperimentalWarning`. Store-identity and schema failures reject that operation before data is exposed or mutated. ## Model Experience @@ -43,20 +42,22 @@ Like the JSONL backend, the plugin copies each frozen `session/event` into one c #### What the model sees -SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Row metadata and raw chunks are not messages. +Nothing specific to SQLite. Resume restores the same logical events and derived messages as JSONL; physical packed tags never reach prompts, tools, replay, or live `session/event` delivery. #### Token effect -Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call. +Zero live-request tokens. Resume pays only for the retained logical history and current request envelope. #### KV Cache effect -SQLite storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append. +Physical packing does not mutate request prefixes. Provider cache reuse depends on the reconstructed history, current envelope, and model route exactly as with other persistence backends. ## Known Limitations and Deferred Work -- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. -- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. -- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve). -- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion API; `ON DELETE CASCADE` is wired for such out-of-band cleanup). -- **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver. +- **Interim SQLite-specific design** — This efficiency-focused implementation is informed by [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb). A unified relational-database design with multiple backends and configurable schemas is deferred; neither schema stability nor migration support is guaranteed during pre-release development. +- **Packing follows durable batch boundaries** — compatible runs split by the write-behind window or an explicit flush remain separate physical records; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio. +- **Synchronous compression** — Node's SQLite and Zstandard calls block the JavaScript thread; the 4 KiB threshold limits per-frame work for small records. +- **`DatabaseSync` blocks the event loop** — physical row reduction does not make SQLite operations asynchronous. +- **Busy waits block the event loop** — SQLite waits inside synchronous `DatabaseSync` calls; only a busy journal-mode transition yields between attempts, and the open-relative cutoff prevents another attempt rather than interrupting an active call. +- **External SQL readers must understand physical tags** — supported consumers read through this provider rather than treating every `events.type` as a logical event type. +- **No deletion or background historical compaction** — normal appends are insert-only. diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md index 67c6bcfbec..96a396d237 100644 --- a/packages/session/session-persistence-sqlite/README.zh.md +++ b/packages/session/session-persistence-sqlite/README.zh.md @@ -2,61 +2,62 @@ [English](README.md) | 中文 -SQLite 持久会话存储后端:第二个 `SessionPersistence` 提供方(见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),满足与 `dsh-session-persistence-jsonl` 相同的约定(仅追加、连续 seq、延迟实体化、在 load 时关闭中断轮次),但用 `node:sqlite` 行而非文件字节表达。 +一个可选启用的 SQLite `SessionPersistence` 提供方。它将符合条件的 `assistant/chunk` 连续段存入打包后的物理行,对大型 payload 选择性应用 Zstandard 压缩,并对来源序列进行 delta 编码,同时恢复完全一致的逻辑 `SessionEvent[]`。随产品交付的组合均不选择它;部署方需显式挂载本包并提供数据库路径。 -`locate(meta)` 返回 `undefined`:所有会话共享一个数据库,因此不存在真实、独立的逐会话 transcript(文本记录)路径。 +`locate(meta)` 返回 `undefined`,因为所有会话共享同一个数据库。该提供方不暴露逐会话原始产物。 ## 存储模型 -每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。 +Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行存储一个逻辑事件。打包行把 `text-chunks`、`reasoning-chunks` 或 `tool-call-chunks` 用作物理 `type`;`seq` 与 `time` 标识所表示的第一个事件,`data` 保存共享的分片打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储记录,而不是 `SessionEventMap` 成员。 -仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前均会被拒绝,因为该未发布格式无迁移。 +Schema 17 在本包内拥有 codec,不导入其他持久化格式中可变的实现。只有字段完全匹配、连续且属于同一分片块的文本、推理或工具调用 delta 才会打包。未知字段、surface 元数据、序列缺口、不兼容的块/调用身份以及不安全时间戳仍以标量行存储。一个打包行最多表示 1,024 个事件,未压缩 UTF-8 `data` 最多 1 MiB;更长的连续段会在不改变逻辑事件的前提下分割。读取会在向持久化协调器返回数据前,重建每个原始序列号、时间戳、token 边界、参数片段和 payload。 -在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。 +序列化后的 `data` 小于 4 KiB 时保持为 SQLite `TEXT`。达到或超过该阈值时,写入方会使用 Zstandard level 3,并且只在 frame 小于原文本的情况下存储 `BLOB`;读取方会先解压,再执行 UTF-8 校验和 JSON 解析。`source_event_seqs` 仍是完整且有序的来源数组。第一个序列使用无符号 varint,后续序列使用 ZigZag varint 编码的有符号差值,并存为 `BLOB`;不会省略任何来源,也不会把数组转换成范围。 -## 行上的约定语义 +每次追加持有 `BEGIN IMMEDIATE`,验证有界物理尾部,只打包新的持久批次,插入这些记录,并把会话 revision 递增一次。普通追加绝不删除或替换既有事件行。默认 200 毫秒写后缓冲窗口因此仍能压缩高频流,而物理写入量与新增持久批次成正比,不会反复改写不断增长的打包值。存储层逻辑尾部检查会在陈旧写入方执行变更前拒绝该写入。 -- **Append = 事务。**`append` 围绕批次运行 `BEGIN`/`COMMIT`:它实体化 `sessions` 行(如果仍未实体化),并 INSERT 每个事件,首先断言连续 seq 约定(第一个事件 `seq` 必须等于已存储 next-seq)。批次中失败(重复 seq 上的 UNIQUE 违规)会完全回滚,使已存储日志和内存游标保持一致。(`load()` 已平衡已存储日志,因此 `append` 不必修复崩溃尾部。) -- **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。已创建但从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。 -- **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。 -- **非修改式检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会删除撕裂尾部行、追加恢复行或更改轻量修订。 -- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。 +完整读取按首个逻辑序列号的顺序扫描物理行。反向扫描会定位最后一个有效 `turn/end`,但不会保留每个物理行的解码副本;正向扫描则逐行解码并校验,写入最终返回的逻辑事件数组。`readFrom(id, fromSeq)` 只检查最大行跨度内的打包前驱,并把后缀锚定在可能包含 `fromSeq` 的最早前驱;这样既可包含从打包行内部开始的事件范围,也能检测相互重叠的物理损坏,而不会解析无关的更早标量行。畸形打包行按全有或全无处理:已提交区域中的损坏会拒绝读取,最终撕裂行则在可变恢复期间从其物理起点删除。修复会在持有写锁时重新读取尾部,并在删除任何数据前拒绝陈旧 marker。打包 `data` 超出 schema 字节上限时,会在解析 JSON 前拒绝。 + +## Schema 兼容性 + +全新数据库直接初始化为 schema 17。旧 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;这个预发布提供方不提供迁移。每条语句和固定 pragma 都位于随包发布的 `.sql` 资源中;值使用 SQLite 参数,运行时代码不会拼装查询文本。 ## 配置(schemastery) ```ts interface Config { - path: string // SQLite database file path, or ':memory:' for an in-process DB - journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' - preparedSessionCacheSize?: number // positive integer; default 5 - writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647 + path: string + journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' + busyTimeoutMs?: number + preparedSessionCacheSize?: number + writeBatchMaxDelayMs?: number } ``` -## 写入路径 - -与 JSONL 后端一样,插件将每个冻结的 `session/event` 复制到对应活动会话的 controller 中,每个活动会话各有一个 controller。第一个待处理事件会开启配置的固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一个事务;该次写入期间接纳的事件会形成另一个独立有界的后续批次。`session/flush` 会取消等待并排空当前与待处理批次。Controller 会持久化一次 fork 种子,并保留写入游标,使恢复操作绝不重新 append 已存储事件;它还会在 apply 时为活动会话设置初始状态,因为 HMR(热模块替换)不回放 `session/created`。dispose(资源释放)会在关闭数据库前排空每个保留的 controller。每个事件仍各占一行 SQLite 记录;批处理只把更多 INSERT 归入同一个事务和同一次修订版本递增。 +`journalMode` 默认为 `wal`,`busyTimeoutMs` 默认为 `5,000`,`preparedSessionCacheSize` 默认为 `5`,`writeBatchMaxDelayMs` 默认为 `200`。该超时限制每次同步 SQLite 锁等待的时长。SQLite 在切换 journal mode 时可能立即返回 `SQLITE_BUSY`,因此冷打开会在尝试之间让出执行,并在从打开时开始计算的重试截止点后不再发起新尝试。正在执行的同步 SQLite 调用可能在该截止点之后才完成。提供方会在每个连接上禁用可信 schema 与内存映射 I/O,然后读回这两项设置。提供方还会读回所选 journal mode 并要求它匹配;内存数据库显式接受 SQLite 返回的 `memory`。选择 journal 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。在 POSIX 上,数据库父目录和文件必须归当前用户所有,父目录不得允许组或其他用户写入,文件不得授予组或其他用户任何权限。符号链接和非普通文件会被拒绝。Windows 同样拒绝符号链接与非普通文件,但部署方仍负责把目录和文件 ACL 限制给 harness 用户。路径与所有权错误会拒绝插件初始化。Node SQLite 在第一次持久化操作时才加载;导入时只抑制 Node 22 精确的 SQLite `ExperimentalWarning`。存储身份与 schema 错误会在暴露或变更数据前拒绝该操作。 ## 模型体验 ### 恢复的对话历史 -#### 模型看到的内容 +#### 模型看到什么 -SQLite 存储不会向当前请求提供提示词或 schema。加载会恢复与 JSONL 相同的呈现历史,并保留之前的 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有已持久化调用的 assistant 请求;已有持久化调用但无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。行元数据和原始分片不会成为消息。 +没有 SQLite 特有内容。恢复得到与 JSONL 相同的逻辑事件和派生消息;物理打包标签绝不会进入 prompt、工具、回放或实时 `session/event` 投递。 #### Token 影响 -SQLite 存储不会增加当前请求的 token 用量。恢复会还原已保留的历史,并产生当前 envelope 以及每个中断调用所附、以引用形式呈现的修复结果文本所产生的 token 开销。 +实时请求增加零 token。恢复只为保留的逻辑历史和当前请求 envelope 付出 token。 #### KV Cache 影响 -SQLite 存储不修改当前请求前缀。只有重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果会追加到末尾。 +物理打包不会改变请求前缀。与其他持久化后端相同,提供方 cache 复用取决于重建历史、当前 envelope 和模型路由。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件循环;对本地存储可接受,对繁忙多会话服务器是吞吐上限。 -- **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。 -- **只有 pristine 新数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。 -- **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理配置)。 -- **TODO:** 该后端直接调用 `node:sqlite`。如果采用 Cordis 数据库服务(`cordis/db` / `@cordisjs` SQL driver 插件),应改为通过该服务路由,而不在此直接持有 `DatabaseSync`;约定接口(`SessionPersistence`)不会变,只更换存储驱动。 +- **过渡性的 SQLite 专用设计**——这一以效率为重点的实现参考了 [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb)。支持多种后端与可配置 schema 的统一关系数据库设计尚待后续完善;预发布开发阶段不保证 schema 稳定性或迁移支持。 +- **打包服从持久批次边界**——被写后缓冲窗口或显式 flush 分开的兼容连续段会保留为不同物理记录;这以打包率受时序影响为代价,避免改写既有行。 +- **同步压缩**——Node 的 SQLite 与 Zstandard 调用都会阻塞 JavaScript 线程;4 KiB 阈值限制了小型记录的逐 frame 工作。 +- **`DatabaseSync` 会阻塞事件循环**——减少物理行不会使 SQLite 操作变为异步。 +- **繁忙等待会阻塞事件循环**——SQLite 会在同步 `DatabaseSync` 调用内等待;只有繁忙的 journal-mode 切换会在两次尝试之间让出执行,而且从打开时计算的截止点只阻止新尝试,不会中断正在执行的调用。 +- **外部 SQL 读取方必须理解物理标签**——受支持的消费方通过本提供方读取,而不是把每个 `events.type` 都当作逻辑事件类型。 +- **没有删除或后台历史压缩**——普通追加只做插入。 diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 0335901faf..17f16fd5ca 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-sqlite", - "description": "SQLite durable session persistence backend for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "description": "SQLite durable session persistence with physical chunk-row packing", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, @@ -28,11 +28,13 @@ "files": [ "lib/index.js", "lib/invariant.js", + "resources/sql/**/*.sql", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -41,9 +43,13 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "typescript": "^6.0.3" } } diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql new file mode 100644 index 0000000000..67edb1dd06 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql @@ -0,0 +1 @@ +BEGIN IMMEDIATE; diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin.sql b/packages/session/session-persistence-sqlite/resources/sql/begin.sql new file mode 100644 index 0000000000..1775571fa7 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/begin.sql @@ -0,0 +1 @@ +BEGIN; diff --git a/packages/session/session-persistence-sqlite/resources/sql/commit.sql b/packages/session/session-persistence-sqlite/resources/sql/commit.sql new file mode 100644 index 0000000000..87ef767444 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/commit.sql @@ -0,0 +1 @@ +COMMIT; diff --git a/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql new file mode 100644 index 0000000000..ab5f81d89a --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql @@ -0,0 +1,2 @@ +DELETE FROM events +WHERE session_id = ? AND seq >= ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql new file mode 100644 index 0000000000..c8ccb1392a --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql @@ -0,0 +1 @@ +PRAGMA foreign_keys = ON; diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql new file mode 100644 index 0000000000..92b4de310d --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql @@ -0,0 +1,3 @@ +INSERT INTO events + (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql new file mode 100644 index 0000000000..f516014d93 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql @@ -0,0 +1,2 @@ +INSERT INTO persistence_state (singleton, store_id) +VALUES (1, ?); diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql new file mode 100644 index 0000000000..0f4c37efca --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql @@ -0,0 +1 @@ +PRAGMA journal_mode = DELETE; diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql new file mode 100644 index 0000000000..02445b260b --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql @@ -0,0 +1 @@ +PRAGMA journal_mode = PERSIST; diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql new file mode 100644 index 0000000000..d119c32bec --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql @@ -0,0 +1 @@ +PRAGMA journal_mode = TRUNCATE; diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql new file mode 100644 index 0000000000..2d30d8af9f --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql @@ -0,0 +1 @@ +PRAGMA journal_mode = WAL; diff --git a/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql new file mode 100644 index 0000000000..22bcd0ea34 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql @@ -0,0 +1 @@ +PRAGMA mmap_size = 0; diff --git a/packages/session/session-persistence-sqlite/resources/sql/rollback.sql b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql new file mode 100644 index 0000000000..3b18e77376 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql @@ -0,0 +1 @@ +ROLLBACK; diff --git a/packages/session/session-persistence-sqlite/resources/sql/schema.sql b/packages/session/session-persistence-sqlite/resources/sql/schema.sql new file mode 100644 index 0000000000..41d247dba7 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/schema.sql @@ -0,0 +1,30 @@ +CREATE TABLE persistence_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + store_id TEXT NOT NULL +) STRICT; + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + origin TEXT, + delegation_depth INTEGER, + agent_preset TEXT, + incarnation TEXT NOT NULL, + revision INTEGER NOT NULL +) STRICT; + +CREATE TABLE events ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + time INTEGER NOT NULL, + data ANY NOT NULL, + source_event_seqs ANY, + surface_op TEXT, + ignorable INTEGER CHECK (ignorable IS NULL OR ignorable IN (0, 1)), + PRIMARY KEY (session_id, seq) +) STRICT; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql new file mode 100644 index 0000000000..3de4abbb88 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql @@ -0,0 +1 @@ +PRAGMA application_id; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql new file mode 100644 index 0000000000..a5748dd974 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql @@ -0,0 +1,4 @@ +SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable +FROM events +WHERE session_id = ? AND seq >= ? +ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql new file mode 100644 index 0000000000..76437f8eaf --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql @@ -0,0 +1,4 @@ +SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable +FROM events +WHERE session_id = ? +ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql new file mode 100644 index 0000000000..58e55155f4 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql @@ -0,0 +1 @@ +PRAGMA mmap_size; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql new file mode 100644 index 0000000000..54a52180fc --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql @@ -0,0 +1,6 @@ +SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable +FROM events +WHERE session_id = ? AND seq >= ? AND seq < ? + AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks') + AND ignorable = 0 +ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql new file mode 100644 index 0000000000..216925f35a --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql @@ -0,0 +1,4 @@ +SELECT type, name, tbl_name, sql +FROM sqlite_schema +WHERE name NOT GLOB 'sqlite_*' +ORDER BY type, name; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql new file mode 100644 index 0000000000..456cba91c3 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql @@ -0,0 +1,4 @@ +SELECT id, version, created_at, cwd, parent_session, seed_length, origin, + delegation_depth, agent_preset, incarnation, revision +FROM sessions +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql new file mode 100644 index 0000000000..37a1f22e36 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql @@ -0,0 +1,3 @@ +SELECT id, version, created_at, cwd, parent_session, seed_length, origin, + delegation_depth, agent_preset, incarnation, revision +FROM sessions; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql new file mode 100644 index 0000000000..168e09cb80 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql @@ -0,0 +1,3 @@ +SELECT store_id +FROM persistence_state +WHERE singleton = 1; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql new file mode 100644 index 0000000000..b41be01714 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql @@ -0,0 +1 @@ +PRAGMA synchronous; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql new file mode 100644 index 0000000000..2d958de7c3 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql @@ -0,0 +1,5 @@ +SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable +FROM events +WHERE session_id = ? +ORDER BY seq DESC +LIMIT ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql new file mode 100644 index 0000000000..d304b1f850 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql @@ -0,0 +1 @@ +PRAGMA trusted_schema; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql new file mode 100644 index 0000000000..60665bb66e --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS count +FROM sqlite_schema +WHERE name NOT GLOB 'sqlite_*'; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql new file mode 100644 index 0000000000..4edeca1a4d --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql @@ -0,0 +1 @@ +PRAGMA user_version; diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql new file mode 100644 index 0000000000..617d10cab3 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql @@ -0,0 +1 @@ +PRAGMA application_id = 1146308688; diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql new file mode 100644 index 0000000000..5aac576e8c --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql @@ -0,0 +1 @@ +PRAGMA user_version = 17; diff --git a/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql new file mode 100644 index 0000000000..b0380b1197 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql @@ -0,0 +1 @@ +PRAGMA synchronous = FULL; diff --git a/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql new file mode 100644 index 0000000000..973c6d1def --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql @@ -0,0 +1 @@ +PRAGMA trusted_schema = OFF; diff --git a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql new file mode 100644 index 0000000000..2cfbcb2b82 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql @@ -0,0 +1,3 @@ +UPDATE sessions +SET revision = revision + 1 +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql new file mode 100644 index 0000000000..c1000e4daa --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql @@ -0,0 +1,13 @@ +INSERT INTO sessions + (id, version, created_at, cwd, parent_session, seed_length, origin, + delegation_depth, agent_preset, incarnation, revision) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) +ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + created_at = excluded.created_at, + cwd = excluded.cwd, + parent_session = excluded.parent_session, + seed_length = excluded.seed_length, + origin = excluded.origin, + delegation_depth = excluded.delegation_depth, + agent_preset = excluded.agent_preset; diff --git a/packages/session/session-persistence-sqlite/src/codec.ts b/packages/session/session-persistence-sqlite/src/codec.ts new file mode 100644 index 0000000000..5ee2126c6e --- /dev/null +++ b/packages/session/session-persistence-sqlite/src/codec.ts @@ -0,0 +1,343 @@ +/** + * Schema-17 physical chunk-row codec. This package owns the durable tags, + * validation, and row-size limits independently from other persistence formats. + * @module @deepseek-ai/dsh-session-persistence-sqlite/codec + */ + +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/* jscpd:ignore-start -- schema 17 deliberately owns a frozen physical codec; + * importing or sharing the JSONL codec would let that format mutate this database interpreter. */ +type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta' +type DeltaEvent = SessionEvent<'assistant/chunk'> + +interface RunDataBase { + readonly turn: number + readonly step: number + readonly index: number + readonly dt: number[] +} + +interface TextRunData extends RunDataBase { + readonly texts: string[] +} + +interface ToolCallRunData extends RunDataBase { + readonly id: Extract['id'] + readonly name?: string + readonly args: string[] +} + +/** One schema-17 packed physical record. */ +export type ChunkRow = + | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData } + | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData } + | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData } + +/** One scalar event or schema-17 packed physical record. */ +export type StorageRecord = SessionEvent | ChunkRow + +/** Minimum eligible members in a packed physical record. */ +export const MIN_PACKED_ROW_MEMBERS = 3 +/** Maximum logical members represented by one packed physical record. */ +export const MAX_PACKED_ROW_MEMBERS = 1_024 +/** Maximum UTF-8 bytes in one packed physical record's data column. */ +export const MAX_PACKED_DATA_BYTES = 1_048_576 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function hasExactKeys(value: object, keys: readonly string[]): boolean { + return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)) +} + +function classify(event: SessionEvent): DeltaKind | undefined { + if (event.type !== 'assistant/chunk') return undefined + if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined + if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined + const data: unknown = event.data + if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined + if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined + const chunk = data.chunk + if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string' + ? chunk.type + : undefined + case 'tool-call-delta': { + const validKeys = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta']) + || (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) + && typeof chunk.name === 'string') + return validKeys && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string' + ? chunk.type + : undefined + } + default: + return undefined + } +} + +function toolCallOf(event: DeltaEvent): { readonly id: string; readonly name?: string } { + return event.data.chunk as { readonly id: string; readonly name?: string } +} + +function indexOf(event: DeltaEvent): number { + return (event.data.chunk as { readonly index: number }).index +} + +function continues(previous: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean { + if (next.seq !== previous.seq + 1 || !Number.isSafeInteger(next.time - previous.time)) return false + if (next.data.turn !== previous.data.turn || next.data.step !== previous.data.step) return false + if (indexOf(next) !== indexOf(previous)) return false + if (kind !== 'tool-call-delta') return true + const left = toolCallOf(previous) + const right = toolCallOf(next) + return left.id === right.id + && Object.hasOwn(left, 'name') === Object.hasOwn(right, 'name') + && left.name === right.name +} + +function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow { + const first = run[0] as DeltaEvent + const base = { + turn: first.data.turn, + step: first.data.step, + index: indexOf(first), + dt: run.slice(1).map((event, index) => event.time - (run[index] as DeltaEvent).time), + } + const envelope = { seq0: first.seq, time0: first.time } + if (kind === 'tool-call-delta') { + const call = toolCallOf(first) + return { + type: 'tool-call-chunks', + ...envelope, + data: { + ...base, + id: call.id as Extract['id'], + ...Object.hasOwn(call, 'name') ? { name: call.name as string } : {}, + args: run.map(event => (event.data.chunk as { readonly argumentsDelta: string }).argumentsDelta), + }, + } + } + const data = { + ...base, + texts: run.map(event => (event.data.chunk as { readonly text: string }).text), + } + return kind === 'text-delta' + ? { type: 'text-chunks', ...envelope, data } + : { type: 'reasoning-chunks', ...envelope, data } +} + +function packedDataBytes(row: ChunkRow): number { + return Buffer.byteLength(JSON.stringify(row.data)) +} + +function emitBoundedRun(out: StorageRecord[], kind: DeltaKind, completeRun: readonly DeltaEvent[]): void { + let offset = 0 + while (completeRun.length - offset >= MIN_PACKED_ROW_MEMBERS) { + let low = MIN_PACKED_ROW_MEMBERS + let high = Math.min(completeRun.length - offset, MAX_PACKED_ROW_MEMBERS) + const largest = buildRow(kind, completeRun.slice(offset, offset + high)) + if (packedDataBytes(largest) <= MAX_PACKED_DATA_BYTES) { + out.push(largest) + offset += high + continue + } + high -= 1 + let accepted = 0 + let acceptedRow: ChunkRow | undefined + while (low <= high) { + const middle = Math.floor((low + high) / 2) + const candidate = buildRow(kind, completeRun.slice(offset, offset + middle)) + if (packedDataBytes(candidate) <= MAX_PACKED_DATA_BYTES) { + accepted = middle + acceptedRow = candidate + low = middle + 1 + } else { + high = middle - 1 + } + } + if (accepted === 0) { + out.push(completeRun[offset] as DeltaEvent) + offset += 1 + continue + } + /* v8 ignore next -- accepted is set only with its same-branch candidate. */ + out.push(acceptedRow ?? malformed(kind, 'bounded encoder lost its accepted row')) + offset += accepted + } + out.push(...completeRun.slice(offset)) +} + +/** + * Pack eligible logical chunk runs into bounded schema-17 records. + * @param events - logical events in sequence order. + * @returns scalar and packed physical records in equivalent order. + */ +export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] { + const out: StorageRecord[] = [] + let kind: DeltaKind | undefined + let run: DeltaEvent[] = [] + const flush = (): void => { + if (kind === undefined) out.push(...run) + else emitBoundedRun(out, kind, run) + kind = undefined + run = [] + } + for (const event of events) { + const nextKind = classify(event) + if (nextKind === undefined) { + flush() + out.push(event) + continue + } + const delta = event as DeltaEvent + const previous = run.at(-1) + if (nextKind === kind && previous !== undefined && continues(previous, delta, nextKind)) { + run.push(delta) + continue + } + flush() + kind = nextKind + run = [delta] + } + flush() + return out +} + +function malformed(tag: string, reason: string): never { + throw new Error(`malformed ${tag} storage row: ${reason}`) +} + +function validateRunData( + tag: string, + data: Record, + payloadKey: 'texts' | 'args', + serializedBytes?: number, +): string[] { + if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') { + malformed(tag, 'turn/step/index must be numbers') + } + const payload = data[payloadKey] + if (!Array.isArray(payload) + || payload.length < MIN_PACKED_ROW_MEMBERS + || payload.length > MAX_PACKED_ROW_MEMBERS + || payload.some(member => typeof member !== 'string')) { + malformed(tag, `${payloadKey} must contain ${MIN_PACKED_ROW_MEMBERS}..${MAX_PACKED_ROW_MEMBERS} strings`) + } + const gaps = data.dt + if (!Array.isArray(gaps) || gaps.some(gap => !Number.isSafeInteger(gap))) { + malformed(tag, 'dt must be an array of safe integers') + } + if (gaps.length !== payload.length - 1) malformed(tag, 'dt length must match the member count') + if ((serializedBytes ?? Buffer.byteLength(JSON.stringify(data))) > MAX_PACKED_DATA_BYTES) { + malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`) + } + return payload as string[] +} + +function validateRow( + value: Record, + tag: ChunkRow['type'], + serializedBytes?: number, +): ChunkRow { + if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) malformed(tag, 'invalid envelope fields') + if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) malformed(tag, 'seq0 must be non-negative') + if (!Number.isSafeInteger(value.time0)) malformed(tag, 'time0 must be a safe integer') + const data = value.data + if (!isRecord(data)) malformed(tag, 'data must be an object') + let payload: string[] + if (tag === 'tool-call-chunks') { + const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args']) + if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) { + malformed(tag, 'invalid tool-call data fields') + } + if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) { + malformed(tag, 'id and optional name must be strings') + } + payload = validateRunData(tag, data, 'args', serializedBytes) + } else { + if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) malformed(tag, 'invalid text data fields') + payload = validateRunData(tag, data, 'texts', serializedBytes) + } + if (!Number.isSafeInteger((value.seq0 as number) + payload.length - 1)) malformed(tag, 'member seqs exceed safe integers') + let time = value.time0 as number + for (const gap of data.dt as number[]) { + time += gap + if (!Number.isSafeInteger(time)) malformed(tag, 'member times exceed safe integers') + } + return value as unknown as ChunkRow +} + +function expandRow(row: ChunkRow): SessionEvent[] { + const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts + const events: SessionEvent[] = [] + let time = row.time0 + for (let index = 0; index < members.length; index += 1) { + if (index > 0) time += row.data.dt[index - 1] as number + let chunk: StreamChunk + switch (row.type) { + case 'text-chunks': + chunk = { type: 'text-delta', index: row.data.index, text: members[index] as string } + break + case 'reasoning-chunks': + chunk = { type: 'reasoning-delta', index: row.data.index, text: members[index] as string } + break + case 'tool-call-chunks': + chunk = { + type: 'tool-call-delta', + index: row.data.index, + id: row.data.id, + ...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {}, + argumentsDelta: members[index] as string, + } + break + } + events.push({ + type: 'assistant/chunk', + seq: row.seq0 + index, + time, + data: { turn: row.data.turn, step: row.data.step, chunk }, + }) + } + return events +} + +/** + * Decode one scalar or packed schema-17 record. + * @param value - parsed physical-record value. + * @returns the represented logical events. + */ +export function decodeStorageRecord(value: unknown): SessionEvent[] { + if (!isRecord(value)) return [value as SessionEvent] + const tag = value.type + if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') { + return [value as SessionEvent] + } + return expandRow(validateRow(value, tag)) +} + +/** + * Decode one packed row from its exact uncompressed data value. The byte bound + * rejects oversized input before JSON parsing and avoids serializing it again. + * @param tag - validated packed physical type. + * @param seq0 - first represented logical sequence number. + * @param time0 - first represented logical timestamp. + * @param serializedData - decoded SQLite data-column text. + * @returns the represented logical events. + */ +export function decodeSerializedChunkRow( + tag: ChunkRow['type'], + seq0: number, + time0: number, + serializedData: string, +): SessionEvent[] { + const bytes = Buffer.byteLength(serializedData) + if (bytes > MAX_PACKED_DATA_BYTES) malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`) + return expandRow(validateRow({ type: tag, seq0, time0, data: JSON.parse(serializedData) as unknown }, tag, bytes)) +} +/* jscpd:ignore-end */ diff --git a/packages/session/session-persistence-sqlite/src/compression.ts b/packages/session/session-persistence-sqlite/src/compression.ts new file mode 100644 index 0000000000..272b099a37 --- /dev/null +++ b/packages/session/session-persistence-sqlite/src/compression.ts @@ -0,0 +1,276 @@ +/** + * Fixed physical-record compression for SQLite. Schema-owned functions + * encode logical events and decode tagged rows before persistence consumers + * observe them. + * @module @deepseek-ai/dsh-session-persistence-sqlite/compression + */ + +import { TextDecoder } from 'node:util' +import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib' +import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' +import { + decodeSerializedChunkRow, + type ChunkRow, + MAX_PACKED_DATA_BYTES, + type StorageRecord, +} from './codec.ts' +import type { EventRow } from './schema.ts' + +/** One physical row ready for SQLite parameter binding. */ +export interface BoundRecord { + readonly seq: number + readonly type: string + readonly time: number + readonly data: string | Uint8Array + readonly sourceEventSeqs: Uint8Array | null + readonly surfaceOp: string | null + readonly ignorable: number | null +} + +/** Small values stay as SQLite text to avoid per-frame CPU and byte overhead. */ +export const ZSTD_DATA_THRESHOLD_BYTES = 4_096 + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) +const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) +const ZSTD_COMPRESSION_LEVEL = 3 +const PACKED_ROW_SENTINEL = 0 + +const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const +type ChunkTag = typeof CHUNK_TAGS[number] + +function isChunkTag(value: string): value is ChunkTag { + return (CHUNK_TAGS as readonly string[]).includes(value) +} + +/** + * Decode one physical SQLite row into its complete logical event span. + * @param row - detached SQLite event row. + * @returns every logical event represented by the row. + */ +export function decodeRow(row: EventRow): SessionEvent[] { + if (row.ignorable !== PACKED_ROW_SENTINEL) return [decodeScalarRow(row)] + if (!isChunkTag(row.type)) { + throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`) + } + if (row.source_event_seqs !== null || row.surface_op !== null) { + throw new Error(`malformed ${row.type} storage row: packed surface fields must be null`) + } + return decodeSerializedChunkRow( + row.type, + row.seq, + row.time, + decodeData(row.data, MAX_PACKED_DATA_BYTES), + ) +} + +/** + * Convert a storage record to SQLite column values. + * @param record - scalar event or packed chunk record. + * @returns column values for one physical insert. + */ +export function bindRecord(record: StorageRecord): BoundRecord { + if (isChunkRow(record)) { + return { + seq: record.seq0, + type: record.type, + time: record.time0, + data: encodeData(JSON.stringify(record.data)), + sourceEventSeqs: null, + surfaceOp: null, + ignorable: PACKED_ROW_SENTINEL, + } + } + const event = record + const surface = event as SessionEvent + return { + seq: event.seq, + type: event.type, + time: event.time, + data: encodeData(JSON.stringify(event.data)), + sourceEventSeqs: surface.sourceEventSeqs === undefined + ? null + : encodeSourceEventSeqs(surface.sourceEventSeqs), + surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp), + ignorable: event.ignorable === true ? 1 : null, + } +} + +function encodeData(serialized: string): string | Uint8Array { + const bytes = Buffer.from(serialized) + if (bytes.length < ZSTD_DATA_THRESHOLD_BYTES) return serialized + const compressed = zstdCompressSync(bytes, { + params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL }, + }) + return compressed.length < bytes.length ? compressed : serialized +} + +function decodeData(value: string | Uint8Array, maxOutputLength?: number): string { + if (typeof value === 'string') return value + const decoded = maxOutputLength === undefined + ? zstdDecompressSync(value) + : zstdDecompressSync(value, { maxOutputLength }) + return UTF8_DECODER.decode(decoded) +} + +function encodeSourceEventSeqs(values: readonly number[]): Uint8Array { + const bytes: number[] = [] + let previous = 0n + for (let index = 0; index < values.length; index += 1) { + const sourceSeq = values[index] as number + if (!Number.isSafeInteger(sourceSeq) || sourceSeq < 0) { + throw new TypeError('sourceEventSeqs must contain non-negative safe integers') + } + const value = BigInt(sourceSeq) + const encoded = index === 0 + ? value + : value >= previous + ? (value - previous) * 2n + : ((previous - value) * 2n) - 1n + appendVarint(bytes, encoded) + previous = value + } + return Buffer.from(bytes) +} + +function appendVarint(bytes: number[], value: bigint): void { + let remaining = value + while (remaining >= 0x80n) { + bytes.push(Number(remaining & 0x7fn) | 0x80) + remaining >>= 7n + } + bytes.push(Number(remaining)) +} + +function decodeSourceEventSeqs(bytes: Uint8Array): number[] { + const values: number[] = [] + let previous = 0n + let offset = 0 + while (offset < bytes.length) { + const first = values.length === 0 + const decoded = readVarint(bytes, offset, first ? MAX_SAFE_INTEGER : MAX_ZIGZAG_INTEGER) + offset = decoded.offset + const delta = first + ? decoded.value + : (decoded.value & 1n) === 0n + ? decoded.value / 2n + : -((decoded.value + 1n) / 2n) + const value = first ? delta : previous + delta + if (value < 0n || value > MAX_SAFE_INTEGER) { + throw new Error('malformed source_event_seqs storage value: decoded seq is out of range') + } + values.push(Number(value)) + previous = value + } + return values +} + +function readVarint( + bytes: Uint8Array, + offset: number, + limit: bigint, +): { readonly value: bigint; readonly offset: number } { + let value = 0n + let shift = 0n + while (offset < bytes.length) { + const byte = bytes[offset] as number + offset += 1 + value |= BigInt(byte & 0x7f) << shift + if ((byte & 0x80) === 0) { + if (shift > 0n && (byte & 0x7f) === 0) { + throw new Error('malformed source_event_seqs storage value: non-canonical varint') + } + if (value > limit) { + throw new Error('malformed source_event_seqs storage value: varint is out of range') + } + return { value, offset } + } + shift += 7n + if (shift > 56n) { + throw new Error('malformed source_event_seqs storage value: varint is out of range') + } + } + throw new Error('malformed source_event_seqs storage value: truncated varint') +} + +function isChunkRow(record: StorageRecord): record is ChunkRow { + return isChunkTag(record.type) && 'seq0' in record && !('seq' in record) +} + +function decodeScalarRow(row: EventRow): SessionEvent { + const surfaceFields = { + ...row.source_event_seqs === null + ? {} + : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs) }, + ...row.surface_op === null + ? {} + : { surfaceOp: JSON.parse(row.surface_op) as SessionEvent['surfaceOp'] }, + } + return { + type: row.type as SessionEvent['type'], + seq: row.seq, + time: row.time, + data: JSON.parse(decodeData(row.data)) as SessionEvent['data'], + ...surfaceFields, + ...row.ignorable === 1 ? { ignorable: true as const } : {}, + } as SessionEvent +} + +/** + * Validate and flatten physical rows into their logical prefix. A malformed + * row or logical gap is committed corruption when a later valid turn end + * exists; otherwise it starts a removable physical tail. + * @param rows - physical rows ordered by their first logical sequence. + * @param base - logical sequence expected from the first selected row. + * @returns the contiguous logical prefix and optional physical deletion base. + */ +export function scanRows( + rows: readonly EventRow[], + base = 0, +): { preserved: SessionEvent[]; tornFrom?: number } { + let lastTurnEndRow = -1 + for (let index = rows.length - 1; index >= 0; index -= 1) { + try { + if (decodeRow(rows[index] as EventRow).some(event => event.type === 'turn/end')) { + lastTurnEndRow = index + break + } + } catch { + // A malformed row cannot prove that an earlier physical prefix committed. + } + } + + const preserved: SessionEvent[] = [] + let expected = base + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const physical = rows[rowIndex] as EventRow + let logicalEvents: SessionEvent[] | undefined + try { + logicalEvents = decodeRow(physical) + } catch { + // The committed-prefix rule below owns whether this invalid row is fatal or repairable. + } + if (logicalEvents === undefined) { + if (rowIndex <= lastTurnEndRow) { + throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`) + } + return { preserved, tornFrom: physical.seq } + } + let contiguous = true + for (const event of logicalEvents) { + if (event.seq !== expected) { + contiguous = false + break + } + expected += 1 + } + if (!contiguous) { + if (rowIndex <= lastTurnEndRow) { + throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`) + } + return { preserved, tornFrom: physical.seq } + } + preserved.push(...logicalEvents) + } + return { preserved } +} diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 969f1f5fac..5d6c3bb674 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -1,90 +1,45 @@ /** - * SQLite durable session-persistence backend. It maps each session header and - * event to rows, and delegates write-path orchestration to - * {@link PersistenceCoordinator}. It has no independent per-session artifact, - * so its locator returns `undefined`. + * Opt-in SQLite persistence provider. Logical sessions remain unchanged; + * the physical backend packs eligible chunk runs into schema-17 rows. * @module @deepseek-ai/dsh-session-persistence-sqlite */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { randomUUID } from 'node:crypto' -import { statSync } from 'node:fs' -import { DatabaseSync } from 'node:sqlite' -import { mkdir, open } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import type { + SessionEvent, + SessionHeader, + SessionId, + SessionPreparation, +} from '@deepseek-ai/dsh-session' import { - DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, - type StoredPrefix, type StoredSuffix, + DEFAULT_PREPARED_SESSION_CACHE_SIZE, + DEFAULT_WRITE_BATCH_MAX_DELAY_MS, + MAX_WRITE_BATCH_DELAY_MS, + PersistenceCoordinator, + SessionPersistence, + type SessionInspection, + type SessionLocation, + type SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' -import { - type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, -} from './schema.ts' +import type { JournalMode } from './schema.ts' +import { SqliteStore } from './store.ts' export { SCHEMA_VERSION } from './schema.ts' -/** - * Serialize an event's optional envelope fields for SQL binding. The surface - * fields are nullable TEXT columns — null when the event has no surface - * metadata (non-surface events, events written before surface support); the - * ignorable marker is a nullable INTEGER column — `1` iff the envelope carries - * `ignorable: true`. - */ -function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] { - const se = event as SessionEvent - return [ - se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null, - se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, - event.ignorable === true ? 1 : null, - ] -} - -/** Build the source-qualified revision shared by full and lightweight reads. */ -function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision { - return SessionPersistenceRevision( - `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, - ) -} - -/** - * Exclusively create a missing database file with owner-only permissions. - * Existing files retain their modes, and errors other than `EEXIST` propagate. - * `DatabaseSync` reopens by path, so this does not protect confidentiality or - * integrity when another principal can replace the database entry in its parent - * directory. - */ -async function createDatabaseFile(path: string): Promise { - try { - const handle = await open(path, 'wx', 0o600) - await handle.close() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - } -} +/** Default wait for another SQLite connection's write reservation. */ +export const DEFAULT_BUSY_TIMEOUT_MS = 5_000 +/** Largest busy timeout accepted by SQLite's signed millisecond interface. */ +export const MAX_BUSY_TIMEOUT_MS = 2_147_483_647 /** Plugin configuration. */ export interface Config { - /** - * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). On filesystems with POSIX modes, - * missing directories and databases are created owner-only; existing path - * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect confidentiality or - * integrity when another principal can replace the database entry in its - * parent directory. - */ + /** SQLite database path, or `:memory:` for an in-process database. */ path: string - /** - * SQLite `journal_mode` pragma. `wal` (the default) is the recorded - * durability model; pick a rollback-journal mode (`delete`/`truncate`/ - * `persist`) on filesystems where WAL's shared-memory files do not work - * (network mounts). See {@link JournalMode}. - */ + /** Durable SQLite journal mode; defaults to `wal`. */ journalMode?: JournalMode + /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */ + busyTimeoutMs?: number /** Maximum cold Session preparations retained for history-to-resume reuse. */ preparedSessionCacheSize?: number /** Fixed live-event coalescing window; not a backend completion deadline. */ @@ -92,84 +47,49 @@ export interface Config { } /** - * The SQLite persistence backend. Load as a plugin; it registers as - * `ctx.sessionPersistence` and (via the coordinator) installs the write-path - * listeners. Its torn-tail marker is the seq to delete from. + * SQLite `SessionPersistence` provider with a schema-owned physical codec. */ -export class SqliteSessionPersistence extends SessionPersistence implements PersistenceBackend { +export class SqliteSessionPersistence extends SessionPersistence { override readonly supportsRawArtifacts = false + override readonly name = 'session-persistence-sqlite' static inject = ['sessions'] static Config: z = z.object({ path: z.string().required(), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), + busyTimeoutMs: z.number().step(1).min(0).max(MAX_BUSY_TIMEOUT_MS).default(DEFAULT_BUSY_TIMEOUT_MS), preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS) .default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS), }) - /** - * Backend label for the coordinator's dispose diagnostics. Intentionally - * shadows cordis `Service.name` (set to `'sessionPersistence'` by the base); - * see the JSONL backend for why this does not affect service resolution. - */ - override readonly name = 'session-persistence-sqlite' - - private db!: DatabaseSync - private storeIdentity!: string - private ready: Promise - private coordinator: PersistenceCoordinator + private readonly store: SqliteStore + private readonly coordinator: PersistenceCoordinator constructor(ctx: Context, public config: Config) { super(ctx) - // Programmatic wrappers may construct the backend without Schemastery normalization. const preparedSessionCacheSize = config.preparedSessionCacheSize ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs ?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS - // Open asynchronously so directory creation does not block plugin apply; - // every storage hook awaits the same readiness promise. - this.ready = this.openDb(config.path, (config as Required).journalMode) - this.coordinator = new PersistenceCoordinator(this.ctx, this, { + this.store = new SqliteStore({ + path: config.path, + journalMode: config.journalMode ?? 'wal', + busyTimeoutMs: config.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS, + }) + this.coordinator = new PersistenceCoordinator(this.ctx, this.store, { preparedSessionCacheSize, writeBatchMaxDelayMs, }) } - private async openDb(path: string, journalMode: JournalMode): Promise { - const actual = path === ':memory:' ? path : resolve(path) - if (actual !== ':memory:') { - await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) - await createDatabaseFile(actual) - } - this.db = openDatabase(actual, journalMode) - try { - const row = this.db.prepare( - 'SELECT store_id FROM persistence_state WHERE singleton = 1', - ).get() as { store_id: string } | undefined - /* v8 ignore next -- openDatabase inserts the singleton before returning. */ - if (row === undefined) { - throw new Error(`session database at "${actual}" has no store identity`) - } - if (row.store_id.length === 0) { - throw new Error(`session database at "${actual}" has no valid store identity`) - } - if (actual !== ':memory:') { - const identity = statSync(actual, { bigint: true }) - this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.store_id}` - } else { - this.storeIdentity = `memory:store:${row.store_id}` - } - } catch (error: unknown) { - this.db.close() - throw error - } + /** Reject self-contained path and ownership failures without loading Node SQLite. */ + protected async [Service.init](): Promise { + await this.store.validatePath() } - // --- SessionPersistence service API (delegated to the coordinator) --- - - /** SQLite has one database, not an independent local artifact per session. */ + /** SQLite has one database, not an independent per-session artifact. */ locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } @@ -194,220 +114,20 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers return this.coordinator.inspect(id, signal) } - readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + readFrom( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.coordinator.readFrom(id, fromSeq, signal) } - // One method serves both public `list` and the backend hook; delegating it to - // the coordinator would call this hook recursively. - - // --- PersistenceBackend hooks (the SQLite storage primitives) --- - - /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ - loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { - return this.readPrefix(id, signal) + list(signal?: AbortSignal): Promise { + return this.store.list(signal) } - /** Read one row's revision without loading its events. */ - async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - const row = this.rowFor(id) - return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) - } - - /** - * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the - * read scales with the suffix, not the log. Torn rows past the preserved - * region are dropped, never repaired (non-mutating read). - */ - async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - const row = this.rowFor(id) - if (row === undefined) return undefined - const meta = rowToMeta(row) - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') - .all(id, fromSeq) as unknown as EventRow[] - signal?.throwIfAborted() - const { preserved } = scanRows(eventRows, fromSeq) - return { meta, events: preserved } - } - - /** - * Read a session's row + ordered events into a {@link StoredPrefix}. The - * torn-tail marker is the seq from which a never-committed tail must be deleted - * (`scanRows` already returns it as `number | undefined`). - */ - private async readPrefix(id: SessionId, signal?: AbortSignal): Promise | undefined> { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - this.db.exec('BEGIN') - let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined - try { - const row = this.rowFor(id) - if (row !== undefined) { - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] - snapshot = { row, eventRows } - } - this.db.exec('COMMIT') - } catch (error: unknown) { - /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */ - this.db.exec('ROLLBACK') - throw error - /* v8 ignore stop */ - } - signal?.throwIfAborted() - if (snapshot === undefined) return undefined - const { row, eventRows } = snapshot - const { preserved, tornFrom } = scanRows(eventRows) - return { - meta: rowToMeta(row), - events: preserved, - revision: sqliteRevision(this.storeIdentity, row), - ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, - } - } - - /** - * Durably append a batch in ONE transaction: materialize the sessions row (if - * lazy) and INSERT every event, or roll back entirely. The transaction is the - * atomicity + durability boundary, so a mid-batch failure (a UNIQUE violation - * on a duplicated seq) leaves the stored log untouched. - */ - async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { - await this.ready - const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ) - this.db.exec('BEGIN') - try { - if (!isMaterialized) this.writeRow(meta) - for (const event of events) { - const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) - } - this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) - this.db.exec('COMMIT') - } catch (error) { - this.db.exec('ROLLBACK') - throw error - } - } - - /** - * Make a crash repair durable in ONE transaction: DELETE the torn tail (from - * `tornMarker`) and INSERT the synthetic `closers`. After COMMIT the stored rows - * == the balanced log. - */ - async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise { - await this.ready - this.db.exec('BEGIN') - try { - if (tornMarker !== undefined) { - this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(meta.id, tornMarker) - } - if (closers.length > 0) { - const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ) - for (const event of closers) { - const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) - } - } - if (tornMarker !== undefined || closers.length > 0) { - this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) - } - this.db.exec('COMMIT') - } catch (error) { - // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or - // deleted as torn first); this rolls back a DB-level failure (disk full, - // etc.), unreachable in test. - /* v8 ignore start */ - this.db.exec('ROLLBACK') - throw error - /* v8 ignore stop */ - } - } - - /** List all materialized sessions' metadata (every row is a materialized session). */ - async list(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - const rows = this.db - .prepare('SELECT * FROM sessions') - .all() as unknown as SessionRow[] - signal?.throwIfAborted() - return rows.map(rowToMeta) - } - - /** List metadata with a source-qualified monotonic revision per session. */ - async listSnapshots(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] - signal?.throwIfAborted() - return rows.map(row => ({ - header: rowToMeta(row), - revision: SessionPersistenceRevision( - `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, - ), - })) - } - - /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ - async close(): Promise { - await this.ready - this.db.close() - } - - // --- row helpers --- - - /** Fetch a session's row, or undefined if absent. */ - private rowFor(id: SessionId): SessionRow | undefined { - return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined - } - - /** - * Insert-or-replace a session's metadata row. The only caller is the first - * materializing `appendBatch`, so writing the row IS the materialization (its - * existence is the signal `list` reads). - */ - private writeRow(meta: SessionHeader): void { - this.db.prepare(` - INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) - ON CONFLICT(id) DO UPDATE SET - version = excluded.version, - created_at = excluded.created_at, - cwd = excluded.cwd, - parent_session = excluded.parent_session, - seed_length = excluded.seed_length, - origin = excluded.origin, - delegation_depth = excluded.delegation_depth, - agent_preset = excluded.agent_preset - `).run( - meta.id, - meta.version, - meta.createdAt, - meta.cwd ?? null, - meta.parentSession ?? null, - meta.seedLength ?? null, - meta.origin ?? null, - meta.delegationDepth ?? null, - meta.agentPreset ?? null, - randomUUID(), - ) + listSnapshots(signal?: AbortSignal): Promise { + return this.store.listSnapshots(signal) } } diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts index 7a5e905e30..8436d5fbe5 100644 --- a/packages/session/session-persistence-sqlite/src/invariant.ts +++ b/packages/session/session-persistence-sqlite/src/invariant.ts @@ -15,8 +15,8 @@ export const name = 'session-persistence-sqlite-invariant' export const inject = ['invariants'] /** - * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; - * this package exposes no continuously observable in-process relation. + * No runtime invariant: physical packing is observable only by database + * round-trip and row-count checks, not a continuous in-process relation. */ const install: InvariantInstaller = () => {} diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7402d7d4b..39fc00504b 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -1,87 +1,86 @@ /** - * Schema + load-time helpers for the SQLite session-persistence backend: the - * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per - * `SessionEvent`), the database open/configure step, and the last-`turn/end` - * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as - * the JSONL backend. - * - * @module dsh-session-persistence-sqlite/schema + * SQLite schema ownership and durable-row validation. + * @module @deepseek-ai/dsh-session-persistence-sqlite/schema */ import { randomUUID } from 'node:crypto' -import { DatabaseSync } from 'node:sqlite' -import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' +import { isAbsolute } from 'node:path' +import { performance } from 'node:perf_hooks' +import type { DatabaseSync } from 'node:sqlite' +import { setTimeout as delay } from 'node:timers/promises' +import { + SessionId, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import { sql } from './sql.ts' -/** - * The on-disk schema version. Bumped only on a breaking change to the table - * layout; orthogonal to a session's own `version` (which versions the EVENT - * vocabulary, stored per session in the `sessions` row). - */ -export const SCHEMA_VERSION = 15 - -/** SQLite application id protecting unrelated databases from persistence writes. */ +/** Current physical-record schema with packed and compressed event rows. */ +export const SCHEMA_VERSION = 17 +/** Application id reserved for DeepSeek Harness SQLite session databases. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 -/** - * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). - * The row's EXISTENCE is the materialization signal: it is written only by the - * first `append` (lazy materialization), so a created-but-never-appended - * session has no row and is absent from `list`, mirroring the JSONL - * backend's "no file until first append". - */ +/** A materialized session's metadata and monotonic revision. */ export interface SessionRow { - id: string - version: number - created_at: number - cwd: string | null - parent_session: string | null - seed_length: number | null - origin: 'subagent' | null - /** Stable identity assigned when this log is materialized. */ - incarnation: string - /** Monotonic log-change token incremented in each mutating transaction. */ - revision: number - delegation_depth: number | null - agent_preset: string | null + readonly id: string + readonly version: number + readonly created_at: number + readonly cwd: string | null + readonly parent_session: string | null + readonly seed_length: number | null + readonly origin: 'subagent' | null + readonly incarnation: string + readonly revision: number + readonly delegation_depth: number | null + readonly agent_preset: string | null } -/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ +/** One physical event row; packed rows may represent multiple logical events. */ export interface EventRow { - seq: number - type: string - time: number - data: string - /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */ - source_event_seqs: string | null - /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */ - surface_op: string | null - /** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */ - ignorable: number | null + readonly seq: number + readonly type: string + readonly time: number + readonly data: string | Uint8Array + readonly source_event_seqs: Uint8Array | null + readonly surface_op: string | null + readonly ignorable: number | null } -/** - * Journal modes the backend will run under. `wal` is the default and the - * durability model the persistence ADR records; the rollback-journal modes - * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's - * shared-memory files do not work (network mounts). `memory`/`off` are - * excluded: dropping journal durability silently contradicts what this - * backend promises. - */ +/** Durable journal modes accepted by the backend. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +interface SchemaObjectRow { + readonly type: string + readonly name: string + readonly tbl_name: string + readonly sql: string +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu +const JOURNAL_BUSY_RETRY_INTERVAL_MS = 10 +type DatabaseSyncConstructor = typeof import('node:sqlite')['DatabaseSync'] + /** - * Open the database and apply its schema and pragmas. An empty database with a - * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty - * unversioned database and every other non-current version reject rather than - * being migrated in place. - * @param path - the SQLite database file to open (created when absent). + * Open and validate a SQLite session database. + * @param Database - lazily imported Node SQLite constructor. + * @param path - SQLite path, including `:memory:`. * @param journalMode - validated journal pragma. - * @returns the open handle with pragmas applied and all three tables ensured. + * @param busyTimeoutMs - validated maximum wait for a competing SQLite lock. + * @returns the configured database handle. + * @throws when connection settings, schema ownership, or SQLite setup cannot be validated. */ -export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { - const db = new DatabaseSync(path) +export async function openDatabase( + Database: DatabaseSyncConstructor, + path: string, + journalMode: JournalMode, + busyTimeoutMs: number, +): Promise { + const deadline = performance.now() + busyTimeoutMs + const db = new Database(path, { timeout: busyTimeoutMs }) try { - configureDatabase(db, path, journalMode) + configureConnectionSecurity(db, path) + configureDatabase(Database, db, path) + await selectJournalMode(db, path, journalMode, deadline) + configureDurability(db, path) return db } catch (error: unknown) { db.close() @@ -89,182 +88,337 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy } } -function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void { - db.exec('PRAGMA foreign_keys = ON') +function configureConnectionSecurity(db: DatabaseSync, path: string): void { + db.exec(sql('trusted-schema-off')) + const trustedSchema = integerField(db.prepare(sql('select-trusted-schema')).get(), 'trusted_schema') + /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */ + if (trustedSchema !== 0) { + throw new Error(`session database at "${path}" retained trusted_schema=${trustedSchema}, expected 0`) + } + db.exec(sql('mmap-off')) + if (path === ':memory:') return + const mmapSize = integerField(db.prepare(sql('select-mmap-size')).get(), 'mmap_size') + /* v8 ignore next 3 -- supported file-backed SQLite connections return the fixed setting. */ + if (mmapSize !== 0) { + throw new Error(`session database at "${path}" retained mmap_size=${mmapSize}, expected 0`) + } +} + +function configureDatabase( + Database: DatabaseSyncConstructor, + db: DatabaseSync, + path: string, +): void { + db.exec(sql('foreign-keys-on')) let began = false try { - db.exec('BEGIN IMMEDIATE') + db.exec(sql('begin-immediate')) began = true - // Validate while holding the write lock so no other connection can change - // schema ownership between inspection and initialization. - const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } - const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } - const { count: userObjectCount } = db.prepare( - "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'", - ).get() as { count: number } + const onDisk = integerField(db.prepare(sql('select-user-version')).get(), 'user_version') + const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id') + const userObjectCount = integerField(db.prepare(sql('select-user-object-count')).get(), 'count') if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { throw new Error(`session database at "${path}" has an unversioned schema or application identity`) } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { - throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) + throw new Error( + `session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`, + ) } - if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { + if (onDisk !== 0 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { throw new Error( `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, ) } - db.exec(` - CREATE TABLE IF NOT EXISTS persistence_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - store_id TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - created_at INTEGER NOT NULL, - cwd TEXT, - parent_session TEXT, - seed_length INTEGER, - origin TEXT, - delegation_depth INTEGER, - agent_preset TEXT, - incarnation TEXT NOT NULL, - revision INTEGER NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS events ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - time INTEGER NOT NULL, - data TEXT NOT NULL, - source_event_seqs TEXT, - surface_op TEXT, - ignorable INTEGER, - PRIMARY KEY (session_id, seq) - ) STRICT - `) - db.prepare( - 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)', - ).run(randomUUID()) - if (onDisk === 0) { - db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) - db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) - } - db.exec('COMMIT') + if (onDisk === 0) initializeDatabase(db) + validateRequiredSchema(Database, db, path) + db.exec(sql('commit')) began = false } catch (error: unknown) { - /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */ + /* v8 ignore else -- a failed begin leaves no transaction to roll back. */ if (began) { - /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */ + /* v8 ignore next 5 -- retain the original ownership failure if rollback fails too. */ try { - db.exec('ROLLBACK') + db.exec(sql('rollback')) } catch { - // The original SQLite failure remains the actionable cause. + // The original database-ownership failure remains actionable. } } throw error } - // The validated union is safe to interpolate into a non-bindable PRAGMA. - // Apply it only after ownership validation and initialization commit. - db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) } -/** - * Reconstruct the {@link SessionHeader} from a `sessions` row. - * @param row - the `sessions` table row. - * @returns the header, `NULL` columns mapped to omitted optional fields. - */ -export function rowToMeta(row: SessionRow): SessionHeader { - if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) { - throw new Error('stored session createdAt must be a non-negative safe integer') - } - return { - version: row.version, - id: row.id as SessionId, - createdAt: row.created_at, - ...row.cwd !== null ? { cwd: row.cwd } : {}, - ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, - ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, - ...row.origin !== null ? { origin: row.origin } : {}, - ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, - ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, - } -} - -/** - * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). - * @param row - the `events` table row; `data` and the surface columns hold JSON text. - * @returns the reconstructed event; throws when a JSON column fails to parse - * ({@link scanRows} treats that as a hole, not corruption, in the tail). - */ -export function rowToEvent(row: EventRow): SessionEvent { - // Surface-metadata fields are conditional on the event type in the type - // system; spread them so each variant gets only the fields it declares. - const surfaceFields = { - ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {}, - ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {}, - } - const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {} - return { - type: row.type as SessionEvent['type'], - seq: row.seq, - time: row.time, - data: JSON.parse(row.data) as SessionEvent['data'], - ...surfaceFields, - ...ignorableField, - } as SessionEvent -} - -/** - * Find the preserved prefix of ordered event rows. Fully written rows in an - * interrupted final turn remain in the prefix. The first unparsable row or seq - * gap after the last `turn/end` marks a tolerated torn tail; the same hole in - * the committed region rejects. - * - * @param rows - one session's event rows, ordered by seq ascending. - * @param base - the seq the first row is expected to carry; `0` for a whole - * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`). - * @returns the preserved event prefix, plus `tornFrom` — the seq the physical - * delete starts at — when a torn tail exists. - */ -export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } { - // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole. - // (The seq/type COLUMNS are always present even when `data` is corrupt.) - interface Parsed { ok: boolean; event?: SessionEvent } - const parsed: Parsed[] = rows.map((row) => { +async function selectJournalMode( + db: DatabaseSync, + path: string, + journalMode: JournalMode, + deadline: number, +): Promise { + let result: unknown + while (true) { try { - return { ok: true, event: rowToEvent(row) } - } catch { - return { ok: false } + result = db.prepare(sql(journalResource(journalMode))).get() + break + } catch (error: unknown) { + const remainingMs = Math.max(0, Math.ceil(deadline - performance.now())) + if (!isSqliteBusy(error) || remainingMs === 0) throw error + await delay(Math.min(JOURNAL_BUSY_RETRY_INTERVAL_MS, remainingMs)) + if (performance.now() >= deadline) throw error + } + } + const selected = stringField(result, 'journal_mode').toLowerCase() + const expected = path === ':memory:' ? 'memory' : journalMode + /* v8 ignore next 3 -- SQLite returns the selected mode from these fixed, valid pragmas. */ + if (selected !== expected) { + throw new Error(`session database at "${path}" selected journal mode ${selected}, expected ${expected}`) + } +} + +function configureDurability(db: DatabaseSync, path: string): void { + db.exec(sql('synchronous-full')) + const synchronous = integerField(db.prepare(sql('select-synchronous')).get(), 'synchronous') + /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */ + if (synchronous !== 2) { + throw new Error(`session database at "${path}" retained synchronous=${synchronous}, expected FULL (2)`) + } +} + +function isSqliteBusy(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && Reflect.get(error, 'errcode') === 5 +} + +function journalResource(mode: JournalMode): + | 'journal-mode-wal' + | 'journal-mode-delete' + | 'journal-mode-truncate' + | 'journal-mode-persist' { + switch (mode) { + case 'wal': return 'journal-mode-wal' + case 'delete': return 'journal-mode-delete' + case 'truncate': return 'journal-mode-truncate' + case 'persist': return 'journal-mode-persist' + } +} + +function initializeDatabase(db: DatabaseSync): void { + db.exec(sql('schema')) + db.prepare(sql('insert-persistence-state')).run(randomUUID()) + db.exec(sql('set-application-id')) + db.exec(sql('set-user-version-17')) +} + +let canonicalSchema: readonly SchemaObjectRow[] | undefined + +function expectedSchema(Database: DatabaseSyncConstructor): readonly SchemaObjectRow[] { + if (canonicalSchema !== undefined) return canonicalSchema + const reference = new Database(':memory:') + try { + reference.exec(sql('foreign-keys-on')) + reference.exec(sql('schema')) + canonicalSchema = schemaObjects(reference) + return canonicalSchema + } finally { + reference.close() + } +} + +function schemaObjects(db: DatabaseSync): SchemaObjectRow[] { + return db.prepare(sql('select-schema-objects')).all().map((value) => { + const row = record(value, 'schema object') + return { + type: stringField(row, 'type'), + name: stringField(row, 'name'), + tbl_name: stringField(row, 'tbl_name'), + sql: normalizeSql(stringField(row, 'sql')), } }) +} - // The last index that is a valid `turn/end` — holes through a closed turn - // are always committed corruption. - let lastTurnEnd = -1 - for (let i = parsed.length - 1; i >= 0; i--) { - if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break } +function normalizeSql(value: string): string { + return value.replaceAll(/\s+/gu, ' ').trim() +} + +function validateRequiredSchema( + Database: DatabaseSyncConstructor, + db: DatabaseSync, + path: string, +): void { + if (JSON.stringify(schemaObjects(db)) !== JSON.stringify(expectedSchema(Database))) { + throw new Error(`session database at "${path}" does not contain the required schema objects`) + } +} + +/** + * Recheck schema ownership inside the caller's mutation transaction. + * @param Database - constructor used to validate the canonical schema. + * @param db - open owned database with an active immediate transaction. + * @param path - database location used in ownership diagnostics. + * @throws when another writer changed the application identity, schema, or version. + */ +export function validateSchemaForMutation( + Database: DatabaseSyncConstructor, + db: DatabaseSync, + path: string, +): void { + const version = integerField(db.prepare(sql('select-user-version')).get(), 'user_version') + const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id') + if (applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { + throw new Error( + `session database application id changed before mutation (expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}, got ${applicationId})`, + ) + } + validateRequiredSchema(Database, db, path) + if (version !== SCHEMA_VERSION) { + throw new Error(`session database schema changed before mutation (expected ${SCHEMA_VERSION}, got ${version})`) + } +} + +/** + * Decode and validate one durable session row. + * @param value - value returned by SQLite. + * @returns a validated session row. + */ +export function decodeSessionRow(value: unknown): SessionRow { + const row = record(value, 'stored session metadata') + const id = nonemptyStringField(row, 'id') + const version = safeIntegerField(row, 'version') + const cwd = nullableStringField(row, 'cwd') + if (cwd !== null && !isAbsolute(cwd)) throw new Error('stored session cwd must be absolute') + const parent = nullableStringField(row, 'parent_session') + const origin = nullableStringField(row, 'origin') + if (origin !== null && origin !== 'subagent') throw new Error('stored session origin must be subagent or null') + const incarnation = nonemptyStringField(row, 'incarnation') + if (!UUID.test(incarnation)) throw new Error('stored session incarnation must be a UUID') + return { + id, + version, + created_at: nonnegativeSafeIntegerField(row, 'created_at'), + cwd, + parent_session: parent, + seed_length: nullableNonnegativeSafeIntegerField(row, 'seed_length'), + origin, + delegation_depth: nullableNonnegativeSafeIntegerField(row, 'delegation_depth'), + agent_preset: nullableStringField(row, 'agent_preset'), + incarnation, + revision: nonnegativeSafeIntegerField(row, 'revision'), + } +} + +/** + * Decode and validate one durable event row before JSON interpretation. + * @param value - value returned by SQLite. + * @returns a validated physical event row. + */ +export function decodeEventRow(value: unknown): EventRow { + const row = record(value, 'stored event') + const ignorable = nullableSafeIntegerField(row, 'ignorable') + if (ignorable !== null && ignorable !== 0 && ignorable !== 1) { + throw new Error('stored event ignorable must be 0, 1, or null') } + return { + seq: nonnegativeSafeIntegerField(row, 'seq'), + type: nonemptyStringField(row, 'type'), + time: safeIntegerField(row, 'time'), + data: stringOrBlobField(row, 'data'), + source_event_seqs: nullableBlobField(row, 'source_event_seqs'), + surface_op: nullableStringField(row, 'surface_op'), + ignorable, + } +} + +/** + * Validate the singleton identity read from durable storage. + * @param value - value returned by SQLite. + * @returns the UUID store identity. + */ +export function decodeStoreIdentity(value: unknown): string { + const identity = nonemptyStringField(value, 'store_id') + if (!UUID.test(identity)) throw new Error('stored store_id must be a UUID') + return identity +} - // Preserve the contiguous prefix, including a complete interrupted turn; - // holes through the last committed boundary throw, while later holes stop. - const preserved: SessionEvent[] = [] - for (let i = 0; i < rows.length; i++) { - const p = parsed[i] - if (!p?.ok || p.event === undefined) { - if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`) - break // torn tail fragment after the last turn/end — stop, tolerate - } - if (p.event.seq !== base + i) { - if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`) - break // gap after the last turn/end — torn tail, stop - } - preserved.push(p.event) +/** + * Reconstruct an immutable session header from a validated metadata row. + * @param row - validated stored metadata row. + * @returns the session header. + */ +export function rowToMeta(row: SessionRow): SessionHeader { + return { + version: row.version, + id: SessionId(row.id), + createdAt: row.created_at, + ...row.cwd === null ? {} : { cwd: row.cwd }, + ...row.parent_session === null ? {} : { parentSession: SessionId(row.parent_session) }, + ...row.seed_length === null ? {} : { seedLength: row.seed_length }, + ...row.origin === null ? {} : { origin: row.origin }, + ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, + ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }, } +} + +function record(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null) throw new Error(`${label} must be an object`) + return value as Record +} + +function stringField(value: unknown, key: string): string { + const field = record(value, 'SQLite row')[key] + if (typeof field !== 'string') throw new Error(`stored ${key} must be a string`) + return field +} + +function nonemptyStringField(value: unknown, key: string): string { + const field = stringField(value, key) + if (field.length === 0) throw new Error(`stored ${key} must not be empty`) + return field +} + +function nullableStringField(value: unknown, key: string): string | null { + const field = record(value, 'SQLite row')[key] + if (field === null) return null + if (typeof field !== 'string') throw new Error(`stored ${key} must be a string or null`) + return field +} + +function stringOrBlobField(value: unknown, key: string): string | Uint8Array { + const field = record(value, 'SQLite row')[key] + if (typeof field === 'string' || field instanceof Uint8Array) return field + throw new Error(`stored ${key} must be a string or blob`) +} + +function nullableBlobField(value: unknown, key: string): Uint8Array | null { + const field = record(value, 'SQLite row')[key] + if (field === null || field instanceof Uint8Array) return field + throw new Error(`stored ${key} must be a blob or null`) +} + +function integerField(value: unknown, key: string): number { + const field = record(value, 'SQLite row')[key] + if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer`) + return field as number +} + +function safeIntegerField(value: unknown, key: string): number { + return integerField(value, key) +} + +function nonnegativeSafeIntegerField(value: unknown, key: string): number { + const field = integerField(value, key) + if (field < 0) throw new Error(`stored ${key} must be non-negative`) + return field +} + +function nullableSafeIntegerField(value: unknown, key: string): number | null { + const field = record(value, 'SQLite row')[key] + if (field === null) return null + if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer or null`) + return field as number +} - // Any rows past the preserved prefix are a never-committed torn tail; their - // first seq is the deletion point for load's physical repair. - return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved } +function nullableNonnegativeSafeIntegerField(value: unknown, key: string): number | null { + const field = nullableSafeIntegerField(value, key) + if (field !== null && field < 0) throw new Error(`stored ${key} must be non-negative or null`) + return field } diff --git a/packages/session/session-persistence-sqlite/src/sql.ts b/packages/session/session-persistence-sqlite/src/sql.ts new file mode 100644 index 0000000000..5d14855723 --- /dev/null +++ b/packages/session/session-persistence-sqlite/src/sql.ts @@ -0,0 +1,65 @@ +/** + * Closed, package-owned SQL resource loading for SQLite. + * @module @deepseek-ai/dsh-session-persistence-sqlite/sql + */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const SQL_RESOURCES = [ + 'begin', + 'begin-immediate', + 'commit', + 'delete-events-from', + 'foreign-keys-on', + 'insert-event', + 'insert-persistence-state', + 'journal-mode-delete', + 'journal-mode-persist', + 'journal-mode-truncate', + 'journal-mode-wal', + 'mmap-off', + 'rollback', + 'schema', + 'select-application-id', + 'select-events', + 'select-events-from', + 'select-mmap-size', + 'select-packed-predecessors', + 'select-schema-objects', + 'select-session', + 'select-sessions', + 'select-store-id', + 'select-synchronous', + 'select-tail-events', + 'select-trusted-schema', + 'select-user-object-count', + 'select-user-version', + 'set-application-id', + 'set-user-version-17', + 'synchronous-full', + 'trusted-schema-off', + 'update-session-revision', + 'upsert-session', +] as const + +/** A resource basename selected exclusively by package code. */ +export type SqlResourceName = typeof SQL_RESOURCES[number] + +const cache = new Map() + +/** + * Load an immutable SQL statement by closed resource name. + * @param name - package-owned resource basename. + * @returns the resource text. + */ +export function sql(name: SqlResourceName): string { + const cached = cache.get(name) + if (cached !== undefined) return cached + const statement = readFileSync( + fileURLToPath(new URL(`../resources/sql/${name}.sql`, import.meta.url)), + 'utf8', + ) + cache.set(name, statement) + return statement +} diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts new file mode 100644 index 0000000000..8e3e578cce --- /dev/null +++ b/packages/session/session-persistence-sqlite/src/store.ts @@ -0,0 +1,470 @@ +/** + * SQLite storage primitives: transactional append-batch packing, physical + * reads, schema validation, revisions, repair, and lifecycle closure. + * @module @deepseek-ai/dsh-session-persistence-sqlite/store + */ + +import { randomUUID } from 'node:crypto' +import { statSync } from 'node:fs' +import { lstat, mkdir, open } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import type { DatabaseSync, StatementSync } from 'node:sqlite' +import { + type SessionEvent, + type SessionHeader, + type SessionId, +} from '@deepseek-ai/dsh-session' +import { + SessionPersistenceRevision, + type PersistenceBackend, + type SessionPersistenceRevision as PersistenceRevision, + type SessionPersistenceSnapshot, + type StoredPrefix, + type StoredSuffix, +} from '@deepseek-ai/dsh-session-persistence' +import { + MAX_PACKED_ROW_MEMBERS, + packChunkRuns, +} from './codec.ts' +import { + bindRecord, + decodeRow, + scanRows, + type BoundRecord, +} from './compression.ts' +import { + type EventRow, + type JournalMode, + decodeEventRow, + decodeSessionRow, + decodeStoreIdentity, + openDatabase, + validateSchemaForMutation, + rowToMeta, + type SessionRow, +} from './schema.ts' +import { sql } from './sql.ts' + +/** Storage options resolved by the service provider. */ +export interface SqliteStoreOptions { + readonly path: string + readonly journalMode: JournalMode + readonly busyTimeoutMs: number +} + +/** SQLite implementation of the coordinator's physical backend hooks. */ +export class SqliteStore implements PersistenceBackend { + readonly name = 'session-persistence-sqlite' + private db!: DatabaseSync + private databaseConstructor!: typeof import('node:sqlite')['DatabaseSync'] + private storeIdentity!: string + private databasePath!: string + private opened = false + private pathReady: Promise | undefined + private ready: Promise | undefined + + constructor(private readonly options: SqliteStoreOptions) {} + + /** + * Validate filesystem ownership without importing or opening Node SQLite. + * @returns settlement of the store's one path-validation operation. + */ + validatePath(): Promise { + this.pathReady ??= this.preparePath(this.options.path) + return this.pathReady + } + + /** + * Lazily open and validate the database on first persistence use. + * @returns settlement of the store's one database-open operation. + */ + open(): Promise { + this.ready ??= this.openDb() + return this.ready + } + + private async preparePath(path: string): Promise { + const actual = path === ':memory:' ? path : resolve(path) + if (actual !== ':memory:') { + await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) + await validateParentDirectory(dirname(actual)) + await validateDatabaseFileIfPresent(actual) + } + this.databasePath = actual + } + + private async openDb(): Promise { + await this.validatePath() + if (this.databasePath !== ':memory:') { + await createDatabaseFile(this.databasePath) + await validateDatabaseFile(this.databasePath) + } + const { DatabaseSync } = await loadNodeSqlite() + this.databaseConstructor = DatabaseSync + this.db = await openDatabase( + DatabaseSync, + this.databasePath, + this.options.journalMode, + this.options.busyTimeoutMs, + ) + try { + const row = this.db.prepare(sql('select-store-id')).get() + if (row === undefined) { + throw new Error(`session database at "${this.databasePath}" has no valid store identity`) + } + let storeId: string + try { + storeId = decodeStoreIdentity(row) + } catch (error: unknown) { + throw new Error(`session database at "${this.databasePath}" has no valid store identity`, { cause: error }) + } + if (this.databasePath === ':memory:') { + this.storeIdentity = `memory:store:${storeId}` + } else { + const identity = statSync(this.databasePath, { bigint: true }) + this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${storeId}` + } + this.opened = true + } catch (error: unknown) { + this.db.close() + throw error + } + } + + async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + await this.observe(signal) + const snapshot = this.readTransaction(() => { + const row = this.rowFor(id) + if (row === undefined) return undefined + const eventRows = this.db.prepare(sql('select-events')).all(id).map(decodeEventRow) + return { row, eventRows } + }) + signal?.throwIfAborted() + if (snapshot === undefined) return undefined + const scanned = scanRows(snapshot.eventRows) + return { + meta: rowToMeta(snapshot.row), + events: scanned.preserved, + revision: sqliteRevision(this.storeIdentity, snapshot.row), + ...scanned.tornFrom === undefined ? {} : { tornMarker: scanned.tornFrom }, + } + } + + async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise { + await this.observe(signal) + const row = this.rowFor(id) + signal?.throwIfAborted() + return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) + } + + async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { + await this.observe(signal) + const snapshot = this.readTransaction(() => { + const row = this.rowFor(id) + if (row === undefined) return undefined + return { row, ...this.physicalSpanFrom(id, fromSeq) } + }) + signal?.throwIfAborted() + if (snapshot === undefined) return undefined + const { preserved } = scanRows(snapshot.eventRows, snapshot.base) + return { meta: rowToMeta(snapshot.row), events: preserved.filter(event => event.seq >= fromSeq) } + } + + async appendBatch( + meta: SessionHeader, + events: readonly SessionEvent[], + isMaterialized: boolean, + ): Promise { + await this.open() + if (events.length === 0) return + this.db.exec(sql('begin-immediate')) + try { + validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) + const tailRows = this.tailRows(meta.id) + const currentLast = this.logicalLastEvent(meta.id, tailRows) + const expected = currentLast === undefined ? 0 : currentLast.seq + 1 + const first = events[0] as SessionEvent + if (first.seq !== expected) { + throw new Error(`session ${meta.id} append starts at seq ${first.seq}, stored next seq is ${expected}`) + } + if (!isMaterialized) this.writeRow(meta) + + const insert = this.insertStatement() + for (const record of packChunkRuns(events)) this.insertRecord(insert, meta.id, bindRecord(record)) + this.incrementRevision(meta.id) + this.db.exec(sql('commit')) + } catch (error: unknown) { + this.rollback(error, 'append') + } + } + + async commitRepair( + meta: SessionHeader, + tornMarker: number | undefined, + closers: readonly SessionEvent[], + ): Promise { + await this.open() + if (tornMarker === undefined && closers.length === 0) return + this.db.exec(sql('begin-immediate')) + try { + validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) + const row = this.rowFor(meta.id) + if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`) + const currentRows = this.db.prepare(sql('select-events')).all(meta.id).map(decodeEventRow) + const current = scanRows(currentRows) + if (tornMarker !== undefined) { + if (current.tornFrom !== tornMarker) { + throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`) + } + this.db.prepare(sql('delete-events-from')) + .run(meta.id, tornMarker) + } else if (current.tornFrom !== undefined) { + throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`) + } + if (closers.length > 0) { + const expected = current.preserved.at(-1)?.seq === undefined + ? 0 + : (current.preserved.at(-1) as SessionEvent).seq + 1 + if (closers[0]?.seq !== expected) { + throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`) + } + const insert = this.insertStatement() + for (const closer of closers) this.insertRecord(insert, meta.id, bindRecord(closer)) + } + this.incrementRevision(meta.id) + this.db.exec(sql('commit')) + } catch (error: unknown) { + this.rollback(error, 'repair') + } + } + + async list(signal?: AbortSignal): Promise { + await this.observe(signal) + const rows = this.sessionRows() + signal?.throwIfAborted() + return rows.map(rowToMeta) + } + + /** + * Return every materialized header with its source-qualified revision. + * @param signal - optional cancellation before or after the metadata query. + * @returns stored headers and revisions without loading event rows. + */ + async listSnapshots(signal?: AbortSignal): Promise { + await this.observe(signal) + const rows = this.sessionRows() + signal?.throwIfAborted() + return rows.map(row => ({ + header: rowToMeta(row), + revision: sqliteRevision(this.storeIdentity, row), + })) + } + + async close(): Promise { + if (this.ready === undefined) { + if (this.pathReady !== undefined) await Promise.allSettled([this.pathReady]) + return + } + await Promise.allSettled([this.ready]) + if (!this.opened) return + this.opened = false + this.db.close() + } + + private rowFor(id: SessionId): SessionRow | undefined { + const value = this.db.prepare(sql('select-session')).get(id) + return value === undefined ? undefined : decodeSessionRow(value) + } + + private async observe(signal: AbortSignal | undefined): Promise { + signal?.throwIfAborted() + await this.open() + signal?.throwIfAborted() + } + + private readTransaction(read: () => T): T { + this.db.exec(sql('begin')) + try { + const value = read() + this.db.exec(sql('commit')) + return value + } catch (error: unknown) { + this.rollback(error, 'read') + } + } + + private sessionRows(): SessionRow[] { + return this.db.prepare(sql('select-sessions')).all().map(decodeSessionRow) + } + + private rollback(error: unknown, operation: string): never { + try { + this.db.exec(sql('rollback')) + } catch (rollbackError: unknown) { + /* v8 ignore next -- requires SQLite to fail both an operation and its immediate rollback. */ + throw new AggregateError([error, rollbackError], `${this.name} ${operation} failed and rollback also failed`) + } + throw error + } + + private incrementRevision(id: SessionId): void { + const updated = this.db.prepare(sql('update-session-revision')) + .run(id) + /* v8 ignore next -- materialized writes follow coordinator create(); other writes upsert in this transaction. */ + if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`) + } + + private tailRows(id: SessionId): EventRow[] { + const tail = this.db.prepare(sql('select-tail-events')).all(id, 2).map(decodeEventRow).reverse() + if (tail.length === 0) return [] + return this.physicalSpanFrom(id, (tail[0] as EventRow).seq).eventRows + } + + /** Select the bounded physical span that may represent `fromSeq`. */ + private physicalSpanFrom( + id: SessionId, + fromSeq: number, + ): { readonly base: number; readonly eventRows: EventRow[] } { + const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1) + const packedPredecessors = this.db.prepare(sql('select-packed-predecessors')) + .all(id, packedFloor, fromSeq) + .map(decodeEventRow) + let base = fromSeq + for (const predecessor of packedPredecessors) { + try { + const last = decodeRow(predecessor).at(-1) + if (last !== undefined && last.seq >= fromSeq) base = Math.min(base, predecessor.seq) + } catch { + // A malformed bounded predecessor may cover fromSeq; include it so the scanner fails closed. + base = Math.min(base, predecessor.seq) + } + } + const eventRows = this.db.prepare(sql('select-events-from')).all(id, base).map(decodeEventRow) + return { base, eventRows } + } + + private logicalLastEvent(id: SessionId, tailRows: readonly EventRow[]): SessionEvent | undefined { + if (tailRows.length === 0) return undefined + const { preserved, tornFrom } = scanRows(tailRows, (tailRows[0] as EventRow).seq) + if (tornFrom !== undefined) throw new Error(`session ${id} has an invalid physical tail at seq ${tornFrom}`) + return preserved.at(-1) + } + + private insertStatement(): StatementSync { + return this.db.prepare(sql('insert-event')) + } + + private insertRecord(insert: StatementSync, id: SessionId, record: BoundRecord): void { + insert.run( + id, + record.seq, + record.type, + record.time, + record.data, + record.sourceEventSeqs, + record.surfaceOp, + record.ignorable, + ) + } + + private writeRow(meta: SessionHeader): void { + this.db.prepare(sql('upsert-session')).run( + meta.id, + meta.version, + meta.createdAt, + meta.cwd ?? null, + meta.parentSession ?? null, + meta.seedLength ?? null, + meta.origin ?? null, + meta.delegationDepth ?? null, + meta.agentPreset ?? null, + randomUUID(), + ) + } +} + +function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision { + return SessionPersistenceRevision( + `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, + ) +} + +async function createDatabaseFile(path: string): Promise { + try { + const handle = await open(path, 'wx', 0o600) + await handle.close() + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } +} + +async function validateParentDirectory(path: string): Promise { + const parent = await lstat(path) + if (parent.isSymbolicLink() || !parent.isDirectory()) { + throw new Error(`session database parent "${path}" must be a real directory`) + } + const uid = process.getuid?.() + /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful + * uid/mode bits; POSIX tests cover owner and mode rejection. */ + if (uid !== undefined && (parent.uid !== uid || (parent.mode & 0o022) !== 0)) { + throw new Error(`session database parent "${path}" must be owned by the current user and not group/world-writable`) + } + /* v8 ignore stop */ +} + +async function validateDatabaseFile(path: string): Promise { + const file = await lstat(path) + if (file.isSymbolicLink() || !file.isFile()) { + throw new Error(`session database "${path}" must be a regular file, not a symbolic link`) + } + const uid = process.getuid?.() + /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful + * uid/mode bits; POSIX tests cover owner and mode rejection. */ + if (uid !== undefined && (file.uid !== uid || (file.mode & 0o077) !== 0)) { + throw new Error(`session database "${path}" must be owned by the current user and accessible only by that user`) + } + /* v8 ignore stop */ +} + +async function validateDatabaseFileIfPresent(path: string): Promise { + try { + await validateDatabaseFile(path) + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +let nodeSqlite: Promise | undefined + +/** Load Node SQLite once so concurrent stores share one warning-filter lifetime. */ +function loadNodeSqlite(): Promise { + nodeSqlite ??= importNodeSqlite() + return nodeSqlite +} + +/** Import Node 22's SQLite dependency without its process-wide experimental warning. */ +async function importNodeSqlite(): Promise { + const emitWarning = Reflect.get(process, 'emitWarning') + /* v8 ignore start -- Node 22 alone emits this warning; primary coverage runs on Node 24. */ + const filteredEmitWarning = (warning: string | Error, ...args: unknown[]): void => { + const message = warning instanceof Error ? warning.message : warning + const first = args[0] + const type = warning instanceof Error + ? warning.name + : typeof first === 'string' + ? first + : typeof first === 'object' && first !== null && 'type' in first + ? first.type + : undefined + if (message === 'SQLite is an experimental feature and might change at any time' + && type === 'ExperimentalWarning') return + Reflect.apply(emitWarning, process, [warning, ...args]) + } + Reflect.set(process, 'emitWarning', filteredEmitWarning) + try { + return await import('node:sqlite') + } finally { + Reflect.set(process, 'emitWarning', emitWarning) + } + /* v8 ignore stop */ +} diff --git a/packages/session/session-persistence-sqlite/tests/built-package.spec.ts b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts new file mode 100644 index 0000000000..ebbe2fc691 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts @@ -0,0 +1,36 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const builtBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) + +const probe = String.raw` +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const load = path => import(pathToFileURL(resolve(path)).href); +const [{ Context }, { default: SessionStore }, { default: Sqlite }] = await Promise.all([ + load('vendor/cordis/lib/index.js'), + load('packages/core/session/lib/index.js'), + load('packages/session/session-persistence-sqlite/lib/index.js'), +]); +const ctx = new Context(); +await ctx.plugin(SessionStore); +await ctx.plugin(Sqlite, { path: ':memory:' }); +console.log(JSON.stringify(await ctx.sessionPersistence.list())); +await ctx.fiber.dispose(); +` + +describe.skipIf(!existsSync(builtBundle))('SQLite built package', () => { + it('loads packaged SQL resources from the published entry', async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { + cwd: repoRoot, + timeout: 15_000, + }) + expect(stderr).toBe('') + expect(JSON.parse(stdout) as unknown).toEqual([]) + }) +}) diff --git a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts new file mode 100644 index 0000000000..f0fc765393 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +vi.mock('node:zlib', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + zstdCompressSync: (input: ArrayBufferView) => Buffer.alloc(input.byteLength + 1), + } +}) + +import { bindRecord, ZSTD_DATA_THRESHOLD_BYTES } from '../src/compression.ts' + +describe('SQLite compression fallback', () => { + it('keeps large data as text when its Zstandard frame is not smaller', () => { + const event = { + type: 'assistant/message', + seq: 0, + time: 1, + data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES) }, + } as unknown as SessionEvent + + expect(typeof bindRecord(event).data).toBe('string') + }) +}) diff --git a/packages/session/session-persistence-sqlite/tests/compression.spec.ts b/packages/session/session-persistence-sqlite/tests/compression.spec.ts new file mode 100644 index 0000000000..93f4da380f --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/compression.spec.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from 'vitest' +import { zstdCompressSync } from 'node:zlib' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { + decodeStorageRecord, + MAX_PACKED_DATA_BYTES, + MAX_PACKED_ROW_MEMBERS, + packChunkRuns, + type StorageRecord, +} from '../src/codec.ts' +import { + bindRecord, + decodeRow, + scanRows, + ZSTD_DATA_THRESHOLD_BYTES, +} from '../src/compression.ts' +import type { EventRow } from '../src/schema.ts' + +function chunk(seq: number, text = `token-${seq}`): SessionEvent { + return { + type: 'assistant/chunk', + seq, + time: 1_000 + seq, + data: { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text }, + }, + } +} + +function event(seq: number, time: number, value: StreamChunk, turn = 1, step = 1): SessionEvent { + return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk: value } } +} + +function row(record: StorageRecord): EventRow { + const bound = bindRecord(record) + return { + seq: bound.seq, + type: bound.type, + time: bound.time, + data: bound.data, + source_event_seqs: bound.sourceEventSeqs, + surface_op: bound.surfaceOp, + ignorable: bound.ignorable, + } +} + +describe('SQLite compression', () => { + it('stores a 100-member run in one row and restores every logical event', () => { + const events = Array.from({ length: 100 }, (_, index) => chunk(index)) + const records = packChunkRuns(events) + expect(records).toHaveLength(1) + expect(records[0]?.type).toBe('text-chunks') + expect(scanRows(records.map(row)).preserved).toEqual(events) + }) + + it('partitions long and large runs within schema-owned row limits', () => { + const long = Array.from({ length: MAX_PACKED_ROW_MEMBERS + 3 }, (_, index) => chunk(index)) + const longRecords = packChunkRuns(long) + expect(longRecords).toHaveLength(2) + expect(scanRows(longRecords.map(row)).preserved).toEqual(long) + + const large = Array.from({ length: 4 }, (_, index) => chunk(index, 'x'.repeat(300_000))) + const largeRecords = packChunkRuns(large) + expect(largeRecords).toHaveLength(2) + for (const record of largeRecords) { + if (record.type.endsWith('-chunks')) { + expect(Buffer.byteLength(JSON.stringify(record.data))).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES) + } + } + expect(scanRows(largeRecords.map(row)).preserved).toEqual(large) + + const individuallyLarge = Array.from({ length: 3 }, (_, index) => chunk(index, 'x'.repeat(400_000))) + expect(packChunkRuns(individuallyLarge)).toEqual(individuallyLarge) + + const byteBound = Array.from({ length: 10 }, (_, index) => chunk(index, 'x'.repeat(150_000))) + const byteBoundRecords = packChunkRuns(byteBound) + expect(byteBoundRecords.length).toBeGreaterThan(1) + expect(scanRows(byteBoundRecords.map(row)).preserved).toEqual(byteBound) + }) + + it('packs every owned kind and preserves optional tool-call names', () => { + const events = [ + ...[0, 1, 2].map(seq => event(seq, seq, { type: 'reasoning-delta', index: 1, text: `${seq}` })), + ...[3, 4, 5].map(seq => event(seq, seq, { + type: 'tool-call-delta', index: 2, id: CallId('named'), name: 'write', argumentsDelta: `${seq}`, + })), + ...[6, 7, 8].map(seq => event(seq, seq, { + type: 'tool-call-delta', index: 3, id: CallId('unnamed'), argumentsDelta: `${seq}`, + })), + ] + const records = packChunkRuns(events) + expect(records.map(record => record.type)).toEqual([ + 'reasoning-chunks', 'tool-call-chunks', 'tool-call-chunks', + ]) + expect(records.flatMap(decodeStorageRecord)).toEqual(events) + }) + + it('keeps every off-format delta scalar and splits incompatible runs', () => { + const malformed = (seq: number, data: unknown): SessionEvent => ({ + type: 'assistant/chunk', seq, time: 10 + seq, data, + } as SessionEvent) + const values: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { ...chunk(1), extra: true } as unknown as SessionEvent, + { ...chunk(-1), seq: -1 }, + { ...chunk(3), time: 1.5 }, + malformed(4, 'data'), + malformed(5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' }, extra: 1 }), + malformed(6, { turn: '1', step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }), + malformed(7, { turn: 1, step: 1, chunk: 'chunk' }), + malformed(8, { turn: 1, step: 1, chunk: { type: 'text-delta', index: '0', text: 'x' } }), + malformed(9, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } }), + malformed(10, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } }), + malformed(11, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'id', name: 1, argumentsDelta: 'x' } }), + malformed(12, { turn: 1, step: 1, chunk: { type: 'usage', index: 0, totalTokens: 1 } }), + ] + expect(packChunkRuns(values)).toEqual(values) + + const gap = [chunk(0), chunk(1), chunk(3)] + const step = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 0, text: 'x' }, 1, 2)] + const block = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 1, text: 'x' })] + const unsafeTime = [ + event(0, Number.MIN_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'a' }), + event(1, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'b' }), + event(2, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'c' }), + ] + const toolName = [0, 1, 2].map(seq => event(seq, seq, { + type: 'tool-call-delta', index: 0, id: CallId('id'), + ...seq === 2 ? {} : { name: 'write' }, argumentsDelta: 'x', + })) + for (const events of [gap, step, block, unsafeTime, toolName]) { + expect(packChunkRuns(events)).toEqual(events) + } + }) + + it.each([ + ['extra envelope field', { type: 'text-chunks', seq0: 0, time0: 1, data: {}, extra: true }], + ['negative sequence', { type: 'text-chunks', seq0: -1, time0: 1, data: {} }], + ['fractional time', { type: 'text-chunks', seq0: 0, time0: 1.5, data: {} }], + ['primitive data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'bad' }], + ['text fields', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: [] } }], + ['non-numeric placement', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: '1', step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }], + ['non-array members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: 'abc' } }], + ['too few members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }], + ['too many members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: Array(1_024).fill(0), texts: Array(1_025).fill('a') } }], + ['non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 1, 'c'] } }], + ['invalid gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0.5], texts: ['a', 'b', 'c'] } }], + ['non-array gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: '00', texts: ['a', 'b', 'c'] } }], + ['gap arity', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b', 'c'] } }], + ['oversized data', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['x'.repeat(400_000), 'x'.repeat(400_000), 'x'.repeat(400_000)] } }], + ['sequence overflow', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }], + ['time overflow', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1, 0], texts: ['a', 'b', 'c'] } }], + ['tool fields', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], args: ['a', 'b', 'c'] } }], + ['tool id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }], + ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'id', name: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }], + ])('rejects malformed packed data: %s', (_label, record) => { + expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/) + }) + + it('decodes the schema-17 row vocabulary without another package codec', () => { + const fixture: EventRow = { + seq: 7, + type: 'text-chunks', + time: 90, + data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }), + source_event_seqs: null, + surface_op: null, + ignorable: 0, + } + expect(decodeRow(fixture)).toEqual([ + { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } }, + { ...chunk(8, 'b'), time: 92, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'b' } } }, + { ...chunk(9, 'c'), time: 91, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'c' } } }, + ]) + expect(decodeStorageRecord('scalar')).toEqual(['scalar']) + expect(decodeStorageRecord(chunk(0))).toEqual([chunk(0)]) + }) + + it('rejects surface columns on packed rows', () => { + const packed = row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!) + const invalid: EventRow[] = [ + { ...packed, source_event_seqs: Buffer.alloc(0) }, + { ...packed, surface_op: '"append"' }, + ] + for (const candidate of invalid) { + expect(() => decodeRow(candidate)).toThrow(/surface fields must be null/) + } + }) + + it('rejects the packed discriminator on a scalar event type', () => { + const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) + expect(() => decodeRow({ ...scalar, ignorable: 0 })) + .toThrow(/packed discriminator requires a chunk tag/) + }) + + it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])( + 'preserves an ignorable logical event named %s as a scalar row', + (type) => { + const logical = { + type, + seq: 0, + time: 1, + data: { future: true }, + ignorable: true, + } as unknown as SessionEvent + const physical = row(logical) + expect(physical.ignorable).toBe(1) + expect(decodeRow(physical)).toEqual([logical]) + }, + ) + + it('compresses large data and delta-encodes complete provenance arrays', () => { + const sources = Array.from({ length: 2_000 }, (_, index) => index + 10) + const event = { + type: 'assistant/message', + seq: sources.at(-1)! + 1, + time: 1, + data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES * 2) }, + sourceEventSeqs: sources, + surfaceOp: 'append', + } as unknown as SessionEvent + const bound = bindRecord(event) + expect(bound.data).toBeInstanceOf(Uint8Array) + expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array) + expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources))) + expect(decodeRow(row(event))).toEqual([event]) + + const small = bindRecord({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) + expect(typeof small.data).toBe('string') + }) + + it('round-trips empty, descending, and maximum-safe provenance deltas', () => { + for (const sources of [ + [], + [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2], + ]) { + const event = { + type: 'assistant/message', + seq: Number.MAX_SAFE_INTEGER, + time: 1, + data: {}, + sourceEventSeqs: sources, + surfaceOp: 'append', + } as unknown as SessionEvent + expect(decodeRow(row(event))).toEqual([event]) + } + }) + + it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => { + const event = { + type: 'assistant/message', + seq: 1, + time: 1, + data: {}, + sourceEventSeqs: [sourceSeq], + surfaceOp: 'append', + } as unknown as SessionEvent + expect(() => bindRecord(event)).toThrow(/non-negative safe integers/) + }) + + it('rejects malformed compressed and delta-encoded values', () => { + const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) + expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow() + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80]) })) + .toThrow(/truncated varint/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80, 0x00]) })) + .toThrow(/non-canonical varint/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x01]) })) + .toThrow(/decoded seq is out of range/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, + ]) })).toThrow(/decoded seq is out of range/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, + ]) })).toThrow(/varint is out of range/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.alloc(9, 0x80) })) + .toThrow(/varint is out of range/) + }) + + it('rejects an oversized packed data column before JSON decoding', () => { + const oversized: EventRow = { + seq: 0, + type: 'text-chunks', + time: 1, + data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1), + source_event_seqs: null, + surface_op: null, + ignorable: 0, + } + expect(() => decodeRow(oversized)).toThrow(/data exceeds/) + }) + + it('bounds packed data while decompressing', () => { + const serialized = JSON.stringify({ + turn: 1, + step: 1, + index: 0, + dt: [0, 0], + texts: ['x'.repeat(MAX_PACKED_DATA_BYTES), 'b', 'c'], + }) + const oversized: EventRow = { + seq: 0, + type: 'text-chunks', + time: 1, + data: zstdCompressSync(serialized), + source_event_seqs: null, + surface_op: null, + ignorable: 0, + } + expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/) + }) + + it('distinguishes removable and committed physical corruption', () => { + const start = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) + const skipped = row({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }) + expect(scanRows([start, skipped])).toEqual({ preserved: [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ], tornFrom: 2 }) + + const end = row({ + type: 'turn/end', + seq: 3, + time: 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }) + expect(() => scanRows([start, skipped, end])).toThrow(/invalid committed physical row at seq 2/) + + const malformed = { + ...row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!), + data: '{not json', + } + const committedEnd = row({ + type: 'turn/end', + seq: 1, + time: 4, + data: { turn: 1, reason: { kind: 'completed' } }, + }) + expect(() => scanRows([malformed, committedEnd])) + .toThrow(/invalid committed physical row at seq 0/) + }) + + it('treats a malformed packed tail as one removable physical row', () => { + const malformed: EventRow = { + seq: 0, + type: 'text-chunks', + time: 1, + data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }), + source_event_seqs: null, + surface_op: null, + ignorable: 0, + } + expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 }) + }) +}) diff --git a/packages/session/session-persistence-sqlite/tests/differential.spec.ts b/packages/session/session-persistence-sqlite/tests/differential.spec.ts new file mode 100644 index 0000000000..45ec734992 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/differential.spec.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { Context } from '@deepseek-ai/cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import { meta } from '../../session-persistence/tests/contract.ts' +import { testSql } from './test-sql.ts' + +type BackendName = 'jsonl-zstd' | 'sqlite' + +interface MountedBackend { + readonly persistence: SessionPersistence + dispose(): Promise +} + +const directories: string[] = [] +afterEach(async () => { + for (const directory of directories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function freshDirectory(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)) + directories.push(directory) + return directory +} + +async function mount(name: BackendName, root: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + switch (name) { + case 'jsonl-zstd': { + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: join(root, 'jsonl') }) + return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } } + } + case 'sqlite': { + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: join(root, 'sessions.db') }) + return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } } + } + } +} + +function closedChunkLog( + entries: readonly { readonly chunk: StreamChunk; readonly time: number; readonly ignorable?: true }[], +): SessionEvent[] { + const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({ + type: 'assistant/chunk', + seq: index + 2, + time, + data: { turn: 1, step: 1, chunk }, + ...ignorable === true ? { ignorable } : {}, + })) + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + ...chunks, + { type: 'step/end', seq: chunks.length + 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'turn/end', + seq: chunks.length + 3, + time: 4, + data: { turn: 1, reason: { kind: 'completed' } }, + }, + ] +} + +function packingMatrixLog(): SessionEvent[] { + const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [ + ...Array.from({ length: 5 }, (_, index) => ({ + chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` }, + time: 1_000 + index, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + chunk: { type: 'reasoning-delta' as const, index: 1, text: `reason-${index}` }, + time: 990 - index, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + chunk: { + type: 'tool-call-delta' as const, + index: 2, + id: CallId('named-call'), + name: 'write', + argumentsDelta: `{${index}`, + }, + time: 2_000 + index, + })), + ...Array.from({ length: 3 }, (_, index) => ({ + chunk: { + type: 'tool-call-delta' as const, + index: 3, + id: CallId('unnamed-call'), + argumentsDelta: `${index}}`, + }, + time: 3_000 + index, + })), + { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 }, + { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 }, + { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 }, + { chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true }, + { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 }, + ] + return closedChunkLog(entries) +} + +function storageTagCollisionLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ...['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].map((type, index) => ({ + type, + seq: index + 1, + time: index + 2, + data: { future: true }, + ignorable: true as const, + }) as unknown as SessionEvent), + { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] { + const result: SessionEvent[][] = [] + let offset = 0 + let index = 0 + while (offset < events.length) { + const size = sizes[index % sizes.length] as number + result.push(events.slice(offset, offset + size)) + offset += size + index += 1 + } + return result +} + +async function verifyBackend( + name: BackendName, + root: string, + events: readonly SessionEvent[], + sizes: readonly number[], +): Promise { + const header = { ...meta('differential', '/work'), delegationDepth: 0 } + let mounted = await mount(name, root) + try { + await mounted.persistence.create(header) + for (const batch of batches(events, sizes)) { + await mounted.persistence.append(header.id, batch) + } + expect(await mounted.persistence.inspect(header.id), name).toEqual({ meta: header, events }) + expect(await mounted.persistence.list(), name).toEqual([header]) + const revision = (await mounted.persistence.listSnapshots())[0]?.revision + for (let fromSeq = 0; fromSeq <= events.length + 1; fromSeq += 1) { + expect((await mounted.persistence.readFrom(header.id, fromSeq)).events, `${name} seq ${fromSeq}`) + .toEqual(events.slice(fromSeq)) + } + expect((await mounted.persistence.listSnapshots())[0]?.revision, name).toBe(revision) + } finally { + await mounted.dispose() + } + + mounted = await mount(name, root) + try { + expect(await mounted.persistence.inspect(header.id), `${name} reopen`).toEqual({ meta: header, events }) + } finally { + await mounted.dispose() + } +} + +const streamChunkArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }), + fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }), + fc.record({ + type: fc.constant<'tool-call-delta'>('tool-call-delta'), + index: fc.nat(2), + id: fc.constantFrom(CallId('call-1'), CallId('call-2')), + argumentsDelta: fc.string(), + }), + fc.record({ + type: fc.constant<'tool-call-delta'>('tool-call-delta'), + index: fc.nat(2), + id: fc.constantFrom(CallId('call-1'), CallId('call-2')), + name: fc.constantFrom('read', 'write'), + argumentsDelta: fc.string(), + }), + fc.record({ + type: fc.constant<'block-start'>('block-start'), + index: fc.nat(2), + blockType: fc.constant<'text'>('text'), + }), + fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }), +) + +const randomWorkload = fc.record({ + entries: fc.array(fc.record({ + chunk: streamChunkArbitrary, + time: fc.oneof( + { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) }, + { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) }, + ), + ignorable: fc.option(fc.constant(true), { nil: undefined }), + }), { maxLength: 30 }), + batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }), +}).map(({ entries, batchSizes }) => ({ + events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({ + chunk, + time, + ...ignorable === true ? { ignorable } : {}, + }))))) as SessionEvent[], + batchSizes, +})) + +describe('SQLite cross-backend differential behavior', () => { + it('preserves ignorable logical events whose names match physical storage tags', async () => { + const events = storageTagCollisionLog() + const directory = await freshDirectory('dsh-sqlite-storage-tag-collision-') + const root = join(directory, 'sqlite') + await verifyBackend('sqlite', root, events, [2, 1]) + const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true }) + try { + expect(db.prepare(testSql('count-physical-types')).all()).toEqual([]) + expect(db.prepare(testSql('count-ignorable-events')).get()).toEqual({ count: 3 }) + } finally { + db.close() + } + }) + + it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => { + const events = packingMatrixLog() + for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) { + const directory = await freshDirectory(`dsh-sqlite-matrix-${partitionIndex}-`) + for (const name of ['jsonl-zstd', 'sqlite'] as const) { + const root = join(directory, name) + await verifyBackend(name, root, events, sizes) + if (name === 'sqlite') { + const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true }) + try { + expect(db.prepare(testSql('count-physical-types')).all()).toEqual([ + [ + { type: 'reasoning-chunks', count: 1 }, + { type: 'text-chunks', count: 1 }, + { type: 'tool-call-chunks', count: 2 }, + ], + [], + [ + { type: 'reasoning-chunks', count: 1 }, + { type: 'text-chunks', count: 1 }, + { type: 'tool-call-chunks', count: 1 }, + ], + ][partitionIndex]) + expect(db.prepare(testSql('count-ignorable-events')).get()) + .toEqual({ count: 1 }) + } finally { + db.close() + } + } + } + } + }, 30_000) + + it('matches JSONL/Zstandard across randomized logical logs and append partitions', async () => { + await fc.assert(fc.asyncProperty(randomWorkload, async ({ events, batchSizes }) => { + const directory = await freshDirectory('dsh-sqlite-property-') + for (const name of ['jsonl-zstd', 'sqlite'] as const) { + await verifyBackend(name, join(directory, name), events, batchSizes) + } + }), { numRuns: 100, seed: 0x5A17E }) + }, 60_000) + +}) diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql new file mode 100644 index 0000000000..bc0d60887b --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD COLUMN unexpected TEXT; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql new file mode 100644 index 0000000000..b335590faa --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql @@ -0,0 +1,2 @@ +SELECT COUNT(*) AS count +FROM events; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql new file mode 100644 index 0000000000..4de44570ec --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS count +FROM events +WHERE ignorable = 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql new file mode 100644 index 0000000000..1c11b3dd0b --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS count +FROM events +WHERE type = 'text-chunks' AND ignorable = 0; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql new file mode 100644 index 0000000000..ba5e7f9d72 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql @@ -0,0 +1,6 @@ +SELECT type, COUNT(*) AS count +FROM events +WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks') + AND ignorable = 0 +GROUP BY type +ORDER BY type; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql new file mode 100644 index 0000000000..d63c09c540 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql @@ -0,0 +1,14 @@ +CREATE TABLE persistence_state (singleton ANY, store_id ANY); +CREATE TABLE sessions ( + id ANY, version ANY, created_at ANY, cwd ANY, parent_session ANY, + seed_length ANY, origin ANY, delegation_depth ANY, agent_preset ANY, + incarnation ANY, revision ANY +); +CREATE TABLE events ( + session_id ANY, seq ANY, type ANY, time ANY, data ANY, + source_event_seqs ANY, surface_op ANY, ignorable ANY +); +INSERT INTO persistence_state (singleton, store_id) +VALUES (1, '00000000-0000-4000-8000-000000000000'); +PRAGMA application_id = 1146308688; +PRAGMA user_version = 17; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql new file mode 100644 index 0000000000..23c813df9a --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql @@ -0,0 +1 @@ +CREATE TABLE unrelated (value TEXT); diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql new file mode 100644 index 0000000000..c337450451 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql @@ -0,0 +1 @@ +DELETE FROM persistence_state; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql new file mode 100644 index 0000000000..c8f86c2f8f --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql @@ -0,0 +1,2 @@ +DELETE FROM events +WHERE session_id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql new file mode 100644 index 0000000000..5d3a64c1e5 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql @@ -0,0 +1,3 @@ +UPDATE persistence_state +SET store_id = '' +WHERE singleton = 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql new file mode 100644 index 0000000000..82eb0af005 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql @@ -0,0 +1,2 @@ +INSERT INTO events (session_id, seq, type, time, data, ignorable) +VALUES (?, ?, ?, ?, ?, ?); diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql new file mode 100644 index 0000000000..3ca0e6bd0c --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS rows, + COALESCE(MAX(length(CAST(data AS BLOB))), 0) AS largest +FROM events; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql new file mode 100644 index 0000000000..e39da0a3f4 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql @@ -0,0 +1,14 @@ +PRAGMA foreign_keys = OFF; +ALTER TABLE events RENAME TO strict_events; +CREATE TABLE events ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + time INTEGER NOT NULL, + data TEXT NOT NULL, + source_event_seqs TEXT, + surface_op TEXT, + ignorable INTEGER, + PRIMARY KEY (session_id, seq) +); +DROP TABLE strict_events; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql new file mode 100644 index 0000000000..91c3167273 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql @@ -0,0 +1,3 @@ +SELECT seq, rowid +FROM events +ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql new file mode 100644 index 0000000000..037c34641a --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql @@ -0,0 +1,4 @@ +SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, ignorable +FROM events +WHERE session_id = ? +ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql new file mode 100644 index 0000000000..14b6e17742 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql @@ -0,0 +1,5 @@ +SELECT seq, type, data +FROM events +WHERE session_id = ? +ORDER BY seq DESC +LIMIT 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql new file mode 100644 index 0000000000..4edeca1a4d --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql @@ -0,0 +1 @@ +PRAGMA user_version; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql new file mode 100644 index 0000000000..79d31a3a57 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql @@ -0,0 +1 @@ +PRAGMA application_id = 12345; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql new file mode 100644 index 0000000000..fa5f49e3b2 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql @@ -0,0 +1 @@ +PRAGMA user_version = 15; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql new file mode 100644 index 0000000000..0750749350 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql @@ -0,0 +1 @@ +PRAGMA user_version = 16; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql new file mode 100644 index 0000000000..5aac576e8c --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql @@ -0,0 +1 @@ +PRAGMA user_version = 17; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql new file mode 100644 index 0000000000..a4d6b530a2 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql @@ -0,0 +1,3 @@ +UPDATE sessions +SET origin = 'external', delegation_depth = -1, seed_length = -1 +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts new file mode 100644 index 0000000000..8574d1c97f --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts @@ -0,0 +1,102 @@ +import { readdir, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url)) +const SQL_LITERAL = /^\s*(?:ALTER|ATTACH|BEGIN|COMMIT|CREATE|DELETE|DETACH|DROP|INSERT|PRAGMA|REINDEX|RELEASE|ROLLBACK|SAVEPOINT|SELECT|UPDATE|VACUUM|WITH)\s/iu // eslint-disable-line @stylistic/max-len + +async function filesUnder(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }) + return (await Promise.all(entries.map(async entry => entry.isDirectory() + ? filesUnder(`${path}/${entry.name}`) + : [`${path}/${entry.name}`]))).flat() +} + +function sqlLiteralText(node: ts.Node): string | undefined { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text + if (node.kind === ts.SyntaxKind.TemplateHead) { + return (node as ts.Node & { readonly text: string }).text + } + return undefined +} + +function isOwnedSqlSource(node: ts.Expression | undefined, source: ts.SourceFile): boolean { + if (node === undefined) return false + if (ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && (node.expression.text === 'sql' || node.expression.text === 'testSql')) return true + if (!ts.isIdentifier(node) || node.text !== 'source') return false + const call = node.parent + if (!ts.isCallExpression(call) + || call.arguments.length !== 1 + || call.arguments[0] !== node + || !ts.isPropertyAccessExpression(call.expression) + || call.expression.expression.kind !== ts.SyntaxKind.SuperKeyword + || call.expression.name.text !== 'prepare') return false + let method: ts.Node | undefined = node.parent + while (method !== undefined && !ts.isMethodDeclaration(method)) method = method.parent + if (method === undefined + || method.name.getText(source) !== 'prepare' + || method.parameters.length !== 1 + || method.parameters[0]?.name.getText(source) !== 'source') return false + let classNode: ts.Node | undefined = method.parent + while (classNode !== undefined && !ts.isClassExpression(classNode)) classNode = classNode.parent + if (classNode === undefined || classNode.name?.text !== 'JournalFailureDatabase') return false + const guard = method.body?.statements[0] + if (guard === undefined + || !ts.isIfStatement(guard) + || !ts.isBinaryExpression(guard.expression) + || guard.expression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken + || guard.expression.left.getText(source) !== 'source' + || guard.expression.right.getText(source) !== "sql('journal-mode-wal')") return false + return ts.isReturnStatement(guard.thenStatement) + && guard.thenStatement.expression === call +} + +describe('SQLite SQL resource boundary', () => { + it('keeps statements and query assembly out of TypeScript files', async () => { + const files = (await Promise.all([ + filesUnder(`${PACKAGE_ROOT}/src`), + filesUnder(`${PACKAGE_ROOT}/tests`), + ])).flat().filter(path => path.endsWith('.ts')) + const violations: string[] = [] + for (const path of files) { + const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true) + const usesNodeSqlite = source.statements.some(statement => ts.isImportDeclaration(statement) + && ts.isStringLiteral(statement.moduleSpecifier) + && statement.moduleSpecifier.text === 'node:sqlite') + const visit = (node: ts.Node): void => { + const literal = sqlLiteralText(node) + if (literal !== undefined && SQL_LITERAL.test(literal)) { + violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: SQL literal`) + } + // Awaited prepare() is SessionPersistence; DatabaseSync.prepare() is synchronous. + if (usesNodeSqlite + && ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && (node.expression.name.text === 'exec' + || (node.expression.name.text === 'prepare' && !ts.isAwaitExpression(node.parent)))) { + const argument = node.arguments[0] + if (!isOwnedSqlSource(argument, source)) { + violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: unowned query source`) + } + } + ts.forEachChild(node, visit) + } + visit(source) + } + expect(violations).toEqual([]) + }) + + it('keeps resource text static instead of interpolated', async () => { + const files = (await Promise.all([ + filesUnder(`${PACKAGE_ROOT}/resources/sql`), + filesUnder(`${PACKAGE_ROOT}/tests/resources/sql`), + ])).flat() + for (const path of files) { + expect(path.endsWith('.sql')).toBe(true) + expect(await readFile(path, 'utf8')).not.toContain('${') + } + }) +}) diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 8f0c49e71f..f49d5d8e69 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,949 +1,833 @@ -import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { Context } from '@deepseek-ai/cordis' -import { existsSync } from 'node:fs' -import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { once } from 'node:events' +import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' import { DatabaseSync } from 'node:sqlite' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceSqlite, { + DEFAULT_BUSY_TIMEOUT_MS, + SCHEMA_VERSION, +} from '@deepseek-ai/dsh-session-persistence-sqlite' import { + runCoordinatorContract, + type CoordinatorFixture, +} from '../../session-persistence/tests/coordinator-contract.ts' +import { + meta, + runPersistenceContract, +} from '../../session-persistence/tests/contract.ts' +import { MAX_PACKED_DATA_BYTES } from '../src/codec.ts' +import { + decodeEventRow, + decodeSessionRow, + decodeStoreIdentity, openDatabase, - rowToEvent, + validateSchemaForMutation, rowToMeta, - scanRows, SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, - type EventRow, + type SessionRow, } from '../src/schema.ts' -import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' -import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' +import { SqliteStore } from '../src/store.ts' +import { sql } from '../src/sql.ts' +import { testSql } from './test-sql.ts' const dirs: string[] = [] -afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +afterEach(async () => { + for (const directory of dirs.splice(0)) await rm(directory, { recursive: true, force: true }) +}) -async function expectFlushError(promise: Promise, message: RegExp): Promise { - try { - await promise - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toMatch(message) - return - } - throw new Error('expected flush to reject') +async function freshDbPath(prefix = 'dsh-sqlite-'): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)) + dirs.push(directory) + return join(directory, 'sessions.db') } -async function freshDbPath(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-')) - dirs.push(dir) - return join(dir, 'sessions.db') -} - -/** A context with the session store + SQLite backend, plus a teardown. */ -async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise }> { +async function backendFailure(path: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path }) - return { ctx, dispose: () => fiber.dispose() } + try { + await ctx.plugin(SessionPersistenceSqlite, { path }) + await ctx.sessionPersistence.list() + return undefined + } catch (error: unknown) { + return error + } finally { + await ctx.fiber.dispose() + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function databaseWithJournalFailure( + nextFailure: () => Error | undefined, +): typeof DatabaseSync { + return class JournalFailureDatabase extends DatabaseSync { + override prepare(source: string) { + if (source !== sql('journal-mode-wal')) return super.prepare(source) + const statement = super.prepare(sql('journal-mode-wal')) + const get = statement.get.bind(statement) + Object.defineProperty(statement, 'get', { + value: () => { + const failure = nextFailure() + if (failure !== undefined) throw failure + return get() + }, + }) + return statement + } + } +} + +function chunk(seq: number, text = `token-${seq}`): SessionEvent { + return { + type: 'assistant/chunk', + seq, + time: 1_000 + seq, + data: { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text }, + }, + } +} + +function chunkLog(count: number): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + ...Array.from({ length: count }, (_, index) => chunk(index + 2)), + { type: 'step/end', seq: count + 2, time: count + 3, data: { turn: 1, step: 1 } }, + { + type: 'turn/end', + seq: count + 3, + time: count + 4, + data: { turn: 1, reason: { kind: 'completed' } }, + }, + ] +} + +async function measureWriteTraffic( + path: string, + events: readonly SessionEvent[], +): Promise<{ + readonly walBytes: number + readonly idleWalBytes: number + readonly rows: number + readonly largest: number + readonly inserted: number + readonly changed: number + readonly removed: number +}> { + interface PhysicalRow { + readonly rowid: number + readonly seq: number + readonly type: string + readonly time: number + readonly data: string | Uint8Array + readonly source_event_seqs: Uint8Array | null + readonly surface_op: string | null + readonly ignorable: number | null + } + const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => ( + typeof left === 'string' || left === null + ? left === right + : right instanceof Uint8Array && Buffer.from(left).equals(Buffer.from(right)) + ) + const sameRow = (left: PhysicalRow, right: PhysicalRow): boolean => ( + left.rowid === right.rowid + && left.seq === right.seq + && left.type === right.type + && left.time === right.time + && sameValue(left.data, right.data) + && sameValue(left.source_event_seqs, right.source_event_seqs) + && left.surface_op === right.surface_op + && left.ignorable === right.ignorable + ) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceSqlite, { path, writeBatchMaxDelayMs: 200 }) + try { + const header = meta('traffic') + await ctx.sessionPersistence.create(header) + let previous = new Map() + let inserted = 0 + let changed = 0 + let removed = 0 + const probe = new DatabaseSync(path, { readOnly: true }) + try { + const selectRows = probe.prepare(testSql('select-event-rows')) + for (let offset = 0; offset < events.length; offset += 40) { + await ctx.sessionPersistence.append(header.id, events.slice(offset, offset + 40)) + const current = new Map((selectRows.all(header.id) as unknown as PhysicalRow[]) + .map(row => [row.seq, row])) + for (const [seq, row] of current) { + const old = previous.get(seq) + if (old === undefined) inserted += 1 + else if (!sameRow(old, row)) changed += 1 + } + for (const seq of previous.keys()) if (!current.has(seq)) removed += 1 + previous = current + } + } finally { + probe.close() + } + const db = new DatabaseSync(path, { readOnly: true }) + const measured = db.prepare(testSql('measure-write-traffic')).get() as { rows: number; largest: number } + db.close() + const walBytes = (await stat(`${path}-wal`)).size + await new Promise(resolve => setTimeout(resolve, 250)) + return { + walBytes, + idleWalBytes: (await stat(`${path}-wal`)).size, + rows: measured.rows, + largest: measured.largest, + inserted, + changed, + removed, + } + } finally { + await ctx.fiber.dispose() + } } -// Run the same backend-agnostic contract as JSONL to pin identical semantics. runPersistenceContract('sqlite', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' }) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() }, } }) -// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid -// JSON past the committed seq, exercising coordinator repair against real database rows. runCoordinatorContract('sqlite', async (): Promise => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) - const path = join(dir, 'sessions.db') + const directory = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) + const path = join(directory, 'sessions.db') return { - mount: async ctx => ctx.plugin(SqliteSessionPersistence, { path }), + mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }), corruptTail: async (id) => { - // A row past the committed region whose `data` does not parse: scanRows - // bounds the preserved prefix at it and returns its seq as tornFrom, which - // the backend surfaces to the coordinator as the tornMarker to delete from. - const db = openDatabase(path, 'wal') - const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?') - .get(id) as { n: number }).n - db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - .run(id, next, 'assistant/chunk', 99, '{not valid json') + const db = new DatabaseSync(path) + const last = db.prepare(testSql('select-last-event')) + .get(id) as { seq: number; type: string; data: string } + const logicalLength = last.type === 'text-chunks' + ? (JSON.parse(last.data) as { texts: string[] }).texts.length + : 1 + const next = last.seq + logicalLength + db.prepare(testSql('insert-corrupt-event')) + .run(id, next, 'assistant/chunk', 99, '{not valid json', null) db.close() }, - cleanup: async () => { await rm(dir, { recursive: true, force: true }) }, + cleanup: async () => { await rm(directory, { recursive: true, force: true }) }, } }) -describe('scanRows', () => { - // scanRows works off EventRows (data is a JSON string column); build them from SessionEvents - // so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to - // its nullable columns so the conversion remains faithful. - const rows = (events: SessionEvent[]): EventRow[] => - events.map((e) => { - const se = e as SessionEvent - return { - seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), - source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null, - surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, - ignorable: e.ignorable === true ? 1 : null, - } +describe('SessionPersistenceSqlite physical packing', () => { + it('loads from cordis.yml and packs through the assembled service', async () => { + const path = await freshDbPath('dsh-sqlite-loader-') + const configPath = join(path, '..', 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-persistence-sqlite'", + ' config:', + ` path: ${JSON.stringify(path)}`, + '', + ].join('\n')) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(join(path, '..')).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.internal = { + version: 'sqlite', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-session') return SessionStore + if (specifier === '@deepseek-ai/dsh-session-persistence-sqlite') { + return SessionPersistenceSqlite + } + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, }) + await ctx.loader.await() - it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => { - const { preserved, tornFrom } = scanRows(rows(oneTurnLog())) - expect(preserved).toEqual(oneTurnLog()) - expect(tornFrom).toBeUndefined() - }) - - it('PRESERVES the real events of an interrupted turn after the last turn/end', () => { - // turn 1 committed (0..5) + a crashed turn 2 (turn/start 6, step/start 7, no - // close): all 8 rows are intact, so the whole prefix is preserved and there - // is no torn fragment to delete. (load() then synthesizes the closers.) - const withOpenTurn: SessionEvent[] = [ - ...oneTurnLog(), - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, - ] - const { preserved, tornFrom } = scanRows(rows(withOpenTurn)) - expect(preserved.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - expect(tornFrom).toBeUndefined() - }) - - it('preserves the contiguous prefix and flags a torn tail at a seq gap', () => { - // A gap after seq 0 (no committed turn/end): seq 0 is the preserved - // interrupted-turn event; the gap bounds it and marks the torn fragment. - const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing - ] - const { preserved, tornFrom } = scanRows(rows(gapped)) - expect(preserved.map(e => e.seq)).toEqual([0]) - expect(tornFrom).toBe(1) - }) - - it('an empty log preserves nothing and has no torn tail', () => { - expect(scanRows([])).toEqual({ preserved: [] }) - }) - - it('throws on a seq gap inside the committed region (before the last turn/end)', () => { - const gapped: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing - { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/) - }) - - it('throws on an unparsable row inside the committed region', () => { - const withCorruptCommitted: EventRow[] = [ - { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end - { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null }, - ] - expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/) - }) - - it('tolerates an unparsable torn-tail row after the last turn/end', () => { - const withCorruptTail: EventRow[] = [ - ...rows(oneTurnLog()), - { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after - ] - const { preserved, tornFrom } = scanRows(withCorruptTail) - expect(preserved).toEqual(oneTurnLog()) - expect(tornFrom).toBe(6) - }) -}) - -describe('rowToMeta', () => { - it('restores optional origin metadata', () => { - expect(rowToMeta({ - id: 'with-origin', - version: 0, - created_at: 1, - cwd: null, - parent_session: null, - seed_length: null, - origin: 'subagent', - incarnation: 'with-origin', - revision: 1, - delegation_depth: null, - agent_preset: null, - })).toMatchObject({ id: 'with-origin', origin: 'subagent' }) - }) - - it('rejects fractional stored creation metadata', () => { - expect(() => rowToMeta({ - id: 'fractional', - version: 0, - created_at: 1.5, - cwd: null, - parent_session: null, - seed_length: null, - origin: null, - incarnation: 'fractional', - revision: 1, - delegation_depth: null, - agent_preset: null, - })).toThrow('stored session createdAt must be a non-negative safe integer') - }) - - it('restores the agent preset a session was composed from', () => { - // The preset decides the resumed session's tools and prompt; a row that - // dropped it would rebuild a composition the stored history contradicts. - expect(rowToMeta({ - id: 'composed', - version: 0, - created_at: 1, - cwd: null, - parent_session: null, - seed_length: null, - origin: null, - incarnation: 'composed', - revision: 1, - delegation_depth: null, - agent_preset: 'minimal', - })).toMatchObject({ agentPreset: 'minimal' }) - }) -}) - -describe('SqliteSessionPersistence: durability and crash semantics', () => { - it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { - const path = await freshDbPath() - const m = meta('legacy-header-delta', '/legacy') - const db = openDatabase(path, 'wal') - db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)') - .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta') - const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 })) - insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } })) - insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } })) - db.close() - - const mounted = await backend(path) - await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/) - await mounted.dispose() - }) - - it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { - const path = await freshDbPath() - const m = meta('legacy-header-fallback', '/legacy') - const db = openDatabase(path, 'wal') - db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)') - .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-fallback') - db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - .run(m.id, 0, 'request/header', 1, JSON.stringify({ - header: { config: { model: 'legacy' } }, - reason: 'fallback', - })) - db.close() - - const mounted = await backend(path) - await expect(mounted.ctx.sessionPersistence.load(m.id)) - .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/) - await mounted.dispose() - }) - - it('has no independent per-session log location', async () => { - const { ctx, dispose } = await backend() - expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined() - await dispose() - }) - - it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => { - const path = await freshDbPath() - const m = meta('crash') - // Run 1: persist a complete turn, then a half-written second turn (no turn/end). - const ctx1 = new Context() - await ctx1.plugin(SessionStore) - const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path }) - await ctx1.sessionPersistence.create(m) - await ctx1.sessionPersistence.append(m.id, oneTurnLog()) - await ctx1.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, - ]) - await fiber1.dispose() - - // Run 2: load PRESERVES the interrupted turn's real events (a turn can be huge - // — never truncated) and closes the orphaned turn with synthetic boundary - // events: step/end (the step was open) then turn/end {interrupted}. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path }) - const loaded = await ctx2.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.type)).toEqual([ - 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 - 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers - ]) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) - const last = loaded.events.at(-1)! - expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' }) - - // load durably closed the turn, so the next append continues at the balanced - // length (seq 10) and a reload round-trips identically. - await ctx2.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, - { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, - ]) - const reloaded = await ctx2.sessionPersistence.load(m.id) - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) - await fiber2.dispose() - }) - - it('load() durably closes the interrupted turn: the synthetic closers are on disk after load', async () => { - const path = await freshDbPath() - const m = meta('load-closes') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5 - await b1.dispose() - // Hand-write an interrupted turn (turn/start seq 6, no turn/end). - const db = openDatabase(path, 'wal') - db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') - .run(m.id, 'turn/start', JSON.stringify({ turn: 2 })) - db.close() - - const b2 = await backend(path) - const loaded = await b2.ctx.sessionPersistence.load(m.id) - // turn 2's real turn/start (seq 6) is preserved + a synthetic turn/end (seq 7). - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - expect(loaded.events.at(-1)!.type).toBe('turn/end') - // load() is mutating: the synthetic turn/end MUST be on disk so the stored log - // is balanced and the cursor is truthful (contract: load closes, not defers). - const probe = openDatabase(path, 'wal') - const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[] - probe.close() - expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - expect(stored.at(-1)!.type).toBe('turn/end') - await b2.dispose() - }) - - it('all-tail load: a session whose only turn never closed is preserved and closed on load', async () => { - const path = await freshDbPath() - const m = meta('all-tail') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - // A first turn that NEVER completed: turn/start + user/message, no turn/end. - await b1.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 2, data: createUserMessage({ - content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }), surfaceOp: 'append' }, - ]) - await b1.dispose() - - // A fresh backend loads it: the interrupted (only) turn's real events are - // preserved and closed with a synthetic turn/end {interrupted} — NOT - // truncated. The session was materialized, so list() reports it present. - const b2 = await backend(path) - const loaded = await b2.ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end']) - expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } }) - expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id) - await b2.dispose() - }) - - it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => { - const path = await freshDbPath() - openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION - // Bump user_version past what this build supports. - const dbNewer = openDatabase(path, 'wal') - dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`) - dbNewer.close() - expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/) - - // The immediately preceding layout lacks the required store identity and is - // rejected rather than migrated (unreleased software, no backward-compat). - const olderPath = await freshDbPath() - openDatabase(olderPath, 'wal').close() - const dbOlder = openDatabase(olderPath, 'wal') - dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`) - dbOlder.close() - expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) - }) - - it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => { - const path = await freshDbPath() - const legacy = new DatabaseSync(path) - legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)') - legacy.close() - - expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/) - - const unchanged = new DatabaseSync(path) - expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) - expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) - expect(unchanged.prepare( - "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'", - ).get()).toEqual({ name: 'sessions' }) - unchanged.close() - }) - - it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => { - const path = await freshDbPath() - const unrelated = new DatabaseSync(path) - unrelated.exec('CREATE TABLE sqliteX (value TEXT)') - unrelated.exec("INSERT INTO sqliteX VALUES ('safe')") - unrelated.close() - - expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/) - - const unchanged = new DatabaseSync(path) - expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' }) - expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 }) - expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) - expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) - unchanged.close() - }) - - it('rejects view-only and foreign-application unversioned databases without mutation', async () => { - const viewPath = await freshDbPath() - const viewOnly = new DatabaseSync(viewPath) - viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value') - viewOnly.close() - - expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/) - const unchangedView = new DatabaseSync(viewPath) - expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) - expect(unchangedView.prepare( - "SELECT type FROM sqlite_schema WHERE name = 'foreign_view'", - ).get()).toEqual({ type: 'view' }) - unchangedView.close() - - const applicationPath = await freshDbPath() - const foreignApplication = new DatabaseSync(applicationPath) - foreignApplication.exec('PRAGMA application_id = 12345') - foreignApplication.close() - - expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/) - const unchangedApplication = new DatabaseSync(applicationPath) - expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 }) - expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) - expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) - unchangedApplication.close() - }) - - it('rejects a current-version database with a foreign application identity', async () => { - const path = await freshDbPath() - const foreign = new DatabaseSync(path) - foreign.exec('PRAGMA application_id = 12345') - foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) - foreign.close() - - expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/) - - const unchanged = new DatabaseSync(path) - expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 }) - expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) - expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) - unchanged.close() - }) - - it('rolls back schema objects and identity stamps when initialization fails', async () => { - const path = await freshDbPath() - const conflicting = new DatabaseSync(path) - conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) - conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) - conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id") - conflicting.close() - - expect(() => openDatabase(path, 'wal')).toThrow() - - const unchanged = new DatabaseSync(path) - expect(unchanged.prepare( - "SELECT type FROM sqlite_schema WHERE name = 'persistence_state'", - ).get()).toEqual({ type: 'view' }) - expect(unchanged.prepare( - "SELECT type FROM sqlite_schema WHERE name = 'sessions'", - ).get()).toBeUndefined() - expect(unchanged.prepare( - "SELECT type FROM sqlite_schema WHERE name = 'events'", - ).get()).toBeUndefined() - expect(unchanged.prepare('PRAGMA application_id').get()) - .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID }) - expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) - expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) - unchanged.close() - }) - - it('stamps the persistence application identity with the schema version', async () => { - const path = await freshDbPath() - openDatabase(path, 'wal').close() + const header = meta('loader') + const events = chunkLog(4) + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, events) + expect((await ctx.sessionPersistence.inspect(header.id)).events).toEqual(events) + await ctx.fiber.dispose() const db = new DatabaseSync(path) - expect(db.prepare('PRAGMA application_id').get()) - .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID }) - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) + expect(db.prepare(testSql('count-packed-events')).get()) + .toEqual({ count: 1 }) db.close() }) - it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { - // Version 3 identified two incompatible sibling layouts, so it is always rejected. + it('packs each append once without rewriting earlier rows and seeks inside packed rows', async () => { const path = await freshDbPath() - openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION - const db = openDatabase(path, 'wal') - db.exec('PRAGMA user_version = 3') - db.close() - expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/) - }) - - it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => { - const path = await freshDbPath() - const m = meta('corrupt-tail') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5 - await b1.dispose() - - // A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary - // from seq/type columns without parsing the tail, preserves the committed prefix, and load - // deletes the row; invalid JSON inside the committed region would remain fatal. - const db = openDatabase(path, 'wal') - db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') - .run(m.id, 'turn/start', '{not valid json') - db.close() - - const b2 = await backend(path) - const loaded = await b2.ctx.sessionPersistence.load(m.id) - expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers) - // load physically deleted the corrupt tail row, so a fresh append continues. - await b2.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }, - ]) - const reloaded = await b2.ctx.sessionPersistence.load(m.id) - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await b2.dispose() - }) - - it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' }) - const m = meta('rollback') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5 + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path }) + const header = meta('packed') + const events = chunkLog(100) + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, events.slice(0, 3)) + await ctx.sessionPersistence.append(header.id, events.slice(3, 4)) + const before = new DatabaseSync(path, { readOnly: true }) + const originalRows = before.prepare(testSql('select-event-rowids')).all() + before.close() + await ctx.sessionPersistence.append(header.id, events.slice(4)) - // A batch that re-states an already-stored seq must be rejected and leave - // the stored log unchanged (the UNIQUE (session_id, seq) constraint fires - // inside the transaction → ROLLBACK). - await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow() - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events).toEqual(oneTurnLog()) // unchanged + const inspected = await ctx.sessionPersistence.inspect(header.id) + expect(inspected.events).toEqual(events) + for (const fromSeq of [0, 2, 25, 101, 104, 105]) { + expect((await ctx.sessionPersistence.readFrom(header.id, fromSeq)).events) + .toEqual(events.filter(event => event.seq >= fromSeq)) + } await fiber.dispose() + + const db = new DatabaseSync(path) + expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION }) + expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 }) + expect(db.prepare(testSql('count-packed-events')).get()) + .toEqual({ count: 1 }) + expect(db.prepare(testSql('select-event-rowids')).all().slice(0, originalRows.length)) + .toEqual(originalRows) + db.close() }) - it('persists across separate backend instances over the same file', async () => { - const path = await freshDbPath() - const m = meta('persist', '/proj') - const ctx1 = new Context() - await ctx1.plugin(SessionStore) - const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path }) - await ctx1.sessionPersistence.create(m) - await ctx1.sessionPersistence.append(m.id, oneTurnLog()) - await fiber1.dispose() + it.runIf(process.platform !== 'win32')('bounds paced-stream WAL extent without rewriting committed rows', async () => { + const events = chunkLog(1_000) + const measured = await measureWriteTraffic(await freshDbPath('dsh-sqlite-traffic-'), events) - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path }) - expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id) - const loaded = await ctx2.sessionPersistence.load(m.id) - expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' }) - expect(loaded.events).toEqual(oneTurnLog()) - await fiber2.dispose() + expect(measured).toMatchObject({ rows: 31, inserted: 31, changed: 0, removed: 0 }) + expect(measured.inserted).toBe(measured.rows) + expect(measured.largest).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES) + expect(measured.idleWalBytes).toBe(measured.walBytes) }) - it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => { - const pathA = await freshDbPath() - const pathB = await freshDbPath() - const m = meta('revision-source') - const a = await backend(pathA) - await a.ctx.sessionPersistence.create(m) - await a.ctx.sessionPersistence.append(m.id, oneTurnLog()) - const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision - await a.dispose() + it('includes a packed predecessor when an overlapping scalar tail hides it', async () => { + const path = await freshDbPath('dsh-sqlite-overlap-') + const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const header = meta('overlap') + await store.appendBatch(header, [chunk(0), chunk(1), chunk(2)], false) - const probeA = openDatabase(pathA, 'wal') - const storeIdA = (probeA.prepare( - 'SELECT store_id FROM persistence_state WHERE singleton = 1', - ).get() as { store_id: string }).store_id - probeA.close() + const db = new DatabaseSync(path) + db.prepare(testSql('insert-corrupt-event')) + .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null) + db.close() - const aliasA = `${pathA}.alias` - await symlink(pathA, aliasA) - const reopenedA = await backend(aliasA) - expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA) - await reopenedA.dispose() + expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)]) - const b = await backend(pathB) - await b.ctx.sessionPersistence.create(m) - await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) - const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision - const probeB = openDatabase(pathB, 'wal') - const storeIdB = (probeB.prepare( - 'SELECT store_id FROM persistence_state WHERE singleton = 1', - ).get() as { store_id: string }).store_id - probeB.close() - expect(storeIdB).not.toBe(storeIdA) - expect(revisionB).not.toBe(revisionA) - expect(String(revisionA)).toMatch(/:revision:1$/) - expect(String(revisionB)).toMatch(/:revision:1$/) - await b.dispose() + const malformed = new DatabaseSync(path) + malformed.prepare(testSql('delete-session-events')).run(header.id) + malformed.prepare(testSql('insert-corrupt-event')) + .run(header.id, 0, 'text-chunks', 1, '{not json', 0) + malformed.close() + expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([]) + await store.close() }) - it('binds a full stored prefix to the same revision as a lightweight read', async () => { - const b = await backend() - const m = meta('stored-prefix-revision') - await b.ctx.sessionPersistence.create(m) - await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) - const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence + it('waits for a competing process within the configured busy timeout', async () => { + const path = await freshDbPath('dsh-sqlite-busy-') + const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: 1_000 }) + const header = meta('busy') + await store.appendBatch(header, [chunk(0)], false) - const stored = await persistence.loadStored(m.id) - expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) - expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() - await b.dispose() - }) - - it('changes revisions when a deleted session id is materialized again in the same database', async () => { - const path = await freshDbPath() - const m = meta('recreated-revision') - const first = await backend(path) - await first.ctx.sessionPersistence.create(m) - await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) - const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision - await first.dispose() - - const cleanup = openDatabase(path, 'wal') - cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) - cleanup.close() - - const second = await backend(path) - await second.ctx.sessionPersistence.create(m) - await second.ctx.sessionPersistence.append(m.id, oneTurnLog()) - const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision - expect(after).not.toBe(before) - expect(String(before)).toMatch(/:revision:1$/) - expect(String(after)).toMatch(/:revision:1$/) - await second.dispose() - }) - - it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => { - const b = await backend() - const internals = b.ctx.sessionPersistence as unknown as { ready: Promise } - const originalReady = internals.ready - const readiness = Promise.withResolvers() - internals.ready = readiness.promise - const reason = new Error('SQLite snapshot readiness cancelled') - const controller = new AbortController() - const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal) - let settled = false - void pending.then( - () => { settled = true }, - () => { settled = true }, - ) - - controller.abort(reason) - await Promise.resolve() - expect(settled).toBe(false) - - readiness.resolve(undefined) - await expect(pending).rejects.toBe(reason) - internals.ready = originalReady - await b.dispose() - }) - - it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(15) - }) - - it('keeps the revision stable for an empty repair hook', async () => { - const b = await backend() - const m = meta('empty-repair') - await b.ctx.sessionPersistence.create(m) - await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) - const before = await b.ctx.sessionPersistence.listSnapshots() - await (b.ctx.sessionPersistence as SqliteSessionPersistence).commitRepair(m, undefined, []) - expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before) - await b.dispose() - }) -}) - -describe('SqliteSessionPersistence: edge cases', () => { - it('resolves the preparation-cache default without schema normalization', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - let persistence!: SqliteSessionPersistence - await ctx.plugin(Object.assign((inner: Context) => { - persistence = new SqliteSessionPersistence(inner, { - path: ':memory:', - journalMode: 'wal', - }) - }, { inject: ['sessions'] })) - - expect(await persistence.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('uses the configured preparation cache through the public service', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { - path: ':memory:', - preparedSessionCacheSize: 1, - writeBatchMaxDelayMs: 1, + const holder = spawn(process.execPath, ['--input-type=module', '-e', String.raw` + import { DatabaseSync } from 'node:sqlite'; + const db = new DatabaseSync(process.argv[1]); + db.exec('BEGIN IMMEDIATE'); + process.stdout.write('locked\n'); + setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100); + `, path], { stdio: ['ignore', 'pipe', 'pipe'] }) + const exited = new Promise((resolve, reject) => { + holder.once('error', reject) + holder.once('exit', resolve) }) - const m = meta('sqlite-preparation-cache') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - - const preparation = await ctx.sessionPersistence.prepare(m.id) - expect(preparation.session.header).toEqual(m) - preparation[Symbol.dispose]() - await fiber.dispose() + try { + await once(holder.stdout, 'data') + await expect(store.appendBatch(header, [chunk(1)], true)).resolves.toBeUndefined() + const code = await exited + expect(code).toBe(0) + expect((await store.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)]) + } finally { + if (holder.exitCode === null) holder.kill() + await store.close() + } }) - it('rejects and closes a current-schema database with an invalid store identity', async () => { - const path = await freshDbPath() - const db = openDatabase(path, 'wal') - db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1") + it('rejects an older SQLite physical schema', async () => { + const path = await freshDbPath('dsh-sqlite-old-schema-') + const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS) + seed.exec(testSql('set-user-version-16')) + seed.close() + await chmod(path, 0o600) + await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)) + .rejects.toThrow(/schema version 16.*incompatible/) + }) + + it('rejects a stale physical append without replacing the winning tail', async () => { + const path = await freshDbPath('dsh-sqlite-stale-') + const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const second = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const header = meta(SessionId('stale')) + await first.appendBatch(header, [chunk(0)], false) + await second.appendBatch(header, [chunk(1)], true) + await expect(first.appendBatch(header, [chunk(1)], true)).rejects.toThrow(/stored next seq is 2/) + expect((await first.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)]) + await first.close() + await second.close() + }) + + it('rejects a stale repair without deleting a newer winning tail', async () => { + const path = await freshDbPath('dsh-sqlite-stale-repair-') + const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const winner = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const header = meta(SessionId('stale-repair')) + await stale.appendBatch(header, [chunk(0)], false) + const db = new DatabaseSync(path) + db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null) db.close() + expect((await stale.loadStored(header.id))?.tornMarker).toBe(1) + await winner.commitRepair(header, 1, []) + await winner.appendBatch(header, [chunk(1), chunk(2)], true) + await expect(stale.commitRepair(header, 1, [])).rejects.toThrow(/repair is stale/) + expect((await stale.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1), chunk(2)]) + await stale.close() + await winner.close() + }) +}) - const b = await backend(path) - await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/) - await expect(b.dispose()).resolves.toBeUndefined() +describe('SessionPersistenceSqlite schema ownership', () => { + it('accepts every configured journal mode and SQLite memory mode result', async () => { + const resources = { + wal: 'journal-mode-wal', + delete: 'journal-mode-delete', + truncate: 'journal-mode-truncate', + persist: 'journal-mode-persist', + } as const + for (const mode of ['wal', 'delete', 'truncate', 'persist'] as const) { + ;(await openDatabase(DatabaseSync, ':memory:', mode, DEFAULT_BUSY_TIMEOUT_MS)).close() + const path = await freshDbPath(`dsh-sqlite-journal-${mode}-`) + const db = await openDatabase(DatabaseSync, path, mode, DEFAULT_BUSY_TIMEOUT_MS) + expect(db.prepare(sql(resources[mode])).get()).toEqual({ journal_mode: mode }) + expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 }) + expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 }) + expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 }) + db.close() + } }) - it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => { - if (process.platform === 'win32') return - const path = await freshDbPath() - const dir = dirname(path) - await chmod(dir, 0o755) + it('retries a busy journal-mode transition within its retry budget', async () => { + const path = await freshDbPath('dsh-sqlite-journal-busy-') + let attempts = 0 + const BusyOnceDatabase = databaseWithJournalFailure(() => { + attempts += 1 + return attempts === 1 + ? Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 5, + errstr: 'database is locked', + }) + : undefined + }) - const b = await backend(path) - await b.ctx.sessionPersistence.list() - - expect((await stat(dir)).mode & 0o777).toBe(0o755) - expect((await stat(path)).mode & 0o777).toBe(0o600) - expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600) - expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600) - await b.dispose() - }) - - it('creates a persistent rollback journal with owner-only mode', async () => { - if (process.platform === 'win32') return - const path = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'persist' }) - const m = meta('persist-permissions') - - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - - expect((await stat(path)).mode & 0o777).toBe(0o600) - expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600) - await fiber.dispose() - }) - - it('preserves the mode of an existing database file', async () => { - if (process.platform === 'win32') return - const path = await freshDbPath() - await writeFile(path, '', { mode: 0o644 }) - await chmod(path, 0o644) - - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'delete' }) - await ctx.sessionPersistence.list() - - expect((await stat(path)).mode & 0o777).toBe(0o644) - await fiber.dispose() - }) - - it('surfaces an invalid database path during pre-creation', async () => { - const path = await freshDbPath() - const b = await backend(`${path}\0`) - - await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) - await b.dispose() - }) - - it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { - const path = await freshDbPath() - const m = meta('rollback-insert') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) - - // A SECOND backend over the same file loads the session first, so it adopts - // cursor 6 (the committed length) into its OWN in-memory state. - const b2 = await backend(path) - await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2 - const turn2: SessionEvent[] = [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ] - // b1 commits seq 6..7 first. - await b1.ctx.sessionPersistence.append(m.id, turn2) - // b2 still thinks its cursor is 6, so this batch passes the contiguity check - // but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint - // mid-transaction → ROLLBACK + rethrow. - await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/) - // b1's turn is intact; b2's rolled-back attempt left nothing extra. - const loaded = await b1.ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await b1.dispose() - await b2.dispose() - }) - - it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => { - // :memory: databases always report journal_mode=memory, so probe file DBs. - const walPath = await freshDbPath() - const bWal = await backend(walPath) - await bWal.ctx.sessionPersistence.create(meta('jm-wal')) - const probe = openDatabase(walPath, 'wal') - expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal') - probe.close() - await bWal.dispose() - - const deletePath = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path: deletePath, journalMode: 'delete' }) - await ctx.sessionPersistence.create(meta('jm-delete')) - // Probe through a second connection: journal_mode=delete is a per-database - // property only insofar as no WAL files exist — assert the world, not the - // backend's self-report (no -wal sidecar after writes in delete mode). - const db = openDatabase(deletePath, 'delete') - expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete') + const db = await openDatabase(BusyOnceDatabase, path, 'wal', 100) + expect(attempts).toBe(2) + expect(db.prepare(sql('journal-mode-wal')).get()).toEqual({ journal_mode: 'wal' }) + expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 }) + expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 }) + expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 }) db.close() - expect(existsSync(`${deletePath}-wal`)).toBe(false) - await fiber.dispose() }) - it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => { - const path = await freshDbPath() - // Instance 1 materializes a session and disposes. - const b1 = await backend(path) - const s1 = b1.ctx.sessions.create(SessionId('hmr-collide')) - appendLog(s1, oneTurnLog()) - await b1.ctx.sessions.flush(s1) - await b1.dispose() + it('does not retry journal failures outside the available busy budget', async () => { + for (const { errcode, timeout } of [ + { errcode: 5, timeout: 0 }, + { errcode: 6, timeout: 100 }, + ]) { + let attempts = 0 + const FailingDatabase = databaseWithJournalFailure(() => { + attempts += 1 + return Object.assign(new Error(`SQLite error ${errcode}`), { errcode }) + }) + await expect(openDatabase( + FailingDatabase, + await freshDbPath(`dsh-sqlite-journal-failure-${errcode}-`), + 'wal', + timeout, + )).rejects.toThrow(`SQLite error ${errcode}`) + expect(attempts).toBe(1) + } + }) - // A fresh context with an UNRELATED live session reusing the id meets a - // materialized row that is NOT a prefix of its events → reject. + it('starts no journal retry after its open-relative cutoff', async () => { + let attempts = 0 + const BusyDatabase = databaseWithJournalFailure(() => { + attempts += 1 + return Object.assign(new Error('database is locked'), { errcode: 5 }) + }) + const clock = vi.spyOn(performance, 'now') + .mockReturnValueOnce(0) + .mockReturnValueOnce(50) + .mockReturnValueOnce(100) + try { + await expect(openDatabase( + BusyDatabase, + await freshDbPath('dsh-sqlite-journal-cutoff-'), + 'wal', + 100, + )).rejects.toThrow('database is locked') + } finally { + clock.mockRestore() + } + expect(attempts).toBe(1) + }) + + it('paces repeated busy journal-mode attempts', async () => { + let attempts = 0 + const BusyDatabase = databaseWithJournalFailure(() => { + attempts += 1 + return Object.assign(new Error('database is locked'), { errcode: 5 }) + }) + await expect(openDatabase( + BusyDatabase, + await freshDbPath('dsh-sqlite-journal-paced-'), + 'wal', + 50, + )).rejects.toThrow('database is locked') + expect(attempts).toBeGreaterThan(1) + expect(attempts).toBeLessThanOrEqual(6) + }) + + it('rejects unversioned, incompatible, and foreign-application databases', async () => { + const unversionedPath = await freshDbPath('dsh-sqlite-unversioned-') + const unversioned = new DatabaseSync(unversionedPath) + unversioned.exec(testSql('create-unrelated-table')) + unversioned.close() + await expect(openDatabase(DatabaseSync, unversionedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/unversioned schema/) + + const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-') + const incompatible = new DatabaseSync(incompatiblePath) + incompatible.exec(testSql('set-user-version-16')) + incompatible.close() + await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/) + + const foreignPath = await freshDbPath('dsh-sqlite-foreign-') + const foreign = new DatabaseSync(foreignPath) + foreign.exec(testSql('set-user-version-17')) + foreign.exec(testSql('set-application-id-12345')) + foreign.close() + await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/) + }) + + it('rejects changed columns and non-strict owned tables', async () => { + const changedPath = await freshDbPath('dsh-sqlite-columns-') + ;(await openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close() + const changed = new DatabaseSync(changedPath) + changed.exec(testSql('add-unexpected-column')) + changed.close() + await expect(openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/) + + const nonStrictPath = await freshDbPath('dsh-sqlite-nonstrict-') + ;(await openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close() + const nonStrict = new DatabaseSync(nonStrictPath) + nonStrict.exec(testSql('replace-events-with-nonstrict-table')) + nonStrict.close() + await expect(openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/) + + const loosePath = await freshDbPath('dsh-sqlite-loose-') + const loose = new DatabaseSync(loosePath) + loose.exec(testSql('create-loose-schema')) + loose.close() + await expect(openDatabase(DatabaseSync, loosePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/) + }) + + it('rejects schema ownership changes observed at mutation time', async () => { + const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS) + changedVersion.exec(testSql('set-user-version-16')) + expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') }) + .toThrow(/schema changed before mutation/) + changedVersion.close() + + const changedApplication = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS) + changedApplication.exec(testSql('set-application-id-12345')) + expect(() => { validateSchemaForMutation(DatabaseSync, changedApplication, ':memory:') }) + .toThrow(/application id changed before mutation/) + changedApplication.close() + }) + + it('validates creation time and restores every optional header field', () => { + const base: SessionRow = { + id: 'stored-header', + version: 0, + created_at: 1, + cwd: '/project', + parent_session: 'parent', + seed_length: 4, + origin: 'subagent', + incarnation: '00000000-0000-4000-8000-000000000000', + revision: 1, + delegation_depth: 2, + agent_preset: 'minimal', + } + expect(rowToMeta(decodeSessionRow(base))).toMatchObject({ + cwd: '/project', + parentSession: 'parent', + seedLength: 4, + origin: 'subagent', + delegationDepth: 2, + agentPreset: 'minimal', + }) + expect(() => decodeSessionRow({ ...base, created_at: -1 })).toThrow(/created_at/) + expect(() => decodeSessionRow({ ...base, origin: 'external' })).toThrow(/origin/) + expect(() => decodeSessionRow({ ...base, delegation_depth: -1 })).toThrow(/delegation_depth/) + }) + + it('rejects malformed SQLite row primitives generically', () => { + const base: SessionRow = { + id: 'stored-header', + version: 0, + created_at: 1, + cwd: '/project', + parent_session: null, + seed_length: null, + origin: null, + incarnation: '00000000-0000-4000-8000-000000000000', + revision: 1, + delegation_depth: null, + agent_preset: null, + } + for (const [value, message] of [ + [null, /object/], + [{ ...base, id: 1 }, /id.*string/], + [{ ...base, id: '' }, /id.*empty/], + [{ ...base, version: '0' }, /version.*safe integer/], + [{ ...base, cwd: 'relative' }, /cwd.*absolute/], + [{ ...base, cwd: 1 }, /cwd.*string or null/], + [{ ...base, incarnation: 'invalid' }, /incarnation.*UUID/], + [{ ...base, seed_length: '1' }, /seed_length.*safe integer or null/], + [{ ...base, agent_preset: 1 }, /agent_preset.*string or null/], + ] as const) { + expect(() => decodeSessionRow(value)).toThrow(message) + } + + const eventRow = { + seq: 0, type: 'turn/start', time: 1, data: '{}', + source_event_seqs: null, surface_op: null, ignorable: null, + } + for (const [value, message] of [ + [null, /object/], + [{ ...eventRow, seq: '0' }, /seq.*safe integer/], + [{ ...eventRow, type: '' }, /type.*empty/], + [{ ...eventRow, time: '1' }, /time.*safe integer/], + [{ ...eventRow, data: 1 }, /data.*string or blob/], + [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/], + [{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/], + ] as const) { + expect(() => decodeEventRow(value)).toThrow(message) + } + expect(() => decodeStoreIdentity({ store_id: 'invalid' })).toThrow(/store_id.*UUID/) + }) + + it('rejects invalid durable metadata before exposing a session header', async () => { + const path = await freshDbPath('dsh-sqlite-metadata-') + const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const header = meta('invalid-metadata') + await store.appendBatch(header, [chunk(0)], false) + const db = new DatabaseSync(path) + db.prepare(testSql('update-invalid-session-metadata')).run(header.id) + db.close() + await expect(store.list()).rejects.toThrow(/seed_length|origin|delegation_depth/) + await expect(store.loadStored(header.id)).rejects.toThrow(/seed_length|origin|delegation_depth/) + await store.close() + }) + + it('uses the shared persistence application identity', () => { + expect(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID).toBe(0x44534850) + }) +}) + +describe('SessionPersistenceSqlite edge behavior', () => { + it('keeps a fresh database unopened until the first persistence operation', async () => { + const path = await freshDbPath('dsh-sqlite-lazy-') const ctx = new Context() await ctx.plugin(SessionStore) - let session!: Session - await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('hmr-collide')) - }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 1 }) - await ctx.plugin(SqliteSessionPersistence, { path }) - await expectFlushError(ctx.sessions.flush(session), /id collision/) + await ctx.plugin(SessionPersistenceSqlite, { path }) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + const emitWarning = Reflect.get(process, 'emitWarning') + expect(await ctx.sessionPersistence.list()).toEqual([]) + expect(Reflect.get(process, 'emitWarning')).toBe(emitWarning) + expect(typeof (await stat(path)).size).toBe('number') await ctx.fiber.dispose() }) -}) -describe('surface field round-trip', () => { - it('rowToEvent parses surface fields from EventRow columns', () => { - const row: EventRow = { - seq: 0, type: 'assistant/message', time: 1, - data: JSON.stringify({ turn: 1, step: 1, content: [] }), - source_event_seqs: JSON.stringify([3, 5]), - surface_op: JSON.stringify('append'), - ignorable: null, - } - const event = rowToEvent(row) - expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5]) - expect((event as SurfaceEvent).surfaceOp).toBe('append') - }) - - it('rowToEvent handles replace surfaceOp object', () => { - const row: EventRow = { - seq: 0, type: 'assistant/message', time: 1, - data: JSON.stringify({ turn: 1, step: 1, content: [] }), - source_event_seqs: JSON.stringify([0, 1]), - surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }), - ignorable: null, - } - const event = rowToEvent(row) - expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) - expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 }) - }) - - it('scanRows with surface columns reconstructs events with surface fields', () => { - const rows: EventRow[] = [ - { seq: 0, type: 'user/message', time: 1, - data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), - source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null }, - { seq: 1, type: 'turn/end', time: 2, - data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), - source_event_seqs: null, surface_op: null, ignorable: 1 }, - ] - const { preserved } = scanRows(rows) - expect(preserved).toHaveLength(2) - expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined() - expect((preserved[1] as SessionEvent).surfaceOp).toBeUndefined() - }) - - it('append and load round-trips surface fields through SQLite', async () => { + it('disposes after path validation without opening the database', async () => { + const path = await freshDbPath('dsh-sqlite-unused-') const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' }) - const session = ctx.sessions.create(SessionId('roundtrip-surface')) - session.append('turn/start', { turn: 1 }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('assistant/message', { - turn: 1, step: 1, - message: createMessage({ - role: 'assistant', - content: [], - source: { - kind: 'model', - ...{ provider: 'mock', model: 'mock' }, - }, - }), - }, { surfaceOp: 'append', sourceEventSeqs: [2] }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface')) - expect(loaded.events).toHaveLength(6) - const um = loaded.events[2]! - expect((um as SurfaceEvent).surfaceOp).toBe('append') - expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined() - const am = loaded.events[3]! - expect((am as SurfaceEvent).surfaceOp).toBe('append') - expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2]) - await fiber.dispose() + await ctx.plugin(SessionPersistenceSqlite, { path }) + await ctx.fiber.dispose() + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + + const untouchedPath = await freshDbPath('dsh-sqlite-never-validated-') + const untouched = new SqliteStore({ + path: untouchedPath, + journalMode: 'wal', + busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS, + }) + await untouched.close() + await expect(stat(untouchedPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) - it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => { + it('uses constructor defaults and exposes locate and prepare directly', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' }) - const session = ctx.sessions.create(SessionId('surface-noseq')) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.sessions.flush(session) - const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq')) - expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append') - expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined() - await fiber.dispose() + let persistence!: SessionPersistenceSqlite + await ctx.plugin(Object.assign((inner: Context) => { + persistence = new SessionPersistenceSqlite(inner, { path: ':memory:' }) + }, { inject: ['sessions'] })) + + const header = meta('direct-provider') + const events = chunkLog(3) + expect(persistence.locate(header)).toBeUndefined() + await persistence.create(header) + await persistence.append(header.id, events) + const preparation = await persistence.prepare(header.id) + expect(preparation.session.header).toEqual(header) + preparation[Symbol.dispose]() + await ctx.fiber.dispose() + }) + + it('keeps empty mutations inert and rolls back a repair without metadata', async () => { + const store = new SqliteStore({ + path: ':memory:', + journalMode: 'wal', + busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS, + }) + const header = meta('empty-store') + await store.appendBatch(header, [], false) + await store.commitRepair(header, undefined, []) + expect(await store.readStoredRevision(header.id)).toBeUndefined() + await expect(store.commitRepair(header, 0, [])).rejects.toThrow(/metadata row is missing/) + await store.close() + }) + + it('rejects omitted torn markers and stale closer positions', async () => { + const path = await freshDbPath('dsh-sqlite-repair-validation-') + const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const header = meta('repair-validation') + await store.appendBatch(header, [chunk(0)], false) + const db = new DatabaseSync(path) + db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null) + db.close() + await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/) + await store.commitRepair(header, 1, []) + await expect(store.commitRepair(header, undefined, [chunk(2)])).rejects.toThrow(/closer starts at seq 2/) + + const cleared = new DatabaseSync(path) + cleared.prepare(testSql('delete-session-events')).run(header.id) + cleared.close() + await store.commitRepair(header, undefined, [chunk(0)]) + expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)]) + await store.close() + }) + + it('rejects malformed physical tail rows before appending', async () => { + const path = await freshDbPath('dsh-sqlite-tail-') + const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + const header = meta('invalid-tail') + await store.appendBatch(header, [chunk(0)], false) + const db = new DatabaseSync(path) + db.prepare(testSql('insert-corrupt-event')) + .run(header.id, 1, 'assistant/chunk', 2, '{not json', null) + db.close() + + await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/) + await store.close() + }) + + it('rejects missing and empty store identities', async () => { + for (const mode of ['missing', 'empty'] as const) { + const path = await freshDbPath(`dsh-sqlite-identity-${mode}-`) + const db = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS) + if (mode === 'missing') db.exec(testSql('delete-persistence-state')) + else db.exec(testSql('empty-store-id')) + db.close() + await chmod(path, 0o600) + + expect(errorMessage(await backendFailure(path))).toMatch(/no valid store identity/) + } + }) + + it('rejects invalid paths during service initialization', async () => { + const path = await freshDbPath('dsh-sqlite-invalid-path-') + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(SessionPersistenceSqlite, { path: `${path}\0` })).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG_VALUE', + }) + await ctx.fiber.dispose() + }) + + it('rejects non-files and symbolic links', async () => { + const directoryPath = await freshDbPath('dsh-sqlite-directory-') + await mkdir(directoryPath) + expect(errorMessage(await backendFailure(directoryPath))) + .toMatch(/must be a regular file/) + + const linkPath = await freshDbPath('dsh-sqlite-link-') + const target = join(linkPath, '..', 'target.db') + await writeFile(target, '') + await symlink(target, linkPath) + expect(errorMessage(await backendFailure(linkPath))) + .toMatch(/not a symbolic link/) + + const parentLinkPath = await freshDbPath('dsh-sqlite-parent-link-') + const realParent = join(parentLinkPath, '..', 'real-parent') + const linkedParent = join(parentLinkPath, '..', 'linked-parent') + await mkdir(realParent, { mode: 0o700 }) + await symlink(realParent, linkedParent) + expect(errorMessage(await backendFailure(join(linkedParent, 'sessions.db')))) + .toMatch(/must be a real directory/) + }) + + it.runIf( + process.getuid !== undefined && process.getuid() !== 0, + )('rejects permissive files and writable parents', async () => { + const permissivePath = await freshDbPath('dsh-sqlite-permissive-') + await writeFile(permissivePath, '') + await chmod(permissivePath, 0o644) + expect(errorMessage(await backendFailure(permissivePath))) + .toMatch(/accessible only by that user/) + + const writableParentPath = await freshDbPath('dsh-sqlite-parent-') + await chmod(join(writableParentPath, '..'), 0o770) + expect(errorMessage(await backendFailure(writableParentPath))) + .toMatch(/not group\/world-writable/) + }) + + it('surfaces database creation failures after path validation', async () => { + const path = await freshDbPath('dsh-sqlite-create-failure-') + const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) + await store.validatePath() + const parent = join(path, '..') + await rm(parent, { recursive: true }) + await writeFile(parent, 'not a directory') + await expect(store.open()).rejects.toThrow(/ENOENT|ENOTDIR/) + await store.close() }) }) diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts new file mode 100644 index 0000000000..77b53a404e --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/test-sql.ts @@ -0,0 +1,32 @@ +/** Test-only loader for fixed SQLite fixtures. */ + +import { readFileSync } from 'node:fs' + +export type TestSqlName = + | 'add-unexpected-column' + | 'count-events' + | 'count-ignorable-events' + | 'count-packed-events' + | 'count-physical-types' + | 'create-loose-schema' + | 'create-unrelated-table' + | 'delete-persistence-state' + | 'delete-session-events' + | 'empty-store-id' + | 'insert-corrupt-event' + | 'measure-write-traffic' + | 'replace-events-with-nonstrict-table' + | 'select-last-event' + | 'select-event-rowids' + | 'select-event-rows' + | 'select-user-version' + | 'set-application-id-12345' + | 'set-user-version-15' + | 'set-user-version-16' + | 'set-user-version-17' + | 'update-invalid-session-metadata' + +/** Load one fixed test SQL resource. */ +export function testSql(name: TestSqlName): string { + return readFileSync(new URL(`./resources/sql/${name}.sql`, import.meta.url), 'utf8') +} diff --git a/packages/session/session-persistence-sqlite/tsconfig.json b/packages/session/session-persistence-sqlite/tsconfig.json index 2bb79c5919..8865e04321 100644 --- a/packages/session/session-persistence-sqlite/tsconfig.json +++ b/packages/session/session-persistence-sqlite/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../llm/llm" + }, { "path": "../session-persistence" }, diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 74b808b05e..d5623dca3f 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md README.md: 335f40e7439fc4a49fa49bb4541f101250468d1b -README.zh.md: 5a50f051a2c53a5f27b009df11ad46f5952a32ac +README.zh.md: 7cda3f271398fca8279fcdfa92801aa219e9e8ed diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 5a50f051a2..7cda3f2713 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-shell` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition,同级包负责 Service Provider,Consumer 注入该服务。 +会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-shell` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)):本包负责 Service Definition,同级包负责 Service Provider,Consumer 注入该服务。 持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 @@ -31,13 +31,13 @@ ## 写入协调器 -`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)。 +`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md)。 每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。 崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 @@ -56,7 +56,7 @@ | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 | -协调器断言已存储 id,并在修复或活动会话接管前比较已存储/活动会话 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的修订值仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,修订值重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同的非修改式检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。 +协调器断言已存储 id,并在修复或活动会话接管前比较已存储/活动会话 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的修订值仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,修订值重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同的非修改式检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)。 ## 元数据与位置类型 diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 73bba56675..43b0108dc9 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index eb5f9714c4..63def62528 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -1170,7 +1170,8 @@ export class PersistenceCoordinator { this.live.set(session, restored) return restored } - const seed = session.events.map(e => structuredClone(e)) + // Session owns this stable deep-frozen snapshot; backends only serialize it. + const seed = session.events const live: LiveSessionState = { init: Promise.resolve(), writes: this.createWriteBehind(session, () => live.init), diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 50ad798e04..6c63d72b9f 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -180,6 +180,7 @@ class ControlledBackend implements PersistenceBackend { readonly name = 'session-persistence-controlled' readonly store: MemoryStore = new Map() readonly lifecycle: string[] = [] + lastAppendedBatch: readonly SessionEvent[] | undefined appendAttempts = 0 loadAttempts = 0 repairAttempts = 0 @@ -212,6 +213,7 @@ class ControlledBackend implements PersistenceBackend { } async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { + this.lastAppendedBatch = events const attempt = ++this.appendAttempts await this.beforeAppend?.(attempt) const entry = this.store.get(m.id) @@ -279,6 +281,28 @@ runCoordinatorContract('memory', async (): Promise => { } }) +describe('PersistenceCoordinator seed ownership', () => { + it('retains the immutable session seed without cloning it', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) + const seed = session.events + await ctx.sessions.flush(session) + + expect(backend.lastAppendedBatch).toBe(seed) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) +}) + describe('PersistenceCoordinator bounded writes', () => { it('cancels the batching deadline when live initialization rejects', async () => { vi.useFakeTimers() diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 618533d282..609f146f24 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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/session/session-projection-cache/README.md -README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e -README.zh.md: 5e38ee98b04bc8f856538112d2922b64cadce4d5 +README.md: 33908578a5127f2b6bb78ed7467833aaaa2cf085 +README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 5d4ad07fab..33908578a5 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -2,12 +2,13 @@ English | [中文](README.zh.md) -The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). +The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. +- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. - **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. @@ -27,7 +28,7 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c ## Listing read (`cachedSnapshot(meta)`) -The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column. +The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column. ## Cold read (`coldSnapshot(id, signal?)`) diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 5e38ee98b0..0ca410f915 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -2,12 +2,13 @@ [English](README.md) | 中文 -持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 +持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 +- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。 @@ -27,7 +28,7 @@ ## 列表读(`cachedSnapshot(meta)`) -零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 +零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 ## 冷读(`coldSnapshot(id, signal?)`) diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 61694c5fb9..867717ba28 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 66ad637402..f7bdb62a3a 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -1,6 +1,6 @@ /** * Persisted projection cache (`ctx.sessionProjectionCache`): durable - * checkpoints of every registered projection unit's state, one record per + * checkpoints of every client-visible or explicitly persisted projection unit's state, one record per * session on the domain data form (`session_projcache` domain — the shipped * json backend lands it beside `workspace.json`). The cache is a fold * shortcut, never an authority: a row is possibly stale (its `seq` @@ -185,10 +185,9 @@ export class SessionProjectionCache extends Service { if (!related) throw new Error('unrelated log identity') restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) } catch { - // The recoverable restore failures: an unrelated record, or a row - // overreaching the stored log end (or predating the floor). Both imply - // floor > 0 (baseSeq-0 restores never throw and an unrelated record - // still carried a usable watermark), so the full log is a fresh read. + // Recoverable failures are an unrelated record, a row outside the + // supplied suffix or log end, and stateSchema rejection. The full read + // removes every checkpoint seed and lets each unit refold from init. const whole = await persistence.readFrom(id, 0, signal) restored = this.ctx.sessionProjections.restore({}, whole.events, 0) } diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 6395ed7d0f..89154ec108 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -19,6 +19,10 @@ import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage- import SessionProjectionCache from '../src/index.ts' declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'cache-test/marks': MarksState + 'cache-test/marks2': Map + } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } } @@ -35,14 +39,17 @@ declare module '@deepseek-ai/dsh-session/types' { } type MarksState = { marks: string[] } | null -const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({ +const marksUnit = (stateVersion = 1) => ({ key: 'cache-test/marks', - schema: z.object({ marks: z.array(z.string()) }), + stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), init: () => null, apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state), - view: state => state ?? { marks: [] }, + wire: { + viewSchema: z.object({ marks: z.array(z.string()) }), + view: state => state ?? { marks: [] }, + }, stateVersion, -}) +}) satisfies ProjectionDefinition<'cache-test/marks', MarksState> /** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */ function fakePersistence(logs: Map) { @@ -175,11 +182,10 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null }) // A unit whose state violates the plain-JSON contract fails the write loud. ctx.sessionProjections.register({ - key: 'cache-test/marks2' as never, - schema: { parse: (value: unknown) => value } as never, + key: 'cache-test/marks2', + stateSchema: z.custom>(() => true), init: () => new Map(), - apply: (state: unknown) => state, - view: () => null as never, + apply: state => state, stateVersion: 1, }) await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable') @@ -286,6 +292,19 @@ describe('SessionProjectionCache cold read', () => { expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined) }) + it('discards malformed persisted state and degrades to one full re-read', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['malformed', storedLog([['real']])]]) + seedRow(pool, 'malformed', { ver: 1, seq: 1, val: { marks: 'not-an-array' } }) + const { cache, persistence } = await harness({ pool, logs }) + + const snapshot = await cache.coldSnapshot(SessionId('malformed')) + + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] }) + expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined) + expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined) + }) + it('write-back failure is contained: the snapshot is still served', async () => { const pool = new MemoryMediaPool() const logs = new Map([['soft', storedLog([['a']])]]) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index fb26401df9..7ce339474e 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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/session/session-projection/README.md -README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62 -README.zh.md: 2a3af5620f84ff9697268101a6cfb894232b68b7 +README.md: 3b7ccecb7040b5340cd24da45d99bbfdc13fa15c +README.zh.md: bdf691761a6debf8f904924ab7198a5aed093b02 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 9018b133bb..3b7ccecb70 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -9,21 +9,23 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr ### Public API - `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence. -- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`. -- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). +- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per client-visible unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`. +- `ctx.sessionProjections.stateOf(session, key)` Read one registered unit's current host state without computing unrelated views. The returned value is a live read-only reference; callers must not mutate it. +- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is available only through `stateOf`. ### Key Types -- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host unit, wire block, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. -- `ProjectionDefinition` — `{ key, schema, init(), apply(state, event), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter. +- `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. +- `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here. +- `ProjectionDefinition` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. ## Contract - **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch. - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). -- **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly. -- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage. +- **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`. +- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. - **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. @@ -41,8 +43,8 @@ None; projections never assemble or send provider requests. ## Known Limitations and Deferred Work -- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. +- **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. -- **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. +- **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 2a3af5620f..bdf691761a 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -2,28 +2,30 @@ [English](README.md) | 中文 -会话投影 Service Definition 与驱动注册表。它拥有 `ctx.sessionProjections`:该注册表在已提交的会话事件上驱动每个已注册的投影单元,并向载体提供完整的最终值,目前包括 api-proxy 历史尾页和 `session/projection` 推送帧。领域注册的只是纯数学;驱动权归框架。[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) 记录了设计理由。 +会话投影 Service Definition 与驱动注册表。它拥有 `ctx.sessionProjections`:该注册表在已提交的会话事件上驱动每个已注册的投影单元,并向载体提供完整的最终值,目前包括 api-proxy 历史尾页和 `session/projection` 推送帧。领域注册的只是纯数学;驱动权归框架。[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md) 记录了设计理由。 ## 服务:`SessionProjectionRegistry`(ctx 键:`sessionProjections`) ### 公开 API - `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。 -- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。 -- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。 +- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的客户端可见单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。 +- `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回值是活的只读引用;调用方不得修改。 +- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态只能通过 `stateOf` 读取。 ### 关键类型 -- `SessionProjectionMap`——整条链路唯一的 merge-extensible 类型表(host 侧单元、协议块、React 钩子)。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。 -- `ProjectionDefinition`——`{ key, schema, init(), apply(state, event), view(state), stateVersion }`:由三个纯同步函数外加若干声明构成的状态驱动计算单元(state-driven computation unit),绝不是一个不透明的 getter。 +- `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。 +- `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。 +- `ProjectionDefinition`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 ## 约定 - **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 -- **单元的同步纪律。**`init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。 -- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 +- **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 - **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 @@ -41,8 +43,8 @@ ## 已知限制与暂缓事项 -- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 +- **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 - **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 -- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。 +- **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。 diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index dbcc686ef8..4ab423add7 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 9f0c24e72e..e457370794 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -1,9 +1,9 @@ /** - * Service Definition and drive registry for the session-projection capability seam: the merge-extensible `SessionProjectionMap` type - * table, the `ProjectionDefinition` state-driven computation unit contract, + * Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type + * tables, the `ProjectionDefinition` state-driven computation unit contract, * and the `ctx.sessionProjections` registry that DRIVES every registered unit * forward eagerly over committed session events. Domain host plugins - * contribute pure mathematics (init/apply/view); the framework owns the + * contribute pure folds and optional client views; the framework owns the * subscription, the per-session watermark cache, and change notification; * carriers consume the snapshot read face and the change feed. Neither side * knows the other @@ -27,28 +27,31 @@ declare module '@deepseek-ai/cordis' { } } -import type { SessionProjectionMap } from './types.ts' +import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' -export type { SessionProjectionMap } from './types.ts' +export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' /** - * One domain's state-driven computation unit: three pure synchronous - * functions plus declarations — never an opaque getter. The framework drives + * One domain's state-driven computation unit: a pure synchronous fold plus + * declarations and an optional client view — never an opaque getter. The framework drives * `apply` on every committed session event; the domain holds no - * subscriptions and owns only the mathematics. All three functions MUST be - * synchronous (an async unit would tear the carriers' consistency cut) and + * subscriptions and owns only the computation. All functions MUST be + * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -export interface ProjectionDefinition { - /** The projection key this unit owns (its `SessionProjectionMap` entry). */ +export interface ProjectionDefinition< + K extends keyof SessionProjectionStateMap, + S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], +> { + /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ key: K - /** Validates the wire payload (`view` output) before it leaves the host. */ - schema: ZodType + /** Validates persisted state before it seeds a fold. */ + stateSchema: ZodType /** * State for the empty log. * @returns the initial state. */ - init(): S + init(): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -57,13 +60,18 @@ export interface ProjectionDefinition { * @param event - the next committed session event. * @returns the next state (same reference when the event is not the unit's). */ - apply(state: S, event: SessionEvent): S - /** - * State → wire payload (the read-side projection). - * @param state - the current state. - * @returns the whole current value for this unit's key. - */ - view(state: S): SessionProjectionMap[K] + apply(state: NoInfer, event: SessionEvent): NoInfer + /** Client view. Omit for host-only units. */ + wire?: K extends keyof SessionProjectionMap ? { + /** Validates the wire payload before it leaves the host. */ + viewSchema: ZodType + /** + * State → wire payload (the read-side projection). + * @param state - the current state. + * @returns the whole current value for this unit's key. + */ + view(state: NoInfer): SessionProjectionMap[K] + } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` @@ -86,14 +94,14 @@ export type ProjectionChangeListener = ( ) => void /** - * One consistent read cut over every registered unit for one session. + * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`). */ export interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ asOfSeq: number - /** Whole current value per registered key. */ + /** Whole current client value per registered key. */ values: Partial } @@ -120,10 +128,10 @@ export type ProjectionCheckpoint = Record /** Type-erased unit view the drive machinery works with (the registration contract already proved the typed form). */ interface ErasedDefinition { key: string - schema: { parse(value: unknown): unknown } + stateSchema: { parse(value: unknown): unknown } init(): unknown apply(state: unknown, event: SessionEvent): unknown - view(state: unknown): unknown + wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number } @@ -156,7 +164,8 @@ interface Registration { * `ctx.sessionProjections`: the projection unit table and its drive. The * service subscribes to `session/event` once; every committed event passes * every registered unit's `apply` (eager drive), and a changed state - * reference notifies the change feed with the schema-validated view. + * reference in a client-visible unit notifies the change feed with the + * schema-validated view. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -191,22 +200,54 @@ export class SessionProjectionRegistry extends Service { * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ - register(definition: ProjectionDefinition): () => void { + register< + K extends keyof SessionProjectionMap, + S extends SessionProjectionStateMap[K], + >( + definition: Omit, 'wire'> & { + wire: NonNullable['wire']> + }, + ): () => void + /** + * Register one host-only unit. Its state is omitted from client snapshots + * and always checkpointed like every other unit. + * @param definition - key, state schema, pure unit functions, and stateVersion. + * @returns the exact disposer that unregisters this unit. + */ + register< + K extends Exclude, + S extends SessionProjectionStateMap[K], + >( + definition: Omit, 'wire'>, + ): () => void + register( + definition: ProjectionDefinition, + ): () => void { + const wire = definition.wire as { + viewSchema: ZodType + view(state: S): unknown + } | undefined + const erased: ErasedDefinition = { + key: definition.key, + stateSchema: definition.stateSchema, + init: () => definition.init(), + apply: (state, event) => definition.apply(state as S, event), + wire: wire === undefined + ? undefined + : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, + stateVersion: definition.stateVersion, + } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`) } const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { - const key = definition.key as string + const key = erased.key const existing = this.registrations.get(key) if (existing === undefined) { - this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 }) + this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) } else { - // A differing `stateVersion` is the one incompatibility this can name: - // the versioned contract says the cached state shape differs, so the - // two registrants cannot share cells. Anything else about a definition - // is functions, which no runtime comparison can tell apart. - if (existing.def.stateVersion !== definition.stateVersion) { - throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(definition.stateVersion)}`) + if (existing.def.stateVersion !== erased.stateVersion) { + throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) } existing.refs += 1 } @@ -224,7 +265,7 @@ export class SessionProjectionRegistry extends Service { /** * Subscribe to the change feed. The registration is an effect on the * calling context's fiber. - * @param listener - called once per unit whose state reference changed, per committed event. + * @param listener - called once per client-visible unit whose state reference changed, per committed event. * @returns the exact disposer that unsubscribes. */ onChanged(listener: ProjectionChangeListener): () => void { @@ -238,24 +279,41 @@ export class SessionProjectionRegistry extends Service { } /** - * One consistent cut over every registered unit for one session, read from + * Read one unit's current host state without computing unrelated views. + * The returned value is live; callers must not mutate it. + * @param session - the session whose state is read. + * @param key - the registered unit key. + * @returns current state, or `undefined` when the key is not registered. + */ + stateOf( + session: Session, + key: K, + ): SessionProjectionStateMap[K] | undefined { + const registration = this.registrations.get(key) + if (registration === undefined) return undefined + return this.cellFor(registration, session).state as SessionProjectionStateMap[K] + } + + /** + * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). * Fully synchronous — every value and `asOfSeq` reflect the same log - * position. Each value passes its unit's schema before leaving. + * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @returns the snapshot; `values` is empty when no unit is registered. + * @returns the snapshot; `values` is empty when no client-visible unit is registered. */ snapshot(session: Session): ProjectionSnapshot { const values: Record = {} for (const registration of this.registrations.values()) { + if (registration.def.wire === undefined) continue const cell = this.cellFor(registration, session) - values[registration.def.key] = registration.def.schema.parse(registration.def.view(cell.state)) + values[registration.def.key] = registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state)) } - return { asOfSeq: session.seq - 1, values: values } + return { asOfSeq: session.seq - 1, values } } /** - * State-level checkpoint of every registered unit for one session, read + * State-level checkpoint of every persisted unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory * log). This is the write side of the persisted projection cache: the * returned rows are the `(key → {ver, seq, val})` part of the durable @@ -266,7 +324,7 @@ export class SessionProjectionRegistry extends Service { * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). * @param session - the session whose unit states are checkpointed. - * @returns one row per registered key; empty when no unit is registered. + * @returns one row per registered key. */ checkpoint(session: Session): ProjectionCheckpoint { const rows: ProjectionCheckpoint = {} @@ -311,8 +369,8 @@ export class SessionProjectionRegistry extends Service { /** * View a checkpoint's rows without any log read: for every registered - * unit whose row's `ver` matches, serve the schema-validated - * `view` of the stored state; mismatched or absent rows leave their key + * client-visible unit whose row's `ver` matches, serve the schema-validated + * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key * absent (a cold or listing consumer treats it as not-yet-available and a * fuller read path refolds it). The zero-I/O rung of the read ladder — * values are as stale as their rows, never wrong. @@ -323,15 +381,22 @@ export class SessionProjectionRegistry extends Service { const values: Record = {} for (const registration of this.registrations.values()) { const def = registration.def + if (def.wire === undefined) continue const row = checkpoint[def.key] if (row === undefined || row.ver !== def.stateVersion) continue - values[def.key] = def.schema.parse(def.view(row.val)) + let state: unknown + try { + state = def.stateSchema.parse(row.val) + } catch { + continue + } + values[def.key] = def.wire.viewSchema.parse(def.wire.view(state)) } return values } /** - * Cold read: fold every registered unit over a stored log suffix, seeding + * Cold read: fold every persisted unit over a stored log suffix, seeding * each from its checkpoint row when usable — the one read recipe (cached * state + forward tail replay + `view`) applied without a live `Session`. * Call with the events returned by a persistence @@ -352,7 +417,11 @@ export class SessionProjectionRegistry extends Service { * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. */ - restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): + restore( + checkpoint: ProjectionCheckpoint, + events: readonly SessionEvent[], + baseSeq: number, + ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { const endSeq = events.at(-1)?.seq ?? baseSeq - 1 const values: Record = {} @@ -370,12 +439,12 @@ export class SessionProjectionRegistry extends Service { + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', ) } - let state = usable ? row.val : def.init() + let state = usable ? def.stateSchema.parse(row.val) : def.init() const from = usable ? row.seq : baseSeq - 1 for (const event of events) { if (event.seq > from) state = def.apply(state, event) } - values[def.key] = def.schema.parse(def.view(state)) + if (def.wire !== undefined) values[def.key] = def.wire.viewSchema.parse(def.wire.view(state)) refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state } } return { @@ -415,8 +484,8 @@ export class SessionProjectionRegistry extends Service { const changed = !Object.is(next, cell.state) cell.state = next cell.observedSeq = event.seq - if (changed && this.listeners.size > 0) { - const value = registration.def.schema.parse(registration.def.view(next)) + if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { + const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next)) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) } diff --git a/packages/session/session-projection/src/types.ts b/packages/session/session-projection/src/types.ts index 37d24b5b40..8373e4677b 100644 --- a/packages/session/session-projection/src/types.ts +++ b/packages/session/session-projection/src/types.ts @@ -9,9 +9,16 @@ */ /** - * The single projection type table for the whole chain (host provider, wire - * block, client cell, React hook). Domain packages merge their key here via - * declaration merging; values are wire-JSON whole values. How a value is - * rendered is the slot system's business, never this layer's. + * The merge-extensible client projection table shared by wire blocks, client + * cells, and React hooks. Domain packages merge their client-visible key here; + * values are wire-JSON whole values. How a value is rendered is the slot + * system's business, never this layer's. */ export interface SessionProjectionMap {} + +/** + * The merge-extensible host fold-state table. Each client-visible key also + * appears in {@link SessionProjectionMap}; host-only keys appear only here. + * Values must be plain JSON so the projection cache can persist them. + */ +export interface SessionProjectionStateMap {} diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 4ae4a3face..8d184a759f 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -16,9 +16,13 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'test/marks': MarksState + 'test/count': number + } + interface SessionProjectionMap { 'test/marks': { marks: string[] } - 'test/count': number } } @@ -28,24 +32,27 @@ declare module '@deepseek-ai/dsh-session/types' { } } -/** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ type MarksState = { marks: string[] } | null -const marksUnit = (): ProjectionDefinition<'test/marks', MarksState> => ({ +/** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ +const marksUnit = (): Omit, 'wire'> + & { wire: NonNullable['wire']> } => ({ key: 'test/marks', - schema: z.object({ marks: z.array(z.string()) }), + stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), init: () => null, apply: (state, event) => (event.type === 'test/mark' ? (event).data : state), - view: state => state ?? { marks: [] }, + wire: { + viewSchema: z.object({ marks: z.array(z.string()) }), + view: state => state ?? { marks: [] }, + }, stateVersion: 1, }) -/** Counting unit over every event — state changes on each apply. */ +/** Host-only counting unit over every event — state changes on each apply. */ const countUnit = (): ProjectionDefinition<'test/count', number> => ({ key: 'test/count', - schema: z.number().int().nonnegative(), + stateSchema: z.number().int().nonnegative(), init: () => 0, apply: state => state + 1, - view: state => state, stateVersion: 1, }) @@ -111,7 +118,7 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(other).values['test/marks']).toEqual({ marks: ['two'] }) }) - it('runs every registered unit — a changing unit notifies while a same-reference unit stays silent', async () => { + it('updates host-only units without publishing them to wire listeners', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) @@ -120,11 +127,9 @@ describe('SessionProjectionRegistry drive', () => { changedKeys.push(key) }) session.append('turn/start', { turn: 1 }) - // count applied (+1 change), marks returned the same reference. - expect(changedKeys).toEqual(['test/count']) - const snapshot = ctx.sessionProjections.snapshot(session) - expect(snapshot.values['test/count']).toBe(1) - expect(snapshot.values['test/marks']).toEqual({ marks: [] }) + expect(changedKeys).toEqual([]) + expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: [] } }) }) it('shares one unit between registrants of the same key', async () => { @@ -200,7 +205,19 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) - it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => { + it('snapshot serves client views and excludes host-only state', async () => { + const { ctx, session } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + mark(session, ['a', 'b']) + const values = ctx.sessionProjections.snapshot(session).values + expect(values['test/marks']).toEqual({ marks: ['a', 'b'] }) + expect('test/count' in values).toBe(false) + expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect('test/unregistered' in values).toBe(false) + }) + + it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) @@ -277,7 +294,7 @@ describe('SessionProjectionRegistry drive', () => { }, full, 0) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) - expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events + expect('test/count' in snapshot.values).toBe(false) // The refreshed rows sit at the served cut, ready for a durable write-back. expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } }) expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) @@ -295,20 +312,22 @@ describe('SessionProjectionRegistry drive', () => { { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] - const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3) + const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3) expect(snapshot.asOfSeq).toBe(4) // marks already covers the tail (watermark 4): nothing re-applied. expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) - // count folds exactly seqs 3 and 4 on top of its checkpoint. - expect(snapshot.values['test/count']).toBe(5) + // count folds exactly seqs 3 and 4 on top of its checkpoint, but remains host-only. + expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) + expect('test/count' in snapshot.values).toBe(false) // Empty tail (checkpoint is current): the cut sits at baseSeq - 1. - const { snapshot: current } = ctx.sessionProjections.restore({ + const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: 4, val: 5 }, }, [], 5) expect(current.asOfSeq).toBe(4) - expect(current.values['test/count']).toBe(5) + expect('test/count' in current.values).toBe(false) + expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) }) it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => { @@ -324,6 +343,36 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({}) }) + it('viewCheckpoint and restore exclude host-only state while retaining its checkpoint', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const rows = { + 'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } }, + 'test/count': { ver: 1, seq: 4, val: 5 }, + } + expect(ctx.sessionProjections.viewCheckpoint(rows)).toEqual({ + 'test/marks': { marks: ['stored'] }, + }) + + const restored = ctx.sessionProjections.restore(rows, [], 5) + expect(restored.snapshot.values).toEqual({ + 'test/marks': { marks: ['stored'] }, + }) + expect(restored.checkpoint['test/count']).toEqual(rows['test/count']) + }) + + it('rejects version-matching rows whose state no longer matches the registered schema', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + const drifted = { + 'test/marks': { ver: 1, seq: 2, val: { marks: 'not-an-array' } }, + } + + expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({}) + expect(() => ctx.sessionProjections.restore(drifted, [], 3)).toThrow() + }) + it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) @@ -334,7 +383,9 @@ describe('SessionProjectionRegistry drive', () => { expect(floor).toBe(9) // …an intact log serves the anchor event and the checkpoint stands as-is. const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } - expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10) + const anchored = ctx.sessionProjections.restore(rows, [anchor], 9) + expect(anchored.snapshot.values).toEqual({}) + expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 10 }) // …while a log crash-repaired down to fewer events returns an empty tail: // the row overreaches the proven end and a tail read cannot fix this key. expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) @@ -343,21 +394,25 @@ describe('SessionProjectionRegistry drive', () => { { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const { snapshot } = ctx.sessionProjections.restore(rows, events, 0) + const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0) expect(snapshot.asOfSeq).toBe(1) - expect(snapshot.values['test/count']).toBe(2) + expect(snapshot.values).toEqual({}) + expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 }) }) it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register({ key: 'test/marks', - schema: z.object({ marks: z.array(z.string()) }), + stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), init: () => null as MarksState, apply: state => state, - // A Promise (what an accidentally-async view would return) is not the - // declared shape: the boundary parse rejects it before it leaves. - view: () => Promise.resolve({ marks: [] }) as never, + wire: { + viewSchema: z.object({ marks: z.array(z.string()) }), + // A Promise (what an accidentally-async view would return) is not the + // declared shape: the boundary parse rejects it before it leaves. + view: () => Promise.resolve({ marks: [] }) as never, + }, stateVersion: 1, }) expect(() => ctx.sessionProjections.snapshot(session)).toThrow() diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 929940aeaa..34ffdb3963 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index a000300873..4ab43036c4 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -62,6 +62,12 @@ interface SessionStatsState extends SessionStatsTotals { pendingCalls: Record } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + sessionStats: SessionStatsState + } +} + const sessionStatsSchema = z.object({ turns: z.number().int().nonnegative(), steps: z.number().int().nonnegative(), @@ -73,6 +79,23 @@ const sessionStatsSchema = z.object({ decodeTokens: z.number().nonnegative(), }).strict() +/** + * The fold state's shape (totals plus in-flight boundaries), validated on + * persisted-cache rows after their `ver` gate — the unit's input boundary. + * The view is a strict subset of the state, so this schema extends + * `sessionStatsSchema` (the wire output boundary) with the boundary fields. + */ +const sessionStatsStateSchema = sessionStatsSchema.extend({ + lastTurn: z.number().int().nonnegative().nullable(), + openStep: z.object({ + turn: z.number().int().nonnegative(), + step: z.number().int().nonnegative(), + startTime: z.number().nonnegative(), + firstTokenTime: z.number().nonnegative().nullable(), + }).nullable(), + pendingCalls: z.record(z.string(), z.number().nonnegative()), +}) + /** * Provider-reported completion tokens, guarded the way the window fold guards * node usage. @@ -86,9 +109,10 @@ function usageOutputTokens(usage: unknown): number | null { } /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */ -export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = { +export const sessionStatsProjectionDefinition = { key: 'sessionStats', - schema: sessionStatsSchema, + stateVersion: 1, + stateSchema: sessionStatsStateSchema, init: () => ({ turns: 0, steps: 0, @@ -169,15 +193,17 @@ export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStat return state } }, - view: state => ({ - turns: state.turns, - steps: state.steps, - llmMs: state.llmMs, - toolMs: state.toolMs, - ttftMs: state.ttftMs, - ttftSteps: state.ttftSteps, - decodeMs: state.decodeMs, - decodeTokens: state.decodeTokens, - }), - stateVersion: 1, -} + wire: { + viewSchema: sessionStatsSchema, + view: state => ({ + turns: state.turns, + steps: state.steps, + llmMs: state.llmMs, + toolMs: state.toolMs, + ttftMs: state.ttftMs, + ttftSteps: state.ttftSteps, + decodeMs: state.decodeMs, + decodeTokens: state.decodeTokens, + }), + }, +} satisfies ProjectionDefinition<'sessionStats', SessionStatsState> diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts index ebe728181b..63688ce436 100644 --- a/packages/session/session-stats/tests/projection.spec.ts +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -154,11 +154,11 @@ function at(time: number, type: string, data: unknown): SessionEvent { /** Fold a synthetic event list through the definition and view the result. */ function fold(events: readonly SessionEvent[]): SessionStatsProjection { - const state = events.reduce( + const state = events.reduce[0]>( (folded, event) => sessionStatsProjectionDefinition.apply(folded, event), sessionStatsProjectionDefinition.init(), ) - return sessionStatsProjectionDefinition.view(state) + return sessionStatsProjectionDefinition.wire.view(state) } describe('sessionStats wall-time fold (controlled timestamps)', () => { @@ -269,7 +269,7 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { .toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 })) // The first message closed the step boundary; a defensive duplicate finds // no open step and folds to the same reference. - const state = events.reduce( + const state = events.reduce[0]>( (folded, event) => sessionStatsProjectionDefinition.apply(folded, event), sessionStatsProjectionDefinition.init(), ) diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index c119707a56..31fa16233c 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry-otel/README.md README.md: 351690b5941bfe33cfbbf591269683f706701978 -README.zh.md: e7031ff971cc8c9f2d1ce436513fbef1b9f27149 +README.zh.md: ae33e9df046e87d0c9726b8710525270cdd324e2 diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index e7031ff971..ae33e9df04 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -29,13 +29,13 @@ 上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.sessionTelemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 -已挂载的服务通过 seam 的 [`SessionTelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。 +已挂载的服务通过 seam 的 [`SessionTelemetrySharingStatus`](../session-telemetry/README.zh.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。 `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。 ## 哪些数据会离开本机 -在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `sessionTelemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `sessionTelemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 +在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `sessionTelemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `sessionTelemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.zh.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 ## 字段映射 diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index b0d7be922b..6869f2381b 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index efeccd9894..99cd673d4d 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md README.md: e4ebee1324bc1228d6d97d891159685b05f94e4f -README.zh.md: 963d5f6d1c5c0f60f5bf14956f3b490d60bd6574 +README.zh.md: 718f07db3fb8d8f8a21236dcb917a33ed3e0cad8 diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index 963d5f6d1c..718f07db3f 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -遥测(telemetry)Service Definition 声明 `SessionTelemetrySink` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 +遥测(telemetry)Service Definition 声明 `SessionTelemetrySink` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)。 ## 后端约定 @@ -20,6 +20,8 @@ 在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 + + ## 脱敏 waterfall(瀑布式事件) 每条记录在投影后立即经过 `sessionTelemetry/record` waterfall,这是 Service Definition 的脱敏扩展点。本包自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall;按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本;权威会话日志永不改写。 @@ -46,6 +48,6 @@ ## 已知限制与暂缓事项 -- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 +- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)。 - **不内置脱敏规则**:未挂载 `sessionTelemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 - **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。 diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 67b9379e08..a30bf99632 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/README.i18n.yaml b/packages/session/session-title-all-prompts-llm/README.i18n.yaml index 2d337c5a7c..06c98ab145 100644 --- a/packages/session/session-title-all-prompts-llm/README.i18n.yaml +++ b/packages/session/session-title-all-prompts-llm/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-title-all-prompts-llm/README.md README.md: cdda77f4bd92b927a156db093d690062ea10d531 -README.zh.md: 25af029ce1002ecffcd40e453213899d8f0b8f31 +README.zh.md: 8cf028ecc545f7ea2a494f0c9554d13e48b3adbf diff --git a/packages/session/session-title-all-prompts-llm/README.zh.md b/packages/session/session-title-all-prompts-llm/README.zh.md index 25af029ce1..8cf028ecc5 100644 --- a/packages/session/session-title-all-prompts-llm/README.zh.md +++ b/packages/session/session-title-all-prompts-llm/README.zh.md @@ -4,7 +4,7 @@ 可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结所有符合条件的用户消息。它注册 `all-prompts` 节奏,并在每条新用户提示词后启动新 revision,同时使用预置历史与子会话提示词。较新的 revision 会中止并取代旧工作;即使提供方忽略取消,也无法提交陈旧输出。 -该插件使用完整且必填的[共享 LLM(大语言模型)配置](../session-title-llm/README.md#configuration)。同时省略 `provider` 与 `model` 时,会继承每个当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。如果最终封装的聚合提示词超过 `maxInputBytes`,请求会失败而不是截断历史;自动使用时会发出警告并保留先前标题。 +该插件使用完整且必填的[共享 LLM(大语言模型)配置](../session-title-llm/README.zh.md#configuration)。同时省略 `provider` 与 `model` 时,会继承每个当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。如果最终封装的聚合提示词超过 `maxInputBytes`,请求会失败而不是截断历史;自动使用时会发出警告并保留先前标题。 ## 模型体验 diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 6287d55029..a5077458f2 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/README.i18n.yaml b/packages/session/session-title-first-prompt-llm/README.i18n.yaml index cb3350a925..b5a4ed3952 100644 --- a/packages/session/session-title-first-prompt-llm/README.i18n.yaml +++ b/packages/session/session-title-first-prompt-llm/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-title-first-prompt-llm/README.md README.md: fef0651e1c4ba0032e6a1854590d5ac023bd71d8 -README.zh.md: 377779467a091b2c68d91a8be1903702a0808d10 +README.zh.md: 19998df3ab799fe77e18e207468bce92faf03ec8 diff --git a/packages/session/session-title-first-prompt-llm/README.zh.md b/packages/session/session-title-first-prompt-llm/README.zh.md index 377779467a..19998df3ab 100644 --- a/packages/session/session-title-first-prompt-llm/README.zh.md +++ b/packages/session/session-title-first-prompt-llm/README.zh.md @@ -4,7 +4,7 @@ 可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结第一条符合条件的用户消息。它注册 `first-prompt` 节奏,只在全新非 fork 会话首次创建回退时自动运行,并将结果归因于该消息的确切 seq。自动失败会保留回退,之后只能通过 `ctx.sessionTitle.refresh()` 重试。 -该插件使用完整且必填的[共享 LLM(大语言模型)配置](../session-title-llm/README.md#configuration)。同时省略 `provider` 与 `model` 时,会继承当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。 +该插件使用完整且必填的[共享 LLM(大语言模型)配置](../session-title-llm/README.zh.md#configuration)。同时省略 `provider` 与 `model` 时,会继承当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。 ## 模型体验 diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index a11c796906..5b21e64c18 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/README.i18n.yaml b/packages/session/session-title-llm/README.i18n.yaml index 6d84c3559c..d75451c2b9 100644 --- a/packages/session/session-title-llm/README.i18n.yaml +++ b/packages/session/session-title-llm/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-title-llm/README.md README.md: db03205cabf9f723ee9bf6ad122026d75cb92113 -README.zh.md: f325cbd9344c15e91f3193204c27c40d2e3b62a6 +README.zh.md: 7e3a7d30022cd2dec4b59d98b90b9f02e5d6e9b3 diff --git a/packages/session/session-title-llm/README.zh.md b/packages/session/session-title-llm/README.zh.md index f325cbd934..7e3a7d3002 100644 --- a/packages/session/session-title-llm/README.zh.md +++ b/packages/session/session-title-llm/README.zh.md @@ -12,6 +12,8 @@ 路由与输入验证完成后,辅助模块会在模型分发前直接通过 `Session` 追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。持久化会立即观察到该记录;追加不需要标题专属标记、类型断言、结算队列或刷写。分发的请求封套会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。拦截器会与记录保持一致,而循环专用重建观察者不会把它与对话请求头比较。DeepSeek 适配器会根据该用途禁用思考,使少量输出预算全部用于可见标题文本;其他适配器负责自身用途专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。 + + ## 配置 除成对的路由覆盖项外,每个字段都必填;库不提供默认值。 diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 52c8e55bad..53deb28e58 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index bbe1cfc651..e28149f030 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-title/README.md README.md: e923bd9700180214f235c4971d4b020565bfee43 -README.zh.md: 8e84ac27d9f1509c8530b838eeb5eb2216d87199 +README.zh.md: 6b1eef269dea65c66c1e9a70ce9469c66d192e84 diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 8e84ac27d9..6b1eef269d 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -31,7 +31,7 @@ fork 出的会话会原样继承种子中的标题事件。首消息节奏不会 提供方会提供带品牌类型的稳定 id、自动模式(`first-prompt` 或 `all-prompts`)和 `generate(request)`。请求携带活跃会话、截至一次固定修订的所有符合条件消息、可用时当前已记录的主请求路由,以及取消信号。结果包含非空标题、该请求中互不重复且有序的来源消息 seq,以及生成该标题时使用的可选提供方/模型路由。服务会在结果持久保存前进行规范化和验证。 -参见[会话标题数据结构](../../../docs/subsystems/session-title.md)与[已实现决策](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)。 +参见[会话标题数据结构](../../../docs/subsystems/session-title.zh.md)与[已实现决策](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md)。 ## 模型体验 diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 3395816244..d1a66146ed 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 6ab14e7263..5ed0f1ca4d 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -306,12 +306,13 @@ export class SessionTitleService extends Service { // string clients list rows read. The unit child activates only when a // projection registry is composed (headless assemblies stay unaffected). ctx.inject(['sessionProjections'], (projectionCtx) => { + const titleSchema = zod.union([zod.string().min(1), zod.null()]) projectionCtx.sessionProjections.register<'title', string | null>({ key: 'title', - schema: zod.union([zod.string().min(1), zod.null()]), + stateSchema: titleSchema, init: () => null, apply: (state, event) => (event.type === 'session/title' ? event.data.title : state), - view: state => state, + wire: { viewSchema: titleSchema, view: state => state }, stateVersion: 1, }) }) diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 76b27dc9b1..ad93926202 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -13,6 +13,9 @@ export {} declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + title: string | null + } interface SessionProjectionMap { /** * The session's current normalized title — the latest `session/title` diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index bb8ce4625d..2715db4fd4 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -72,11 +72,20 @@ describe('SessionTitleService', () => { expect(session.surface.nodes).toEqual([message.seq]) }) - it('derives a fallback title from the direct prompt instead of baked prefix context', async () => { + it('derives a fallback title from the direct prompt instead of injected context', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('prefixed-title')) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Referenced session snapshot' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [], + }, + }), { surfaceOp: 'append' }) session.append('turn/start', { turn: 1, }) diff --git a/packages/settings/README.i18n.yaml b/packages/settings/README.i18n.yaml index 0e9a8956bc..3523bfd4c7 100644 --- a/packages/settings/README.i18n.yaml +++ b/packages/settings/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/README.md README.md: 9aad9391160ba6232d2de5005010ea99d8760abe -README.zh.md: 1ffc4f5184ac044d9605b256e9b57dcceecfa562 +README.zh.md: 30b9fab712dd2802e41f03fbf715b5f541519f8e diff --git a/packages/settings/README.zh.md b/packages/settings/README.zh.md index 1ffc4f5184..30b9fab712 100644 --- a/packages/settings/README.zh.md +++ b/packages/settings/README.zh.md @@ -6,7 +6,7 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`settings/`](settings/README.md) | 定义命名空间注册、分层解析与提交 | `ctx.settings` | -| [`settings-file/`](settings-file/README.md) | 在本地文件中存储设置并观察外部编辑 | 注册到 `ctx.settings` | +| [`settings/`](settings/README.zh.md) | 定义命名空间注册、分层解析与提交 | `ctx.settings` | +| [`settings-file/`](settings-file/README.zh.md) | 在本地文件中存储设置并观察外部编辑 | 注册到 `ctx.settings` | -子系统参考——命名空间、owner scope、解析顺序、热提交——见 [docs/subsystems/settings.md](../../docs/subsystems/settings.md)。 +子系统参考——命名空间、owner scope、解析顺序、热提交——见 [docs/subsystems/settings.md](../../docs/subsystems/settings.zh.md)。 diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 7e5cc60efa..1bc49c1f7f 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 5966a19a93..e0620f6fc8 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/README.i18n.yaml b/packages/shell/README.i18n.yaml index 65372e643b..3f17b799d3 100644 --- a/packages/shell/README.i18n.yaml +++ b/packages/shell/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/README.md README.md: f075db4fbe57c6052fea89b30e35126117f75a7c -README.zh.md: 2fdd92aac8d768024b6b4a9d6bd96bb6f5dafb05 +README.zh.md: 21425afbaf237887134ed29152e46ad699c9f639 diff --git a/packages/shell/README.zh.md b/packages/shell/README.zh.md index 2fdd92aac8..21425afbaf 100644 --- a/packages/shell/README.zh.md +++ b/packages/shell/README.zh.md @@ -6,14 +6,14 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`shell/`](shell/README.md) | 定义 Service Provider 与 Consumer 共享的执行器约定。 | `ctx.shell` | -| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.shell`) | -| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.shell`) | -| [`pwsh-local/`](pwsh-local/README.md) | 采用 Windows 特有的进程行为执行 PowerShell 命令。 | (注册 `ctx.shell`) | -| [`shell-env/`](shell-env/README.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.shellEnv` | -| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools`) | -| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) | +| [`shell/`](shell/README.zh.md) | 定义 Service Provider 与 Consumer 共享的执行器约定。 | `ctx.shell` | +| [`bash-local/`](bash-local/README.zh.md) | 通过本地 [`subprocess`](../subprocess/README.zh.md) 服务执行命令。 | (注册 `ctx.shell`) | +| [`bash-sandbox/`](bash-sandbox/README.zh.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.zh.md) 后端。 | (注册 `ctx.shell`) | +| [`pwsh-local/`](pwsh-local/README.zh.md) | 采用 Windows 特有的进程行为执行 PowerShell 命令。 | (注册 `ctx.shell`) | +| [`shell-env/`](shell-env/README.zh.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.shellEnv` | +| [`tool-bash/`](tool-bash/README.zh.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools`) | +| [`tool-pwsh/`](tool-pwsh/README.zh.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) | 叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。 -子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/shell.md](../../docs/subsystems/shell.md)。 +子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/shell.md](../../docs/subsystems/shell.zh.md)。 diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 9e8a0eaf85..82a7387566 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/bash-local/README.md README.md: 5e62ea24676f3bedf32b1b48f8618d703d5fb462 -README.zh.md: 6f236f3dd78bd444eb57443ba068a0271ac5d8df +README.zh.md: 92f842a777aebec2e2cb6a8c54966e46202531f1 diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index 6f236f3dd7..92f842a777 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 @@ -23,11 +23,11 @@ ## 行为 - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。 -- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。 -- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 -- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 -- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回活动的 `ShellProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。job id、所有权、轮询和通知属于通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.md),工具层会在其中注册该句柄。 +- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。 +- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 +- **后台进程**:`start()` 会立即返回活动的 `ShellProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。job id、所有权、轮询和通知属于通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md),工具层会在其中注册该句柄。 ## 模型体验 @@ -39,9 +39,9 @@ ## 已知限制与暂缓事项 -- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 +- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 - **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 - **后台 spawn 失败提示只交付一次**:subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 -凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。 +凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 记录;这些机制归它所有。 diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index 68fb8a5cb1..e096da800a 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/README.i18n.yaml b/packages/shell/bash-sandbox/README.i18n.yaml index 6792ffa955..ce3625a642 100644 --- a/packages/shell/bash-sandbox/README.i18n.yaml +++ b/packages/shell/bash-sandbox/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/shell/bash-sandbox/README.md -README.md: fe9a0b1a891bd2b9102357f48141822c70465ddf -README.zh.md: 648264615dc476f9442b9bb9ae3a332196f4e104 +README.md: dd0f6dd80394ee8bf0349f706e8fd77cd3c14189 +README.zh.md: 00b146ea88ffb449c7eaca1de6185657592fe774 diff --git a/packages/shell/bash-sandbox/README.md b/packages/shell/bash-sandbox/README.md index fe9a0b1a89..dd0f6dd803 100644 --- a/packages/shell/bash-sandbox/README.md +++ b/packages/shell/bash-sandbox/README.md @@ -19,7 +19,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `ShellRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **The runner path or syscall must match.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn '`. A present path also requires `syscall: 'spawn'` or the exact `spawn `. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessRuntime` synchronously throws the same runner-identifying `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code check and a remaining fatal stderr line must both match after exact informational-line exclusions. A match takes priority over denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `job_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path. - **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.shell.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance. -- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. +- **File effects only.** The mode vocabulary claims only file effects. Network stays unrestricted; process visibility is backend-specific and documented by [`dsh-sandbox-local`](../../sandbox/sandbox-local/). - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors. @@ -82,7 +82,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox. +- **Confinement covers file effects only** — network restriction and a uniform process-visibility guarantee are absent, so the modes are not a general-purpose security sandbox. - **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed. - **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous `SubprocessRuntime` throw that names the runner path instead fails `start()` immediately. - **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile. diff --git a/packages/shell/bash-sandbox/README.zh.md b/packages/shell/bash-sandbox/README.zh.md index 648264615d..00b146ea88 100644 --- a/packages/shell/bash-sandbox/README.zh.md +++ b/packages/shell/bash-sandbox/README.zh.md @@ -19,7 +19,7 @@ - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `ShellRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 - **Runner 路径或 syscall 必须匹配。** 进程启动前,调用方拥有的 workdir 必须经独立验证可用,Node 必须报告 `ENOENT` 或 `EACCES`,并且错误必须符合以下一种形态:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码检查和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `job_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 - **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.shell.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升权引导。 -- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 +- **只限制文件影响。** 模式词汇只声称文件影响。网络仍不受限制;进程可见性因后端而异,具体见 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值。 @@ -42,7 +42,7 @@ #### 模型看到的内容 -基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。策略归属方会另行贡献当前且不区分具体能力的 `sandbox:policy` 上下文。 +基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。策略归属方会另行贡献当前且不区分具体能力的 `sandbox:policy` 上下文。 #### Token 影响 @@ -70,7 +70,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。判定为 runner 失败的 spawn 错误会以原始 spawn 错误作为详细信息;如果拒绝没有通过 `ENOENT`/`EACCES` 的 `path` 或 `syscall` 证据指明 `argv[0]`,它仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: `,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.zh.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。判定为 runner 失败的 spawn 错误会以原始 spawn 错误作为详细信息;如果拒绝没有通过 `ENOENT`/`EACCES` 的 `path` 或 `syscall` 证据指明 `argv[0]`,它仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: `,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。 #### Token 影响 @@ -82,7 +82,7 @@ ## 已知限制与暂缓事项 -- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 +- **限制只覆盖文件影响**:不提供网络限制和统一的进程可见性保证,因此这些模式不是通用安全沙箱。 - **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 - **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `job_output` 读取通用任务时呈现;`SubprocessRuntime` 同步抛出的错误包含 runner 路径时,则会使 `start()` 立即失败。 - **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index e0614889ac..1ceaa9e14e 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index c9ce9f1d6b..a7712c1df6 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/pwsh-local/README.md README.md: 2eccc59b919d1f729eef52a42581f4da0f1d9e60 -README.zh.md: a03f80343711a1471bc96017e23476a3253ec46c +README.zh.md: b7072ebd9be4e148cedd4edd6c20b12536814856 diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index a03f803437..b7072ebd9b 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、dispose(资源释放))属于 subprocess 服务。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、dispose(资源释放))属于 subprocess 服务。 命令字符串作为单个 argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。 @@ -28,13 +28,13 @@ 这是 `dsh-bash-local` 的 Windows 对应实现,有意逐次调用保持语义一致: - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 -- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 +- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 - **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 -- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 -- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 -- **后台进程**——`start()` 立即返回存活的 `ShellProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为一条按分段标记、通过消费游标推进的增量。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务 dispose(被终止并 join)。一切任务相关职责(job id、所有权、轮询、通知)都在通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 +- **后台进程**——`start()` 立即返回存活的 `ShellProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为一条按分段标记、通过消费游标推进的增量。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务 dispose(被终止并 join)。一切任务相关职责(job id、所有权、轮询、通知)都在通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 ## 模型体验 @@ -54,4 +54,4 @@ - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 - **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 -清理启发式与 spill 保留的注意事项见 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md),相关机制由其负责。 +清理启发式与 spill 保留的注意事项见 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md),相关机制由其负责。 diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index bd4d4d9569..ea47e54eaa 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/README.i18n.yaml b/packages/shell/pwsh-sandbox/README.i18n.yaml index 27d1e0d7c6..3a64425236 100644 --- a/packages/shell/pwsh-sandbox/README.i18n.yaml +++ b/packages/shell/pwsh-sandbox/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/pwsh-sandbox/README.md README.md: 533b89b533084843a17153f3f3da39f21d271b04 -README.zh.md: 370cc6fd4a3961d6f79c5c8829ac5c42e093c2a5 +README.zh.md: 8607554d10562bc519f379030ccd9518cb63b3b6 diff --git a/packages/shell/pwsh-sandbox/README.zh.md b/packages/shell/pwsh-sandbox/README.zh.md index 370cc6fd4a..8607554d10 100644 --- a/packages/shell/pwsh-sandbox/README.zh.md +++ b/packages/shell/pwsh-sandbox/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -沙盒消费型的 [`ctx.shell` 执行器 seam](../shell/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 +沙盒消费型的 [`ctx.shell` 执行器 seam](../shell/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 65a3863bec..8172f84b21 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index 44eff1b922..a8eb16c5b9 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/shell-env/README.md README.md: b056289bd58e960e9fd36ee84ed20579e7bde42e -README.zh.md: 3ced6d63525db8a15f9202311c9ca9e32335cab8 +README.zh.md: 76d1af497b603d4df41fa1b65eaeddd38c0ae9b3 diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index 3ced6d6352..76d1af497b 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -17,7 +17,7 @@ ## Managed environment -每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent(智能体)的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。 +每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.zh.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent(智能体)的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。 `ctx.shellEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 3fdf0741d3..3699ebbb78 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index a4e67f3760..5317414e55 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/shell/README.md README.md: a15980ead797a73c18dd6d8825a4b38cf845df60 -README.zh.md: 87a7dffdfb28fcf8a58e6254bf450e513dd458f4 +README.zh.md: 008b19835697d5d9514cf5472696852cfc684acd diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 87a7dffdfb..008b198356 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -13,7 +13,7 @@ | `@deepseek-ai/dsh-bash-sandbox` | Service Provider:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 | | `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.shell`、面向模型的工具 schema | -该拆分是一个标准的能力 seam([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):`dsh-bash-sandbox` 是位于同一 Service Definition 之后的沙箱执行器——Consumer 检测其 `sandboxMode` 能力并添加升权字段,无需导入提供方——容器化或远程执行器也可以同样接入。 +该拆分是一个标准的能力 seam([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)):`dsh-bash-sandbox` 是位于同一 Service Definition 之后的沙箱执行器——Consumer 检测其 `sandboxMode` 能力并添加升权字段,无需导入提供方——容器化或远程执行器也可以同样接入。 ## 服务 API(`ctx.shell`) @@ -33,9 +33,9 @@ `ShellExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `ShellExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 -每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。 +每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.zh.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.shellEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.shellEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 Service Definition 中,两个工具便永远不会在 marker 约定上漂移。 @@ -50,4 +50,4 @@ ## 已知限制与暂缓事项 - **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。 -- **前台超时始终由执行器负责**:seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 +- **前台超时始终由执行器负责**:seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md) 明确暂缓。 diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 0da37192d9..a52c37f3e0 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index d81a95be5f..e512e891ce 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash-persistent/README.md README.md: 606920d087b42344f34b70103e167b0046d3cfd5 -README.zh.md: dd88db87cb617fb2f4d35ada9550d0142e32e979 +README.zh.md: 85652bac23be02bd2c72753070734d91fb43ee98 diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index dd88db87cb..85652bac23 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -19,7 +19,7 @@ #### 模型所见 -生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段;persona 与环境指导由部署负责。 +生成的 [`bash` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段;persona 与环境指导由部署负责。 #### Token 影响 diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 2232d909e4..a0b7a5d428 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 61deb3afb7..f9cf8d764a 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -186,6 +186,36 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: TerminalSessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() @@ -277,6 +307,15 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight); + // re-observing status before the next send closes that gap. + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -319,16 +358,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent bash shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config, + ) } // The shell reads stdin again (its prompt, or a foreground child's own // read) without having printed the end marker — e.g. `exec`, an interrupt, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 0386295c4c..4cdebbbdce 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -98,6 +98,7 @@ type StubMode = | 'incremental-fallback' | 'empty-page-after-latest' | 'paged-scrollback' + | 'exit-after-send' class StubPtySession implements TerminalBackendSession { readonly motd = 'stub> ' @@ -109,6 +110,7 @@ class StubPtySession implements TerminalBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -127,6 +129,7 @@ class StubPtySession implements TerminalBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -171,6 +174,18 @@ class StubPtySession implements TerminalBackendSession { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send while the exit event is still in + // flight; the shell flips to exited before the tool's next poll, + // exactly like the real backend. The tool must re-observe status + // instead of sending. + const output = `${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -395,6 +410,21 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + expect(text(await call(ctx, owner, 'echo "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('reports a shell exit when the backend has no code or signal', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index ccdd7fe9cd..f3acda2aca 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md README.md: 21749bf0a1cd3cdc46257fb2a02ba6b4ab1f5ee6 -README.zh.md: e79190ced58abbd73d85127f3a4362f4e6b292cd +README.zh.md: 860db1a4c347d1aefe04f70e14a65e4b511ff7f6 diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index e79190ced5..860db1a4c3 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `bash` 工具,注册在 `ctx.shell` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.jobs` 运行时,并通过 `job_output`、`job_list` 和 `job_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-jobs` 提供。 -需要加载执行器 Service Provider(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-shell-env`](../shell-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约定是 bash 方言——请挂载能解析 bash 的执行器。 +需要加载执行器 Service Provider(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-shell-env`](../shell-env/README.zh.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约定是 bash 方言——请挂载能解析 bash 的执行器。 包根只公开 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。 @@ -28,7 +28,7 @@ ### 托管 shell 环境 -每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-shell-env`](../shell-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约定——贡献方注册、重复键/未声明键的显式报错机制、内置项保留与贡献方示例——载于该包的 README。快照通过专用的 `ShellExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 +每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-shell-env`](../shell-env/README.zh.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约定——贡献方注册、重复键/未声明键的显式报错机制、内置项保留与贡献方示例——载于该包的 README。快照通过专用的 `ShellExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 @@ -42,17 +42,17 @@ ## 工具仅使用具名参数构建请求 -`ShellExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)。 +`ShellExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)。 ## 权限与升权 除非启用沙箱的执行器([`dsh-bash-sandbox`](../bash-sandbox/))限制命令,否则命令以执行器的完整权限运行。仅拒绝型沙箱会把拒绝作为结果事实报告,并在此渲染为拒绝标记;逐调用的允许/拒绝/询问策略由 `tools/pre-execute` waterfall(瀑布式事件)负责(参见 docs/architecture.md)。 -需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由见 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由见 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。 ## 逐会话模式切换 -对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责特定于该操作的有效模式与重试引导。参见 [`dsh-shell` 折叠计算](../shell/README.md)和[沙箱切换约定](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责特定于该操作的有效模式与重试引导。参见 [`dsh-shell` 折叠计算](../shell/README.zh.md)和[沙箱切换约定](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。 ## 模型体验 @@ -80,7 +80,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor #### 模型看到的内容 -模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions` 和 `justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。 +模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions` 和 `justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。 #### Token 影响 @@ -94,7 +94,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor #### 模型看到的内容 -renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: ]`、`[sandbox: file access denied under mode]`、`[timed out after ms]`、`[killed by signal: ]` 和 `[exit code: ]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.md)。 +renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: ]`、`[sandbox: file access denied under mode]`、`[timed out after ms]`、`[killed by signal: ]` 和 `[exit code: ]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md)。 #### Token 影响 @@ -108,7 +108,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### 模型看到的内容 -启动会精确返回 `started background job `。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: ]`、沙箱事实,以及 `exit code: ` 或 `signal: ` 等终止详情。[`dsh-tool-jobs`](../../jobs/tool-jobs/README.md) 负责模型可见的状态行、完成通知、列表和取消响应。 +启动会精确返回 `started background job `。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: ]`、沙箱事实,以及 `exit code: ` 或 `signal: ` 等终止详情。[`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md) 负责模型可见的状态行、完成通知、列表和取消响应。 #### Token 影响 @@ -135,5 +135,5 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr ## 已知限制与延期工作 - **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。 -- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 +- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 - **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `job_kill`,或依赖持有者/服务的 dispose。 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 0de6feef49..1c545b267a 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml new file mode 100644 index 0000000000..cad7c52a6d --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -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 packages/shell/tool-pwsh-persistent/README.md +README.md: a57c940801606c2eef450f0e54fecb434c62a406 +README.zh.md: 75f6babfaffc79120f96441bdd5e08895b8e452f diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md new file mode 100644 index 0000000000..a57c940801 --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +English | [中文](README.zh.md) + +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the terminal backend (a `terminal-bash` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered terminal backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `pwsh` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. + +#### Token effect + +Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real terminal backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. +- Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). +- A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. +- There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. +- SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the bootstrap's `[Console]::` encoding pin and prompt marker. Commands can still settle through the printable prompt and silence tier, but non-ASCII output may follow the host code page. +- The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md new file mode 100644 index 0000000000..75f6babfaf --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +[English](README.md) | 中文 + +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.terminals` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 terminal backend(配置 `shellDialect: pwsh` 的 `terminal-bash` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 terminal backend。 | +| `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | +| `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | +| `description` | 持久 shell 描述 | 模型可见的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型看到什么 + +生成的 [`pwsh` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-pwsh-persistent),含配置的 `description`。本插件不贡献独立的 system-prompt 段落;persona 与环境指引由部署方负责。 + +#### Token 影响 + +`pwsh` 可见期间每个请求有固定的 schema 成本。 + +#### KV Cache 影响 + +配置的 description 与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型看到什么 + +命令共享每个 Agent 的一个 shell,因此 cwd、`$env:` 变量、函数和后台任务跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行(PSReadLine 会把提交的输入渲染回输出流;marker 锚定提取与包装器原文剥离将其移除)。非零包装命令追加 `[exit code: N]` —— 命令运行原生程序时是精确的原生退出码,PowerShell 终止性错误为 `1`。shell 在报告状态前退出的,改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(backend 两者都没有时;Windows 强杀按无 signal 的 exit 1 报告),然后重置并告知模型下一次调用从全新 shell 开始。长输出保留最早的前缀并附裁剪提示;若 PTY 已丢弃该前缀,结果会明确说明。超时返回有界的部分输出、关闭不确定的 shell 并报告重置。 + +#### Token 影响 + +数据相关。`maxOutputChars` 限制保留的命令输出;固定裁剪、前缀丢失、状态、超时与重置诊断可能扩展结果。 + +#### KV Cache 影响 + +追加式工具结果跟随可复用的请求前缀。 + +## 已知限制与延后工作 + +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 terminal backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 +- 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 +- 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 +- 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 +- SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker。命令仍可通过可打印提示符和静默档结算,但非 ASCII 输出可能沿用宿主代码页。 +- BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json new file mode 100644 index 0000000000..b69c485a3a --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh-persistent", + "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", + "version": "0.1.0-rc.8", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/shell/tool-pwsh-persistent" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts new file mode 100644 index 0000000000..f0a575363e --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -0,0 +1,514 @@ +/* jscpd:ignore-start -- deliberate mirror of tool-bash-persistent (persistent-pty note 2026-08-11-pwsh-persistent-pty): + the PowerShell counterpart shares the session registry, polling loop, and reset contract by design. */ +/** + * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-pwsh-persistent + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { defineTool } from '@deepseek-ai/dsh-tools' + +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. +const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean + exitCode?: number +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`, + end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`, + } +} + +/** + * Escape a command body for embedding in the wrapper's double-quoted string. + * Backtick escapes keep every character literal: backtick first so the + * escapes this function inserts are never re-escaped, `$` so no expansion + * happens at wrapper construction, and `\r\n`/ESC so multi-line commands and + * raw control bytes ride one physical input line without PSReadLine mangling. + * @param value - the model's PowerShell command text. + * @returns the escaped double-quoted-string body. + */ +function quoteForPwsh(value: string): string { + return value + .replaceAll('`', '``') + .replaceAll('"', '`"') + .replaceAll('$', '`$') + .replaceAll('\r', '') + .replaceAll('\n', '`n') + .replaceAll('\x1b', '`e') +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line: PSReadLine renders the echoed + // input, and a wrapped line would split the echo the extraction strips. + // The echoed END nonce can never fabricate completion because the status + // regex needs digits immediately after it and the echo continues with + // quote characters. + const body = quoteForPwsh(command) + return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)` +} + +function stripPrompt(text: string): string { + let result = text.replace(/\r?\n$/, '') + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + let captured = text.slice(start, end) + // The PSReadLine echo carries the wrapper source (including both marker + // nonces) before the real markers; anchor on the real markers excludes it, + // and stripping the wrapper covers the rare case where the real START + // scrolled out and extraction fell back to the echoed copy. + captured = captured.replaceAll(wrapper, '') + return { + text: captured.replace(/^\r?\n/, '').replace(/\r?\n$/, ''), + incomplete: startMarker < 0, + exitCode: Number(status), + } +} + +function promptCompleted(result: TerminalSendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, + fallback: string, + fallbackTruncated = false, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: false, + } + } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) + return { + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')), + incomplete: fallbackTruncated || fallbackStart < 0, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: TerminalSessionId, + latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), +): RetainedOutput { + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated + while (true) { + if (offset >= latest.totalLines) break + const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + const withPrefix = output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered + const marker = output.exitCode !== undefined && output.exitCode !== 0 + ? `[exit code: ${output.exitCode}]` + : undefined + return appendStatusMarker(withPrefix, marker) +} + +function appendStatusMarker(content: string, marker: string | undefined): string { + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` +} + +function renderShellExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[shell killed by signal: ${signal}]` + : exitCode !== null + ? `[shell exited: code ${exitCode}]` + : '[shell exited]' + return appendStatusMarker(content, marker) +} + +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: TerminalSessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + wrapped: string, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + +/** + * The pwsh prompt function that overrides the backend bootstrap value with + * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime + * because raw ESC characters in submitted input are unreliable under + * PSReadLine. + */ +const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() + const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + + const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise => { + if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.terminals.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-pwsh-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.terminals.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, combinedSignal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-pwsh-persistent owner cache cleanup') + } + const setup = ctx.terminals.startSend(owner, spawned.sessionId, { + text: PWSH_PROMPT_SETUP, + submit: true, + signal: combinedSignal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent pwsh shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent pwsh initialization failed') + throw error + } + })() + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + let fallbackTruncated = false + + while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight, and + // the echoed wrapper can then carry a marker end without status digits); + // re-observing status before the next send closes that gap. + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, + ) + } + let operation + let result + try { + operation = ctx.terminals.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent pwsh send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + const partial = renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent pwsh command timed out') + return [ + // TODO: Report a timeout only; this signal does not establish an OOM. + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent pwsh command aborted') + commandDeadline.signal.throwIfAborted() + } + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } + if (result.sessionStatus.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config, + ) + } + if (promptCompleted(result)) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + } + await pause() + } +} + +/** + * Register the model-facing persistent `pwsh` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The PowerShell command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('pwsh requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-pwsh-persistent' +export const inject = ['tools', 'terminals'] + +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent pwsh tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `pwsh` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-pwsh-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-pwsh-persistent: description must be non-empty') + } + registerPersistentPwsh(ctx, resolved) +} + +/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh-persistent/src/invariant.ts b/packages/shell/tool-pwsh-persistent/src/invariant.ts new file mode 100644 index 0000000000..6f436019f2 --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`. + * @module @deepseek-ai/dsh-tool-pwsh-persistent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public API solely for an invariant. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..1a95d7fe23 --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-pwsh-loader-agent') + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-terminal'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-subprocess-local'", + "- name: '@deepseek-ai/dsh-terminal-bash'", + ' config:', + ' shellDialect: pwsh', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 300', + ' handoffGraceMs: 300', + ' scrollbackLines: 20000', + ' timeoutMs: 8000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", + ' config:', + ' timeoutMs: 20000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-terminal', TerminalSessionService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], + ['@deepseek-ai/dsh-terminal-bash', TerminalBash], + ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'pwsh', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh']) + await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested') + const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_PWSH') + + const multiline = text(await execute( + 'multiline', + '$value = "line one"\nWrite-Output "${value}:it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_PWSH') + + const hereString = text(await execute( + 'here-string', + "$h = @'\nalpha\nbeta\n'@\nWrite-Output $h", + )) + expect(hereString).toBe('alpha\nbeta') + + const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next pwsh call starts from the workspace') + expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root) + }, 60_000) +}) diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..8d76abd3a6 --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -0,0 +1,636 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import type { + TerminalBackend, + TerminalBackendSession, + TerminalReadRequest, + TerminalSendOperation, + TerminalSendRequest, + TerminalSessionStatus, + TerminalSignal, + TerminalWaitReason, +} from '@deepseek-ai/dsh-terminal' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-pwsh-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-pwsh-${++callNumber}`), + name: 'pwsh', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'signal-exit' + | 'unknown-exit' + | 'wait-for-abort' + | 'end-on-abort' + | 'idle-then-normal' + | 'large' + | 'nonzero' + | 'torn-status' + | 'finish-torn-status' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' + | 'incremental-fallback' + | 'empty-page-after-latest' + | 'paged-scrollback' + | 'with-echo' + | 'exit-after-send' + | 'prompt-collision' + +const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ +const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ + +class StubTerminalSession implements TerminalBackendSession { + readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' + readonly pid = 123 + statusValue: TerminalSessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + historyTruncated = false + throwOnSend = false + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: TerminalSendRequest): TerminalSendOperation { + this.sends += 1 + if (request.text.startsWith('function prompt')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + const start = START_PATTERN.exec(request.text)?.[0] + const end = END_PATTERN.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = START_PATTERN.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `pwsh: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `pwsh: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = START_PATTERN.exec(sent)?.[0] + const end = END_PATTERN.exec(sent)?.[0] + if (this.mode === 'with-echo') { + // The PSReadLine echo renders the submitted wrapper before the real + // markers; the tool must strip it from the captured result. + const output = `${sent}\n${start ?? ''}\nhello from stub\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send with an echoed wrapper (marker end, + // no status digits) while the exit event is still in flight; the shell + // flips to exited before the tool's next poll, exactly like the real + // ConPTY backend. The tool must re-observe status instead of sending. + const output = `${sent}\n${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } + if (this.mode === 'incremental-fallback') { + const incremental = `${start ?? ''}\nincrement\n${this.motd}` + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) + } + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' + : this.mode === 'prompt-collision' ? this.motd + : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : this.mode === 'exit' + ? { kind: 'exited', exitCode: 9, signal: null } + : { kind: 'exited', exitCode: null, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(request: TerminalReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } + const lines = this.scrollback.split('\n') + if (this.mode === 'paged-scrollback') { + const offset = request.offset ?? 0 + const end = lines.length - offset + const start = Math.max(0, end - 3) + const returnedLines = end - start + return { + text: lines.slice(start, end).join('\n'), + totalLines: lines.length, + lineBegin: offset, + lineEnd: offset + returnedLines, + truncated: this.historyTruncated, + } + } + return { + text: this.scrollback, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, + lineBegin: 0, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, + truncated: this.historyTruncated, + } + } + + signal(_signal: TerminalSignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: TerminalWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>, delta = ''): TerminalSendOperation { + return { + done, + readOutput: () => ({ delta, truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubTerminalSession[] = [] + const backend: TerminalBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubTerminalSession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolPwshPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TerminalSessionService) + const stub = stubBackend(initialMode) + ctx.terminals.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolPwshPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-pwsh-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['pwsh']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('pwsh')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('pwsh')).toBeUndefined() + }) + + it('strips the echoed wrapper from captured output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'with-echo' + const result = text(await call(ctx, owner, 'Write-Output hi')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_START_') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_END_') + expect(result).not.toContain('Invoke-Expression') + }) + + it('preserves command output that equals the private shell prompt', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'prompt-collision' + expect(text(await call(ctx, owner, 'complete prompt collision'))).toBe(session.motd) + }) + + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + expect(text(await call(ctx, owner, 'Write-Output "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'incremental-fallback' + session.scrollback = '' + expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('pwsh: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('pwsh: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('[shell exited: code 9]') + expect(exited).not.toContain('[exit code: 9]') + expect(exited).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))) + .toContain('[shell killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) + const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) + }) + + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + + it('reports a shell exit when the backend has no code or signal', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'unknown-exit' + + expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]') + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') + }) + + it('assembles retained output across backward scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'paged-scrollback' + session.scrollback = 'older one\nolder two\nolder three\nolder four\n' + + expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') + }) + + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('pwsh: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next pwsh call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command timed out') + }) + + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TerminalSessionService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.terminals.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.terminals.list(owner)).toEqual([]) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolPwshPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolPwshPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/shell/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json new file mode 100644 index 0000000000..42ce584d6a --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../../terminal/terminal" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 23f927d45f..c5dcb85005 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/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/shell/tool-pwsh/README.md -README.md: af1de3d84c8815f9875faaa8e2fd6a79dff018c2 -README.zh.md: 1f662094e5d423299ae704efddc6a0b27bdfc128 +README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 +README.zh.md: 0a66b119ac2bf0386c0686e9e22ddfe1c3bb87b1 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index af1de3d84c..e862fcf0ca 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 1f662094e5..0a66b119ac 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -66,7 +66,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### 模型看到的内容 -模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 #### Token 影响 @@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## 已知限制与暂缓事项 -- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.zh.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.zh.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index e87a546b62..a12763e2df 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index e935ba1c20..4e2383be6c 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/README.md README.md: a9fc04cbd4e81ddcf4b9cfebb4a4d8b9e8888227 -README.zh.md: 84bfa69e133962e27ecb792d6b989ca446eee787 +README.zh.md: 4b5f4e4ffdef0ea45797a5861840e13ec4405ab5 diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index 84bfa69e13..4b5f4e4ffd 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -6,11 +6,11 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`skill/`](skill/README.md) | 定义 skill 提供方注册和查找 | `ctx.skills` | -| [`skill-badge/`](skill-badge/README.md) | 贡献可选的内置 dsh 徽章 skill | 注册到 `ctx.skills` | -| [`skill-filesystem/`](skill-filesystem/README.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` | -| [`tool-skill/`](tool-skill/README.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` | +| [`skill/`](skill/README.zh.md) | 定义 skill 提供方注册和查找 | `ctx.skills` | +| [`skill-badge/`](skill-badge/README.zh.md) | 贡献可选的内置 dsh 徽章 skill | 注册到 `ctx.skills` | +| [`skill-filesystem/`](skill-filesystem/README.zh.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` | +| [`tool-skill/`](tool-skill/README.zh.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` | 该能力位于核心控制主干之外,可以使用本地、嵌入式或远程提供方,而无需更改面向模型的约定。 -子系统参考——发现优先级、目录快照、`skill` 加载器——见 [docs/subsystems/skills.md](../../docs/subsystems/skills.md)。 +子系统参考——发现优先级、目录快照、`skill` 加载器——见 [docs/subsystems/skills.md](../../docs/subsystems/skills.zh.md)。 diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 520d3eb81c..e5ae518ffc 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/README.i18n.yaml b/packages/skill/skill-filesystem/README.i18n.yaml index ea3b9892b8..ea8f3ec05e 100644 --- a/packages/skill/skill-filesystem/README.i18n.yaml +++ b/packages/skill/skill-filesystem/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-filesystem/README.md README.md: 33fb550a9ca804c4f0f380232c2bfd94cc58a67e -README.zh.md: 4c09111fc2d31bf5467b57f90f65cf68e298da09 +README.zh.md: c1d24f590c7fc525fbf15356f7a8bee9c4d1308b diff --git a/packages/skill/skill-filesystem/README.zh.md b/packages/skill/skill-filesystem/README.zh.md index 4c09111fc2..c1d24f590c 100644 --- a/packages/skill/skill-filesystem/README.zh.md +++ b/packages/skill/skill-filesystem/README.zh.md @@ -16,7 +16,7 @@ |---|---|---| | `providerName` | `filesystem` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 | | `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 | -| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | +| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.zh.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 | | `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 | | `watch` | `true` | 监视宿主本地根,并在目录成员或 frontmatter 可能发生变化时使本地提供方失效。 | diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index c105ba58c7..c5b5812fb5 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index ad9b3221f5..93933f56d8 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 918a10d01b..b1c6702524 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md README.md: 4cfd8f22b3cfaf22b6f9f6cdc48f0db994858906 -README.zh.md: 9651e6d3209af77d3a2bbabb1bf0ec93647d3e90 +README.zh.md: 4b4fc6382a7f3e00592b6ebbf0b4887748cddf70 diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 9651e6d320..4b4fc6382a 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -14,7 +14,7 @@ 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。身份比对针对本插件所注册的那个定义,而非按自身名字回查,因此本插件既可全局挂载,也可挂在单个 agent 的组装内——在后者中 `register()` 只注册到该 agent 的层中。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 -`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 +`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md) 负责定义持久初始目录和替换目录的生命周期。 ## 工具:`skill` @@ -65,7 +65,7 @@ A user may also invoke a skill directly; its block then appears #### 模型看到的内容 -模型会看到生成的 [`skill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 +模型会看到生成的 [`skill` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-skill)。 #### Token 影响 diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index cdd1bbfa04..5693ee978b 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/spill/README.i18n.yaml b/packages/spill/README.i18n.yaml index 1d1ace946c..4dfc9aac96 100644 --- a/packages/spill/README.i18n.yaml +++ b/packages/spill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/README.md README.md: 74fd4837cb23767754174df168f2f4ba71c2b938 -README.zh.md: 2417bb793dca1cb10ead6208cb6ad896c18cdf03 +README.zh.md: 9a1f3495220142f7a34f806548c5ca5cbfab8734 diff --git a/packages/spill/README.zh.md b/packages/spill/README.zh.md index 2417bb793d..9a1f349522 100644 --- a/packages/spill/README.zh.md +++ b/packages/spill/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`spill/`](spill/README.md) | 定义 spill 存储 | `ctx.spillStore` | -| [`spill-local/`](spill-local/README.md) | 在会话范围的本地文件中存储 spill 文本 | 注册到 `ctx.spillStore` | -| [`spill-policy/`](spill-policy/README.md) | 应用执行后 spill 策略 | 监听 `ctx.tools` | +| [`spill/`](spill/README.zh.md) | 定义 spill 存储 | `ctx.spillStore` | +| [`spill-local/`](spill-local/README.zh.md) | 在会话范围的本地文件中存储 spill 文本 | 注册到 `ctx.spillStore` | +| [`spill-policy/`](spill-policy/README.zh.md) | 应用执行后 spill 策略 | 监听 `ctx.tools` | -参见[工具输出 spill 决策](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中记录了存储、保留和工具自有输出处理之间的边界。 +参见[工具输出 spill 决策](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md),其中记录了存储、保留和工具自有输出处理之间的边界。 -子系统参考——`SaveTextSpill`、所有者/来源、品牌化定位符——见 [docs/subsystems/spill.md](../../docs/subsystems/spill.md);依据见[工具输出 spill Agent Note](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 +子系统参考——`SaveTextSpill`、所有者/来源、品牌化定位符——见 [docs/subsystems/spill.md](../../docs/subsystems/spill.zh.md);依据见[工具输出 spill Agent Note](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)。 diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml index 0a0ab3823d..37659c3aac 100644 --- a/packages/spill/spill-local/README.i18n.yaml +++ b/packages/spill/spill-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-local/README.md README.md: 2270a65d9270e1549a9e48d6a36b821e48c29070 -README.zh.md: 907a2b5c98f3ee07e0b896b8827e6a00fa1827df +README.zh.md: b3e4999d8f2d982ef01637199299f79d06e12c4b diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md index 907a2b5c98..b3e4999d8f 100644 --- a/packages/spill/spill-local/README.zh.md +++ b/packages/spill/spill-local/README.zh.md @@ -18,7 +18,7 @@ |---|---|---| | `root` | 私有 0700 临时目录 | spill 文件的根目录。设置后可将这些文件保存在已知位置。 | -`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 +`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)。 ## 模型体验 diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index f74a1ba404..b4fb7d19ab 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/README.i18n.yaml b/packages/spill/spill-policy/README.i18n.yaml index f98bf76167..e6fe69d9e3 100644 --- a/packages/spill/spill-policy/README.i18n.yaml +++ b/packages/spill/spill-policy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-policy/README.md README.md: 715d10bee6d32b0753e0f6afcc18d83fe0261a60 -README.zh.md: 89635717e82f078055f6594b2aa8a49715435060 +README.zh.md: 11fe4aada6a5138dd1f7bb537cd8ad1463d72b05 diff --git a/packages/spill/spill-policy/README.zh.md b/packages/spill/spill-policy/README.zh.md index 89635717e8..11fe4aada6 100644 --- a/packages/spill/spill-policy/README.zh.md +++ b/packages/spill/spill-policy/README.zh.md @@ -30,11 +30,11 @@ **尽力而为**:没有会话所有者、没有 `ctx.spillStore` 后端,或 `saveText` 返回拒绝 ⇒ 策略记录警告并返回原始结果。spill 失败绝不会将成功调用变为 `isError`,也不会隐藏内联结果。成功替换时只会更改 `content`;规范的程序化值保持不变。 -**dispatch-log 分支:**注册在 `tools/code-dispatch-log` 上的第二个监听器,把同一套上限、替换流水线与尽力而为的回退应用到每个 `run_code` 子调用结果的持久化副本上(产物标签为 `dispatch`,按子调用 id 归档)。程序的值不受影响,因为它早已完整跨过 worker 边界;`read` 子调用同样设界:日志副本不是模型上下文,因此不会发生 read-again 循环,而 `read` 恰恰是最容易产生巨型日志的工具([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 +**dispatch-log 分支:**注册在 `tools/code-dispatch-log` 上的第二个监听器,把同一套上限、替换流水线与尽力而为的回退应用到每个 `run_code` 子调用结果的持久化副本上(产物标签为 `dispatch`,按子调用 id 归档)。程序的值不受影响,因为它早已完整跨过 worker 边界;`read` 子调用同样设界:日志副本不是模型上下文,因此不会发生 read-again 循环,而 `read` 恰恰是最容易产生巨型日志的工具([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md))。 ## 范围 -该策略只能看到最终格式化的呈现结果,看不到工具的内部资源或规范值。如果提供方已经截断内容(例如 `web-fetch-http.maxBodyChars`),spill 产物保存的是工具返回的完整格式化结果,而非完整原始源。提供方/资源上限仍然是必需的,并且与该策略相互独立。`glob`/`grep` 负责对项级呈现结果执行 spill,因为渲染前仍然存在完整的已获取值;bash 流负责在获取时 spill。通用策略预先注册自己的 waterfall(瀑布式事件)监听器,然后再委托,因此无论插件加载顺序如何,普通工具自身的异步投影都会在通用字节限制之前完成。详见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 +该策略只能看到最终格式化的呈现结果,看不到工具的内部资源或规范值。如果提供方已经截断内容(例如 `web-fetch-http.maxBodyChars`),spill 产物保存的是工具返回的完整格式化结果,而非完整原始源。提供方/资源上限仍然是必需的,并且与该策略相互独立。`glob`/`grep` 负责对项级呈现结果执行 spill,因为渲染前仍然存在完整的已获取值;bash 流负责在获取时 spill。通用策略预先注册自己的 waterfall(瀑布式事件)监听器,然后再委托,因此无论插件加载顺序如何,普通工具自身的异步投影都会在通用字节限制之前完成。详见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)。 ## 模型体验 diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 7ebe6e30c9..14f0193528 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/README.i18n.yaml b/packages/spill/spill/README.i18n.yaml index 0a84c591d5..695457a9af 100644 --- a/packages/spill/spill/README.i18n.yaml +++ b/packages/spill/spill/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill/README.md README.md: f36dccfa1e6f1f21e80c0b0aa97ee150a0f5fd45 -README.zh.md: 268e390923e30150a8d36b719b7dad75827f99fe +README.zh.md: 64f0a99bd1cdb10af2bbe0007de802d8ce658b7a diff --git a/packages/spill/spill/README.zh.md b/packages/spill/spill/README.zh.md index 268e390923..64f0a99bd1 100644 --- a/packages/spill/spill/README.zh.md +++ b/packages/spill/spill/README.zh.md @@ -26,7 +26,7 @@ `SaveTextSpill`(owner、source、suggestedName、content)是请求;`SpillRef`(locator、bytes、retrievalHint)是结果。`SpillLocator` 是[带品牌类型](../../util/brand)的值,并以不透明字符串的形式呈现给模型;对 `dsh-spill-local` 而言它是本地路径,但未来的后端可以返回 URI、键或命令 token,无需修改策略/工具消费方。`SpillOwner.sessionId` 是保存时存储命名空间:fork 后的会话会从种子日志继承现有定位信息,无需复制或更改其归属;fork 后新产生的 spill 使用子会话 id。`SpillSource` 记录产生该 spill 的 `toolName`、`callId` 和 `label`,供后端命名和检查使用,不用于访问控制。完整约定见 `src/types.ts`。 -设计原理见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中说明了为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 +设计原理见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md),其中说明了为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 ## 模型体验 diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index d1c436a2af..6ae414c7f1 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/storage/README.i18n.yaml b/packages/storage/README.i18n.yaml index 2d6db691b7..d0c1293124 100644 --- a/packages/storage/README.i18n.yaml +++ b/packages/storage/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/storage/README.md README.md: dd9eb5ac97529c75df5986c56b714331dca0b6f1 -README.zh.md: a69414826b3ffd613c432836ea506b664ceb8da5 +README.zh.md: 615212e46b358c55792e2cbc550797c7b0c8ca91 diff --git a/packages/storage/README.zh.md b/packages/storage/README.zh.md index a69414826b..615212e46b 100644 --- a/packages/storage/README.zh.md +++ b/packages/storage/README.zh.md @@ -6,11 +6,11 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`storage/`](storage/README.md) | 将已注册后端与类型化数据形式连接起来 | `ctx.storage` | -| [`storage-json/`](storage-json/README.md) | 在 JSON 文件中存储数据 | 注册后端 `json` | -| [`storage-sqlite/`](storage-sqlite/README.md) | 在 SQLite 中存储数据 | 注册后端 `sqlite` | -| [`storage-domain/`](storage-domain/README.md) | 提供经过验证的领域记录存储 | `ctx.storageDomain` | +| [`storage/`](storage/README.zh.md) | 将已注册后端与类型化数据形式连接起来 | `ctx.storage` | +| [`storage-json/`](storage-json/README.zh.md) | 在 JSON 文件中存储数据 | 注册后端 `json` | +| [`storage-sqlite/`](storage-sqlite/README.zh.md) | 在 SQLite 中存储数据 | 注册后端 `sqlite` | +| [`storage-domain/`](storage-domain/README.zh.md) | 提供经过验证的领域记录存储 | `ctx.storageDomain` | -消费方使用数据形式,而不是直接访问后端。[领域存储决策](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)记录了该家族的设计。 +消费方使用数据形式,而不是直接访问后端。[领域存储决策](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)记录了该家族的设计。 -子系统参考——后端约定、`StorageForms`、`DomainSpec`/`Domain`、`domain/changed`——见 [docs/subsystems/storage.md](../../docs/subsystems/storage.md)。 +子系统参考——后端约定、`StorageForms`、`DomainSpec`/`Domain`、`domain/changed`——见 [docs/subsystems/storage.md](../../docs/subsystems/storage.zh.md)。 diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml index 80ccac9a5f..9c4c1c8b4c 100644 --- a/packages/storage/storage-domain/README.i18n.yaml +++ b/packages/storage/storage-domain/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/storage/storage-domain/README.md -README.md: 7c058ab557cfb128f54c15e715685d59f6720696 +README.md: 59f5590867d35169ad7636075da524ebbf162489 README.zh.md: 10eb33711e147bc5ac6b7770a196bfea4fe66306 diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index 7c058ab557..59f5590867 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Domain data form for the DeepSeek Harness storage hub: exposes the injectable `ctx.storageDomain` service and the matching `ctx.storage.domain` projection after every configured backend is registered. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility. -Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md). ## Configuration diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 7f6a1de899..49fad8a39e 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 0ca8f2d918..109bafd8f5 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/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/storage/storage-json/README.md -README.md: c3417846a70f8a227a19a5abf834156d530e3597 -README.zh.md: e66c711dca838620eb9c90a3edacd2639970505e +README.md: 3a079da3f516af4a3f7bc13dcf0115589fa42ff3 +README.zh.md: f9b2ffb7444893a27a2da1d3dcd324c73452cc92 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index c3417846a7..3a079da3f5 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -JSON backend for the [storage hub](../storage/README.md): one human-readable `.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +JSON backend for the [storage hub](../storage/README.md): one human-readable `.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md). ## Model diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index e66c711dca..f9b2ffb744 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[存储中心](../storage/README.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +[存储中心](../storage/README.zh.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 ## 模型 diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 617f8bcfe0..433d4394be 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index 2215e835e2..b1b8b1def0 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/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/storage/storage-sqlite/README.md -README.md: 48aae14aa8613886d69652c39194878c190259cf -README.zh.md: d25591ac67a96bce2ea74b2686e2fbb8375af959 +README.md: 654ae004986240ae6c7c8f74f80e45089270a0ab +README.zh.md: 9f31c55a3281a02ece793d2810480de4e9d8f339 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index 48aae14aa8..654ae00498 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -SQLite backend for the [storage hub](../storage/README.md): registers as backend `sqlite`, serving the `kv` facet over one `node:sqlite` database file (or `:memory:`). Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +SQLite backend for the [storage hub](../storage/README.md): registers as backend `sqlite`, serving the `kv` facet over one `node:sqlite` database file (or `:memory:`). Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md). ## Storage model diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index d25591ac67..9f31c55a32 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[存储中心](../storage/README.md)的 SQLite 后端:注册为后端 `sqlite`,通过一个数据库提供 `kv` facet;该数据库由 `node:sqlite` 操作,可以是单个文件,也可以是 `:memory:`。设计与取舍见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +[存储中心](../storage/README.zh.md)的 SQLite 后端:注册为后端 `sqlite`,通过一个数据库提供 `kv` facet;该数据库由 `node:sqlite` 操作,可以是单个文件,也可以是 `:memory:`。设计与取舍见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 ## 存储模型 diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index da1f7089d9..7feb271061 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/README.i18n.yaml b/packages/storage/storage/README.i18n.yaml index 09fa051bac..74b71a0f88 100644 --- a/packages/storage/storage/README.i18n.yaml +++ b/packages/storage/storage/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/storage/storage/README.md README.md: bf827220afe0d8b1cbc53b3a12e6d0034004e1d2 -README.zh.md: 86b6994d74a727275e0bcaea2a83bd51f93b6ca1 +README.zh.md: fb52c96e38c17b76ef0e6b70a79177911a088fb7 diff --git a/packages/storage/storage/README.zh.md b/packages/storage/storage/README.zh.md index 86b6994d74..fb52c96e38 100644 --- a/packages/storage/storage/README.zh.md +++ b/packages/storage/storage/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -非会话数据的存储中心(`ctx.storage`):具名后端注册表加已挂载的数据形式设施。中心自身不执行 IO:后端拥有介质,数据形式拥有语义。[存储家族概述](../README.md)列出了这些包;[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)记录了设计理由。 +非会话数据的存储中心(`ctx.storage`):具名后端注册表加已挂载的数据形式设施。中心自身不执行 IO:后端拥有介质,数据形式拥有语义。[存储家族概述](../README.zh.md)列出了这些包;[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)记录了设计理由。 ## 结构 diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index e5e75353ef..e04238dbb8 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 6b69f7ba0c..fdff53c59c 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/README.md README.md: 1b3ccb6afba226e0b6bcf9aacd0c9fa752fdfdbb -README.zh.md: a98874baf8d235758dbe370272ecf230cbfb112c +README.zh.md: 245b187b71b06f0c291966fb30a65941ed18aefb diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index a98874baf8..245b187b71 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,20 +6,20 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`subagent/`](subagent/README.md) | 定义提供方注册、委派和继续执行 | `ctx.subagents` | -| [`subagent-inprocess/`](subagent-in-process-driver/README.md) | 提供共享的进程内运行驱动器 | 无 | -| [`subagent-spawn-in-process/`](subagent-spawn-in-process/README.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` | -| [`subagent-fork-in-process/`](subagent-fork-in-process/README.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` | -| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` | -| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` | -| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` | -| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` | -| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` | -| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | -| [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | +| [`subagent/`](subagent/README.zh.md) | 定义提供方注册、委派和继续执行 | `ctx.subagents` | +| [`subagent-inprocess/`](subagent-in-process-driver/README.zh.md) | 提供共享的进程内运行驱动器 | 无 | +| [`subagent-spawn-in-process/`](subagent-spawn-in-process/README.zh.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` | +| [`subagent-fork-in-process/`](subagent-fork-in-process/README.zh.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` | +| [`subagent-acp/`](subagent-acp/README.zh.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` | +| [`subagent-codex/`](subagent-codex/README.zh.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` | +| [`subagent-claude-code/`](subagent-claude-code/README.zh.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` | +| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.zh.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` | +| [`tool-subagent/`](tool-subagent/README.zh.md) | 向模型公开委派操作 | 注册到 `ctx.tools` | +| [`tool-subagent-control/`](tool-subagent-control/README.zh.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | +| [`tool-subagent-report/`](tool-subagent-report/README.zh.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | Codex 与 Claude Code 包是彼此独立的可选 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code` 安装其中一个或两个包,再重启该 Profile;每个包只注册自己的休眠 Host provider。要授予工具,请复制一份完整 Agent Preset,删除各对应工具行的 `disabled`,再启动新 Session。移除其中一个包后,下一次 Profile 启动只会撤回对应 provider 及其私有运行时闭包。 -参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 +参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md)的决策。 -子系统参考——启动请求、结果、实时运行、提供方约定、可续跑后台子 agent——见 [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md);设计依据见 [subagent 能力 seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可续跑后台 subagent](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)与[合并 subagent 控制服务](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Note。 +子系统参考——启动请求、结果、实时运行、提供方约定、可续跑后台子 agent——见 [docs/subsystems/subagent.md](../../docs/subsystems/subagent.zh.md);设计依据见 [subagent 能力 seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可续跑后台 subagent](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md)与[合并 subagent 控制服务](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md) Agent Note。 diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 61b76620ae..4aaf1b7552 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 -README.zh.md: 38c882f240dcdd02213acecf266f5a2424b37cce +README.zh.md: 7ae89ece0ce4282ad5b9a20142a2ba9b111f6d88 diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 38c882f240..7ae89ece0c 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -30,8 +30,8 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | -| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | -| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | +| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | ```yaml - id: subagent-acp @@ -57,9 +57,9 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 -本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 +本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)。 ## 模型体验 @@ -93,7 +93,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 已知限制与暂缓事项 -- **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md))。 +- **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md))。 - **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,此处尚未设计这种能力。 - **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 - **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 8a6fba06e7..a9f9f3f270 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index fbe903740e..ff129c60e2 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md README.md: 0260d9c82dee82541e5b60ff7fe2c323cf9b7331 -README.zh.md: 0a301ff53589112afe3f7f3d42d334fbebd04083 +README.zh.md: 484241f1ca23f2b8ee843b6f412d0f779832d400 diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 0a301ff535..484241f1ca 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -本包(package)注册由 Profile 命名、默认名称为 `claude-code` 的 Claude Code subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,让锁定版本的 SDK 选择随包安装的平台 CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或独立的安全失败诊断。 +本包(package)注册由 Profile 命名、默认名称为 `claude-code` 的 Claude Code subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,让锁定版本的 SDK 选择随包安装的平台 CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.zh.md) 结果约定返回严格的最终答案或独立的安全失败诊断。 ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `providerName` | `claude-code` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | | `permissionMode` 值 | 原生行为 | |---|---| diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index fe974b0344..8390ce6c91 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da32c1e25a..0f8ed31eab 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623 -README.zh.md: c51f35d9e419e621b5a2cc3101de6bd5e56aa145 +README.zh.md: 2ea256afb3bb8fdfe57fd555b6db10522a709a56 diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index c51f35d9e4..2ea256afb3 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -本包注册由 Profile 命名、默认名称为 `codex` 的 Codex subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中使用 `app-server --stdio` 启动官方包内 Codex wrapper,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或独立的安全失败诊断。 +本包注册由 Profile 命名、默认名称为 `codex` 的 Codex subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中使用 `app-server --stdio` 启动官方包内 Codex wrapper,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.zh.md) 结果约定返回选定的最终答案或独立的安全失败诊断。 ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 @@ -27,7 +27,7 @@ | `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | | `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | | `permissionMode` 值 | `thread/start` 字段 | 原生行为 | |---|---|---| diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index dbe30e18e1..c8e56f291b 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 296c8afa09..38c93dee23 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -2243,6 +2243,7 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: codexAppServerArgv(), env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index dc98bdc655..69ee09db1a 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md README.md: 8d20b44dacd773345986e2a2fc0e6aca9470bf2c -README.zh.md: 4c9117cad610276ca9d12c914a44dcbaecbbf26e +README.zh.md: 3937724c28a693e3e74cca2e1c3f1ae61766a14e diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index 4c9117cad6..3937724c28 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepSeek Harness 运行时运行,并经由 [TypeScript SDK 客户端](../../sdk/client/README.md) 通过 stdio JSON-RPC 驱动。它是 [`subagent-acp`](../subagent-acp/README.md) 之外的第二个进程外后端,差异在协议格式(wire format)和子进程约定:ACP(Agent Client Protocol)后端能驱动任何 Agent Client Protocol agent(智能体);本后端专门驱动 harness SDK 运行时(`dsh-jsonrpc-agent` bin 或打包后的可执行文件),因此子进程是一个完整的对等 harness,拥有由 `cordis.yml` 决定的组合、会话持久化、模型路由和工具。 +SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepSeek Harness 运行时运行,并经由 [TypeScript SDK 客户端](../../sdk/client/README.zh.md) 通过 stdio JSON-RPC 驱动。它是 [`subagent-acp`](../subagent-acp/README.zh.md) 之外的第二个进程外后端,差异在协议格式(wire format)和子进程约定:ACP(Agent Client Protocol)后端能驱动任何 Agent Client Protocol agent(智能体);本后端专门驱动 harness SDK 运行时(`dsh-jsonrpc-agent` bin 或打包后的可执行文件),因此子进程是一个完整的对等 harness,拥有由 `cordis.yml` 决定的组合、会话持久化、模型路由和工具。 ## 启动与所有权 `start(request)` 先解析子进程工作目录,通过 `DeepSeekHarness` spawn 运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由及可选的 `maxTokens` 输出上限)。因此,履行意味着子运行时已就绪、所有权已移交给调用方。spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。 -工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。 +工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.zh.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。 返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用。 @@ -29,7 +29,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte | `providerName` | `dsh-sdk` | `ctx.subagents` 上的注册名。 | | `command` | 必填 | 每次运行时 spawn 的可执行文件(子运行时 bin 或打包后的可执行文件)。 | | `args` | `[]` | 命令参数(通常是子进程的 `cordis.yml` 路径)。 | -| `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.md) 相同。 | +| `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.zh.md) 相同。 | | `provider` | `deepseek-official` | 写入子进程 `initialize` 的提供方路由。 | | `model` | `deepseek-v4-flash` | 写入子进程 `initialize` 的模型。 | | `maxTokens` | 适配器/提供方路由默认值 | 写入子进程 `initialize` 的单次请求输出 token 上限;对子运行时的根 agent 及其进程内后代生效。 | @@ -55,9 +55,9 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte ## 进程边界 -子进程环境以 [`dsh-subprocess`](../../subprocess/README.md) seam 的 `scrubbedParentEnv()` 为基础,先移除疑似凭据和名称为 `DSH_*` 的环境变量,再合并显式 `config.env` 值。子进程由 SDK 客户端 spawn,而不是经由 `ctx.subprocess` spawn(这是 subprocess README 中记录的 SDK 托管传输例外),因此本后端会自行执行环境清理。JSON-RPC 协议格式才是真正的序列化边界。 +子进程环境以 [`dsh-subprocess`](../../subprocess/README.zh.md) seam 的 `scrubbedParentEnv()` 为基础,先移除疑似凭据和名称为 `DSH_*` 的环境变量,再合并显式 `config.env` 值。子进程由 SDK 客户端 spawn,而不是经由 `ctx.subprocess` spawn(这是 subprocess README 中记录的 SDK 托管传输例外),因此本后端会自行执行环境清理。JSON-RPC 协议格式才是真正的序列化边界。 -本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 +本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)。 ## 模型体验 diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 8db10499cd..84612fe4c0 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index b3c3bd3995..9cd2361d52 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-fork-in-process/README.md README.md: 74c27ff10c76aa711ed3e954e806c00a27aacfa5 -README.zh.md: bca890769bab1f898abd0b71fc8aa321b66e93c8 +README.zh.md: 43e7ef489b33d52b674420d08f7edf8c89fb0e42 diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index bca890769b..43e7ef489b 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -14,7 +14,7 @@ subagent 启动时,父 agent 当前的工具调用轮次仍未结束:其日 ## 启动与能力 -`start(request)` 将已完成轮次的初始内容传给 [`startInProcessRun`](../subagent-in-process-driver/README.md),并等待子 agent 发布。共享驱动器负责取消、深度、定制、结果读取和 dispose(资源释放)。 +`start(request)` 将已完成轮次的初始内容传给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并等待子 agent 发布。共享驱动器负责取消、深度、定制、结果读取和 dispose(资源释放)。 fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。 @@ -23,7 +23,7 @@ fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: | 键 | 含义 | |---|---| | `providerName` | `ctx.subagents` 上的注册表名称(默认 `fork`)。 | -运行生命周期、模型继承与深度跟踪均为共享行为,见 [`dsh-subagent-spawn-in-process`](../subagent-spawn-in-process/README.md)。 +运行生命周期、模型继承与深度跟踪均为共享行为,见 [`dsh-subagent-spawn-in-process`](../subagent-spawn-in-process/README.zh.md)。 ## 模型体验 @@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 #### KV Cache 影响 -在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md))。 +在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 ### 父 agent 工具结果(间接) @@ -58,4 +58,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 -- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)。 +- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.zh.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index f5f0a8b1e6..86baedeca6 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 7c6f691e73..505c480cfe 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-in-process-driver/README.md README.md: 47a5c09fc1c80c5dc3062be82e7355b874a627d3 -README.zh.md: bcd6a2cf31c351722dfefe55e8b8ff75d4252c40 +README.zh.md: b96399795a0fbac05ef1795888aa93c620687f30 diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index bcd6a2cf31..b96399795a 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -20,7 +20,7 @@ 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 -驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.zh.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md)。 ## 取消与所有权 @@ -66,7 +66,7 @@ #### 模型看到的内容 -结构化运行会添加下方的结构化输出指令。它还会添加子 agent 作用域的 `structured_output` 定义,其精确描述为 `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.`,参数使用请求的 schema。该仅运行时存在的定义不在已生成并随产品发布的[工具包索引](../../../docs/tool-catalog.md#tool-package-map)中。其规范确认值是 `{ recorded: true }`,渲染为 `Structured output recorded.`;后续调用会变为 ``Error: structured output already recorded: the run is complete, so `` is not executed``。 +结构化运行会添加下方的结构化输出指令。它还会添加子 agent 作用域的 `structured_output` 定义,其精确描述为 `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.`,参数使用请求的 schema。该仅运行时存在的定义不在已生成并随产品发布的[工具包索引](../../../docs/tool-catalog.zh.md#tool-package-map)中。其规范确认值是 `{ recorded: true }`,渲染为 `Structured output recorded.`;后续调用会变为 ``Error: structured output already recorded: the run is complete, so `` is not executed``。 ##### 结构化输出指令 diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 613eea85ca..b78bec7285 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index 4286fabdd8..1246456cc0 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-spawn-in-process/README.md README.md: f1fb96f2230359cb3ff55c630f29fd34345dbed7 -README.zh.md: 940701d6fac78ce75fda24519fa22b268085ea1a +README.zh.md: 95a3b5cdb7084eb75666f8d62001221c57ac676c diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index 940701d6fa..95a3b5cdb7 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -6,7 +6,7 @@ spawn 提供方会在当前进程中创建一个全新的子 `Agent`。子 agent ## 行为 -`start(request)` 不传入 seed,直接委托给 [`startInProcessRun`](../subagent-in-process-driver/README.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系,并默认继承父 agent 模型(除非覆盖),但以空对话开始运行。 +`start(request)` 不传入 seed,直接委托给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系,并默认继承父 agent 模型(除非覆盖),但以空对话开始运行。 共享驱动器负责深度检查、persona 与工具过滤器设置、结构化输出、通过必需的信号执行取消、单次执行、结果读取和完全停稳后的 dispose(资源释放)。启动遭拒不会留下已发布的子 agent;启动调用兑现后卸载提供方,也不会撤销由持有方拥有的运行。 diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 2f317dde8a..83cd95c007 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index d58becf3bc..a645351bd8 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md README.md: e84a6b486253e81ccf7e7df12c4149e6df4ed9f2 -README.zh.md: 4ca619a9f402f3c6b4f648adf350e7ed7a568799 +README.zh.md: e289863531c1686cedeccadfa76e2661dfa9bfc8 diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 4ca619a9f4..e289863531 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -4,7 +4,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方统一使用 `ctx.subagents` 服务 API;提供方决定子 agent 在当前进程、其他进程,还是通过未来的传输方式运行。 -[subagent 家族概述](../README.md)列出了实现和面向模型的消费方。本包负责提供方注册表、共享请求和结果约定、持久描述符以及可继续子级编排。多个具名提供方可以在该约定背后共存。 +[subagent 家族概述](../README.zh.md)列出了实现和面向模型的消费方。本包负责提供方注册表、共享请求和结果约定、持久描述符以及可继续子级编排。多个具名提供方可以在该约定背后共存。 ## 服务 API @@ -41,7 +41,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 -每个进程内子 agent 都通过一次 `applyChildComposition(childCtx, parent, composition)` 调用完成组装:先加入父级的 agent-preset 组合,再应用子 agent 自己的 persona 和工具限制。加入父级组合正是子 agent 获得能力的途径:所有面向模型的行都位于 agent 平面,完全没有加入任何组合的子 agent 抵达模型时会看到空的工具注册表(见 [`dsh-agent-presets`](../../preset/agent-presets/README.md))。将父级作为参数是刻意设计:这让“组装子 agent 却不做该加入”在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组合、也不需要加入;其面向模型的行位于宿主组合中,子 agent 已能通过工具注册表的全局层解析到它们。 +每个进程内子 agent 都通过一次 `applyChildComposition(childCtx, parent, composition)` 调用完成组装:先加入父级的 agent-preset 组合,再应用子 agent 自己的 persona 和工具限制。加入父级组合正是子 agent 获得能力的途径:所有面向模型的行都位于 agent 平面,完全没有加入任何组合的子 agent 抵达模型时会看到空的工具注册表(见 [`dsh-agent-presets`](../../preset/agent-presets/README.zh.md))。将父级作为参数是刻意设计:这让“组装子 agent 却不做该加入”在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组合、也不需要加入;其面向模型的行位于宿主组合中,子 agent 已能通过工具注册表的全局层解析到它们。 `childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 @@ -57,15 +57,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 + + ## 委派策略 -两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 会为父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)创建快照,并在审批能力已组合时将子 agent 的审批策略固定为 `'never'`,无论父级自身采用何种策略。这样,被委派的子 agent 只能在继承的沙箱范围内行动,每次审批请求(例如 `sandbox_permissions` 升权)都会被确定性拒绝,而不会等待无人处理的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志。因此,新捕获的策略会覆盖种子中的陈旧状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在第一次 await 前捕获策略,并且只为全新物化写入这些委派事件;冷恢复只会重放已持久化的委派事件,不会重新捕获父级策略,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇委派策略 Agent Note。 +两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 会为父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)创建快照,并在审批能力已组合时将子 agent 的审批策略固定为 `'never'`,无论父级自身采用何种策略。这样,被委派的子 agent 只能在继承的沙箱范围内行动,每次审批请求(例如 `sandbox_permissions` 升权)都会被确定性拒绝,而不会等待无人处理的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志。因此,新捕获的策略会覆盖种子中的陈旧状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在第一次 await 前捕获策略,并且只为全新物化写入这些委派事件;冷恢复只会重放已持久化的委派事件,不会重新捕获父级策略,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md)两篇委派策略 Agent Note。 ## 一次性所有权与生命周期 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。共享结果类型不定义提供方类别或生命周期阶段:进程外提供方可以从锁定版本产品提供的结构化事实与已观测的进程结果派生固定展示文本,而消费方只负责原样呈现,不解析该文本。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。共享结果类型不定义提供方类别或生命周期阶段:进程外提供方可以从锁定版本产品提供的结构化事实与已观测的进程结果派生固定展示文本,而消费方只负责原样呈现,不解析该文本。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.zh.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 @@ -103,12 +105,14 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 ## 模型体验 + + ### 结算通知 #### 模型看到的内容 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 7a42bdb7a7..860b37a9e5 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 9473f3b003..c225f71f75 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -12,26 +12,44 @@ import { foldSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' -interface TimingState { +/** Fold state for a subagent's latest timing snapshot. */ +export interface TimingState { /** Milliseconds accumulated across completed post-descriptor turns. */ settledMs: number /** Current open interval kept paired inside the fold. */ - active?: { since: number; through: number } + active?: { since: number; through: number } | undefined /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ - pendingTurnStart?: number + pendingTurnStart?: number | undefined /** Whether the fold has crossed a descriptor in this logical log. */ descriptorSeen: boolean } -// Zod's optional output includes explicit `undefined`; with -// exactOptionalPropertyTypes the public interface permits omission only. -const projectionSchema = z.object({ +const activeIntervalSchema = z.object({ + since: z.number().int().nonnegative(), + through: z.number().int().nonnegative(), +}).strict() + +const projectionSchema: z.ZodType = z.object({ settledMs: z.number().int().nonnegative(), - active: z.object({ - since: z.number().int().nonnegative(), - through: z.number().int().nonnegative(), - }).strict().optional(), -}).strict() as unknown as z.ZodType + active: activeIntervalSchema.optional(), +}).strict().transform(({ settledMs, active }) => ({ + settledMs, + ...active === undefined ? {} : { active }, +})) + +const timingStateSchema: z.ZodType = z.object({ + settledMs: z.number().int().nonnegative(), + active: activeIntervalSchema.optional(), + pendingTurnStart: z.number().int().nonnegative().optional(), + descriptorSeen: z.boolean(), +}).strict() + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + subagentTiming: TimingState + subagent: IdentityState + } +} /** * Fold turn boundaries around the child's own durable descriptor. @@ -41,10 +59,9 @@ const projectionSchema = z.object({ * admits only a child with exactly one descriptor in its own suffix, making * the final reset the child's authoritative timing origin. */ -export const subagentTimingProjectionDefinition: -ProjectionDefinition<'subagentTiming', TimingState> = { +export const subagentTimingProjectionDefinition = { key: 'subagentTiming', - schema: projectionSchema, + stateSchema: timingStateSchema, init: () => ({ descriptorSeen: false, settledMs: 0 }), apply: (state, event) => { if (event.type === 'turn/start') { @@ -78,16 +95,19 @@ ProjectionDefinition<'subagentTiming', TimingState> = { if (state.active === undefined) return state return { ...state, active: { ...state.active, through: event.time } } }, - view: state => ({ - settledMs: state.settledMs, - ...(state.active === undefined ? {} : { active: state.active }), - }), + wire: { + viewSchema: projectionSchema, + view: state => ({ + settledMs: state.settledMs, + ...(state.active === undefined ? {} : { active: state.active }), + }), + }, stateVersion: 2, -} +} satisfies ProjectionDefinition<'subagentTiming', TimingState> interface IdentityState { /** Identity from the last valid descriptor; absent before one, and after an invalid one. */ - identity?: SubagentIdentityProjection + identity?: SubagentIdentityProjection | undefined } // The cast bridges only the optional-label arm: Zod's optional output @@ -95,7 +115,7 @@ interface IdentityState { // from the public interface. The no-value state itself is the serializable // `null` arm — never `undefined` — so every registry read and push frame // survives JSON.stringify losslessly. -const identitySchema = z.discriminatedUnion('mode', [ +const identityValueSchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('one-shot'), label: z.string().optional(), @@ -106,7 +126,13 @@ const identitySchema = z.discriminatedUnion('mode', [ label: z.string(), seq: z.number().int().nonnegative(), }).strict(), -]).nullable() as unknown as z.ZodType +]) as unknown as z.ZodType + +const identitySchema = identityValueSchema.nullable() + +const identityStateSchema: z.ZodType = z.object({ + identity: identityValueSchema.optional(), +}).strict() /** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined { @@ -139,18 +165,17 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u * holding the earlier identity replaces it instead of keeping it stale; * `null` ⟺ no valid descriptor, with the causes deliberately undistinguished. */ -export const subagentIdentityProjectionDefinition: -ProjectionDefinition<'subagent', IdentityState> = { +export const subagentIdentityProjectionDefinition = { key: 'subagent', - schema: identitySchema, + stateSchema: identityStateSchema, init: () => ({}), apply: (state, event) => { if (event.type !== 'subagent/descriptor') return state const identity = descriptorIdentity(event) return identity === undefined ? {} : { identity } }, - view: state => state.identity ?? null, + wire: { viewSchema: identitySchema, view: state => state.identity ?? null }, // Bumped when the identity gained its `seq` field: an older checkpoint row // would replay into a value the schema rejects, so it must refold instead. stateVersion: 2, -} +} satisfies ProjectionDefinition<'subagent', IdentityState> diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 3fd303e7f6..e9a5633189 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -118,6 +118,9 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) } declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + subagentListHostileProbe: { poisoned?: boolean | undefined } + } interface SessionProjectionMap { /** Test-only hostile probe proving per-child isolation of foreign unit failures. */ subagentListHostileProbe: null @@ -130,20 +133,23 @@ declare module '@deepseek-ai/dsh-session-projection/types' { * through it), while the poisoned state detonates only when a listing read * folds or serves this child through the registry. */ -const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = { +const hostileProjectionDefinition = { key: 'subagentListHostileProbe', - schema: z.null(), + stateSchema: z.object({ poisoned: z.boolean().optional() }), init: () => ({}), apply: (state, event) => event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me' ? { poisoned: true } : state, - view: (state) => { - if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log') - return null + wire: { + viewSchema: z.null(), + view: (state) => { + if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log') + return null + }, }, stateVersion: 1, -} +} satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }> describe('SubagentRuntime.listChildren', () => { it('lists live children without persistence, query services, or the continuation runtime', async () => { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index ab3ffac9a2..bfc42eae3b 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -4,16 +4,16 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '../src/index.ts' -import { subagentTimingProjectionDefinition } from '../src/projection.ts' +import { subagentTimingProjectionDefinition, type TimingState } from '../src/projection.ts' function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { return { type, seq, time, data: {} } as SessionEvent } function fold(events: SessionEvent[]) { - let state = subagentTimingProjectionDefinition.init() + let state: TimingState = subagentTimingProjectionDefinition.init() for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item) - return subagentTimingProjectionDefinition.view(state) + return subagentTimingProjectionDefinition.wire.view(state) } describe('subagent timing projection', () => { diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index 16b43e0b14..07ec431848 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md README.md: 91f8d23ac092049e5315418070cdaae025054860 -README.zh.md: 66d4b5a446fc9e412c5fec7bccf27bdefd1f5f77 +README.zh.md: ca63383e541ccb157f5ee8a6a7a7de7be84b9f5a diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 66d4b5a446..ca63383e54 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的全局具名 `send_message`、`interrupt_agent` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message` 与 `interrupt_agent`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,并将 `subagents` 与 `agents` 声明为加载时依赖。其目录读取在调用时还要求会话存储与投影注册表,但不要求任何查询服务。部署可保留根插件工具并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。 +可选的全局具名 `send_message`、`interrupt_agent` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message` 与 `interrupt_agent`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,并将 `subagents` 与 `agents` 声明为加载时依赖。其目录读取在调用时还要求会话存储与投影注册表,但不要求任何查询服务。部署可保留根插件工具并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.zh.md) 负责子到父的方向。 本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源记录为 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为 subagent 的下一个 FIFO 轮次:如果子 agent(智能体)仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。 @@ -16,7 +16,7 @@ #### 模型看到的内容 -已生成的 [schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):`send_message` 包含 `subagent_id` 和 `message`,说明消息会成为 subagent 的下一个轮次、本次调用不会返回 subagent 的回答,以及失败即表示消息未送达;`interrupt_agent` 包含 `agent_id`,说明只有当前轮次会停止、已排队消息保持暂停、后代继续运行,以及接受先于实际停止;`list_agents` 包含可选的 `scope` 枚举。 +已生成的 [schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent-control):`send_message` 包含 `subagent_id` 和 `message`,说明消息会成为 subagent 的下一个轮次、本次调用不会返回 subagent 的回答,以及失败即表示消息未送达;`interrupt_agent` 包含 `agent_id`,说明只有当前轮次会停止、已排队消息保持暂停、后代继续运行,以及接受先于实际停止;`list_agents` 包含可选的 `scope` 枚举。 #### Token 影响 diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 05278fc62f..32ae190516 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 129ba8f92f..e3b06edfa9 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md README.md: ab2bcc5e4b829b66810da4089edb7146ebc1c791 -README.zh.md: c9ae647a44cad8c04603877dddd81d3fbe5be0e3 +README.zh.md: 5bac66a637eeb36dcd2133d387fd304f0912be4a diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index c9ae647a44..5bac66a637 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体),并安装指示子级使用该通道的提示词 section。本包注册的是可继续子级设置贡献,而不是全局工具,因此该工具及其指引只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 +可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体),并安装指示子级使用该通道的提示词 section。本包注册的是可继续子级设置贡献,而不是全局工具,因此该工具及其指引只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.zh.md) 负责,可继续模式不依赖这两个包中的任一个。 子级作用域的 `tool:report` 提示词 section 要求子级在结束前调用一次 `report` 并给出自足的答案,并在部分发现会改变父级下一步动作时提前上报。该指令是引导而非强制:机制本身仍接受一个轮次中调用零次或多次,也没有任何运行时路径会拒绝从不上报的子级。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 @@ -18,7 +18,7 @@ #### 模型看到的内容 -已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明子级必须在结束前上报一次,上报只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。独立的 `tool:report` 提示词 section 在 schema 之外重申该义务,使忽略工具描述的子级仍能读到。 +已生成的 [`report` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明子级必须在结束前上报一次,上报只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。独立的 `tool:report` 提示词 section 在 schema 之外重申该义务,使忽略工具描述的子级仍能读到。 #### Token 影响 diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index dbadc60e88..1e2d66c196 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index b5e6ebf724..29d65cf9da 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md README.md: 28e6213b903ffffa7934e244b2a74ada519b32b2 -README.zh.md: deae0f0ff9e19b627a04704eccf4b8874f34068f +README.zh.md: 74e8896a152c787abd0aebf055d6e13f6158bcd3 diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index deae0f0ff9..74e8896a15 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,9 +10,9 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。 -`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知;失败 Task 的 detail 会保留终止原因与同一份可选提供方诊断。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 +`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知;失败 Task 的 detail 会保留终止原因与同一份可选提供方诊断。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.zh.md)。 -`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 +`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)。 ## 配置 @@ -29,7 +29,7 @@ ## 并发 -前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 job id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 job id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md)。 ## 模型体验 @@ -37,7 +37,7 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 #### Token 影响 @@ -65,7 +65,7 @@ #### 模型看到的内容 -在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知;若结果携带提供方诊断,失败状态的 detail 会包含它。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 +在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知;若结果携带提供方诊断,失败状态的 detail 会包含它。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.zh.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 8de6a04739..ade9120a2c 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index b294c97415..a9aa1fcfe7 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/README.md README.md: 72a30775f45a8140935a013c63e9427e9cbd94d6 -README.zh.md: 35c801a1f4a5e8ad161b8c4ff57ad6ffa07e699d +README.zh.md: 790fa35412e5fd6cd14500d9deab6f8d3d603af0 diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 35c801a1f4..790fa35412 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完全明确指定的受管子进程树,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.md)、[LSP 主机](../lsp/README.md)、[PTY shell 后端](../terminal/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完全明确指定的受管子进程树,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 | 包 | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | -| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`subprocess`](subprocess/README.zh.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | +| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 -子系统参考——spawn spec、输出读取器、结果、`DSH_*` 环境——见 [docs/subsystems/subprocess.md](../../docs/subsystems/subprocess.md);seam 决定见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +子系统参考——spawn spec、输出读取器、结果、`DSH_*` 环境——见 [docs/subsystems/subprocess.md](../../docs/subsystems/subprocess.zh.md);seam 决定见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 10c7c1cd62..646dafcd0a 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: a88eab6776c7259bb8ee8fdad2d6002d8f57e133 -README.zh.md: ce088557875daceb5643f13bef78d6ea9a61e12f +README.md: 0935bb309bd10dec7503a74708a28442223bf296 +README.zh.md: 2f37cbce8df0b5f454d2301b78b44961cf66ea31 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index a88eab6776..0935bb309b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). @@ -26,7 +26,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. -- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. +- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index ce08855787..2f37cbce8d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -2,18 +2,18 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md))。 +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.zh.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.zh.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.zh.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.zh.md))。 ## 行为 - **以适合平台的方式发送信号的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;重新指定父进程并脱离该组的 daemon 仍可能存活。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 -- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 -- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 ## 模型体验 @@ -26,7 +26,7 @@ ## 已知限制与暂缓事项 - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 -- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 +- **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 - **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index ab2c01973c..86db53e276 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "koffi": "^3.1.0", "node-pty": "1.2.0-beta.15" }, "devDependencies": { diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..89effc0082 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -3,6 +3,7 @@ import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import { createWindowsProcessInspector } from './windows-inspector.ts' /** PID plus start identity, preventing teardown escalation after PID reuse. */ export interface ProcessIdentity { @@ -370,5 +371,6 @@ export function createProcessInspector( ): ProcessInspector { if (platform === 'linux') return new LinuxProcessInspector(arch, internals) if (platform === 'darwin') return new MacProcessInspector(internals) + if (platform === 'win32') return createWindowsProcessInspector() throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 6d818c8a7f..0a51287d24 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -50,11 +50,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { * @param terminal - allocated node-pty process. * @param inspector - platform process/session operations. * @param graceMs - TERM-to-KILL and exit-wait grace. + * @param platform - host platform; defaults to the running platform, injectable for deterministic tests. */ constructor( private readonly terminal: IPty, private readonly inspector: ProcessInspector, private readonly graceMs: number, + private readonly platform: NodeJS.Platform = process.platform, ) { this.pid = terminal.pid this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) @@ -98,6 +100,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) { throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead') } + if (this.platform === 'win32') { + if (signal === 'SIGINT') { + // Windows has no process-group signalling: a `\x03` input write is the + // Ctrl-C delivery path conhost turns into a console-wide CTRL_C event + // for attached processes. node-pty's signal kills throw on Windows, so + // no signal ever reaches the inspector. + this.terminal.write('\x03') + return foreground.processGroupId + } + if (signal === 'SIGTSTP' || signal === 'SIGHUP') { + throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`) + } + } this.inspector.signalGroup(foreground.processGroupId, signal) return foreground.processGroupId } @@ -214,6 +229,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async stopShell(): Promise { + if (this.platform === 'win32') { + await this.stopShellWindows() + return + } if (!this.exited) { try { this.terminal.kill('SIGTERM') @@ -233,6 +252,44 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) } + private async stopShellWindows(): Promise { + // node-pty's Windows kill(signal) throws ("Signals not supported on + // windows"), and its bare kill() delegates to a console-list agent that + // fails when the parent has no console. taskkill tree escalation is the + // teardown path, fenced on the shell's start identity like every + // descendant; a root identity miss falls back to the bare kill. taskkill + // termination also does not reliably fire node-pty's exit notification + // (the same console-list agent), so the tiers verify the shell's absence + // through the inspector instead of waiting on `done` alone. + const shellGone = (): boolean => + this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGTERM') + await this.waitForWindowsShellExit() + } + if (!shellGone() && this.rootIdentity === undefined) { + try { + this.terminal.kill() + } catch (_topLevelAlreadyExitedDuringKill) { + // The exit callback is authoritative. + } + await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) + } + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + await this.waitForWindowsShellExit() + } + if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) + } + + private async waitForWindowsShellExit(): Promise { + const until = Date.now() + this.graceMs + while (!this.exited && Date.now() < until) { + if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return + await delay(Math.min(25, Math.max(1, until - Date.now()))) + } + } + private async closeOnce(): Promise { let survivors = await this.stopDescendants() if (survivors.length > 0) { @@ -243,7 +300,24 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (survivors.length > 0) { throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`) } + this.settleExitIfGone() this.dataDisposable.dispose() this.exitDisposable.dispose() } + + private settleExitIfGone(): void { + // An externally taskkilled Windows shell may never fire node-pty's exit + // notification (its console-list agent fails without a parent console), + // which would leave `done` — and every consumer awaiting it — unsettled + // forever. Teardown has just verified the shell's absence through the + // inspector, so a missing exit event is itself the outcome. + if (this.platform !== 'win32') return + if (this.exited) return + /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw; + the identity re-check is a defensive fence for a future caller. */ + if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return + this.exited = true + this.output.end() + this.outcome.resolve({ exitCode: null, signal: null }) + } } diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts new file mode 100644 index 0000000000..7280cbb9ee --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -0,0 +1,325 @@ +/** + * Windows process-table operations for terminal readiness, signalling, and + * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time + * identity and process-handle wait-state liveness, the shell pid as a pseudo + * process group (Windows has no POSIX groups), and taskkill tree signalling. + * The koffi bindings load lazily so + * non-Windows processes never touch Win32 libraries; all decision logic takes + * an injectable internals boundary so suites can pin it on any host. + * @module dsh-subprocess-local/windows-inspector + */ + +import { spawnSync } from 'node:child_process' +import koffi from 'koffi' +import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' + +/** One Toolhelp32 process-table row. */ +export interface ProcessEntry { + pid: number + parentPid: number +} + +/** Creation identity plus the process object's current wait state. */ +export interface WindowsProcessState { + /** GetProcessTimes creation identity used to fence PID reuse. */ + started: string + /** Whether a zero-time process-handle wait reports the process still running. */ + active: boolean +} + +/** Injectable Windows process operations used by one local PTY session. */ +export interface WindowsProcessInspectorInternals { + /** Enumerate the current process table (pid/parent pairs). */ + snapshot(): ProcessEntry[] + /** Return one process's creation identity and wait state, or undefined when unreadable. */ + processState(pid: number): WindowsProcessState | undefined + /** Terminate one process tree; `force` maps to taskkill `/F`. */ + taskkill(pid: number, force: boolean): void +} + +/** + * Walk a process table from one root in children-first order, retaining only + * members whose start identity is readable (unreadable members are detector + * misses, exactly like an unreadable `/proc` entry on Linux). + * @param entries - the process table snapshot. + * @param rootPid - the tree root to descend from. + * @param started - creation-time identity resolver for one member. + * @returns the root and its current transitive descendants, children first. + */ +/* jscpd:ignore-start -- the Windows inspector deliberately mirrors process-inspector.ts: + the decision logic (tree walk, identity fencing, group signalling) is the same contract over + Win32 primitives, per the persistent-pty note 2026-08-11-pwsh-persistent-pty. */ +export function windowsProcessTree( + entries: ProcessEntry[], + rootPid: number, + started: (pid: number) => string | undefined, +): ProcessIdentity[] { + const byPid = new Map(entries.map(entry => [entry.pid, entry])) + const root = byPid.get(rootPid) + if (root === undefined) return [] + const byParent = new Map() + for (const entry of entries) { + const children = byParent.get(entry.parentPid) ?? [] + children.push(entry) + byParent.set(entry.parentPid, children) + } + const visited = new Set() + const result: ProcessIdentity[] = [] + const visit = (entry: ProcessEntry): void => { + if (visited.has(entry.pid)) return + visited.add(entry.pid) + for (const child of byParent.get(entry.pid) ?? []) visit(child) + const identity = started(entry.pid) + if (identity !== undefined) result.push({ pid: entry.pid, started: identity }) + } + visit(root) + return result +} + +/** + * Windows {@link ProcessInspector}. The shell pid stands in for a foreground + * process group: it is a stable pseudo-group that lets the prompt-marker + * readiness path compare foreground identities, while every actual signal + * targets the console-wide tree through taskkill (SIGINT is delivered by the + * terminal handle as a `\x03` input write and never reaches this layer). + */ +export class WindowsProcessInspector implements ProcessInspector { + constructor( + private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), + ) {} + + foregroundPgid(shellPid: number): number { + return shellPid + } + + isStdinWaiting(_pgid: number): boolean { + return false + } + + processTree(rootPid: number): ProcessIdentity[] { + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started) + } + + processSession(_sessionId: number): ProcessIdentity[] { + return [] + } + + isAlive(identity: ProcessIdentity): boolean { + const state = this.internals.processState(identity.pid) + return state?.active === true && state.started === identity.started + } + + signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { + this.internals.taskkill(pgid, signal === 'SIGKILL') + } + + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { + if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') + } +} +/* jscpd:ignore-end */ + +/** + * Create the Windows process inspector. + * @param internals - injectable process operations; defaults to the koffi-backed table. + * @returns the Windows inspector. + */ +export function createWindowsProcessInspector( + internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), +): WindowsProcessInspector { + return new WindowsProcessInspector(internals) +} + +/** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */ +function taskkillTree(pid: number, force: boolean): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree, exit races, and a + // missing taskkill binary are as tolerable here as ESRCH is for POSIX. + spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' }) +} + +declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** + * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs. + * @param value - a handle as koffi may hand it back (pointer, null, or 0n). + * @returns whether the value signals an invalid handle. + */ +export function isInvalidHandle(value: NativePtr | null | undefined): boolean { + if (value === null || value === undefined) return true + const asBigInt = value as bigint + return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n +} + +/** The lazy koffi binding table: every Win32 call the Windows inspector uses. */ +interface Win32Bindings { + createToolhelp32Snapshot(flags: number, processId: number): NativePtr + process32FirstW(snapshot: NativePtr, entry: NativePtr): number + process32NextW(snapshot: NativePtr, entry: NativePtr): number + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + getProcessTimes( + process: NativePtr, + creation: NativePtr, + exit: NativePtr, + kernel: NativePtr, + user: NativePtr, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + closeHandle(handle: NativePtr): number +} + +const PVOID: ReturnType = koffi.pointer('void') + +/** + * Resolve the koffi Win32 struct types once. Registration is lazy and cached + * because koffi's type registry is global per process: test runners that + * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not + * re-register the names. + */ +function win32Structs(): { PROCESSENTRY32W: ReturnType; FILETIME: ReturnType } { + if (cachedStructs !== undefined) return cachedStructs + // koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout. + const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', { + dwSize: 'uint32', + cntUsage: 'uint32', + th32ProcessID: 'uint32', + th32DefaultHeapID: PVOID, + th32ModuleID: 'uint32', + cCntThreads: 'uint32', + th32ParentProcessID: 'uint32', + pcPriClassBase: 'int32', + dwFlags: 'uint32', + szExeFile: koffi.array('char16', 260), + }) + // koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp. + const FILETIME = koffi.struct('FILETIME', { + dwLowDateTime: 'uint32', + dwHighDateTime: 'uint32', + }) + /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */ + if (PROCESSENTRY32W.size !== 568) { + throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`) + } + /* v8 ignore stop */ + cachedStructs = { PROCESSENTRY32W, FILETIME } + return cachedStructs +} + +let cachedStructs: ReturnType | undefined + +const TH32CS_SNAPPROCESS = 0x2 +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +const SYNCHRONIZE = 0x00100000 +const WAIT_OBJECT_0 = 0 +const WAIT_TIMEOUT = 0x102 + +let cachedBindings: Win32Bindings | undefined + +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ +function win32Bindings(): Win32Bindings { + if (cachedBindings !== undefined) return cachedBindings + const { PROCESSENTRY32W, FILETIME } = win32Structs() + const kernel32 = koffi.load('kernel32.dll') + const bind = ( + name: string, + result: ReturnType | string, + args: Array | string>, + ): unknown => kernel32.func('__stdcall', name, result, args) + cachedBindings = { + createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']), + process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + getProcessTimes: bind('GetProcessTimes', 'int', [ + PVOID, + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + ]), + waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + closeHandle: bind('CloseHandle', 'int', [PVOID]), + } as unknown as Win32Bindings + return cachedBindings +} + +/** + * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are + * `any`, so the cast goes through `unknown` to keep the unsafe surface here. + * @param type - the koffi type to allocate. + * @param count - element count. + * @returns the branded allocation pointer. + */ +function allocNative(type: Parameters[0], count: number): NativePtr { + const value: unknown = koffi.alloc(type, count) + return value as NativePtr +} + +/** Enumerate the current process table through Toolhelp32. */ +function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { + const { PROCESSENTRY32W } = win32Structs() + const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API; + the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */ + if (isInvalidHandle(snapshot)) return [] + const entries: ProcessEntry[] = [] + try { + const entry = allocNative(PROCESSENTRY32W, 1) + koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) + let ok = bindings.process32FirstW(snapshot, entry) + while (ok !== 0) { + const record = koffi.decode(entry, PROCESSENTRY32W) as { + th32ProcessID: number + th32ParentProcessID: number + } + entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID }) + ok = bindings.process32NextW(snapshot, entry) + } + } finally { + bindings.closeHandle(snapshot) + } + return entries +} + +/** Read one process's creation identity and current wait state. */ +function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined { + const { FILETIME } = win32Structs() + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) + if (isInvalidHandle(handle)) return undefined + try { + const creation = allocNative(FILETIME, 1) + const exit = allocNative(FILETIME, 1) + const kernel = allocNative(FILETIME, 1) + const user = allocNative(FILETIME, 1) + /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and + cannot be staged deterministically; the absent-process path is covered and the caller + treats undefined as a detector miss. */ + if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined + const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } + const wait = bindings.waitForSingleObject(handle, 0) + /* v8 ignore next -- an opened process handle has exactly one of these two + zero-time wait states; an unexpected Win32 failure is an unreadable process. */ + if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined + return { + started: `${record.dwHighDateTime}:${record.dwLowDateTime}`, + active: wait === WAIT_TIMEOUT, + } + } finally { + bindings.closeHandle(handle) + } +} + +/** The koffi-backed default internals; bindings resolve lazily on first use. */ +function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { + return { + snapshot: () => snapshotWindowsProcesses(win32Bindings()), + processState: pid => windowsProcessState(win32Bindings(), pid), + taskkill: taskkillTree, + } +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index c41d4025d6..41e6b48bc8 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -7,8 +7,16 @@ import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalS import { childEnv } from '../src/spawn.ts' function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { + // Windows has no bash; the suite's simple commands translate to node one-liners. + const argv = process.platform === 'win32' + ? [process.execPath, '-e', { + 'echo managed': 'console.log("managed")', + 'sleep 60': 'setTimeout(() => {}, 60000)', + 'true': '', + }[command] ?? command] + : ['bash', '-c', command] return { - argv: ['bash', '-c', command], + argv, cwd: process.cwd(), stdio: { stdin: 'ignore', @@ -148,9 +156,9 @@ describe('LocalSubprocessRuntime', () => { const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' }) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path']) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt']) - expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD']) + expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')]) expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' })) - .toEqual(['/explicit/tool.EXE']) + .toEqual([resolve('/explicit', 'tool.EXE')]) expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')]) expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4) await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`)) @@ -398,7 +406,8 @@ describe('LocalSubprocessRuntime', () => { const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done - expect(outcome.signal).toBe('SIGTERM') + // Windows teardown terminates through taskkill, which reports no signal. + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('a settled process leaves the live set (disposal does not re-kill it)', async () => { diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..aadf2e1388 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -5,6 +5,7 @@ import { parseProcStat, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' +import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string { const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)] @@ -237,12 +238,13 @@ describe('macOS process inspector', () => { ]) }) - it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => { + it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => { const fake = fakeInternals() fake.setTpgid('-1') expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() fake.internals.exec = () => { throw new Error('gone') } expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() - expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32') + expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd') }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 1f87fd67cc..1f21dfaaa1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + childEnv, killGroup, OutputCollector, spawnSubprocess, @@ -11,6 +12,49 @@ import { import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +/** + * Translate the suite's POSIX command strings into node one-liners on Windows, + * where no bash exists; the translated commands keep the same observable + * stdout/stderr/exit-code contract the bash originals pin on POSIX. + * @param command - the bash `-c` command string used by the test. + * @returns the argv to spawn. + */ +function shellArgv(command: string): string[] { + if (process.platform !== 'win32') return ['bash', '-c', command] + const node = (script: string): string[] => [process.execPath, '-e', script] + switch (command) { + case 'true': return node('') + case 'echo hello': return node('console.log("hello")') + case 'echo hi': return node('console.log("hi")') + case 'echo oops >&2': return node('console.error("oops")') + case 'echo err >&2': return node('console.error("err")') + case 'echo out; echo err >&2': return node('console.log("out"); console.error("err")') + case 'echo out; echo to-parent >&2': return node('console.log("out"); console.error("to-parent")') + case 'echo to-parent; echo err >&2': return node('console.log("to-parent"); console.error("err")') + case 'exit 42': return node('process.exit(42)') + case 'exit 7': return node('process.exit(7)') + case 'pwd': return node('console.log(process.cwd())') + case 'sleep 60': return node('setTimeout(() => {}, 60000)') + case 'cat': return node('process.stdin.pipe(process.stdout)') + case 'unused': return node('') + case 'echo "${TERM:-unset}"': return node('console.log(process.env.TERM ?? "unset")') + case 'echo "$EXTRA_ONE/$EXTRA_TWO"': return node('console.log(process.env.EXTRA_ONE + "/" + process.env.EXTRA_TWO)') + case 'echo "$EXPLICIT_OVERRIDE_PASSWORD"': return node('console.log(process.env.EXPLICIT_OVERRIDE_PASSWORD)') + case 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"': return node('console.log(process.env.SUBPROCESS_TOMBSTONE_PROBE ?? "absent")') + case 'echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"': + return node('console.log("[" + [process.env.DSH_STALE ?? "absent", process.env.DSH_SHELL, process.env.DSH_SESSION_ID].join("|") + "]")') + case 'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"': + return node('console.log("[" + [process.env.DSH_TEST_API_KEY ?? "absent", process.env.DSH_TEST_TOKEN ?? "absent", process.env.SUBPROCESS_TEST_PASSWORD ?? "absent", process.env.DSH_TEST_PLAIN ?? "absent"].join("|") + "]")') + case 'printf "%.0sx" $(seq 1 500)': return node('process.stdout.write("x".repeat(500))') + case 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2': + return node('process.stdout.write("x".repeat(500)); process.stderr.write("e".repeat(500))') + case 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done': + return node('for (let i = 1; i <= 200; i++) console.log("line-" + String(i).padStart(4, "0"))') + default: + throw new Error(`spawn.spec: no win32 node translation for ${JSON.stringify(command)}`) + } +} + const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, failNextUnlink: { value: false }, @@ -48,7 +92,7 @@ type SpecOverrides = Partial[0]> & { function spec(command: string, overrides: SpecOverrides = {}) { const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides return { - argv: ['bash', '-c', command], + argv: shellArgv(command), cwd: process.cwd(), stdio: { stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, @@ -161,7 +205,7 @@ describe('spawnSubprocess', () => { expect(result.stdout.text).toBe('callers-choice\n') }) - it('runs in the requested cwd', async () => { + it.skipIf(process.platform === 'win32')('runs in the requested cwd', async () => { const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) @@ -176,11 +220,12 @@ describe('spawnSubprocess', () => { setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() + // Windows teardown terminates through taskkill, which reports no signal. + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') + expect(result.exitCode).toBe(process.platform === 'win32' ? 1 : null) }) - it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + it.skipIf(process.platform === 'win32')('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.terminate() @@ -231,12 +276,16 @@ describe('spawnSubprocess', () => { expect(forceSignals).toBe(0) } finally { killSpy.mockRestore() - process.kill(helper, 'SIGKILL') + try { + process.kill(helper, 'SIGKILL') + } catch { + // taskkill already took the helper down on Windows. + } await waitGone(helper) } }) - it('terminates the whole process group (grandchildren die too)', async () => { + it.skipIf(process.platform === 'win32')('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) @@ -255,7 +304,7 @@ describe('spawnSubprocess', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('throws when the signal is already aborted before spawn', () => { @@ -275,10 +324,10 @@ describe('spawnSubprocess', () => { running.terminate() running.terminate() const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('does not wait for a Linux group that has only zombie members', async () => { + it.skipIf(process.platform === 'win32')('does not wait for a Linux group that has only zombie members', async () => { const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), { platform: 'linux', @@ -296,7 +345,7 @@ describe('spawnSubprocess', () => { } }) - it('bounds inherited-pipe draining after the shell exits', async () => { + it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) @@ -328,7 +377,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('') }) - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + it.skipIf(process.platform === 'win32')('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-spawn `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) @@ -619,7 +668,7 @@ describe('windows tree semantics (injected platform)', () => { running.terminate() const outcome = await running.done expect(killed).toContain(running.pid) - expect(outcome.signal).toBe('SIGKILL') + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL') }) it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { @@ -630,7 +679,7 @@ describe('windows tree semantics (injected platform)', () => { }) describe('waitForExit', () => { - it('waits for the whole detached tree, not just the shell', async () => { + it.skipIf(process.platform === 'win32')('waits for the whole detached tree, not just the shell', async () => { const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) @@ -650,7 +699,7 @@ describe('waitForExit', () => { }) }) -describe('synchronous host-exit termination', () => { +describe.skipIf(process.platform === 'win32')('synchronous host-exit termination', () => { it('force-kills the current process tree without waiting for the normal grace', async () => { const running = spawnSubprocess(spec('trap "" TERM; sleep 60', { graceMs: 60_000 })) running.terminateForHostExit() @@ -667,7 +716,7 @@ describe('synchronous host-exit termination', () => { }) }) -describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { +describe.skipIf(process.platform === 'win32')('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -733,6 +782,97 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) + it('covers the injected POSIX group paths on any host', async () => { + // Windows has no POSIX groups, so the tree-liveness probe, group + // signalling, and the SIGKILL escalation timer only run here through the + // injected platform; the mock keeps the group alive through TERM and + // terminates the direct child when the escalation tier delivers SIGKILL. + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + platform: 'linux', + linuxProcessGroupHasLiveMembers: () => false, + }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + if (signal === 0) return true + if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL') + return true + } + return realKill(target, signal) + }) + try { + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('treats a vanished group probe as quiescent without signalling', async () => { + const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' }) + } + return realKill(target, signal) + }) + try { + running.terminate() + await new Promise(resolve => setTimeout(resolve, 20)) + realKill(running.pid, 'SIGKILL') + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('childEnv keeps the POSIX spread on non-Windows hosts', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + try { + expect(childEnv({ DSH_X: '1' }).DSH_X).toBe('1') + } finally { + platform.mockRestore() + } + }) + + it('settles through the pipe-drain timer when a descendant holds a collected pipe', async () => { + // The leader spawns a detached grandchild inheriting the collected stdout + // pipe, then exits: `close` cannot settle while the grandchild holds the + // pipe, so the bounded pipe-drain timer must settle the outcome. + const pidFile = join(spillDir, `pipe-drain-${Date.now()}.pid`) + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs: 100 }), + argv: [process.execPath, '-e', childScript], + }) + // The drain timer starts when the child's stdio closes, which can precede + // the pid file becoming visible; measure from before that wait so the + // lower bound cannot be eroded by the pid-file handoff. + const started = Date.now() + const helper = await waitForPidFile(pidFile) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(Date.now() - started).toBeGreaterThanOrEqual(90) + try { + process.kill(helper, 'SIGKILL') + } catch { + // Already gone; the drain bound is the point under test. + } + await waitGone(helper) + }) + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) await expect(running.done).rejects.toThrow() @@ -869,7 +1009,7 @@ describe('argv validation', () => { expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) - it('spawns argv verbatim without shell interpretation', async () => { + it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => { const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) expect(result.stdout.text).toBe('$HOME') }) @@ -889,7 +1029,7 @@ describe('abort edge cases', () => { .toThrow(/aborted before spawn: aborted/) }) - it('reports the terminating signal of an externally self-killed command', async () => { + it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) @@ -930,7 +1070,7 @@ describe('environment and spill-file hardening', () => { } }) - it('creates spill files with owner-only permissions and random names', async () => { + it.skipIf(process.platform === 'win32')('creates spill files with owner-only permissions and random names', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, @@ -941,7 +1081,7 @@ describe('environment and spill-file hardening', () => { expect(mode).toBe(0o600) }) - it('defaults spills into a private per-process directory', async () => { + it.skipIf(process.platform === 'win32')('defaults spills into a private per-process directory', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) @@ -967,6 +1107,6 @@ describe('environment and spill-file hardening', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) }) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 4bfd9f1025..c2aca88085 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -73,6 +73,8 @@ class FakeInspector implements ProcessInspector { this.groups.push([pgid, signal]) } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { + // Mirrors the real inspectors' alive-gated signalling. + if (!this.alive.has(identity.pid)) return if (this.throwProcess) throw new Error('process raced') if (!this.isAlive(identity)) return this.processes.push([identity.pid, signal]) @@ -82,6 +84,12 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) +function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle { + // The suite pins POSIX signalling semantics deterministically on every host; + // the win32 branches get their own platform-explicit tests below. + return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux') +} + describe('LocalTerminalHandle', () => { it('force-kills descendants around the shell during synchronous host exit', () => { const pty = new FakePty() @@ -166,7 +174,7 @@ describe('LocalTerminalHandle', () => { const pty = new FakePty() const inspector = new FakeInspector() inspector.waiting = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const chunks: Buffer[] = [] handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) }) @@ -187,7 +195,7 @@ describe('LocalTerminalHandle', () => { it('rejects unsafe foreground signals and writes after exit', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) inspector.pgid = handle.pid await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') inspector.pgid = undefined @@ -207,7 +215,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() expect(handle.terminate()).toBe(quiescent) @@ -228,7 +236,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() const waiting = handle.terminate() let settled = false @@ -247,7 +255,7 @@ describe('LocalTerminalHandle', () => { const disowned = { pid: 124, started: 'disowned' } inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] inspector.alive.add(124) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() @@ -261,7 +269,7 @@ describe('LocalTerminalHandle', () => { const descendant = { pid: 124, started: 'observed' } inspector.members = [descendant] inspector.alive.add(descendant.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) await handle.inspectForeground() inspector.members = [] @@ -274,7 +282,7 @@ describe('LocalTerminalHandle', () => { it('does not adopt the children of a recycled shell pid', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) pty.emitExit() const imposterChild = { pid: 999, started: 'imposter-child' } @@ -293,7 +301,7 @@ describe('LocalTerminalHandle', () => { const orphan = { pid: 321, started: 'unverifiable' } inspector.members = [orphan] inspector.alive.add(orphan.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([]) @@ -318,7 +326,7 @@ describe('LocalTerminalHandle', () => { } return [] } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']]) expect(pty.kills).toEqual(['SIGTERM']) @@ -332,7 +340,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() @@ -350,7 +358,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const first = handle.terminate() const failed = expect(first).rejects.toThrow('surviving pids: 124') @@ -377,7 +385,7 @@ describe('LocalTerminalHandle', () => { inspector.processes.push([identity.pid, signal]) if (signal === 'SIGKILL') inspector.alive.delete(identity.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() await vi.advanceTimersByTimeAsync(25) await quiescent @@ -388,7 +396,7 @@ describe('LocalTerminalHandle', () => { vi.useFakeTimers() const pty = new FakePty() pty.autoExitOnKill = false - const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10) + const handle = makeHandle(pty, new FakeInspector(), 10) const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') await vi.advanceTimersByTimeAsync(25) await failed @@ -406,7 +414,98 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.throwProcess = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1) + const handle = makeHandle(pty, inspector, 1) await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') }) }) + +describe('LocalTerminalHandle on Windows', () => { + const win32 = 'win32' as NodeJS.Platform + + it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGINT')).resolves.toBe(456) + expect(pty.writes).toEqual(['\x03']) + expect(inspector.groups).toEqual([]) + }) + + it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => { + const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32) + await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows') + await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows') + }) + + it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456) + expect(inspector.groups).toEqual([[456, 'SIGTERM']]) + expect(pty.writes).toEqual([]) + }) + + it('still refuses to SIGKILL the terminal shell on Windows', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + inspector.pgid = handle.pid + await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') + }) + + it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const quiescent = handle.terminate() + await vi.advanceTimersByTimeAsync(5) + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await quiescent + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + }) + + it('reports a shell that survives both taskkill tiers', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + inspector.removeOnSignal = false + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') + await vi.advanceTimersByTimeAsync(25) + await failed + expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await handle.terminate() + }) + + it('skips taskkill escalation entirely when the shell already exited', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + pty.emitExit() + await handle.terminate() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('falls back to the bare node-pty kill when the shell identity was never observable', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await handle.terminate() + expect(pty.kills).toHaveLength(1) + expect(inspector.processes).toEqual([]) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts new file mode 100644 index 0000000000..e00bdeb9e2 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { + createWindowsProcessInspector, + isInvalidHandle, + windowsProcessTree, + WindowsProcessInspector, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' +import type { + NativePtr, + ProcessEntry, + WindowsProcessInspectorInternals, + WindowsProcessState, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' + +function fakeInternals() { + const entries: ProcessEntry[] = [] + const states = new Map() + const kills: Array<[number, boolean]> = [] + return { + internals: { + snapshot: () => [...entries], + processState: pid => states.get(pid), + taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, + } satisfies WindowsProcessInspectorInternals, + add(entry: ProcessEntry, started?: string, active = true): void { + entries.push(entry) + if (started !== undefined) states.set(entry.pid, { started, active }) + }, + kills, + } +} + +describe('windowsProcessTree', () => { + it('walks a table children-first with readable identities only', () => { + const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}` + expect(windowsProcessTree([ + { pid: 10, parentPid: 0 }, + { pid: 11, parentPid: 10 }, + { pid: 12, parentPid: 11 }, + { pid: 13, parentPid: 11 }, + { pid: 14, parentPid: 10 }, + ], 10, started)).toEqual([ + { pid: 13, started: 't13' }, + { pid: 11, started: 't11' }, + { pid: 14, started: 't14' }, + { pid: 10, started: 't10' }, + ]) + }) + + it('returns an empty walk for an absent root', () => { + expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([]) + }) + + it('terminates on a parent cycle instead of recursing forever', () => { + const entries = [ + { pid: 10, parentPid: 11 }, + { pid: 11, parentPid: 10 }, + ] + expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2) + }) +}) + +describe('WindowsProcessInspector (injected internals)', () => { + it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.foregroundPgid(77)).toBe(77) + expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.processSession(77)).toEqual([]) + }) + + it('delegates tree walks and identity checks to the internals', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11') + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.processTree(10)).toEqual([ + { pid: 11, started: 't11' }, + { pid: 10, started: 't10' }, + ]) + expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) + expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) + expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + + fake.add({ pid: 12, parentPid: 10 }, 't12', false) + expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false) + }) + + it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalGroup(77, 'SIGKILL') + inspector.signalGroup(77, 'SIGTERM') + inspector.signalGroup(0, 'SIGKILL') + expect(fake.kills).toEqual([[77, true], [77, false], [0, true]]) + }) + + it('signals a process only while its start identity matches', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11', false) + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL') + inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') + expect(fake.kills).toEqual([[10, true]]) + }) + + it('accepts an injected internals factory through the creator', () => { + const fake = fakeInternals() + expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector) + }) +}) + +describe('isInvalidHandle', () => { + it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => { + const ptr = (value: bigint): NativePtr => value as NativePtr + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(ptr(0n))).toBe(true) + expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true) + expect(isInvalidHandle(ptr(-1n))).toBe(true) + expect(isInvalidHandle(ptr(1234n))).toBe(false) + }) +}) + +const win32 = process.platform === 'win32' ? describe : describe.skip + +win32('WindowsProcessInspector over the real koffi bindings', () => { + it('walks the live process table from the test runner itself', () => { + const inspector = createWindowsProcessInspector() + const tree = inspector.processTree(process.pid) + const self = tree.find(member => member.pid === process.pid) + expect(self).toBeDefined() + expect(inspector.isAlive(self!)).toBe(true) + expect(inspector.foregroundPgid(process.pid)).toBe(process.pid) + }) + + it('reports unreadable identities for absent processes and no-ops tree signalling', () => { + const inspector = createWindowsProcessInspector() + expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow() + expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow() + }) +}) diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index c53673c2cd..74768ea0f9 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md README.md: 5e80bc7205c2f06b3528bbcfcb775941d2fa4545 -README.zh.md: 62a8b0704d73bc433888f841273505fc92748fa9 +README.zh.md: 4744b4aeea07e18a8d149865050f7487f8816061 diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 62a8b0704d..4744b4aeea 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -子进程 seam(`ctx.subprocess`)是一个执行世界的进程部分。抽象的 `SubprocessRuntime` 公开可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、进程树/会话清理,以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。 +子进程 seam(`ctx.subprocess`)是一个执行世界的进程部分。抽象的 `SubprocessRuntime` 公开可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、进程树/会话清理,以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.zh.md)。 ## 约定 @@ -15,7 +15,7 @@ - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 -参见[子进程子系统页面](../../../docs/subsystems/subprocess.md)与[seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +参见[子进程子系统页面](../../../docs/subsystems/subprocess.zh.md)与[seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 ## 模型体验 diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index d03d7f136f..b65756b5c6 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/README.i18n.yaml b/packages/terminal/README.i18n.yaml index 428fa2ac60..db1c3cdca2 100644 --- a/packages/terminal/README.i18n.yaml +++ b/packages/terminal/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/terminal/README.md README.md: 6204eb4de27bc2361f2a49849865f65729dce12c -README.zh.md: 8b7d704440cb8f9082228fc1265500ebe41e1b58 +README.zh.md: 802e8ded884f9f35f9c6872abeca1dd84597add5 diff --git a/packages/terminal/README.zh.md b/packages/terminal/README.zh.md index 8b7d704440..802e8ded88 100644 --- a/packages/terminal/README.zh.md +++ b/packages/terminal/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`pty`](terminal/README.md)(`@deepseek-ai/dsh-terminal`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.terminals` | +| [`pty`](terminal/README.zh.md)(`@deepseek-ai/dsh-terminal`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.terminals` | | `terminal-bash`(`@deepseek-ai/dsh-terminal-bash`) | `ctx.subprocess.spawnTerminal` 之上的 shell 后端:就绪检测、有界终端状态、沙箱策略与会话操作 | 注册到 `ctx.terminals` | | `tool-terminal`(`@deepseek-ai/dsh-tool-terminal`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` | -设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。 +设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md) 中。 -子系统参考——id、后端/会话约定、发送就绪、有界读取——见 [docs/subsystems/terminal.md](../../docs/subsystems/terminal.md);设计与暂缓边界见[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md)。 +子系统参考——id、后端/会话约定、发送就绪、有界读取——见 [docs/subsystems/terminal.md](../../docs/subsystems/terminal.zh.md);设计与暂缓边界见[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md)。 diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index e0d5920efb..d6e544137a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/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/terminal/terminal-bash/README.md -README.md: 72f5b57335febe40b36de85e7df9b6df4bf7cb10 -README.zh.md: 48c051564130d283717828de35d625d65e825052 +README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba +README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 72f5b57335..2f3f59b1ac 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,6 +8,8 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal` The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. + Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`. @@ -31,6 +33,7 @@ A standing-policy change appends an owner-rendered superseding runtime-context s ## Known Limitations and Deferred Work - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. -- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. +- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 48c0515641..f3daa0a3bc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,6 +8,8 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 + 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。受控 `PROMPT_COMMAND` 会在每次输出提示符前重新设定该 `PS1`,因此在 shell 内覆盖提示符不会使后续 send 退化到静默就绪。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。 @@ -31,6 +33,7 @@ ## 已知限制与暂缓事项 - 输出按行规范化;不支持全屏备用缓冲区交互。 -- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。 +- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index b1b8ae6a59..11e95d1371 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index b4faadbbe5..848fd8bf9a 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -1,14 +1,20 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' /** Public plugin configuration. */ export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -37,14 +43,49 @@ export interface Config { disposeGraceMs?: number } -/** Configuration after Schemastery defaults. */ -export type ResolvedConfig = Required +/** Configuration after Schemastery defaults and dialect resolution. */ +export type ResolvedConfig = Omit, 'shellDialect' | 'shellPath' | 'shellArgs'> & { + shellDialect: ShellDialect + shellPath: string + shellArgs: string[] +} + +/** Bash dialect default executable. */ +export const DEFAULT_BASH_SHELL = '/bin/bash' +/** Bash dialect default arguments (interactive, profile-free). */ +export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i'] +/** Pwsh dialect default arguments (interactive host, profile-free). */ +export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] + +/** + * Resolve the effective per-dialect shell specification. Defaulting is this + * explicit step: an unset or empty `shellPath`/`shellArgs` selects the + * dialect's defaults, while a non-empty explicit value always wins. + * (Schemastery materializes an absent optional array as `[]`, so emptiness — + * not just `undefined` — means "dialect default".) + * @param config - Schemastery-resolved plugin configuration. + * @returns the fully resolved configuration. + */ +export function resolveConfig(config: Config): ResolvedConfig { + const shellDialect = config.shellDialect ?? 'bash' + return { + ...(config as Required), + shellDialect, + shellPath: config.shellPath !== undefined && config.shellPath.length > 0 + ? config.shellPath + : (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 + ? config.shellArgs + : (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + } +} /** Schemastery config exposed by the plugin. */ export const Config: z = z.object({ backendType: z.string().default('shell'), - shellPath: z.string().default('/bin/bash'), - shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']), + shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'), + shellPath: z.string().required(false), + shellArgs: z.array(z.string()).required(false), rows: z.number().default(40), cols: z.number().default(160), scrollbackLines: z.number().default(10_000), @@ -59,7 +100,7 @@ export const Config: z = z.object({ }) /** - * Assert every numeric config field is a positive safe integer and bounds compose. + * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. * @returns Narrows the input to the fully resolved configuration. */ diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index d0b2d6a564..79296959ad 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -12,7 +12,8 @@ import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { type Config, type ResolvedConfig, validateConfig } from './config.ts' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' +import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -52,25 +53,42 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { }, { global: true }) } -function childEnvironment(spec: TerminalBackendSpawnSpec): Record { +function childEnvironment(spec: TerminalBackendSpawnSpec, dialect: ShellDialect): Record { // The subprocess provider supplies its own scrubbed ambient base; these are // deliberate terminal-specific overrides layered after it. - return { + const common = { TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', + DSH_SHELL: '1', + DSH_SESSION_ID: spec.owner.id, + DSH_PTY_SESSION_ID: spec.sessionId, + } + if (dialect === 'pwsh') { + // pwsh ignores PS1/PROMPT_COMMAND; its prompt is installed by the startup + // bootstrap instead, and NO_COLOR keeps the renderer quiet. + return { ...common, NO_COLOR: '1' } + } + return { + ...common, PS1: CONTROLLED_PROMPT, // Re-asserting PS1 after the marker keeps prompt readiness working when a // command overwrote the shell variable: bash runs PROMPT_COMMAND before // rendering each prompt, so an override never survives to the next prompt. PROMPT_COMMAND: `printf "\\033]133;D;%s\\007" "$?"; PS1='${CONTROLLED_PROMPT}'`, BASH_SILENCE_DEPRECATION_WARNING: '1', - DSH_SHELL: '1', - DSH_SESSION_ID: spec.owner.id, - DSH_PTY_SESSION_ID: spec.sessionId, } } +/** + * The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker + * before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7` + * build the control bytes at runtime because raw ESC characters in submitted + * input are unreliable under PSReadLine. + */ +export const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" + function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv @@ -85,9 +103,45 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio // TODO(pty-initialize-race-home): Fold this outer abort race into // LocalPtySession.initialize when the send-state consolidation lands; the // session already owns the send lifecycle the race protects. -async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise { +async function startupSession( + session: LocalPtySession, + dialect: ShellDialect, + signal?: AbortSignal, +): Promise { + const start = async (): Promise => { + if (dialect === 'bash') { + await session.initialize(signal) + return + } + // pwsh cannot install its prompt from the environment: write the prompt + // function through the session and wait for the first marker prompt, + // which is also the readiness contract of the bash initialize path. The + // first send also pins UTF-8 output (the shared pwsh-local preamble) + // before anything runs: the session decode path treats PTY bytes as + // UTF-8, and an un-pinned console writes its host code page for + // non-ASCII output. The banner-to-prompt gap can outlast the silence + // bound, so the wait loops over follow-up sends until the controlled + // prompt is actually visible (in the viewport or the retained scrollback + // when it landed between sends), bounded by the send deadline. + let viewport = '' + for (;;) { + const first = viewport.length === 0 + const operation = session.startSend({ + text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '', + submit: first, + ...signal !== undefined ? { signal } : {}, + }) + const result = await operation.done + if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') + if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + viewport = result.viewport + const scrollback = session.read({ offset: 0, count: 20 }).text + if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break + } + session.motd = viewport + } if (signal === undefined) { - await session.initialize(signal) + await start() return } const aborted = Promise.withResolvers() @@ -95,7 +149,7 @@ async function initializeSession(session: LocalPtySession, signal?: AbortSignal) signal.addEventListener('abort', onAbort, { once: true }) try { signal.throwIfAborted() - await Promise.race([session.initialize(signal), aborted.promise]) + await Promise.race([start(), aborted.promise]) } finally { signal.removeEventListener('abort', onAbort) } @@ -128,7 +182,7 @@ export class BashTerminalBackend implements TerminalBackend { const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? policy.workspaceRoot, - env: childEnvironment(spec), + env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, graceMs: this.config.disposeGraceMs, @@ -136,7 +190,7 @@ export class BashTerminalBackend implements TerminalBackend { }) const session = this.createSession(terminal, this.config) try { - await initializeSession(session, spec.signal) + await startupSession(session, this.config.shellDialect, spec.signal) return session } catch (error) { try { @@ -151,6 +205,7 @@ export class BashTerminalBackend implements TerminalBackend { /** Register the local PTY backend. */ export function apply(ctx: Context, config: Config): void { - validateConfig(config) - ctx.terminals.registerBackend(new BashTerminalBackend(ctx, config)) + const resolved = resolveConfig(config) + validateConfig(resolved) + ctx.terminals.registerBackend(new BashTerminalBackend(ctx, resolved)) } diff --git a/packages/terminal/terminal-bash/tests/config.spec.ts b/packages/terminal/terminal-bash/tests/config.spec.ts index 6c72da4363..d7557a2d90 100644 --- a/packages/terminal/terminal-bash/tests/config.spec.ts +++ b/packages/terminal/terminal-bash/tests/config.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import type { Config } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' -import { validateConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' +import { resolveConfig, validateConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' function config(overrides: Partial = {}): Config { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000, disposeGraceMs: 100, @@ -30,3 +30,43 @@ describe('terminal-bash config', () => { expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow() }) }) + +describe('terminal-bash dialect resolution', () => { + it('defaults bash argv to the interactive profile-free form', () => { + const { shellPath, shellArgs, shellDialect } = resolveConfig({ backendType: 'shell', rows: 24, cols: 80 }) + expect(shellDialect).toBe('bash') + expect(shellPath).toBe('/bin/bash') + expect(shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + + it('defaults pwsh argv to the interactive profile-free form and resolves the executable', () => { + const resolved = resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) + expect(resolved.shellDialect).toBe('pwsh') + expect(resolved.shellPath.length).toBeGreaterThan(0) + expect(resolved.shellArgs).toEqual(['-NoLogo', '-NoProfile']) + }) + + it('lets an explicit shell specification win over the dialect defaults', () => { + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'pwsh', shellPath: '/custom/pwsh', shellArgs: ['-NoProfile'], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/custom/pwsh') + expect(resolved.shellArgs).toEqual(['-NoProfile']) + }) + + it('treats empty shell values as unset so Schemastery materialization cannot drop the dialect defaults', () => { + // Schemastery materializes an absent optional array as `[]`; the resolver + // must treat that shape like an unset value or a real bash spawn would + // start non-interactive without the controlled prompt. + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'bash', shellPath: '', shellArgs: [], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/bin/bash') + expect(resolved.shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + + it('validates the effective shell path, not only the raw one', () => { + expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 })) }).not.toThrow() + expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })) }).not.toThrow() + }) +}) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 2a4d7848df..4f8c347221 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { PassThrough } from 'node:stream' +import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -8,7 +9,9 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' -import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash' +import type { TerminalSendRequest, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' +import { BashTerminalBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-terminal-bash' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts' @@ -37,7 +40,7 @@ class RecordingSandbox extends SandboxProvider { function config(): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 10, @@ -216,7 +219,7 @@ describe('BashTerminalBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/workspace') }, }]) }) @@ -244,11 +247,11 @@ describe('BashTerminalBackend startup rollback', () => { expect(spawned).toMatchObject({ argv: ['/sandbox', '--', '/bin/bash', '-i'], - cwd: '/session-workspace', + cwd: resolve('/session-workspace'), }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/session-workspace') }, }]) }) @@ -338,6 +341,132 @@ describe('BashTerminalBackend startup rollback', () => { expect(session.motd).toBe('dsh> ') await session.close('test complete') }) + + it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + let spawned: SubprocessTerminalSpawnSpec | undefined + let sent: TerminalSendRequest | undefined + const session = { + motd: '', + startSend: (request: TerminalSendRequest) => { + sent = request + return { + done: Promise.resolve({ + viewport: 'setup-echo dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async (spec) => { spawned = spec; return terminalHandle() }, + () => session, + ) + expect(await backend.spawn(spec(agent(ctx)))).toBe(session) + expect(sent).toMatchObject({ text: ENCODING_PREAMBLE + PWSH_PROMPT_SETUP, submit: true }) + expect(session.motd).toBe('setup-echo dsh> ') + expect(spawned?.env).toMatchObject({ + TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', + }) + expect(spawned?.env?.PS1).toBeUndefined() + expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined() + }) + + it('keeps waiting for the marker prompt when the first send settles on silence', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: TerminalSendRequest[] = [] + const session = { + motd: '', + startSend: (request: TerminalSendRequest) => { + sends.push(request) + const second = sends.length > 1 + return { + done: Promise.resolve({ + viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n', + waitReason: 'inferred_idle' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + await backend.spawn(spec(agent(ctx))) + expect(sends).toHaveLength(2) + expect(sends[1]).toMatchObject({ text: '', submit: false }) + expect(session.motd).toBe('dsh> ') + }) + + it('rejects a pwsh bootstrap whose shell exits or times out', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sessionFor = (waitReason: TerminalWaitReason): LocalPtySession => ({ + startSend: () => ({ + done: Promise.resolve({ + viewport: 'no-prompt', waitReason, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + }), + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + close: () => Promise.resolve(), + }) as unknown as LocalPtySession + const exited = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) + await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + }) + + it('forwards the spawn signal into the pwsh bootstrap sends', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: TerminalSendRequest[] = [] + const session = { + motd: '', + startSend: (request: TerminalSendRequest) => { + sends.push(request) + return { + done: Promise.resolve({ + viewport: 'dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + const signal = new AbortController().signal + const spawned = await backend.spawn({ ...spec(agent(ctx)), signal }) + expect(spawned.motd).toBe('dsh> ') + expect(sends).toHaveLength(1) + expect(sends[0]?.signal).toBe(signal) + }) }) describe('terminal-bash plugin shape', () => { diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index c7af8668a3..ed1b6ab15d 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -1,4 +1,5 @@ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -12,6 +13,7 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' const roots: string[] = [] @@ -49,6 +51,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { async function harness( mode: 'danger-full-access' | 'workspace-write', timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {}, + dialect: 'bash' | 'pwsh' = 'bash', ) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) @@ -60,6 +63,7 @@ async function harness( await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, { + shellDialect: dialect, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: timing.idleSilenceMs ?? 250, @@ -114,7 +118,9 @@ function processIsRunning(pid: number): boolean { } } -describe('terminal-bash real shell', () => { +// The real-shell suite drives a POSIX bash over the actual node-pty terminal; +// Windows has no bash, and its pwsh counterpart lives in the describe below. +describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => { it('persists cwd and environment across sends, scrubs secrets, and closes', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -267,3 +273,72 @@ describe('terminal-bash real shell', () => { await ctx.terminals.kill(agent, created.sessionId) }, 35_000) }) + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { + it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { + const previous = process.env.DSH_TEST_SECRET + process.env.DSH_TEST_SECRET = 'must-not-leak' + try { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + expect(created.motd).toContain('dsh> ') + + const first = ctx.terminals.startSend(agent, created.sessionId, { + text: '$env:KEEP = "ok"; Set-Location /', + submit: true, + }) + expect((await first.done).waitReason).toBe('stdin_read') + const second = ctx.terminals.startSend(agent, created.sessionId, { + text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', + submit: true, + }) + const result = await second.done + expect(result.viewport).toContain('keep=ok') + expect(result.viewport).toContain('secret=') + expect(result.viewport).not.toContain('must-not-leak') + + expect(ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true) + expect(ctx.terminals.list(agent)).toEqual([]) + } finally { + if (previous === undefined) delete process.env.DSH_TEST_SECRET + else process.env.DSH_TEST_SECRET = previous + } + }, 30_000) + + it('pins UTF-8 output encoding so non-ASCII output survives the byte decode', async () => { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + // The bootstrap itself must have pinned both encodings: the session byte + // decode is UTF-8, so an un-pinned console writing its host code page + // garbles every non-ASCII byte that follows. + const pinned = ctx.terminals.startSend(agent, created.sessionId, { + text: '"console=" + [Console]::OutputEncoding.WebName + " out=" + $OutputEncoding.WebName', + submit: true, + }) + const pinnedResult = await pinned.done + expect(pinnedResult.viewport).toContain('console=utf-8 out=utf-8') + // Char codes keep the submitted line ASCII-only, so the assertion is a + // pure output-decode check. + const sent = ctx.terminals.startSend(agent, created.sessionId, { + text: "[Console]::Write([char]0x4E2D + [char]0x6587 + ' encoding-ok')", + submit: true, + }) + const result = await sent.done + expect(result.viewport).toContain('中文 encoding-ok') + await ctx.terminals.kill(agent, created.sessionId) + }, 30_000) +}) diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index ef9fd215f2..bf46317c5c 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -130,7 +130,7 @@ function makeSession( function config(overrides: Partial = {}): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 20, diff --git a/packages/terminal/terminal-bash/tsconfig.json b/packages/terminal/terminal-bash/tsconfig.json index 40cb387342..b42e1015be 100644 --- a/packages/terminal/terminal-bash/tsconfig.json +++ b/packages/terminal/terminal-bash/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../shell/pwsh-local" + }, { "path": "../../core/agent" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 388309075f..707e69bb53 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/README.i18n.yaml b/packages/terminal/tool-terminal/README.i18n.yaml index c99082f62f..2b36b16d7c 100644 --- a/packages/terminal/tool-terminal/README.i18n.yaml +++ b/packages/terminal/tool-terminal/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/terminal/tool-terminal/README.md README.md: f6fd4020d423425d7b20ad6bc21b4e009cf25c85 -README.zh.md: 827d179515779f87665a80920e72a11be051023f +README.zh.md: dca0ce9280fc7a7d748e90a5c553c0e712e2453d diff --git a/packages/terminal/tool-terminal/README.zh.md b/packages/terminal/tool-terminal/README.zh.md index 827d179515..dca0ce9280 100644 --- a/packages/terminal/tool-terminal/README.zh.md +++ b/packages/terminal/tool-terminal/README.zh.md @@ -41,7 +41,7 @@ Use a terminal session only when work needs persistent terminal state or interac #### 模型看到的内容 -6 个生成的 schema 列在 [`dsh-tool-terminal` 目录章节](../../../docs/tool-catalog.md#deepseek-aidsh-tool-terminal)中。此插件活跃时,请求中会包含它们的固定 schema token;按 agent 范围过滤工具时可能隐藏这些 schema。 +6 个生成的 schema 列在 [`dsh-tool-terminal` 目录章节](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-terminal)中。此插件活跃时,请求中会包含它们的固定 schema token;按 agent 范围过滤工具时可能隐藏这些 schema。 #### Token 影响 diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 9fe3475e74..e34dbb3e17 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/README.i18n.yaml b/packages/test-support/README.i18n.yaml index 484db0b32b..d229930962 100644 --- a/packages/test-support/README.i18n.yaml +++ b/packages/test-support/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/README.md README.md: 8f07d08828c2f4d69676487842373b600037e199 -README.zh.md: b56b082e0d58a32d6af39588fbe6f4ab422af9b7 +README.zh.md: 8793ebd9a4e6b456d34c6fe84983524dfc1d5fa2 diff --git a/packages/test-support/README.zh.md b/packages/test-support/README.zh.md index b56b082e0d..8793ebd9a4 100644 --- a/packages/test-support/README.zh.md +++ b/packages/test-support/README.zh.md @@ -6,13 +6,13 @@ | 包 | 职责 | |---|---| -| [`acp-snapshot/`](acp-snapshot/README.md) | 提供 ACP(Agent Client Protocol)快照测试工具包 | -| [`agent-loop-testkit/`](agent-loop-testkit/README.md) | 为 AgentLoop 测试挂载共享先决条件 | -| [`invariants/`](../runtime-diagnostics/invariants/README.md) | 运行开发期运行时约定断言 | -| [`loader-smoke/`](loader-smoke/README.md) | 启动由 Loader 组合的应用以执行冒烟测试 | -| [`llm-mock-server/`](llm-mock-server/README.md) | 提供确定性的 OpenAI 兼容故障服务器 | -| [`llm-replay/`](llm-replay/README.md) | 为无密钥测试和演示回放已记录的模型响应 | +| [`acp-snapshot/`](acp-snapshot/README.zh.md) | 提供 ACP(Agent Client Protocol)快照测试工具包 | +| [`agent-loop-testkit/`](agent-loop-testkit/README.zh.md) | 为 AgentLoop 测试挂载共享先决条件 | +| [`invariants/`](../runtime-diagnostics/invariants/README.zh.md) | 运行开发期运行时约定断言 | +| [`loader-smoke/`](loader-smoke/README.zh.md) | 启动由 Loader 组合的应用以执行冒烟测试 | +| [`llm-mock-server/`](llm-mock-server/README.zh.md) | 提供确定性的 OpenAI 兼容故障服务器 | +| [`llm-replay/`](llm-replay/README.zh.md) | 为无密钥测试和演示回放已记录的模型响应 | 当一个包获得产品约定和产品消费方时,它会移出 `test-support/`。 -不变式约定记录在 [docs/subsystems/invariants.md](../../docs/subsystems/invariants.md)。 +不变式约定记录在 [docs/subsystems/invariants.md](../../docs/subsystems/invariants.zh.md)。 diff --git a/packages/test-support/acp-snapshot/README.i18n.yaml b/packages/test-support/acp-snapshot/README.i18n.yaml index 3f84ad8132..6a414b9c77 100644 --- a/packages/test-support/acp-snapshot/README.i18n.yaml +++ b/packages/test-support/acp-snapshot/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/acp-snapshot/README.md README.md: 31f4ec0caeb995a10202d4a452ee7e433749762f -README.zh.md: e92d3f17c62b4fe7dea4f5b6c5a0738eef635327 +README.zh.md: 4e9a8236827002a55faf19e4af646ef72b0c725e diff --git a/packages/test-support/acp-snapshot/README.zh.md b/packages/test-support/acp-snapshot/README.zh.md index e92d3f17c6..4e9a823682 100644 --- a/packages/test-support/acp-snapshot/README.zh.md +++ b/packages/test-support/acp-snapshot/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -ACP(Agent Client Protocol)快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[测试策略](../../../docs/testing.md))背后的共享机制。示例只需场景表和 fixture(测试前置数据)目录就能获得完整快照套件;每项比较/保护机制都位于此处,受每文件覆盖率门禁约束,而不是在每个示例中复制。 +ACP(Agent Client Protocol)快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[测试策略](../../../docs/testing.zh.md))背后的共享机制。示例只需场景表和 fixture(测试前置数据)目录就能获得完整快照套件;每项比较/保护机制都位于此处,受每文件覆盖率门禁约束,而不是在每个示例中复制。 四层可单独导入: @@ -11,7 +11,7 @@ ACP(Agent Client Protocol)快照套件工具包:无密钥快照层(`pnpm - **规范化器**:将已捕获内容转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每种原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已进行 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,通过结构化方式仅改写 surface 和持久 inbox 中完整消息的 ID 字段,将已提交 UUID 带入未变化且双向唯一匹配的消息)。 - **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` 伴随文件组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的伴随文件内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新模式写入 fixture 前,仅当一条未变化完整消息的 ID 及其去除身份后的指纹在场景可写入 fixture 的父级/子级日志中均唯一时,该消息才会保留已提交的 UUID;会话包的权威 surface 类型谓词负责选择 surface 载体,与其关联的 `agent/inbox/spliced` 副本也纳入同一映射,且仅改写这些载体中通过验证的 `id` 字段。新增、发生变化、格式错误以及图关系存在歧义的消息保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用归一化后等价的叶值;surface 或 inbox 载体中的完整消息 ID 不参与此路径,因为后续结构化处理负责这些 ID;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件构成有序的主会话/子会话清单;场景表不重复其数量。必须在 vitest 收集时调用。 -签入仓库的会话 fixture 使用规范打包行;[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写较旧的 fixture 布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 +签入仓库的会话 fixture 使用规范打包行;[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写较旧的 fixture 布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 消费方 `*.snapshot.ts` 就是场景表加一次工厂调用: @@ -59,7 +59,7 @@ defineAcpSnapshotSuite({ 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并且仅在启用时要求存在该伴随文件。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消正在运行的 bash 调用会终止一个已脱离的进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 -示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,本包通过为子进程设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema 伴随文件。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 +示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.zh.md) 提供,本包通过为子进程设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema 伴随文件。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md) 中。 约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC 和 Web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示简写、精确结构化 ACP 提示词块、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 @@ -75,4 +75,4 @@ defineAcpSnapshotSuite({ - **会话收集需要原始 JSONL mode**:`runScenario` 收集持久化 `.jsonl` 日志,因此快照配置使用 `persistenceCompression: 'none'`;压缩 JSONL 和 SQLite 组合没有快照收集路径。 - **构建 mode 需要当前产物**:先运行 `pnpm run build`,再选择 `DSH_EXAMPLE_MODE=lib`;源 mode 仍是零构建路径。 -- **后端覆盖仍使用 ACP 驱动器**:保留场景为何使用该传输,见[仅自动化 ACP 决策](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)。 +- **后端覆盖仍使用 ACP 驱动器**:保留场景为何使用该传输,见[仅自动化 ACP 决策](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md#snapshot-boundary)。 diff --git a/packages/test-support/acp-snapshot/package.json b/packages/test-support/acp-snapshot/package.json index d8ca0bcfe4..b98e5c7b21 100644 --- a/packages/test-support/acp-snapshot/package.json +++ b/packages/test-support/acp-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output normalizers, and suite factory", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index e677c8befd..4d7767c95f 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 8c2bcce6e7..6d13d9f230 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index 3f11c82323..90b1bde56f 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -30,7 +30,7 @@ describe('TestRemote', () => { new TestRemote(ctx) // No subscriber for this name: the emit must be inert rather than throwing, // because the wire carries whatever the Host allowlist selected. - expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() + expect(() => { ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() await ctx.fiber.dispose() }) diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index ff21dc51a3..9de79074d9 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml index 8e84398842..4ca5510bb4 100644 --- a/packages/test-support/llm-replay/README.i18n.yaml +++ b/packages/test-support/llm-replay/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/llm-replay/README.md README.md: 24ee2684ad5ff393b95db0075950fe40bce2e0dd -README.zh.md: 5adb16e08604081ebc8db4e07a8a22c031d02ae2 +README.zh.md: db45ce04fb4a63ba5dfe44ff7ddc1917aee89d4c diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md index 5adb16e086..db45ce04fb 100644 --- a/packages/test-support/llm-replay/README.zh.md +++ b/packages/test-support/llm-replay/README.zh.md @@ -64,7 +64,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as ## 插件导出形态 -命名导出 `name` / `inject` / `Config` / `apply`,且**没有默认导出**:Cordis Loader 的 `unwrapExports` 执行 `exports.default ?? exports`,因此意外的默认导出会将模块折叠为函数本身,并丢弃 `inject` 命名空间(见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 +命名导出 `name` / `inject` / `Config` / `apply`,且**没有默认导出**:Cordis Loader 的 `unwrapExports` 执行 `exports.default ?? exports`,因此意外的默认导出会将模块折叠为函数本身,并丢弃 `inject` 命名空间(见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md))。 ## 模型体验 diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 9a6f24bc1b..f2e564503b 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index bf888ba13f..c556f3ebc2 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index 4df1b35bcb..81736fc9bd 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md README.md: 6bbc9fe4f04cc4a82573c643c5c00bbecde069ed -README.zh.md: ae7de52eeded877e92c94dbc6c6e1addf76aa8b4 +README.zh.md: fa2bd8a6cde5c322151b37b066f0d718c52dfcd0 diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index ae7de52eed..fa2bd8a6cd 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`tool-todo/`](tool-todo/README.md) | 存储并公开会话的 todo 列表。 | (注册到 `ctx.tools`) | +| [`tool-todo/`](tool-todo/README.zh.md) | 存储并公开会话的 todo 列表。 | (注册到 `ctx.tools`) | 子级 README 负责工具、持久化和渲染约定。 -事件载荷记录在 [docs/subsystems/session.md](../../docs/subsystems/session.md)。 +事件载荷记录在 [docs/subsystems/session.md](../../docs/subsystems/session.zh.md)。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 469cb71023..116983a0bb 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md README.md: 4848758dd8f2049221901744e5d09cef2dd31591 -README.zh.md: f3e9e50e1c548dc8c0d01b0bae5c91dd813b5899 +README.zh.md: 3c79ca6bba9bb0ffd7e1960dae7000bdb525bdfb diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index f3e9e50e1c..3c79ca6bba 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -26,15 +26,15 @@ ## 渲染 -规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条和专属工具行([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。 +规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条和专属工具行([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md))。 ## 会话投影 -当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经 Service Definition 包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 +当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经 Service Definition 包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md)。 ## 导出形状 -函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会被 Loader 的 `unwrapExports` 折叠为默认导出,并导致 `inject` 丢失(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 +函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会被 Loader 的 `unwrapExports` 折叠为默认导出,并导致 `inject` 丢失(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md))。 ## 模型体验 @@ -42,7 +42,7 @@ #### 模型看到的内容 -模型会看到生成的 [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo)。 +模型会看到生成的 [`todo_write` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-todo)。 #### Token 影响 diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 9e30ebb1d0..12ee07f2ac 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index e92af70313..9c0a8e45a7 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -135,14 +135,14 @@ export function apply(ctx: Context, config: Config): void { ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({ key: 'todos', - schema: todosProjectionSchema, + stateSchema: todosProjectionSchema, init: () => null, apply: (state, event) => { if (event.type === 'todo/write') return event.data.todos if (event.type === 'turn/start') return null return state }, - view: state => state, + wire: { viewSchema: todosProjectionSchema, view: state => state }, stateVersion: 2, }) }) diff --git a/packages/todo/tool-todo/src/types.ts b/packages/todo/tool-todo/src/types.ts index fe37e65d55..d279d1fa97 100644 --- a/packages/todo/tool-todo/src/types.ts +++ b/packages/todo/tool-todo/src/types.ts @@ -13,6 +13,9 @@ import type { TodoItem } from '@deepseek-ai/dsh-session/types' export type { TodoItem } from '@deepseek-ai/dsh-session/types' declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + todos: TodoItem[] | null + } interface SessionProjectionMap { /** * The agent's current whole todo list (the latest `todo/write` snapshot), diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index 3a061e43fd..35034e4c11 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/README.md README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160 -README.zh.md: aae74bf89f657f50f4519b2f8a0628fd391f8de3 +README.zh.md: 9ebd00f71a509e24a0b6c674c56d87c411dd7794 diff --git a/packages/typert/README.zh.md b/packages/typert/README.zh.md index aae74bf89f..9ebd00f71a 100644 --- a/packages/typert/README.zh.md +++ b/packages/typert/README.zh.md @@ -6,6 +6,6 @@ Typert 将源代码分析、运行时存储和 Loader 发现机制分离。 | 包 | 职责 | Cordis 键 | |---|---|---| -| [`registry/`](registry/README.md) | 存储运行时包反射和 schema | `ctx.typert` | -| [`loader/`](loader/README.md) | 发现 Loader 条目并注册生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` | -| [`generator/`](generator/README.md) | 从源代码类型生成运行时产物 | 构建时库 | +| [`registry/`](registry/README.zh.md) | 存储运行时包反射和 schema | `ctx.typert` | +| [`loader/`](loader/README.zh.md) | 发现 Loader 条目并注册生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` | +| [`generator/`](generator/README.zh.md) | 从源代码类型生成运行时产物 | 构建时库 | diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index cb544e3e9a..4c42c13aca 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index c38b7fd9e1..8ce666b96c 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -951,6 +951,12 @@ function anchorFor(headingText: string): string[] { return [``, ''] } +/** Render a subsystem `file:line` source pointer as a file-only link. */ +function sourceLink(source: string): string { + const file = source.split(':')[0] + return `[\`${file}\`](../../${file})` +} + /** Render one harness event entry onto its owning page, nested under its scope heading. */ function renderEvent(e: EventEntry, onPage: string, linkedTypePages: Readonly>): string[] { const out = [...anchorFor(`${e.name} — ${e.mode}`), `#### \`${e.name}\` — ${e.mode}`, ''] @@ -958,7 +964,7 @@ function renderEvent(e: EventEntry, onPage: string, linkedTypePages: Readonly method.signature).join('\n'), onPage, linkedTypePages) if (links) out.push(links, '') } - out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') + out.push(`Source: ${sourceLink(s.source)}`, '') return out } @@ -1010,7 +1016,7 @@ export function renderPageRegion(page: string, services: ServiceEntry[], events: '', '## Cordis API', '', - 'Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).', + 'Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).', '', ] for (const s of services) lines.push(...renderService(s, page, policy.linkedTypePages)) diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index 5cb9351999..ab62291aed 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -5,8 +5,16 @@ import { projectCordisCatalog, renderInheritedPage, renderPageRegion, + type CordisCatalogPolicy, } from '../src/cordis-catalog.ts' -import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERVICE_PAGE } from '../../../../scripts/gen-cordis-catalog.ts' +import { + CORDIS_CATALOG_POLICY, + EVENT_SCOPE_PAGE, + localizePageRegion, + REGION_BEGIN, + REGION_END, + SERVICE_PAGE, +} from '../../../../scripts/gen-cordis-catalog.ts' const workspaceRoot = resolve(import.meta.dirname, '../../../..') @@ -15,7 +23,40 @@ let cached: ReturnType | undefined const projection = (): ReturnType => (cached ??= projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY)) +const SOURCE_LINK_POLICY: CordisCatalogPolicy = { + linkedTypePages: {}, + foundationTypeNames: new Set(), + typeLinkExemptions: {}, + inheritedEvents: [{ name: 'ready', summary: 'Ready.', source: 'vendor/cordis/src/events.ts:9' }], + inheritedServices: [{ name: 'ctx.root', summary: 'Root.', source: 'vendor/cordis/src/context.ts:12' }], +} + describe('Typert-backed Cordis catalog', () => { + it('omits subsystem source lines while preserving inherited Cordis source lines', () => { + const page = renderPageRegion('fixture.md', [{ + key: 'fixture', + type: 'Fixture', + abstract: false, + doc: 'Fixture.', + methods: [], + source: 'packages/fixture/service.ts:24', + }], [{ + name: 'fixture/ready', + scope: 'fixture', + signature: "'fixture/ready'(): void", + jsDoc: '/** Ready. */', + mode: 'emit', + doc: 'Ready.', + source: 'packages/fixture/events.ts:42', + }], SOURCE_LINK_POLICY) + const inherited = renderInheritedPage(SOURCE_LINK_POLICY) + + expect(page).toContain('Source: [`packages/fixture/events.ts`](../../packages/fixture/events.ts)') + expect(page).toContain('Source: [`packages/fixture/service.ts`](../../packages/fixture/service.ts)') + expect(inherited).toContain('([`vendor/cordis/src/events.ts:9`](../../vendor/cordis/src/events.ts))') + expect(inherited).toContain('([`vendor/cordis/src/context.ts:12`](../../vendor/cordis/src/context.ts))') + }) + it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => { const { projector, model } = projection() const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8') @@ -29,11 +70,14 @@ describe('Typert-backed Cordis catalog', () => { CORDIS_CATALOG_POLICY, ) for (const side of [page, page.replace(/\.md$/, '.zh.md')]) { - const committed = expected(`docs/subsystems/${side}`) + const rel = `docs/subsystems/${side}` + const committed = expected(rel) const begin = committed.indexOf(REGION_BEGIN) const end = committed.indexOf(REGION_END) - expect(begin, `docs/subsystems/${side} carries the region`).toBeGreaterThanOrEqual(0) - expect(committed.slice(begin, end + REGION_END.length)).toBe(region) + expect(begin, `${rel} carries the region`).toBeGreaterThanOrEqual(0) + expect(committed.slice(begin, end + REGION_END.length)).toBe( + localizePageRegion(region, rel, workspaceRoot), + ) } } expect(projector.renderRuntimeApi(model)).toBe( diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml index 03abaafe53..b85f70d24a 100644 --- a/packages/typert/loader/README.i18n.yaml +++ b/packages/typert/loader/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/loader/README.md README.md: ab9293de1630fdbe8c560bb9e6d00c272cc34161 -README.zh.md: 4e962c05b73982f03accab938cf3282dfe333dbc +README.zh.md: d5f9d8e5de5d47ed424f4ed03a12c03cb1b53997 diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md index 4e962c05b7..d5f9d8e5de 100644 --- a/packages/typert/loader/README.zh.md +++ b/packages/typert/loader/README.zh.md @@ -12,7 +12,7 @@ ## 模型体验 -无。loader 只向 [`ctx.typert`](../registry/README.md) 提供注册项;任何模型可见投影均由消费方负责。 +无。loader 只向 [`ctx.typert`](../registry/README.zh.md) 提供注册项;任何模型可见投影均由消费方负责。 #### KV Cache 影响 diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 649edf3275..742e199c66 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 6dccd2b5dd..2f4b4cae04 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -398,9 +398,10 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/steady-failure' }) await ctx.loader.await() - await new Promise(resolve => setTimeout(resolve, 20)) - - expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + // The failing contributor's error is reported on the post-await flush. + await vi.waitFor(() => { + expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + }, { timeout: 10_000 }) expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined() }) }) diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index ea21d951c9..de2dfece31 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 5730472079..b3c081b128 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 -README.zh.md: b708e536acb7a5c4dc47e8c83180728e2ce150cd +README.zh.md: 62c5a2141de9ccdd564654658a464d721b77f97d diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index b708e536ac..62c5a2141d 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -生成的 Typert 产物所用的运行时注册表。每个注册项包含某个包在一个 face 上的业务反射信息,以及可选的运行时 Zod schema;`ctx.typert` 会以原子方式同时注册两者,并在发起调用的 Cordis fiber 释放时一并移除它们。TypeScript 分析和代码生成由 [`dsh-typert-generator`](../generator/README.md) 负责。 +生成的 Typert 产物所用的运行时注册表。每个注册项包含某个包在一个 face 上的业务反射信息,以及可选的运行时 Zod schema;`ctx.typert` 会以原子方式同时注册两者,并在发起调用的 Cordis fiber 释放时一并移除它们。TypeScript 分析和代码生成由 [`dsh-typert-generator`](../generator/README.zh.md) 负责。 包反射信息以 `#` 为键。schema 以 `#` 为键,并保留生成方的 Zod 实例。系统按需在消费方边界计算 JSON Schema。 @@ -17,7 +17,7 @@ - `toJSONSchema(key, params?)` 使用 `z.toJSONSchema()` 投影当前有效的 schema,且不缓存结果。 - `typertKey()` 和 `typertPackageKey()` 构造两种稳定的标识形式。 -`@deepseek-ai/dsh-typert-registry/types` 子路径包含注册项和记录的纯类型约定。[`dsh-typert-loader`](../loader/README.md) 会在 Loader 组合中发现并注册生成的宿主侧产物;其他组合所有者可以直接调用 `ctx.typert.register()`。 +`@deepseek-ai/dsh-typert-registry/types` 子路径包含注册项和记录的纯类型约定。[`dsh-typert-loader`](../loader/README.zh.md) 会在 Loader 组合中发现并注册生成的宿主侧产物;其他组合所有者可以直接调用 `ctx.typert.register()`。 ## 模型体验 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 973b16d7d5..d93ac46a38 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index c041aed49e..04314b33ef 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md README.md: 7867f6c235dfd3cb063214381ac2f40ed0f26ce3 -README.zh.md: 65c48de0de038b4910583f79d9fa0c31ea2d67d0 +README.zh.md: 33ca9912f3670dfd3419b7a2a33f462cb4be95a3 diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 65c48de0de..33ca9912f3 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -6,9 +6,9 @@ | 包 | 职责 | |---|---| -| [`brand/`](brand/README.md) | 提供带名义品牌的类型 | -| [`paths/`](home-paths/README.md) | 解析 Harness 数据根目录和共享路径 | -| [`timeout/`](timeout/README.md) | 提供截止时间和超时分类原语 | -| [`retention/`](output-retention/README.md) | 限制保留文本和项集合的大小 | -| [`atomic-write/`](atomic-write/README.md) | 以原子方式替换文件 | -| [`native-command/`](native-command/README.md) | 不经 shell 运行宿主原生命令 | +| [`brand/`](brand/README.zh.md) | 提供带名义品牌的类型 | +| [`paths/`](home-paths/README.zh.md) | 解析 Harness 数据根目录和共享路径 | +| [`timeout/`](timeout/README.zh.md) | 提供截止时间和超时分类原语 | +| [`retention/`](output-retention/README.zh.md) | 限制保留文本和项集合的大小 | +| [`atomic-write/`](atomic-write/README.zh.md) | 以原子方式替换文件 | +| [`native-command/`](native-command/README.zh.md) | 不经 shell 运行宿主原生命令 | diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index c54e974be8..98e35ef83a 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/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/util/atomic-write/README.md -README.md: 4d0b55291955c9d37f4788c7d37ad8e6ce728f70 -README.zh.md: c2d7f0b49fa123befbb663ac43862a40b4ef19b4 +README.md: 0e6501c0ae35bf26d7df67f28ce0ae24c85ecca6 +README.zh.md: 377f85f5c4aab74e8c04068b616de32a8ba063d6 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 4d0b552919..0e6501c0ae 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -30,6 +30,8 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms that the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. +How long a contender waits is a property of the operation the holder runs, so it is stated per call through `waitMs`. The default is sized for file work alone; a holder whose cycle includes a network round trip — a credential mutation that refreshes an expired token — states a longer one, because leaving the default would fail every other writer of that file for the duration. The retry cadence stays fixed: it governs how often a contender asks, which no caller has a reason to vary. + ## Model Experience None, as this is a pure filesystem primitive; nothing here reaches a model request. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index c2d7f0b49f..377f85f5c4 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -30,6 +30,8 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 +等待多久是持锁方所跑操作的属性,因此由每次调用经 `waitMs` 声明。默认值只按纯文件工作量级选定;若持锁方的循环包含一次网络往返——例如刷新过期 token 的凭据变更——就应声明更长的值,否则该文件的其他写入方会在这段时间内全部失败。退避节奏保持固定:它决定竞争者多久问一次,调用方没有理由改变它。 + ## 模型体验 无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 8eb4b96f12..d3f07fb62d 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index 21c9de5f35..3e5764a329 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -78,14 +78,36 @@ async function isLockContention(error: unknown, lockPath: string): Promise( filename: string, operation: () => Promise, + options?: FileLockOptions, ): Promise { const lockPath = `${filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS + const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS) let delay = LOCK_RETRY_INITIAL_MS for (;;) { try { diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 42cbd287c0..683abe51bc 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -28,6 +28,18 @@ async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) } +/** Resolve once the lockfile exists, so contention is measured against a held lock. */ +async function waitForLock(lockPath: string): Promise { + for (;;) { + try { + await stat(lockPath) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 5)) + } + } +} + describe('writeFileAtomic', () => { it('creates the file and its parents with exactly the stated mode', async () => { const dir = await scratch() @@ -105,4 +117,33 @@ describe('withFileLock', () => { })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i) expect(called).toBe(false) }) + + it('waits for the caller-stated limit rather than the protocol default', async () => { + // An operation whose work includes a network round trip legitimately holds + // the lock far longer than the render-and-rename the default was sized + // for. The limit is per call so one such operation cannot fail every other + // writer of the same file, and a caller that states a short one still + // fails fast. + const dir = await scratch() + const target = join(dir, 'document') + let release = (): void => {} + const held = new Promise((resolve) => { release = resolve }) + const holder = withFileLock(target, () => held) + // The holder owns the lock once its lockfile exists; contending before + // that would measure nothing. + await waitForLock(`${target}.lock`) + + // Elapsed time is the assertion that distinguishes a honoured limit from + // the ignored argument: without it the contender simply waits out the + // protocol default and fails with the same message. + const startedAt = Date.now() + await expect(withFileLock(target, async () => 'impatient', { waitMs: 50 })) + .rejects.toThrow(/timed out waiting for the writer lock/) + expect(Date.now() - startedAt).toBeLessThan(1_000) + + const patient = withFileLock(target, async () => 'patient', { waitMs: 10_000 }) + release() + await holder + expect(await patient).toBe('patient') + }) }) diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 1070229b37..9c32cb6284 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 5834c4a490..8e6a045f59 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/README.i18n.yaml b/packages/util/launch-environment/README.i18n.yaml index b28e720234..53a8b13705 100644 --- a/packages/util/launch-environment/README.i18n.yaml +++ b/packages/util/launch-environment/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/launch-environment/README.md README.md: 80ec4d59c38ee296e836f8cff52e3a89b0700ff5 -README.zh.md: 5dc9bfd1fc70e5432ca9f62e81a7d7a23f4a48c9 +README.zh.md: 8fb8df559c1469b25543c15fe03ee7cf67492304 diff --git a/packages/util/launch-environment/README.zh.md b/packages/util/launch-environment/README.zh.md index 5dc9bfd1fc..8fb8df559c 100644 --- a/packages/util/launch-environment/README.zh.md +++ b/packages/util/launch-environment/README.zh.md @@ -32,5 +32,5 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value ## 已知限制与暂缓事项 -- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。产品启动器的 [`.env` 约定](../../boot/app-boot/README.md#profiles) 会在物化之前拒绝 bootstrap 变量。 +- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 的清洗规则抵达子进程。产品启动器的 [`.env` 约定](../../boot/app-boot/README.zh.md#profiles) 会在物化之前拒绝 bootstrap 变量。 - **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。 diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index e51b86bab9..85b4939513 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index e890eca65d..abc1f7ed2a 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md README.md: 8a552ac36fac150ae18b2a8adaab1dd489ecb476 -README.zh.md: 618e50afe7cd157b2dfc4763adfbf1d5d88d213e +README.zh.md: 7b5ea7fa1985d394b88fd5bcdbce9d9831eef8ee diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 618e50afe7..7b5ea7fa19 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -4,7 +4,7 @@ 宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接 spawn 可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时,调用会以错误拒绝;该错误附带退出 `code` 与两路已捕获输出,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。 -它的两个消费方都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关将路径交由默认应用打开的操作([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方的可注入命令边界。 +它的两个消费方都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.zh.md) 后端的 OS 选择器命令,以及网关将路径交由默认应用打开的操作([`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方的可注入命令边界。 它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。 diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index d5ea6fc943..a4e783c0e6 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/README.i18n.yaml b/packages/util/output-retention/README.i18n.yaml index ede6c1260f..aa30e2695f 100644 --- a/packages/util/output-retention/README.i18n.yaml +++ b/packages/util/output-retention/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/output-retention/README.md README.md: e1d2c828fd4c6526cc391e7f93d7fe20df26e268 -README.zh.md: 36730ab83dc7f6a93376a8f0272160d29935e6e0 +README.zh.md: 51055dd33e2cce3b82a1d15d6994f9d9f37a0579 diff --git a/packages/util/output-retention/README.zh.md b/packages/util/output-retention/README.zh.md index 36730ab83d..51055dd33e 100644 --- a/packages/util/output-retention/README.zh.md +++ b/packages/util/output-retention/README.zh.md @@ -4,7 +4,7 @@ 一个轻依赖的**保留**库:为必须限制返回上下文量的工具提供有界的面向模型输出。调用方将项或文本分片送入有界对象,然后取回保留的内容和精确的省略元数据。 -该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。 +该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md) 划定的边界。 它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包直接导入它。 diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 9be1db591f..7092656029 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml index c000fae463..43c4165a7c 100644 --- a/packages/util/timeout/README.i18n.yaml +++ b/packages/util/timeout/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/timeout/README.md README.md: c29f6a7661abf40eadafc7637c1bbca509a62a24 -README.zh.md: ca05fcaf0ac7f3379d746e11a123294e3febf436 +README.zh.md: 38bbebe1462e4351a207b28a831d1274d9578e58 diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md index ca05fcaf0a..38bbebe146 100644 --- a/packages/util/timeout/README.zh.md +++ b/packages/util/timeout/README.zh.md @@ -4,7 +4,7 @@ 超时的**时序与分类**部分:一个零依赖纯函数库(无运行时 harness 依赖),由每个需要限制调用方超时提示、启动 deadline,并在之后区分「已超时」与「已取消」的能力共享。 -它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各能力负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 关闭 `fetch` 套接字,没有任何共享层能够承担全部终止机制。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。 +它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各能力负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 关闭 `fetch` 套接字,没有任何共享层能够承担全部终止机制。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。 它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不持有状态,也不发出事件。「超时服务」必须了解如何停止每项能力的工作,这正是微内核要排除在共享层之外的知识。 @@ -52,7 +52,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout ## 哪些操作不设置超时 -本地文件 `read`/`write`/`edit` 不接受 `timeoutMs`:文件 IO 不设时限地运行,因为截止时间会中止操作系统仍会完成的工作。详见[文件系统子系统页面](../../../docs/subsystems/filesystem.md)。 +本地文件 `read`/`write`/`edit` 不接受 `timeoutMs`:文件 IO 不设时限地运行,因为截止时间会中止操作系统仍会完成的工作。详见[文件系统子系统页面](../../../docs/subsystems/filesystem.zh.md)。 ## 模型体验 diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 7ba37eda73..491f3acee1 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/web/README.i18n.yaml b/packages/web/README.i18n.yaml index 19da6ba386..06a41eba22 100644 --- a/packages/web/README.i18n.yaml +++ b/packages/web/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/README.md README.md: fc37d7cdead59138db149b5a86f0a0c031d40037 -README.zh.md: 53fe673ddaed235bbb938db4757e9cd240928d61 +README.zh.md: 40a64e09b85b0655739f73abe6388d6cc2b40a0d diff --git a/packages/web/README.zh.md b/packages/web/README.zh.md index 53fe673dda..40a64e09b8 100644 --- a/packages/web/README.zh.md +++ b/packages/web/README.zh.md @@ -6,13 +6,13 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`web/`](web/README.md) | 定义 web 提供方注册、选择和共享错误 | `ctx.web` | -| [`web-search-exa/`](web-search-exa/README.md) | 通过 Exa 提供 web 搜索 | 注册到 `ctx.web` | -| [`web-search-perplexity/`](web-search-perplexity/README.md) | 通过 Perplexity 提供 web 搜索 | 注册到 `ctx.web` | -| [`web-search-deepseek/`](web-search-deepseek/README.md) | 提供 DeepSeek 原生 web 搜索 | 注册到 `ctx.web` | -| [`web-fetch-http/`](web-fetch-http/README.md) | 抓取公共 HTTP 和 HTTPS 资源 | 注册到 `ctx.web` | -| [`tool-web/`](tool-web/README.md) | 向模型公开 web 搜索和抓取 | 注册到 `ctx.tools` | +| [`web/`](web/README.zh.md) | 定义 web 提供方注册、选择和共享错误 | `ctx.web` | +| [`web-search-exa/`](web-search-exa/README.zh.md) | 通过 Exa 提供 web 搜索 | 注册到 `ctx.web` | +| [`web-search-perplexity/`](web-search-perplexity/README.zh.md) | 通过 Perplexity 提供 web 搜索 | 注册到 `ctx.web` | +| [`web-search-deepseek/`](web-search-deepseek/README.zh.md) | 提供 DeepSeek 原生 web 搜索 | 注册到 `ctx.web` | +| [`web-fetch-http/`](web-fetch-http/README.zh.md) | 抓取公共 HTTP 和 HTTPS 资源 | 注册到 `ctx.web` | +| [`tool-web/`](tool-web/README.zh.md) | 向模型公开 web 搜索和抓取 | 注册到 `ctx.tools` | -[web 能力决策](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)记录了搜索和抓取共用一项提供方选择服务的原因。 +[web 能力决策](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)记录了搜索和抓取共用一项提供方选择服务的原因。 -子系统参考——搜索/抓取请求与结果、可用性、`WebError`——见 [docs/subsystems/web.md](../../docs/subsystems/web.md);依据(含延后的 SSRF 防护)见 [web 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)。 +子系统参考——搜索/抓取请求与结果、可用性、`WebError`——见 [docs/subsystems/web.md](../../docs/subsystems/web.zh.md);依据(含延后的 SSRF 防护)见 [web 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)。 diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index bfba84d22d..5af88ca380 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/tool-web/README.md README.md: 787b70a5070f48a3bac6435d5d7e8b64c01e0341 -README.zh.md: bce95dbc0eea1bebe59b0b80cafae2a4eb948aa6 +README.zh.md: f0185deffa8643317f5f01f7e1c3af7af1ce1194 diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index bce95dbc0e..f0185deffa 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md)(`tools/execute` 包装层)强制执行。单项操作会转发 `exec.signal`;多查询搜索会把它与批次取消信号融合,使失败查询能够中止其余查询。 +面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.zh.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md))。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.zh.md)(`tools/execute` 包装层)强制执行。单项操作会转发 `exec.signal`;多查询搜索会把它与批次取消信号融合,使失败查询能够中止其余查询。 每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。 @@ -29,7 +29,7 @@ | `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 | | `fetchMaxOutputChars` | `200000` | 同步转换的源字符数与单次完整 `web_fetch` 输出的上限(状态头、渲染后的主体与页脚合并计算);主体被截断时,在能容纳的情况下附带截断提示。 | -`searchMaxQueries` 在完全相同的字符串去重前限制可接受数组、提供方请求扇出与组合后的提供方答案增长;校验会在任何搜索开始前拒绝超限数组,随后分发只保留每个查询第一次出现的位置。该设置与各提供方自己的 `maxUses` 等控制项共同构成产品的搜索预算;通用 seam 不公开提供方内部的原生搜索计数。`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。 +`searchMaxQueries` 在完全相同的字符串去重前限制可接受数组、提供方请求扇出与组合后的提供方答案增长;校验会在任何搜索开始前拒绝超限数组,随后分发只保留每个查询第一次出现的位置。该设置与各提供方自己的 `maxUses` 等控制项共同构成产品的搜索预算;通用 seam 不公开提供方内部的原生搜索计数。`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.zh.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。 ```yaml - id: tool-web @@ -80,7 +80,7 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### 模型看到的内容 -模型会看到生成的 [`web_search` 与 `web_fetch` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-web)。结果数量与超时预算属于部署设置,不是模型参数。 +模型会看到生成的 [`web_search` 与 `web_fetch` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-web)。结果数量与超时预算属于部署设置,不是模型参数。 #### Token 影响 @@ -150,5 +150,5 @@ schema 校验会在执行前拒绝缺失或非数组的 `queries` 字段以及 - **没有覆盖整个批次的原生搜索计数器**:`searchMaxQueries` 限制 `ctx.web.search` 调用数,但提供方可以在每次调用内执行多次原生搜索。例如,配置了 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。部署通过这些独立的消费方与提供方设置控制成本,因为通用 seam 不知道提供方内部的搜索计量单位。 - **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([已归档的依赖决策](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 -- **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 +- **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久化的 URL/域名授权。 diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index d7597c5e7b..6181a92f2b 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/README.i18n.yaml b/packages/web/web-fetch-http/README.i18n.yaml index 8ba306ea66..078606e11b 100644 --- a/packages/web/web-fetch-http/README.i18n.yaml +++ b/packages/web/web-fetch-http/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-fetch-http/README.md README.md: 5589a8e8605a64ae9ef5f6d9978a9b63331d5b0d -README.zh.md: e9cf98feb9065947af1321c87562a2ae9ac21315 +README.zh.md: b0dff1d992f9f84cc8b9b9747544ef5e6c0fc3eb diff --git a/packages/web/web-fetch-http/README.zh.md b/packages/web/web-fetch-http/README.zh.md index e9cf98feb9..b0dff1d992 100644 --- a/packages/web/web-fetch-http/README.zh.md +++ b/packages/web/web-fetch-http/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -一个匿名公共 HTTP(S) `WebFetchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它获取具体 URL,返回状态码和长度受限的解码内容。 +一个匿名公共 HTTP(S) `WebFetchProvider`,用于 harness [web 能力 seam](../web/README.zh.md)(`ctx.web`)。它获取具体 URL,返回状态码和长度受限的解码内容。 这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。它是函数/命名空间插件(`inject: ['web']`)。 @@ -10,7 +10,7 @@ 提供方拥有**安全资源获取**:URL 验证、HTTP 传输、重定向策略、资源兜底超时、中止传播、字节上限、charset 解码、内容类型分类与二进制拒绝。`@deepseek-ai/dsh-tool-web` 拥有**呈现**(HTML→markdown、截断格式)。非 2xx HTTP 响应是*结果*(状态码 + 解码主体),不是错误;`WebError` 只用于无法安全获取或表示资源的失败。 -提供方的 `timeoutMs` 是直接 `ctx.web.fetch()` 调用方和配置有误的部署所用的资源兜底,不是面向模型的工具调用预算。[`dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) 拥有 `web_fetch` 工具调用预算,并让 `exec.signal` 在超时时触发,以强制执行该预算。 +提供方的 `timeoutMs` 是直接 `ctx.web.fetch()` 调用方和配置有误的部署所用的资源兜底,不是面向模型的工具调用预算。[`dsh-tool-call-timeout-policy`](../../guard/timeout-policy/README.zh.md) 拥有 `web_fetch` 工具调用预算,并让 `exec.signal` 在超时时触发,以强制执行该预算。 已交付的 web 工具部署会把提供方兜底设为高于工具预算,因此模型调用通常返回 `TOOL_TIMEOUT`。如果外层截止期限先于提供方的兜底超时触发,提供方会报告 `WEB_ABORTED`,外层策略再将其替换为 `TOOL_TIMEOUT`。因此,`WEB_FETCH_TIMEOUT` 表明直接服务调用方的提供方预算已经耗尽。 @@ -38,7 +38,7 @@ ## 模型体验 -通过 [`dsh-tool-web`](../tool-web/README.md) 间接影响;该工具把此提供方经 `maxBodyChars` 限制的解码文本或由 HTML 转换得到的 markdown 置于抓取结果包装层中,并保留提供方失败;重定向、标头与传输机制保持隐藏。 +通过 [`dsh-tool-web`](../tool-web/README.zh.md) 间接影响;该工具把此提供方经 `maxBodyChars` 限制的解码文本或由 HTML 转换得到的 markdown 置于抓取结果包装层中,并保留提供方失败;重定向、标头与传输机制保持隐藏。 #### KV Cache 影响 @@ -46,6 +46,6 @@ ## 已知限制与暂缓事项 -- **SSRF/私有网络防护暂缓**:不会阻止私有、loopback、link-local、multicast 或其他非公开目标,也不进行 DNS 解析后验证或逐跳重新验证(见 [web 能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。在此功能落地前,该提供方是 SSRF 原语;能够访问敏感内部网络目标的部署**禁止启用它**。 +- **SSRF/私有网络防护暂缓**:不会阻止私有、loopback、link-local、multicast 或其他非公开目标,也不进行 DNS 解析后验证或逐跳重新验证(见 [web 能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。在此功能落地前,该提供方是 SSRF 原语;能够访问敏感内部网络目标的部署**禁止启用它**。 - **只解码文本内容**:包括 html/xhtml 与 `text/*` 加 JSON/XML 家族;缺少 `Content-Type` 或任何二进制类型都会抛出 `WEB_UNSUPPORTED_CONTENT_TYPE`,可提取文本的 PDF 解码属于明确的暂缓工作。 - **charset 只来自 `Content-Type` 标头**(默认为 UTF-8):HTML `` 声明会被忽略;声明但无法识别的 charset 标签会抛出异常,而非回退。 diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 0198f40581..9cc89a1ed0 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 653d2b654f..f4eea9bf3d 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md README.md: 25162a3e82a05bb17f31a69bc65f6552e42906ad -README.zh.md: a7a2566ecb36558c581dd801531bd394eb63dea3 +README.zh.md: 65b3c9175357b44ab07f84bdd10a4a250f55f43f diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index a7a2566ecb..65b3c91753 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。 +由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.zh.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。 这是一个**实现**包:它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 @@ -68,7 +68,7 @@ DeepSeek 返回的提供方生成答案均不被该提供方信任为 `content` #### 模型看到的内容 -通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: `、`DeepSeek search aborted`、`DeepSeek search request failed: `、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装属于消费方。 +通过 [`dsh-tool-web`](../tool-web/README.zh.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: `、`DeepSeek search aborted`、`DeepSeek search request failed: `、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装属于消费方。 #### Token 影响 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index dd924f259d..3fa9f6b5fc 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/README.i18n.yaml b/packages/web/web-search-exa/README.i18n.yaml index 4afbbcbc5d..d94873e683 100644 --- a/packages/web/web-search-exa/README.i18n.yaml +++ b/packages/web/web-search-exa/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-exa/README.md README.md: 5b1212c1fb210a3aca7836875cc295fad87e7437 -README.zh.md: fc9afbe44dcb5dd55c7007a11b35048217143351 +README.zh.md: 0d236cb1cb42ab3742d86872a2329060f8ceb09f diff --git a/packages/web/web-search-exa/README.zh.md b/packages/web/web-search-exa/README.zh.md index fc9afbe44d..0d236cb1cb 100644 --- a/packages/web/web-search-exa/README.zh.md +++ b/packages/web/web-search-exa/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由 [Exa](https://exa.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 Exa 的 `POST /search` 端点并请求高亮摘要内容,把扁平 `results[]` 映射为 seam 规范化的 `WebSearchResult`。 +由 [Exa](https://exa.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.zh.md)(`ctx.web`)。它调用 Exa 的 `POST /search` 端点并请求高亮摘要内容,把扁平 `results[]` 映射为 seam 规范化的 `WebSearchResult`。 这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有 `ctx.web` 键,也不注册面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`),负责注册后端,而非默认导出服务。 @@ -29,7 +29,7 @@ Exa 返回扁平 `results[]`,不返回生成答案,因此省略 `content`。 ## 模型体验 -通过 [`dsh-tool-web`](../tool-web/README.md) 间接影响;该工具保留此提供方经 `maxResults` 限制的 URL、标题、首条 highlight 与发布日期,或将确切的错误消息 `Exa search aborted`、`Exa search request failed: ` 和 `Exa returned an unprocessable response body: ` 置于消费方的错误包装层内;生成答案与提供方私有字段不进入上下文。 +通过 [`dsh-tool-web`](../tool-web/README.zh.md) 间接影响;该工具保留此提供方经 `maxResults` 限制的 URL、标题、首条 highlight 与发布日期,或将确切的错误消息 `Exa search aborted`、`Exa search request failed: ` 和 `Exa returned an unprocessable response body: ` 置于消费方的错误包装层内;生成答案与提供方私有字段不进入上下文。 #### KV Cache 影响 @@ -38,5 +38,5 @@ Exa 返回扁平 `results[]`,不返回生成答案,因此省略 `content`。 ## 已知限制与暂缓事项 - **没有非空白高亮摘要的结果会被整个丢弃**:没有可映射的可移植 snippet,因此返回源可能少于请求数量。 -- **只公开 `searchType`/`numResults`/`highlightsPerResult`**:Exa 的其他控制项(livecrawl、category、域名/日期过滤条件、全文内容)等待提供方无关的 Service Definition 字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **只公开 `searchType`/`numResults`/`highlightsPerResult`**:Exa 的其他控制项(livecrawl、category、域名/日期过滤条件、全文内容)等待提供方无关的 Service Definition 字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。 - **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index b52482cd0c..2f8e4c0ce7 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/README.i18n.yaml b/packages/web/web-search-perplexity/README.i18n.yaml index e0af4f5cbb..0d597f7966 100644 --- a/packages/web/web-search-perplexity/README.i18n.yaml +++ b/packages/web/web-search-perplexity/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-perplexity/README.md README.md: 8800609a05e99d3fa8a61e12b2360e633f0fd330 -README.zh.md: 488c1463d28b7d36c34625a3ddbab1aa3417d70c +README.zh.md: ddf2ef2ef767d7ac2e361e873cefade70ea78115 diff --git a/packages/web/web-search-perplexity/README.zh.md b/packages/web/web-search-perplexity/README.zh.md index 488c1463d2..ddf2ef2ef7 100644 --- a/packages/web/web-search-perplexity/README.zh.md +++ b/packages/web/web-search-perplexity/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由 [Perplexity](https://perplexity.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 Perplexity 的 OpenAI 兼容 `POST /chat/completions` 端点,把生成答案与引用映射为 seam 规范化的 `WebSearchResult`。 +由 [Perplexity](https://perplexity.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.zh.md)(`ctx.web`)。它调用 Perplexity 的 OpenAI 兼容 `POST /chat/completions` 端点,把生成答案与引用映射为 seam 规范化的 `WebSearchResult`。 这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。OpenAI 兼容协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 @@ -47,7 +47,7 @@ #### 模型看到的内容 -通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到生成答案及结构化结果元数据,或只含 URL 的引用。该提供方确切的错误消息为 `Perplexity search aborted`、`Perplexity search request failed: ` 和 `Perplexity returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装层属于消费方。 +通过 [`dsh-tool-web`](../tool-web/README.zh.md),会话模型会看到生成答案及结构化结果元数据,或只含 URL 的引用。该提供方确切的错误消息为 `Perplexity search aborted`、`Perplexity search request failed: ` 和 `Perplexity returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装层属于消费方。 #### Token 影响 @@ -61,5 +61,5 @@ - **引用回退源只含 URL**:Perplexity 省略结构化 `search_results[]` 时,源不含 `title`/`snippet`/`publishedAt`,因此工具只渲染纯主机名标签。 - **超量返回的来源仍会增加 token 消耗和延迟**:协议没有结果数量控制,`maxResults` 只能由 seam 在事后截断。 -- **只公开 `model`/`maxTokens`/`searchRecency`**:Perplexity 的其他搜索控制项(域名过滤条件、`web_search_options` 上下文大小、图片)有待提供方无关的 Service Definition 字段支持(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **只公开 `model`/`maxTokens`/`searchRecency`**:Perplexity 的其他搜索控制项(域名过滤条件、`web_search_options` 上下文大小、图片)有待提供方无关的 Service Definition 字段支持(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。 - **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index f7170f22f2..6acfbdf724 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/README.i18n.yaml b/packages/web/web/README.i18n.yaml index 0b2f806c2a..0b8f351822 100644 --- a/packages/web/web/README.i18n.yaml +++ b/packages/web/web/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web/README.md README.md: 8dfc7f032e25e40207bb3880777b814e67175a20 -README.zh.md: 3354037bfcd77ca2b1a07da710b565065baf543b +README.zh.md: 98922ad83fedb7ded07b2e180f8a6e4261032112 diff --git a/packages/web/web/README.zh.md b/packages/web/web/README.zh.md index 3354037bfc..98922ad83f 100644 --- a/packages/web/web/README.zh.md +++ b/packages/web/web/README.zh.md @@ -56,6 +56,6 @@ ## 已知限制与暂缓事项 - **没有观测接口**:没有提供方变更事件或能力状态查询;可用性只能通过执行 `search()`/`fetch()` 并按抛出的 `WebError` code 路由来观测,无提供方失败是通用的 `WEB_PROVIDER_UNAVAILABLE`,不会枚举逐提供方原因(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md))。 -- **`WebSearchRequest` 只携带 `query` + `maxResults`**:提供方无关的控制项(新近程度、域名过滤条件、区域提示、搜索深度)暂缓至 Exa 与 Perplexity 都能诚实支持时(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **`WebSearchRequest` 只携带 `query` + `maxResults`**:提供方无关的控制项(新近程度、域名过滤条件、区域提示、搜索深度)暂缓至 Exa 与 Perplexity 都能诚实支持时(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。 - **`WebFetchBody` 没有 `pdf` 分支**:可提取文本的 PDF 支持属于明确的暂缓工作;封闭联合会使新增该分支成为三个 web 包中由编译强制执行的变更。 - **提供方支持的页面提取不属于 `fetch()` 范围**:Firecrawl/Tavily 风格的 `web_extract` 能力暂缓,而不会扩展抓取操作。 diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 4338393818..ce0543b3bf 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/README.i18n.yaml b/packages/workflow/README.i18n.yaml index 1b39e5fc7a..ff979da66e 100644 --- a/packages/workflow/README.i18n.yaml +++ b/packages/workflow/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/README.md README.md: 156e9f0d84ced4eab8c25da91d29d239d18b0629 -README.zh.md: 24687f81d612c23786150c5b5a70bce352954663 +README.zh.md: ec65b32bfe2983ae163cb208842969f6f79b5f79 diff --git a/packages/workflow/README.zh.md b/packages/workflow/README.zh.md index 24687f81d6..ec65b32bfe 100644 --- a/packages/workflow/README.zh.md +++ b/packages/workflow/README.zh.md @@ -6,11 +6,11 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`workflow/`](workflow/README.md) | 定义工作流执行和生命周期事件 | `ctx.workflowEngine` | -| [`workflow-worker-thread/`](workflow-worker-thread/README.md) | 在线程中运行工作流脚本 | 注册到 `ctx.workflowEngine` | -| [`tool-workflow/`](tool-workflow/README.md) | 向模型公开通用工作流执行 | 注册到 `ctx.tools` | -| [`tool-ralph/`](tool-ralph/README.md) | 公开使用全新 agent(智能体)的固定 Ralph 工作流 | 注册到 `ctx.tools` | +| [`workflow/`](workflow/README.zh.md) | 定义工作流执行和生命周期事件 | `ctx.workflowEngine` | +| [`workflow-worker-thread/`](workflow-worker-thread/README.zh.md) | 在线程中运行工作流脚本 | 注册到 `ctx.workflowEngine` | +| [`tool-workflow/`](tool-workflow/README.zh.md) | 向模型公开通用工作流执行 | 注册到 `ctx.tools` | +| [`tool-ralph/`](tool-ralph/README.zh.md) | 公开使用全新 agent(智能体)的固定 Ralph 工作流 | 注册到 `ctx.tools` | -worker thread 将工作流执行与宿主事件循环隔离,但不构成安全边界。参见[动态工作流](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)和 [Ralph 工具](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)决策。 +worker thread 将工作流执行与宿主事件循环隔离,但不构成安全边界。参见[动态工作流](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md)和 [Ralph 工具](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md)决策。 -子系统参考——启动请求、`WorkflowMeta`、结果、实时运行、`workflow/*` 事件——见 [docs/subsystems/workflow.md](../../docs/subsystems/workflow.md);决策见[动态工作流](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)与 [Ralph 消费方](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note。 +子系统参考——启动请求、`WorkflowMeta`、结果、实时运行、`workflow/*` 事件——见 [docs/subsystems/workflow.md](../../docs/subsystems/workflow.zh.md);决策见[动态工作流](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md)与 [Ralph 消费方](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md) Agent Note。 diff --git a/packages/workflow/tool-ralph/README.i18n.yaml b/packages/workflow/tool-ralph/README.i18n.yaml index 07a851efb9..d6a1526129 100644 --- a/packages/workflow/tool-ralph/README.i18n.yaml +++ b/packages/workflow/tool-ralph/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-ralph/README.md README.md: ae9d7c80763f751860bc2073950dde331fdd5a8e -README.zh.md: 692ebb693b54ade2c2fbde18f818ced10e9d5863 +README.zh.md: 9e41b9e527f865a090eaa78ffef30bf6b890357b diff --git a/packages/workflow/tool-ralph/README.zh.md b/packages/workflow/tool-ralph/README.zh.md index 692ebb693b..9e41b9e527 100644 --- a/packages/workflow/tool-ralph/README.zh.md +++ b/packages/workflow/tool-ralph/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 `ralph` 工具运行固定的前台工作流,把一个不可变目标依次交给多个全新子 agent(智能体)。它展示如何把专用编排策略实现为基于 [`ctx.workflowEngine`](../workflow/README.md) 和 [`ctx.subagents`](../../subagent/subagent/README.md) 的普通插件:不会向 `agent-loop` 添加 Ralph 模式或全新 agent loop(智能体循环),同会话的[目标领域](../../goal/goal/README.md)也保持独立。策略和暂缓事项由 [Ralph Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)负责。 +面向模型的 `ralph` 工具运行固定的前台工作流,把一个不可变目标依次交给多个全新子 agent(智能体)。它展示如何把专用编排策略实现为基于 [`ctx.workflowEngine`](../workflow/README.zh.md) 和 [`ctx.subagents`](../../subagent/subagent/README.zh.md) 的普通插件:不会向 `agent-loop` 添加 Ralph 模式或全新 agent loop(智能体循环),同会话的[目标领域](../../goal/goal/README.zh.md)也保持独立。策略和暂缓事项由 [Ralph Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md)负责。 ## 契约 @@ -59,7 +59,7 @@ Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop o #### 模型看到的内容 -已生成的 [`ralph` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ralph)公开一个必填 `objective` 字符串和一个可选 `maxRounds` 数字。提供方选择、交接大小、报告 schema、工作流脚本和编排行为均由部署侧控制,不在调用 schema 中。 +已生成的 [`ralph` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ralph)公开一个必填 `objective` 字符串和一个可选 `maxRounds` 数字。提供方选择、交接大小、报告 schema、工作流脚本和编排行为均由部署侧控制,不在调用 schema 中。 #### Token 影响 diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index a795d22ca8..a137c127fe 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 197b47832a..f87a12407f 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md README.md: 918fe5b9aff74b3b959107ec1eebfd7966afe6d1 -README.zh.md: f7e62560a1b581cf64d78dc379b821d17f29b3aa +README.zh.md: 2546a0fc92ef39d71949cc0061dcf363ecc2e22d diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index f7e62560a1..2546a0fc92 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflowEngine`](../workflow/README.md) 定义面向模型的 schema 和运行生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 +面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflowEngine`](../workflow/README.zh.md) 定义面向模型的 schema 和运行生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 ## 模型看到的内容 @@ -10,7 +10,7 @@ ## 生命周期 -收集是同步的(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)):`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose(资源释放)运行,使脚本及其子 agent(智能体)在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`;Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。 +收集是同步的(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.zh.md)):`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose(资源释放)运行,使脚本及其子 agent(智能体)在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`;Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。 对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。 @@ -18,7 +18,7 @@ ## 渲染意图 -渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: `,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 +渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md)):使用一个 `generic` 卡片,标题为 `workflow: `,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 ## 配置 @@ -53,7 +53,7 @@ Use the tool ONLY when the user explicitly asks for a workflow or for #### 模型看到的内容 -工具可见时,已生成的默认 [`workflow` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-workflow) 包含完整的 JavaScript 钩子与元数据约定;`toolName` 可以重命名该定义,模型会提交脚本、元数据和可选 args。 +工具可见时,已生成的默认 [`workflow` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-workflow) 包含完整的 JavaScript 钩子与元数据约定;`toolName` 可以重命名该定义,模型会提交脚本、元数据和可选 args。 #### Token 影响 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 2bd0cd13a9..71872afdc6 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/README.i18n.yaml b/packages/workflow/workflow-worker-thread/README.i18n.yaml index 19bb92d976..c761f76437 100644 --- a/packages/workflow/workflow-worker-thread/README.i18n.yaml +++ b/packages/workflow/workflow-worker-thread/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/workflow-worker-thread/README.md README.md: ba32c9dab4e870dd52c8f8acba3cfa627fa78000 -README.zh.md: 99107aec40a45c8460f10082f2b75b908e3e8692 +README.zh.md: b4c30eac3221b7091b2fc5d73149c244f5abf173 diff --git a/packages/workflow/workflow-worker-thread/README.zh.md b/packages/workflow/workflow-worker-thread/README.zh.md index 99107aec40..b4c30eac32 100644 --- a/packages/workflow/workflow-worker-thread/README.zh.md +++ b/packages/workflow/workflow-worker-thread/README.zh.md @@ -105,7 +105,7 @@ worker 错误、消息失败或提前退出会在清理前关闭消息接纳, #### 模型看到的内容 -通过 [`dsh-tool-workflow`](../tool-workflow/README.md),成功结果只会在该消费方的包装层中公开实体化的最终 JSON 值和子 agent 数量。本引擎提供稳定错误,包括 `workflow script does not parse: `、`invalid meta: `、`agent() requires a non-empty prompt string`、`agent() could not start a child: `、`child agent run failed: `,以及其精确的 `parallel()`、`pipeline()`、`phase()`、选项、schema 和 JSON 边界校验消息。中间子 agent 输出可供脚本使用,但不提供给父模型。 +通过 [`dsh-tool-workflow`](../tool-workflow/README.zh.md),成功结果只会在该消费方的包装层中公开实体化的最终 JSON 值和子 agent 数量。本引擎提供稳定错误,包括 `workflow script does not parse: `、`invalid meta: `、`agent() requires a non-empty prompt string`、`agent() could not start a child: `、`child agent run failed: `,以及其精确的 `parallel()`、`pipeline()`、`phase()`、选项、schema 和 JSON 边界校验消息。中间子 agent 输出可供脚本使用,但不提供给父模型。 #### Token 影响 diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 8e2c094009..0db1d26f1f 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/README.i18n.yaml b/packages/workflow/workflow/README.i18n.yaml index e8f1900e7c..4ed7c36d13 100644 --- a/packages/workflow/workflow/README.i18n.yaml +++ b/packages/workflow/workflow/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/workflow/README.md README.md: cc2c24f62512273ea1657542d0d904b0b870e236 -README.zh.md: 22bda1bd33bc6350c8ff51f27538258bee872fdb +README.zh.md: 0b02cea896b3cb64559ce110c0da2478728f513c diff --git a/packages/workflow/workflow/README.zh.md b/packages/workflow/workflow/README.zh.md index 22bda1bd33..0b02cea896 100644 --- a/packages/workflow/workflow/README.zh.md +++ b/packages/workflow/workflow/README.zh.md @@ -58,4 +58,4 @@ - **没有 token 预算词汇**:引擎会限制并发、条目和子 agent,但请求与结果都不会统计跨子 agent 的模型 token。 - **运行由持有方负责,不由服务跟踪**:卸载引擎不会发现独立的活动句柄;每个消费方都必须 dispose 自己启动的运行。 -暂缓实现的工作流接口见[动态工作流 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 +暂缓实现的工作流接口见[动态工作流 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md)。 diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index bf71730d89..8f5afc51e0 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/README.i18n.yaml b/packages/workspace/README.i18n.yaml index 42e824497a..b8e94e910c 100644 --- a/packages/workspace/README.i18n.yaml +++ b/packages/workspace/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/README.md README.md: d12eb9434ec092834a7c34e424edc1d003430b35 -README.zh.md: 796c48cdbd1916c06e9bb914c6a5149769a58b4e +README.zh.md: 220a18cfeb6ecb9569dfd2bce18bafe6c7aac879 diff --git a/packages/workspace/README.zh.md b/packages/workspace/README.zh.md index 796c48cdbd..220a18cfeb 100644 --- a/packages/workspace/README.zh.md +++ b/packages/workspace/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| [`workspace/`](workspace/README.md) | 注册 workspace 并记录其会话归属 | `ctx.workspaceRegistry` | +| [`workspace/`](workspace/README.zh.md) | 注册 workspace 并记录其会话归属 | `ctx.workspaceRegistry` | -[workspace 包参考](workspace/README.md)负责生命周期、持久化和删除语义。 +[workspace 包参考](workspace/README.zh.md)负责生命周期、持久化和删除语义。 -子系统参考——实体、realpath 规范、注册/解析——见 [docs/subsystems/workspace.md](../../docs/subsystems/workspace.md);存储设计见 [domain KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。 +子系统参考——实体、realpath 规范、注册/解析——见 [docs/subsystems/workspace.md](../../docs/subsystems/workspace.zh.md);存储设计见 [domain KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 20f5d3b867..2c2bd8d4ea 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md README.md: c1c4309efdc85f4bc7a6b0f128df6d9f4f810ae1 -README.zh.md: f44ad04be0c63061200a256c5ce8cb24a4d3c54b +README.zh.md: 4ba66cb5bb83a86cec00b33769f0ac85fef41190 diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index f44ad04be0..4ba66cb5bb 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -4,7 +4,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspaceRegistry`):通过领域数据形式存储持久 workspace 记录、稳定 workspace 顺序和按新到旧排列的候选会话索引。消费方看到 `Workspace` 接口;实体实现保持包私有。 -实体/存储理由见[领域 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md);仅使用头部的引导初始化和 GUI 排序见 [Workspace UI 产品流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md)。 +实体/存储理由见[领域 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md);仅使用头部的引导初始化和 GUI 排序见 [Workspace UI 产品流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md)。 ## 结构 @@ -40,5 +40,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspaceRegistry`): ## 已知限制与暂缓事项 -- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。 +- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md))。 - 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或造成的 cwd 损坏会在下次刷新或重启后被发现。 diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 594815d82b..6ae7159274 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66a4a21b7a..d190033b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -481,6 +484,12 @@ importers: '@deepseek-ai/dsh-e2b': specifier: workspace:* version: link:../packages/e2b/e2b + '@deepseek-ai/dsh-experimental-agent-team': + specifier: workspace:* + version: link:../packages/experimental/agent-team + '@deepseek-ai/dsh-experimental-tool-agent-team': + specifier: workspace:* + version: link:../packages/experimental/tool-agent-team '@deepseek-ai/dsh-fs-e2b': specifier: workspace:* version: link:../packages/e2b/fs-e2b @@ -640,9 +649,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../packages/core/system-prompt - '@deepseek-ai/dsh-team': - specifier: workspace:* - version: link:../packages/experimental/team '@deepseek-ai/dsh-terminal': specifier: workspace:* version: link:../packages/terminal/terminal @@ -688,6 +694,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:* + version: link:../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph @@ -709,9 +718,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:* version: link:../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-team': - specifier: workspace:* - version: link:../packages/experimental/tool-team '@deepseek-ai/dsh-tool-terminal': specifier: workspace:* version: link:../packages/terminal/tool-terminal @@ -863,6 +869,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -884,6 +893,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -1293,6 +1305,9 @@ importers: '@deepseek-ai/dsh-client-ui-attachment': specifier: workspace:^ version: link:../../client/ui-attachment + '@deepseek-ai/dsh-client-ui-brand-official': + specifier: workspace:^ + version: link:../../client/ui-brand-official '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../../client/ui-commands @@ -1335,6 +1350,9 @@ importers: '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-reference': + specifier: workspace:^ + version: link:../../client/ui-reference '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer @@ -1392,6 +1410,12 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference + '@deepseek-ai/dsh-file-reference-local': + specifier: workspace:^ + version: link:../../context/file-reference-local '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1413,6 +1437,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback @@ -1422,6 +1449,9 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-session-stats': specifier: workspace:^ version: link:../../session/session-stats @@ -1434,6 +1464,9 @@ importers: '@deepseek-ai/dsh-storage-json': specifier: workspace:^ version: link:../../storage/storage-json + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-web-frontend': specifier: workspace:^ version: link:../../../apps/web @@ -1446,6 +1479,9 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 + open: + specifier: ^11.0.0 + version: 11.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1735,6 +1771,39 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-brand-official: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-commands: dependencies: clsx: @@ -2068,6 +2137,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -2404,6 +2476,39 @@ importers: specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + packages/client/ui-reference: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/client/ui-renderer: dependencies: use-sync-external-store: @@ -2786,9 +2891,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-theme: dependencies: @@ -3345,11 +3456,58 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/context/file-reference: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + + packages/context/file-reference-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3375,6 +3533,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/context/time-context: dependencies: @@ -3647,6 +3808,21 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/credentials/authorization: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + packages/credentials/credentials: devDependencies: '@deepseek-ai/cordis': @@ -3926,7 +4102,7 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/experimental/team: + packages/experimental/agent-team: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery @@ -3978,7 +4154,7 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent-spawn-in-process - packages/experimental/tool-team: + packages/experimental/tool-agent-team: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery @@ -3996,6 +4172,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-experimental-agent-team': + specifier: workspace:^ + version: link:../agent-team '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -4020,9 +4199,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-team': - specifier: workspace:^ - version: link:../team '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../subagent/tool-subagent-control @@ -4890,6 +5066,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-permission-presets': + specifier: workspace:^ + version: link:../../interaction/permission-presets '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../../storage/storage @@ -5362,6 +5541,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../credentials/authorization '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -6189,15 +6371,27 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages/session/session-projection: dependencies: @@ -6839,6 +7033,61 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/shell/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/skill/skill: dependencies: '@deepseek-ai/schemastery': @@ -7608,6 +7857,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: 1.2.0-beta.15 version: 1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0) @@ -7648,6 +7900,9 @@ importers: packages/terminal/terminal-bash: dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../shell/pwsh-local '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -8468,6 +8723,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../packages/credentials/authorization '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local @@ -8573,6 +8831,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder @@ -11838,6 +12099,10 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -12154,6 +12419,18 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -12662,6 +12939,11 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -12674,6 +12956,15 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -12696,6 +12987,10 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -13343,6 +13638,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -13494,6 +13793,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact@10.29.7: resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} peerDependencies: @@ -13657,6 +13960,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -14336,6 +14643,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -16981,6 +17292,10 @@ snapshots: builtin-modules@3.3.0: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} cac@7.0.0: {} @@ -17288,6 +17603,15 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + defu@6.1.7: {} delaunator@5.1.0: @@ -17923,6 +18247,8 @@ snapshots: ipaddr.js@1.9.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -17931,6 +18257,12 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -17943,6 +18275,10 @@ snapshots: is-what@5.5.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isexe@2.0.0: {} @@ -18767,6 +19103,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -18950,6 +19295,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + preact@10.29.7: {} prelude-ls@1.2.1: {} @@ -19177,6 +19524,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + rw@1.3.3: {} sade@1.8.1: @@ -19912,6 +20261,11 @@ snapshots: ws@8.21.0: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 342b3b0017..8c5a23e37b 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/README.md README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 -README.zh.md: cb6d05cdc2b6395cb460671d863169f5184351b9 +README.zh.md: f41822d66520869e5235a6fedd895b83a52639c9 diff --git a/python/README.zh.md b/python/README.zh.md index cb6d05cdc2..f41822d665 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -8,13 +8,13 @@ | 目录 | 分发名/模块 | 职责 | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | -| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | +| [sdk](sdk/README.zh.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | +| [sdk-runtime](sdk-runtime/README.zh.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | ## 行为 -除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义完整的运行时选择与配置约定。 +除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义完整的运行时选择与配置约定。 ## 贡献者工作流 -[Python 贡献者工作流](development.md)介绍运行时产物构建、包验证、源码模式开发和分发。 +[Python 贡献者工作流](development.zh.md)介绍运行时产物构建、包验证、源码模式开发和分发。 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index a9cdaa8daa..64a2cff09b 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md development.md: 617d030294dafa51aea513adb811bb5f377431c9 -development.zh.md: 2049ad12856c4107788aec168e3724965cebd5af +development.zh.md: be2a6196aed13a4f748e1b178f34603bbd08e5ac diff --git a/python/development.zh.md b/python/development.zh.md index 2049ad1285..be2a6196ae 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -2,7 +2,7 @@ [English](development.md) | 中文 -根据所需的贡献者成果选择工作流:构建运行时产物、验证 SDK、从源码运行或构建分发包。包行为分别见 [SDK 参考](sdk/README.md) 和[运行时载体参考](sdk-runtime/README.md)。 +根据所需的贡献者成果选择工作流:构建运行时产物、验证 SDK、从源码运行或构建分发包。包行为分别见 [SDK 参考](sdk/README.zh.md) 和[运行时载体参考](sdk-runtime/README.zh.md)。 ## 构建运行时产物 @@ -25,7 +25,7 @@ uv sync --project python/sdk --group test uv run --project python/sdk pytest ``` -`python/sdk/tests/test_bundled_runtime.py` 会运行可用的内置载体;某个载体的产物尚未构建时,会跳过该载体。仓库级测试政策见 [测试](../docs/testing.md)。 +`python/sdk/tests/test_bundled_runtime.py` 会运行可用的内置载体;某个载体的产物尚未构建时,会跳过该载体。仓库级测试政策见 [测试](../docs/testing.zh.md)。 该套件面向的是伪造的运行时对端。`scripts/smoke-python-runtime.py` 面向真实的打包运行时;必需的 `python-runtime` CI 任务会用新构建的可执行文件运行全部场景: diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index cd43257014..befad374c1 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000000..b6f12b0564 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,55 @@ +/** Run the complete repository build and bind its client artifacts to their public environment. */ + +import { spawnSync } from 'node:child_process' +import { rmSync } from 'node:fs' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { + CLIENT_BUILD_RECORD_PATH, + clientBuildProcessEnvironment, + repositoryCommitHash, + resolveClientBuildEnvironment, + writeClientBuildRecord, +} from './client-build-environment.ts' + +/** Run one package script through the package manager that invoked this build. */ +function runScript(script: string, environment: NodeJS.ProcessEnv): void { + const packageManager = process.env.npm_execpath + if (packageManager === undefined || packageManager === '') { + throw new Error('build: npm_execpath is unavailable; invoke the build through a package script') + } + const result = spawnSync(process.execPath, [packageManager, 'run', script], { + cwd: resolve(import.meta.dirname, '..'), + env: environment, + stdio: 'inherit', + }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + throw new Error(`build: ${script} exited with ${String(result.status ?? result.signal)}`) + } +} + +/** Run the full build selected by `--profile` or `DSH_BUILD_CLIENT_PROFILE`. */ +function main(): void { + const { values } = parseArgs({ + options: { profile: { type: 'string' } }, + allowPositionals: false, + }) + const root = resolve(import.meta.dirname, '..') + const parentEnvironment = { + ...process.env, + DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, process.env), + } + const clientEnvironment = resolveClientBuildEnvironment(parentEnvironment, values.profile) + const buildEnvironment = clientBuildProcessEnvironment(parentEnvironment, clientEnvironment) + + rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true }) + runScript('build:lib', buildEnvironment) + runScript('build:web', buildEnvironment) + const record = writeClientBuildRecord(root, clientEnvironment) + console.log( + `build: recorded ${String(record.artifacts.fileCount)} client artifact(s) with ${String(Object.keys(record.environment).length)} public value(s)`, + ) +} + +if (import.meta.main) main() diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 755af09093..dc3b63227f 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -9,18 +9,27 @@ import { const experimental: WorkspaceManifest = { dir: 'packages/experimental/prototype', - manifest: { name: '@deepseek-ai/dsh-prototype', private: true }, + manifest: { name: '@deepseek-ai/dsh-experimental-prototype', private: true }, } describe('experimental workspace constraints', () => { + it('requires the experimental package-name prefix', () => { + expect(checkExperimentalManifest({ + ...experimental, + manifest: { ...experimental.manifest, name: '@deepseek-ai/dsh-prototype' }, + })).toEqual([ + '@deepseek-ai/dsh-prototype: experimental package name must start with "@deepseek-ai/dsh-experimental-"', + ]) + }) + it('requires private manifests without publication metadata', () => { expect(checkExperimentalManifest(experimental)).toEqual([]) expect(checkExperimentalManifest({ ...experimental, manifest: { ...experimental.manifest, private: false, publishConfig: { access: 'public' } }, })).toEqual([ - '@deepseek-ai/dsh-prototype: experimental package must set "private": true', - '@deepseek-ai/dsh-prototype: experimental package must omit publishConfig', + '@deepseek-ai/dsh-experimental-prototype: experimental package must set "private": true', + '@deepseek-ai/dsh-experimental-prototype: experimental package must omit publishConfig', ]) }) @@ -31,10 +40,10 @@ describe('experimental workspace constraints', () => { dir: 'packages/core/consumer', manifest: { name: '@deepseek-ai/dsh-consumer', - [section]: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + [section]: { '@deepseek-ai/dsh-experimental-prototype': 'workspace:^' }, }, }])).toEqual([ - `@deepseek-ai/dsh-consumer: ${section}.@deepseek-ai/dsh-prototype must not reference an experimental package`, + `@deepseek-ai/dsh-consumer: ${section}.@deepseek-ai/dsh-experimental-prototype must not reference an experimental package`, ]) }, ) @@ -44,24 +53,24 @@ describe('experimental workspace constraints', () => { dir: 'packages/core/test-only', manifest: { name: '@deepseek-ai/dsh-test-only', - devDependencies: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + devDependencies: { '@deepseek-ai/dsh-experimental-prototype': 'workspace:^' }, }, }, { dir: 'packages/experimental/consumer', manifest: { name: '@deepseek-ai/dsh-experimental-consumer', - dependencies: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + dependencies: { '@deepseek-ai/dsh-experimental-prototype': 'workspace:^' }, }, }, { dir: 'python/sdk-runtime', manifest: { name: '@deepseek-ai/dsh-python-runtime', - dependencies: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + dependencies: { '@deepseek-ai/dsh-experimental-prototype': 'workspace:^' }, }, }] expect(checkExperimentalDependencyIsolation(manifests)).toEqual([ - '@deepseek-ai/dsh-python-runtime: dependencies.@deepseek-ai/dsh-prototype must not reference an experimental package', + '@deepseek-ai/dsh-python-runtime: dependencies.@deepseek-ai/dsh-experimental-prototype must not reference an experimental package', ]) }) }) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 8b98333ce2..e87106ed14 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -50,6 +50,8 @@ const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness. const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git' /** Private packages that participate in workspace checks but not releases. */ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ +/** npm namespace reserved for private experimental packages. */ +const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-' /** Directories whose packages this repository publishes: one release member each. */ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ @@ -156,6 +158,8 @@ const packageFileExtras: Readonly> = { // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], + // SQLite loads every statement from immutable package resources at runtime. + '@deepseek-ai/dsh-session-persistence-sqlite': ['resources/sql/**/*.sql'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], } @@ -242,6 +246,9 @@ export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest): if (!experimentalPackageDirectory.test(dir)) return [] const label = manifest.name ?? dir const errors: string[] = [] + if (manifest.name?.startsWith(experimentalPackageNamePrefix) !== true) { + errors.push(`${label}: experimental package name must start with ${JSON.stringify(experimentalPackageNamePrefix)}`) + } if (manifest.private !== true) errors.push(`${label}: experimental package must set "private": true`) if (manifest.publishConfig !== undefined) errors.push(`${label}: experimental package must omit publishConfig`) return errors diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 9c0f56c85a..ac4535bc1b 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -8,16 +8,19 @@ const runnerPrivatePnpmDestination = '${{ runner.temp }}/setup-pnpm' describe('CI workflow', () => { it('isolates every pnpm action setup destination per runner', () => { - const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/ci.yml'), 'utf8')) - if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('CI workflow must define jobs') - - const setups = Object.entries(workflow.jobs).flatMap(([jobName, job]) => { - if (!isRecord(job) || !Array.isArray(job.steps)) return [] - return job.steps.flatMap((step) => { - if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) return [] - return [{ jobName, step }] - }) - }) + const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml'] + const setups: Array<{ jobName: string; step: unknown }> = [] + for (const file of files) { + const workflow: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8')) + if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`) + for (const [jobName, job] of Object.entries(workflow.jobs)) { + if (!isRecord(job) || !Array.isArray(job.steps)) continue + for (const step of job.steps) { + if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) continue + setups.push({ jobName, step }) + } + } + } expect(setups.length).toBeGreaterThan(0) for (const { jobName, step } of setups) { @@ -29,22 +32,24 @@ describe('CI workflow', () => { it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') + const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows) || !isRecord(workflow.jobs['windows-native']) - || !isRecord(workflow.jobs['wine-apt-cache']) - || !isRecord(workflow.jobs['serial-windows']) || !isRecord(workflow.jobs['node-24']) || !isRecord(workflow.jobs['node-24-coverage']) || !isRecord(workflow.jobs['node-24-consumers']) - || !isRecord(workflow.jobs['all-checks-passed'])) { - throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, node-24, node-24-coverage, node-24-consumers, and all-checks-passed jobs') + || !isRecord(workflow.jobs['all-checks-passed']) + || !isRecord(masterWorkflow.jobs) + || !isRecord(masterWorkflow.jobs['wine-apt-cache']) + || !isRecord(masterWorkflow.jobs['serial-windows'])) { + throw new TypeError('CI workflow must define windows, windows-native, node-24, node-24-coverage, node-24-consumers, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows') } const windows = workflow.jobs.windows const windowsNative = workflow.jobs['windows-native'] - const wineAptCache = workflow.jobs['wine-apt-cache'] - const serialWindows = workflow.jobs['serial-windows'] + const wineAptCache = masterWorkflow.jobs['wine-apt-cache'] + const serialWindows = masterWorkflow.jobs['serial-windows'] const node24 = workflow.jobs['node-24'] const node24Coverage = workflow.jobs['node-24-coverage'] const node24Consumers = workflow.jobs['node-24-consumers'] @@ -80,11 +85,11 @@ describe('CI workflow', () => { )) expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - // wine-apt-cache: master-only, seeds the Wine apt cache. + // wine-apt-cache: master-only, seeds the Wine apt cache, lives in ci-master. expect(wineAptCache.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(wineAptCache['runs-on']).toBe('ubuntu-latest') - // serial-windows: master-only standby, self-hosted, non-blocking. + // serial-windows: master-only standby, self-hosted, non-blocking, lives in ci-master. expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') @@ -108,10 +113,14 @@ describe('CI workflow', () => { expect(aggregate['runs-on']).toContain('vm-backup') }) - it('exempts push from cancellation, so one master merge does not cancel the running drill', () => { - const workflow = loadWorkflow('.github/workflows/ci.yml') + it('exempts push from cancellation in ci-master, so one master merge does not cancel the running drill', () => { + const workflow = loadWorkflow('.github/workflows/ci-master.yml') + const prWorkflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) { - throw new TypeError('CI workflow must define jobs and a workflow-level concurrency block') + throw new TypeError('ci-master workflow must define jobs and a workflow-level concurrency block') + } + if (!isRecord(prWorkflow.jobs)) { + throw new TypeError('ci workflow must define jobs') } // Cancellation applies to the whole superseded RUN, so this has to be @@ -120,11 +129,26 @@ describe('CI workflow', () => { // a drill takes longer than the interval between master merges. The negated // form is load-bearing: `== 'pull_request'` would also stop cancelling // workflow_dispatch, and a re-dispatched runner benchmark holds up to 12 - // larger runners for 15 minutes in this same group on master. The - // expression is evaluated against the NEWLY TRIGGERED run, so a dispatch on - // master still cancels a mid-flight drill; the runbook records that bound. + // larger runners for 15 minutes in this same group on master. expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}") + // The PR-only ci.yml still cancels a superseded run on a new push, so a + // fresh head does not stack a second full 9-job run behind a stale one. + // Unlike ci-master it has no push carve-out: every PR event supersedes. + expect(prWorkflow.concurrency).toMatchObject({ + 'cancel-in-progress': true, + }) + + // The exact event sets are what keep master-only jobs out of the PR check + // panel: ci-master triggers only on push(master) + workflow_dispatch and + // never on pull_request; ci.yml is exactly pull_request-only. Assert the + // full sets so losing the wrong event, or gaining an extra one, fails. + if (!isRecord(workflow.on) || !isRecord(prWorkflow.on)) { + throw new TypeError('both CI workflows must define on') + } + expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch']) + expect(Object.keys(prWorkflow.on)).toEqual(['pull_request']) + // Neither drill may carry a job-level group: it would not exempt the job // from run-scoped cancellation. for (const name of ['serial-linux-selfhosted', 'serial-windows']) { @@ -138,14 +162,7 @@ describe('CI workflow', () => { // What bounds the cost of exempting push: a master push may only carry the // cache seeder and the two drills. Any job reachable on push would start // accumulating uncancelled runs, so the set is pinned here. - // - // Classification is an exact allowlist of the conditions in use, not a - // substring match: `github.event_name != 'pull_request'` mentions - // `pull_request` yet IS push-reachable, so matching on the event name alone - // would silently misclassify it as gated. const NOT_PUSH_REACHABLE = new Set([ - "github.event_name == 'pull_request'", - "always() && github.event_name == 'pull_request'", "github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'", "github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'", ]) @@ -211,6 +228,20 @@ describe('CI workflow', () => { }) }) +describe('DeepSeek e2e workflow', () => { + it('prepares bubblewrap from the pinned payload without a package transaction', () => { + const workflow = loadWorkflow('.github/workflows/e2e.yml') + const e2e = workflowJob(workflow, 'e2e') + if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps') + + const steps = e2e.steps.filter(isRecord) + expect(steps.find(step => step.name === 'Prepare bubblewrap (unrestrict userns)')).toMatchObject({ + run: 'bash scripts/prepare-ci-bubblewrap.sh', + }) + expect(JSON.stringify(steps)).not.toContain('apt-get') + }) +}) + describe('E2B e2e workflow', () => { it('is manual-only and fails loud before running the focused live suite', () => { const workflow = loadWorkflow('.github/workflows/e2b-e2e.yml') @@ -386,24 +417,63 @@ describe('Python release workflows', () => { }) describe('Issue lifecycle workflow', () => { - it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { + it('runs the lifecycle job on every PR/review event but gates token and board steps', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') + const policy = loadWorkflow('.github/workflows/issue-policy.yml') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') + if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps') + + // The job has no job-level `if`, so it is listed on every pull_request / + // pull_request_review event and reports success instead of a gray skip. The + // write-capable steps are gated at step level so approved/commented reviews + // never mint a Project/Issue App token nor touch the board. + expect(lifecycle.on).toHaveProperty('pull_request') + expect(lifecycle.on).toHaveProperty('pull_request_review') + expect(lifecycleJob.if).toBeUndefined() + // Keep the subscription-type gates: issue-lifecycle does not re-subscribe + // ready_for_review (issue-policy owns that) and only reacts to submitted + // review events. const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') - const lifecycleJob = workflowJob(lifecycle, 'lifecycle') - const policy = loadWorkflow('.github/workflows/issue-policy.yml') - const policyPullRequest = workflowEvent(policy, 'pull_request') - expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') expect(lifecycleReview.types).toEqual(['submitted']) - expect(lifecycleJob.if).toBe( - "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", - ) + const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}" + const steps = lifecycleJob.steps.filter(isRecord) + const tokenStep = steps.find(s => s.name === 'Create project token') + const handleStep = steps.find(s => s.name === 'Handle repository event') + expect(tokenStep).toMatchObject({ if: gated }) + expect(handleStep).toMatchObject({ if: gated }) + + // issue-policy owns PR validation; it is read-only and a real gate. + const policyPullRequest = workflowEvent(policy, 'pull_request') expect(policyPullRequest.types).toContain('ready_for_review') }) }) +describe('npm release workflows', () => { + it('keeps publication dispatch-only and pack in the PR workflow', () => { + // pack stays in the PR/master release workflows so a PR proves the set packs. + for (const file of ['release.yml', 'release-vendor.yml']) { + const workflow = loadWorkflow(`.github/workflows/${file}`) + if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`) + expect(Object.keys(workflow.jobs).sort()).toEqual(['pack']) + } + + // publication is workflow_dispatch-only (never a PR check) and keeps the + // npm-publish environment plus the shared dist-tag group. + for (const file of ['release-publish.yml', 'release-vendor-publish.yml']) { + const workflow = loadWorkflow(`.github/workflows/${file}`) + if (!isRecord(workflow.on) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define on and jobs`) + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) + const publish = workflow.jobs.publish + if (!isRecord(publish)) throw new TypeError(`${file} must define a publish job`) + expect(publish.environment).toBe('npm-publish') + expect(publish.concurrency).toMatchObject({ group: 'Release-publish' }) + } + }) +}) + describe('Git hooks', () => { it('leaves frozen Agent Note sidecars to the archive verifier', () => { const lefthook = loadWorkflow('lefthook.yml') diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index c724453283..bd0c3be9af 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -38,6 +38,7 @@ describe('RepositoryCleaner', () => { write(join(root, 'products/shell/lib/types/index.js')) write(join(root, 'products/shell/lib/index.js')) write(join(root, '.typecheck/legacy.tsbuildinfo')) + write(join(root, '.dsh-build/client-build-environment.json')) write(join(root, 'root.tsbuildinfo')) write(join(root, 'packages/removed/ghost/node_modules/.bin/tool')) @@ -46,6 +47,7 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'products/shell/lib'))).toBe(false) expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true) expect(existsSync(join(root, '.typecheck'))).toBe(false) + expect(existsSync(join(root, '.dsh-build'))).toBe(false) expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false) expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false) }) diff --git a/scripts/clean.ts b/scripts/clean.ts index 68e4ff4e71..c0f7d71763 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -67,6 +67,8 @@ export class RepositoryCleaner { const unsafeOrphans: string[] = [] const canonicalRoot = await realpath(this.root) + await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot) + // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot) for (const entry of await readdir(this.root, { withFileTypes: true })) { diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts new file mode 100644 index 0000000000..e2eb0ebe72 --- /dev/null +++ b/scripts/client-build-environment.client.spec.ts @@ -0,0 +1,176 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import yaml from 'js-yaml' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + assertClientBuildEnvironment, + clientBuildEnvironmentDefines, + clientBuildProcessEnvironment, + readClientBuildRecord, + repositoryCommitHash, + resolveClientBuildEnvironment, + writeClientBuildRecord, +} from './client-build-environment.ts' +import { clientBundle } from '../packages/client/tsdown.client.ts' + +const root = resolve(import.meta.dirname, '..') +const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST' +const COMMIT_HASH = '0123456789abcdef0123456789abcdef01234567' +const PROBE_KEY = `process.env.${PROBE_NAME}` +const originalProbe = process.env[PROBE_NAME] +const roots: string[] = [] +const dshBuildWorkflows = [ + 'build-exe-for-python-sdk.yml', + 'ci.yml', + 'e2b-e2e.yml', + 'e2e.yml', + 'release.yml', + 'release-publish.yml', + 'sandbox.yml', +] + +afterEach(() => { + if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME) + else process.env[PROBE_NAME] = originalProbe + vi.resetModules() + for (const fixtureRoot of roots.splice(0)) rmSync(fixtureRoot, { recursive: true, force: true }) +}) + +function write(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function buildFixture(environment: Record): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'apps/web/dist/index.html'), '
') + write(join(fixtureRoot, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') + writeClientBuildRecord(fixtureRoot, environment) + return fixtureRoot +} + +describe('client build environment', () => { + it('requires an exact public environment for a named artifact profile', () => { + const expected = { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + } as const + + expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow() + expect(() => { assertClientBuildEnvironment({}, expected) }).toThrow(/DSH_CLIENT_TITLE/) + expect(() => { assertClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Other' }, expected) }).toThrow(/DSH_CLIENT_TITLE/) + expect(() => { + assertClientBuildEnvironment({ ...expected, DSH_CLIENT_UNDECLARED: 'value' }, expected) + }).toThrow(/DSH_CLIENT_UNDECLARED/) + }) + + it('inherits public values by default and isolates an explicit official profile', () => { + const parent = { + PATH: '/bin', + DSH_BUILD_CLIENT_PROFILE: 'official', + DSH_CLIENT_BUILD_PROFILE: 'local', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'Local title', + DSH_CLIENT_EXTRA: 'local-extra', + } + + expect(resolveClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Local title' })).toEqual({ + DSH_CLIENT_TITLE: 'Local title', + }) + expect(resolveClientBuildEnvironment(parent)).toEqual({ + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + }) + expect(() => { + resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' }) + }).toThrow(/DSH_CLIENT_COMMIT_HASH/) + expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/) + expect(clientBuildProcessEnvironment(parent, { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + })).toEqual({ + PATH: '/bin', + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + }) + expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7)) + }) + + it('defines only public client values over a non-enumerable fallback', () => { + expect(clientBuildEnvironmentDefines({ + PATH: '/bin', + DSH_TEST_API_KEY: 'secret', + DSH_CLIENT_VARIANT: 'quoted "value"', + DSH_CLIENT_EMPTY: '', + DSH_CLIENT_UNSET: undefined, + })).toEqual({ + 'process.env': '{}', + 'process.env.DSH_CLIENT_EMPTY': '""', + 'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""', + }) + }) + + it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => { + process.env[PROBE_NAME] = 'shared-value' + + const configs = clientBundle('@deepseek-ai/dsh-client-ui-sidebar', [ + 'lib/types/index.js', + 'lib/types/invariant.js', + ])({ env: { DSH_BUILD_FACE: 'client' } }) + if (!Array.isArray(configs)) throw new TypeError('client bundle config must be an array') + const dynamic = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar/client') + expect(dynamic?.define).toMatchObject({ + 'process.env': '{}', + [PROBE_KEY]: '"shared-value"', + }) + + const viteConfigPath = '../apps/web/vite.config.ts' + const viteModule: unknown = await import(viteConfigPath) + if (typeof viteModule !== 'object' || viteModule === null) { + throw new TypeError('web Vite config module must be an object') + } + const viteConfig: unknown = Reflect.get(viteModule, 'default') + if (typeof viteConfig === 'function') throw new TypeError('web Vite config must be an object') + if (typeof viteConfig !== 'object' || viteConfig === null) { + throw new TypeError('web Vite config must be an object') + } + expect(Reflect.get(viteConfig, 'define')).toMatchObject({ + 'process.env': '{}', + [PROBE_KEY]: '"shared-value"', + }) + }) + + it('binds the recorded environment to a complete set of client artifacts', () => { + const officialEnvironment = { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + } + const official = buildFixture(officialEnvironment) + const defaultBuild = buildFixture({}) + + expect(readClientBuildRecord(official, officialEnvironment).environment).toEqual(officialEnvironment) + expect(() => { readClientBuildRecord(defaultBuild, officialEnvironment) }).toThrow(/DSH_CLIENT_/) + expect(() => { readClientBuildRecord(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/) + + write(join(official, 'apps/web/dist/index.html'), '
changed
') + expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/) + }) + + it('keeps public client values out of workflow-wide environments', () => { + for (const name of dshBuildWorkflows) { + const path = `.github/workflows/${name}` + const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) + if (typeof document !== 'object' || document === null || Array.isArray(document)) { + throw new TypeError(`${path} must contain a workflow object`) + } + expect(JSON.stringify(document), path).not.toContain('DSH_CLIENT_') + } + }) +}) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts new file mode 100644 index 0000000000..2331db5f42 --- /dev/null +++ b/scripts/client-build-environment.ts @@ -0,0 +1,311 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { + existsSync, + globSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, resolve } from 'node:path' + +/** Prefix reserved for build-time values that may be embedded in browser artifacts. */ +const CLIENT_BUILD_ENV_PREFIX = 'DSH_CLIENT_' + +/** Non-public selector used by build orchestration to request a named client profile. */ +export const CLIENT_BUILD_PROFILE_SELECTOR = 'DSH_BUILD_CLIENT_PROFILE' + +/** Public client environment required by official DSH artifacts. */ +const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_TITLE: 'DeepSeek Harness', +} as const + +/** Public variable carrying the source commit embedded in client artifacts. */ +const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH' + +/** Repository-relative path of the complete client build record. */ +export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json' + +const CLIENT_BUILD_RECORD_FORMAT = 1 +const CLIENT_ARTIFACT_PATTERNS = [ + 'apps/web/dist/**/*', + 'packages/*/*/lib/client.js', + 'packages/*/*/lib/client.js.map', +] as const + +/** Public values embedded in one set of client artifacts. */ +export type ClientBuildEnvironment = Readonly> + +/** + * Resolve the short source commit used by browser build metadata. + * @param root - repository root used when no explicit value is supplied. + * @param environment - environment that may already carry a commit value. + * @returns lowercase 7-character Git commit prefix. + */ +export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEnv = process.env): string { + const explicit = environment[CLIENT_COMMIT_HASH_VARIABLE] + const value = explicit ?? execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + if (!/^[0-9a-f]{7,40}$/iu.test(value)) { + throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} must be a Git commit hash; got ${JSON.stringify(value)}`) + } + return value.slice(0, 7).toLowerCase() +} + +/** + * Resolve the exact public values required by an official build at one commit. + * @param root - repository root whose HEAD must match the built source. + * @param environment - optional explicit commit source for non-Git build environments. + * @returns complete official client environment. + */ +export function officialClientBuildEnvironment( + root: string, + environment: NodeJS.ProcessEnv = process.env, +): Readonly> { + return { + DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment), + ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT, + } +} + +/** Digest of every client artifact produced by the complete root build. */ +interface ClientArtifactDigest { + /** Number of files covered by the digest. */ + readonly fileCount: number + /** Lowercase SHA-256 digest of sorted paths and file contents. */ + readonly sha256: string +} + +/** Durable description of one complete root client build. */ +export interface ClientBuildRecord { + /** Record schema version. */ + readonly formatVersion: number + /** Exact public environment embedded by Vite and tsdown. */ + readonly environment: ClientBuildEnvironment + /** Digest that binds the environment to the current artifacts. */ + readonly artifacts: ClientArtifactDigest +} + +/** + * Collect the public client environment in deterministic key order. + * @param environment - environment inherited by the build process. + * @returns defined `DSH_CLIENT_*` values only. + */ +function clientBuildEnvironment(environment: NodeJS.ProcessEnv): ClientBuildEnvironment { + return Object.fromEntries(Object.entries(environment) + .filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined) + .sort(([left], [right]) => left.localeCompare(right))) as Record +} + +/** + * Resolve the exact public environment selected for a complete client build. + * @param environment - parent process environment. + * @param profile - explicit profile, or the non-public selector when omitted. + * @returns the inherited public values when no profile is selected, otherwise the named profile. + */ +export function resolveClientBuildEnvironment( + environment: NodeJS.ProcessEnv, + profile: string | undefined = environment[CLIENT_BUILD_PROFILE_SELECTOR], +): ClientBuildEnvironment { + if (profile === undefined) return clientBuildEnvironment(environment) + if (profile === 'official') { + const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE] + if (commitHash === undefined) { + throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`) + } + return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT } + } + throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`) +} + +/** + * Construct a subprocess environment containing exactly the selected public values. + * @param environment - parent process environment. + * @param clientEnvironment - complete public environment selected for the build. + * @returns the parent environment with selectors and inherited public values replaced. + */ +export function clientBuildProcessEnvironment( + environment: NodeJS.ProcessEnv, + clientEnvironment: ClientBuildEnvironment, +): NodeJS.ProcessEnv { + const child: NodeJS.ProcessEnv = {} + for (const [name, value] of Object.entries(environment)) { + if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith(CLIENT_BUILD_ENV_PREFIX)) continue + child[name] = value + } + return { ...child, ...clientEnvironment } +} + +/** + * Require the public client environment to match an artifact profile exactly. + * + * An exact key set matters because every prefixed value is eligible for + * inlining: an unexpected variable can change published bytes just as surely + * as a missing or incorrect required value. + * + * @param environment - public environment from a build process or build record. + * @param expected - complete public client environment for the artifact profile. + */ +export function assertClientBuildEnvironment( + environment: Readonly>, + expected: Readonly>, +): void { + const actual = Object.fromEntries(Object.entries(environment) + .filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined) + .sort(([left], [right]) => left.localeCompare(right))) + const normalizedExpected = Object.fromEntries(Object.entries(expected) + .sort(([left], [right]) => left.localeCompare(right))) + if (JSON.stringify(actual) === JSON.stringify(normalizedExpected)) return + + const names = [...new Set([...Object.keys(actual), ...Object.keys(normalizedExpected)])].sort() + const differences = names.filter(name => actual[name] !== normalizedExpected[name]) + throw new Error(`client build environment differs from the required artifact profile: ${differences.join(', ')}`) +} + +/** + * Create bundler substitutions for public client build environment variables. + * + * The empty `process.env` fallback makes an unset static property read + * evaluate to `undefined` without providing a browser `process` global. + * Exact substitutions remain longer matches than that fallback. Dynamic + * property reads and enumeration deliberately observe the empty object. + * + * @param environment - environment inherited by the build process. + * @returns deterministic Vite/tsdown `define` expressions. + */ +export function clientBuildEnvironmentDefines( + environment: NodeJS.ProcessEnv, +): Record { + const defines: Record = { 'process.env': '{}' } + for (const [name, value] of Object.entries(clientBuildEnvironment(environment))) { + defines[`process.env.${name}`] = JSON.stringify(value) + } + return defines +} + +/** + * Write the build record after a complete root build succeeds. + * @param root - repository root containing the generated artifacts. + * @param environment - exact public environment supplied to both bundlers. + * @returns the record written to disk. + */ +export function writeClientBuildRecord( + root: string, + environment: ClientBuildEnvironment, +): ClientBuildRecord { + const record: ClientBuildRecord = { + formatVersion: CLIENT_BUILD_RECORD_FORMAT, + environment: clientBuildEnvironment(environment), + artifacts: clientArtifactDigest(root), + } + const path = resolve(root, CLIENT_BUILD_RECORD_PATH) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`) + return record +} + +/** + * Read a complete build record and prove it still describes the current artifacts. + * @param root - repository root containing the record and generated artifacts. + * @param expected - optional exact public environment required by a consumer. + * @returns the parsed and artifact-verified record. + */ +export function readClientBuildRecord( + root: string, + expected?: Readonly>, +): ClientBuildRecord { + const path = resolve(root, CLIENT_BUILD_RECORD_PATH) + if (!existsSync(path)) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is missing; run a complete pnpm run build first`) + } + + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is invalid JSON: ${detail}`) + } + const record = parseClientBuildRecord(parsed) + if (expected !== undefined) assertClientBuildEnvironment(record.environment, expected) + + const current = clientArtifactDigest(root) + if (current.fileCount !== record.artifacts.fileCount || current.sha256 !== record.artifacts.sha256) { + throw new Error( + `client artifacts differ from ${CLIENT_BUILD_RECORD_PATH}; run a complete pnpm run build before consuming them`, + ) + } + return record +} + +/** Return the deterministic digest of every artifact affected by the public client environment. */ +function clientArtifactDigest(root: string): ClientArtifactDigest { + const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root }) + .map(path => path.replaceAll('\\', '/')) + .filter(path => statSync(resolve(root, path)).isFile()) + .sort() + if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts') + + const digest = createHash('sha256') + for (const path of paths) { + const content = readFileSync(resolve(root, path)) + digest.update(`${Buffer.byteLength(path)}:`) + digest.update(path) + digest.update(`${content.byteLength}:`) + digest.update(content) + } + return { fileCount: paths.length, sha256: digest.digest('hex') } +} + +/** Parse and validate the persisted record before any consumer trusts it. */ +function parseClientBuildRecord(value: unknown): ClientBuildRecord { + if (!isObject(value) || !hasExactKeys(value, ['artifacts', 'environment', 'formatVersion'])) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid top-level schema`) + } + if (value.formatVersion !== CLIENT_BUILD_RECORD_FORMAT) { + throw new Error( + `client build record ${CLIENT_BUILD_RECORD_PATH} uses format ${String(value.formatVersion)}; expected ${String(CLIENT_BUILD_RECORD_FORMAT)}`, + ) + } + if (!isObject(value.environment)) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment`) + } + const environment: Record = {} + for (const [name, entry] of Object.entries(value.environment).sort(([left], [right]) => left.localeCompare(right))) { + if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || typeof entry !== 'string') { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment entry ${name}`) + } + environment[name] = entry + } + if (!isObject(value.artifacts) || !hasExactKeys(value.artifacts, ['fileCount', 'sha256'])) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact digest`) + } + if (!Number.isSafeInteger(value.artifacts.fileCount) || Number(value.artifacts.fileCount) < 1) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact count`) + } + if (typeof value.artifacts.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.artifacts.sha256)) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid SHA-256 digest`) + } + return { + formatVersion: CLIENT_BUILD_RECORD_FORMAT, + environment, + artifacts: { + fileCount: Number(value.artifacts.fileCount), + sha256: value.artifacts.sha256, + }, + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} diff --git a/scripts/gen-cordis-catalog-record.spec.ts b/scripts/gen-cordis-catalog-record.spec.ts index 73720e8be8..3b51562768 100644 --- a/scripts/gen-cordis-catalog-record.spec.ts +++ b/scripts/gen-cordis-catalog-record.spec.ts @@ -10,7 +10,13 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { maybeRecordPair, REGION_BEGIN, REGION_END, spliceRegion } from './gen-cordis-catalog.ts' +import { + localizePageRegion, + maybeRecordPair, + REGION_BEGIN, + REGION_END, + spliceRegion, +} from './gen-cordis-catalog.ts' import { blobHash, renderPairMeta } from './translation-pairing.ts' const PAGE = 'docs/subsystems/fix.md' @@ -142,3 +148,28 @@ describe('spliceRegion', () => { .toThrow('found 2 BEGIN/2 END') }) }) + +describe('localizePageRegion', () => { + it('changes only paired Markdown paths for the Chinese generated region', () => { + const root = mkdtempSync(join(tmpdir(), 'cordis-region-locale-')) + roots.push(root) + mkdirSync(join(root, 'docs/subsystems'), { recursive: true }) + mkdirSync(join(root, 'packages'), { recursive: true }) + mkdirSync(join(root, 'scripts'), { recursive: true }) + writeFileSync(join(root, 'docs/subsystems/target.md'), '# Target\n') + writeFileSync(join(root, 'docs/subsystems/target.zh.md'), '# 目标\n') + writeFileSync(join(root, 'docs/subsystems/excluded.md'), '# Excluded\n') + writeFileSync(join(root, 'docs/subsystems/excluded.zh.md'), '# 排除\n') + writeFileSync(join(root, 'packages/outside.md'), '# Outside\n') + writeFileSync(join(root, 'packages/outside.zh.md'), '# 范围外\n') + writeFileSync(join(root, 'scripts/translation-pairing.manifest.json'), JSON.stringify({ + excluded: ['docs/subsystems/excluded.md'], + })) + const region = `${REGION_BEGIN}\n[Target](target.md#api) [Excluded](excluded.md) [Outside](../../packages/outside.md)\n${REGION_END}` + + expect(localizePageRegion(region, 'docs/subsystems/page.md', root)).toBe(region) + expect(localizePageRegion(region, 'docs/subsystems/page.zh.md', root)).toBe( + `${REGION_BEGIN}\n[Target](target.zh.md#api) [Excluded](excluded.md) [Outside](../../packages/outside.md)\n${REGION_END}`, + ) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fac02f9db3..136d9301b3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -3,8 +3,9 @@ * Typert catalog projection. Every harness `ctx.` service and event scope * maps to exactly one `docs/subsystems/` page through the curated tables below; * the generator injects each page's Cordis API reference between its GENERATED markers — - * byte-identically into both language sides of the pair — and re-records a - * pair's `.i18n.yaml` only when nothing outside the region changed. The + * into both language sides of the pair, localizing paired document paths for + * the Chinese side while retaining every other byte — and re-records a pair's + * `.i18n.yaml` only when nothing outside the region changed. The * projection enforces event modes, JSDoc parameter/return completeness, and * signature type-link coverage; the inherited (vendor) tier renders to * `docs/cordis-api/inherited.md`. `--check` verifies every generated artifact. @@ -32,9 +33,12 @@ import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.t import { blobHash, parsePairMeta, + parseTranslationPairingManifest, partitionGeneratedRegions, renderPairMeta, + translationPairSourcePredicate, } from './translation-pairing.ts' +import { rewriteTranslationLinkLocales } from './translation-links.ts' const root = resolve(import.meta.dirname, '..') const SUBSYSTEMS_DIR = 'docs/subsystems' @@ -64,10 +68,12 @@ export const SERVICE_PAGE: Record = { commands: 'commands.md', compaction: 'compaction.md', cordisInspect: 'extensions.md', + authorization: 'credentials.md', credentials: 'credentials.md', directoryPicker: 'workspace.md', dynamicCordisRunner: 'extensions.md', e2b: 'subprocess.md', + fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', webServer: 'web-server.md', @@ -97,7 +103,7 @@ export const SERVICE_PAGE: Record = { systemPrompt: 'system-prompt.md', jobs: 'jobs.md', sessionTelemetry: 'session-telemetry.md', - teams: 'team.md', + agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', toolResultPruner: 'compaction.md', tools: 'tools.md', @@ -171,6 +177,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'approval': 'approval.md', 'commands': 'commands.md', 'cordis': 'extensions.md', + 'authorization': 'credentials.md', 'credentials': 'credentials.md', 'domain': 'storage.md', 'fs': 'filesystem.md', @@ -183,6 +190,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'system-prompt': 'system-prompt.md', 'session-telemetry': 'session-telemetry.md', 'tools': 'tools.md', + 'webserver': 'web-server.md', 'workflow': 'workflow.md', } @@ -270,7 +278,9 @@ export const LINK_MAP: Readonly> = { RequestErrorAction: 'core.md', RequestFailureContext: 'core.md', PreparedReferencedMessage: 'session-reference.md', + FileReferenceCandidate: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', + SessionReferenceMentionCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', SessionEvent: 'session.md', SessionId: 'core.md', @@ -425,18 +435,18 @@ export const LINK_MAP: Readonly> = { JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', - CreateTeamTaskRequest: 'team.md', - SendTeamMessageRequest: 'team.md', - SendTeamMessageResult: 'team.md', - SpawnTeammateRequest: 'team.md', - SpawnTeammateResult: 'team.md', - TeamId: 'team.md', - TeamMemberView: 'team.md', - TeamMembership: 'team.md', - TeamTaskId: 'team.md', - TeamTaskView: 'team.md', - TeamWaitResult: 'team.md', - UpdateTeamTaskRequest: 'team.md', + CreateTeamTaskRequest: 'agent-team.md', + SendTeamMessageRequest: 'agent-team.md', + SendTeamMessageResult: 'agent-team.md', + SpawnTeammateRequest: 'agent-team.md', + SpawnTeammateResult: 'agent-team.md', + TeamId: 'agent-team.md', + TeamMemberView: 'agent-team.md', + TeamMembership: 'agent-team.md', + TeamTaskId: 'agent-team.md', + TeamTaskView: 'agent-team.md', + TeamWaitResult: 'agent-team.md', + UpdateTeamTaskRequest: 'agent-team.md', TokenMeasurement: 'token-meter.md', CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', @@ -460,8 +470,23 @@ export const LINK_MAP: Readonly> = { SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', SettingsUpdateSource: 'settings.md', + AuthorizationEntry: 'credentials.md', + AuthorizationFlow: 'credentials.md', + AuthorizationInteraction: 'credentials.md', + AuthorizationMethod: 'credentials.md', + AuthorizationNotice: 'credentials.md', + AuthorizationOutcome: 'credentials.md', + AuthorizationPrompt: 'credentials.md', + AuthorizationRequest: 'credentials.md', + AuthorizationSession: 'credentials.md', + AuthorizationSettlement: 'credentials.md', + AuthorizationStatus: 'credentials.md', CredentialRef: 'credentials.md', + CredentialKey: 'credentials.md', CredentialInfo: 'credentials.md', + CredentialRecord: 'credentials.md', + CredentialRecordEntry: 'credentials.md', + CredentialRecordInfo: 'credentials.md', ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-questions.md', AskUserQuestionRequest: 'user-questions.md', @@ -477,6 +502,7 @@ export const LINK_MAP: Readonly> = { PresetSpec: 'permission-presets.md', InvariantInstaller: 'invariants.md', WebRoute: 'web-server.md', + IndexInjection: 'web-server.md', StorageBackend: 'storage.md', StorageForms: 'storage.md', Domain: 'storage.md', @@ -491,6 +517,7 @@ export const LINK_MAP: Readonly> = { WorkflowStartRequest: 'workflow.md', ProjectionDefinition: 'session-projection.md', SessionProjectionMap: 'session-projection.md', + SessionProjectionStateMap: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', @@ -509,7 +536,10 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Exclude', 'Map', + 'NonNullable', + 'Omit', 'Partial', 'Pick', 'Promise', @@ -713,6 +743,19 @@ export interface WalkPartitionMaps { readonly eventWalkExemptions: Readonly> } +/** Project paired Markdown destinations in one generated region to the page's locale. */ +export function localizePageRegion(region: string, pageRel: string, scanRoot: string = root): string { + if (!pageRel.endsWith('.zh.md')) return region + const manifest = parseTranslationPairingManifest( + readFileSync(resolve(scanRoot, 'scripts/translation-pairing.manifest.json'), 'utf8'), + ) + return rewriteTranslationLinkLocales(region, { + repoRoot: scanRoot, + sourcePath: pageRel, + isTranslationPairSource: translationPairSourcePredicate(manifest), + }).content +} + /** * Judge the rendered API and the independent AST scan against the curated * partition maps, fail-closed in both directions for services AND events: a @@ -836,6 +879,7 @@ export function computeOutputs(): [string, string][] { ) for (const side of [page, page.replace(/\.md$/, '.zh.md')]) { const rel = `${SUBSYSTEMS_DIR}/${side}` + const localizedRegion = localizePageRegion(region, rel) let current: string try { current = readFileSync(resolve(root, rel), 'utf8') @@ -846,7 +890,7 @@ export function computeOutputs(): [string, string][] { continue } try { - outputs.push([rel, spliceRegion(current, region)]) + outputs.push([rel, spliceRegion(current, localizedRegion)]) } catch (error) { problems.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1cd41fe3de..5e19c20113 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -189,6 +189,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'], note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.', }, + { + key: 'authorization', + pkg: 'authorization', + title: 'Authorization flow registry', + mode: 'seam', + implementations: [], + consumers: ['llm-pi-ai'], + note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.', + }, { key: 'sessionTelemetry', pkg: 'session-telemetry', @@ -239,6 +248,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['session-reference', 'tool-session-query'], note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.', }, + { + key: 'fileReferences', + pkg: 'file-reference', + title: 'File reference discovery', + mode: 'seam', + implementations: ['file-reference-local'], + note: 'The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents.', + }, { key: 'sessionReferenceResolver', pkg: 'session-reference', @@ -471,12 +488,12 @@ const SERVICE_ROLES: ServiceRole[] = [ note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, { - key: 'teams', - pkg: 'team', + key: 'agentTeams', + pkg: 'agent-team', title: 'Agent Teams coordination domain', mode: 'core', - consumers: ['tool-team'], - note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-team contributes the scoped model policy and controls.', + consumers: ['tool-agent-team'], + note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes the scoped model policy and controls.', }, { key: 'jobs', diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 4166d1b07d..248951def8 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -52,11 +52,11 @@ const LINK_MAP: Record = { SessionTitleModelProvenance: 'session-title.md', SessionTitleProviderId: 'session-title.md', SessionTitleSource: 'session-title.md', - TeamId: 'team.md', - TeamMemberSnapshot: 'team.md', - TeamMessageId: 'team.md', - TeamMessageSnapshot: 'team.md', - TeamTaskSnapshot: 'team.md', + TeamId: 'agent-team.md', + TeamMemberSnapshot: 'agent-team.md', + TeamMessageId: 'agent-team.md', + TeamMessageSnapshot: 'agent-team.md', + TeamTaskSnapshot: 'agent-team.md', } /** One log event, extracted from a `SessionEventMap` declaration. */ diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 2f1abb810d..8475fd585e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -43,6 +43,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -57,8 +58,8 @@ import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import type TeamService from '@deepseek-ai/dsh-team' -import * as ToolTeam from '@deepseek-ai/dsh-tool-team' +import type TeamService from '@deepseek-ai/dsh-experimental-agent-team' +import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' @@ -283,6 +284,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', + dir: 'tool-pwsh-persistent', + source: 'packages/shell/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.terminals', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(TerminalSessionService) + await ctx.plugin(ToolPwshPersistent) + }, + note: + 'One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description.', + }, { pkg: '@deepseek-ai/dsh-tool-str-replace-editor', dir: 'tool-str-replace-editor', @@ -510,10 +524,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ 'The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers\' `ctx.jobs.start()`.', }, { - pkg: '@deepseek-ai/dsh-tool-team', - dir: 'tool-team', - source: 'packages/experimental/tool-team/src/index.ts', - requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.teams', 'an exact live Team member Agent'], + pkg: '@deepseek-ai/dsh-experimental-tool-agent-team', + dir: 'tool-agent-team', + source: 'packages/experimental/tool-agent-team/src/index.ts', + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agentTeams', 'an exact live Team member Agent'], writes: ['tool/call', 'team/member', 'team/message/queued', 'team/message/delivered', 'team/task', 'tool/result'], async mount(ctx) { await ctx.plugin(AgentRegistry) @@ -526,7 +540,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ role: 'lead' as const, name: 'lead', } - ctx.provide('teams', { + ctx.provide('agentTeams', { tryMembership: (candidate: Agent) => candidate === agent ? membership : undefined, membership: () => membership, } as unknown as TeamService) @@ -539,7 +553,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ } as unknown as Agent Object.assign(agent, { ctx: createScope(inner, agent).ctx }) inner.agents.register(agent) - }, { inject: ['tools', 'systemPrompt', 'agents', 'teams'] })) + }, { inject: ['tools', 'systemPrompt', 'agents', 'agentTeams'] })) await ctx.plugin(ToolTeam) catalogChildScopes.set(ctx, agent) }, diff --git a/scripts/gen-translation-brief.ts b/scripts/gen-translation-brief.ts index 0a0d03cc61..fac9e9bcb4 100644 --- a/scripts/gen-translation-brief.ts +++ b/scripts/gen-translation-brief.ts @@ -16,11 +16,13 @@ import { existsSync, globSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { basename, join, resolve, sep } from 'node:path' import { + isTranslationPairingManifestExcluded, isTranslationScopeFile, pairAnchorOfArgument, parseTranslationMarkdown, parseTranslationPairingManifest, TRANSLATION_SCOPE_GLOB_EXCLUDES, + translationPairSourcePredicate, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' @@ -41,10 +43,11 @@ import { const root = resolve(import.meta.dirname, '..') const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) +const isTranslationPairSource = translationPairSourcePredicate(manifest) const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8') function isExcluded(file: string): boolean { - return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) + return isTranslationPairingManifestExcluded(file, manifest) } /** Recorded hashes of one consistency record: basename → blob hash. */ @@ -215,12 +218,23 @@ function planScope( /** Validate a computed mechanical counterpart and write it. */ function applyMechanical(counterpartPath: string, sourceCurrent: string, result: string): void { const counterpartBase = basename(counterpartPath) + const sourcePath = counterpartPath.endsWith('.zh.md') + ? counterpartPath.replace(/\.zh\.md$/, '.md') + : counterpartPath.replace(/\.md$/, '.zh.md') const sourceBase = counterpartBase.endsWith('.zh.md') ? counterpartBase.replace(/\.zh\.md$/, '.md') : counterpartBase.replace(/\.md$/, '.zh.md') const errors = translationStructureDiff( - translationStructureSignature(parseTranslationMarkdown(sourceCurrent), counterpartBase), - translationStructureSignature(parseTranslationMarkdown(result), sourceBase), + translationStructureSignature( + parseTranslationMarkdown(sourceCurrent), + counterpartBase, + { repoRoot: root, sourcePath, isTranslationPairSource, markdown: sourceCurrent }, + ), + translationStructureSignature( + parseTranslationMarkdown(result), + sourceBase, + { repoRoot: root, sourcePath: counterpartPath, isTranslationPairSource, markdown: result }, + ), ) if (errors.length > 0) { throw new Error(`gen-translation-brief: computed mechanical update for ${counterpartPath} violates the pair structure: ${errors.join('; ')}`) diff --git a/scripts/markdown.ts b/scripts/markdown.ts index 1d40e1d8bb..f221d1c342 100644 --- a/scripts/markdown.ts +++ b/scripts/markdown.ts @@ -56,6 +56,101 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v } } +/** Markdown nodes whose authored destination occupies a replaceable source range. */ +export type MarkdownDestinationNode = Extract + +/** One authored Markdown destination and its absolute source offsets. */ +export interface MarkdownDestination { + start: number + end: number + url: string +} + +/** Whether a Markdown URL is external, repository-root absolute, or purely in-page. */ +export function isExternalOrAbsoluteMarkdownUrl(url: string): boolean { + return url.startsWith('#') + || url.startsWith('//') + || url.startsWith('/') + || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) +} + +/** Split one Markdown URL without normalizing its query or fragment suffix. */ +export function splitMarkdownUrlTarget(url: string): { path: string; suffix: string } { + const boundary = url.search(/[?#]/) + if (boundary === -1) return { path: url, suffix: '' } + return { path: url.slice(0, boundary), suffix: url.slice(boundary) } +} + +function skipWhitespace(source: string, start: number): number { + let index = start + while (/\s/.test(source[index] ?? '')) index += 1 + return index +} + +function labelEnd(source: string): number { + const first = source.indexOf('[') + if (first === -1) return -1 + let depth = 0 + for (let index = first; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') index += 1 + else if (char === '[') depth += 1 + else if (char === ']') { + depth -= 1 + if (depth === 0) return index + } + } + return -1 +} + +function destinationRange(rawNode: string, type: MarkdownDestinationNode['type']): { start: number; end: number } { + const endOfLabel = labelEnd(rawNode) + if (endOfLabel === -1) throw new Error(`markdown: cannot locate label end in ${JSON.stringify(rawNode)}`) + let start: number + if (type === 'definition') { + const colon = rawNode.indexOf(':', endOfLabel + 1) + if (colon === -1) throw new Error(`markdown: cannot locate definition separator in ${JSON.stringify(rawNode)}`) + start = skipWhitespace(rawNode, colon + 1) + } else { + if (rawNode[endOfLabel + 1] !== '(') { + throw new Error(`markdown: cannot locate inline destination in ${JSON.stringify(rawNode)}`) + } + start = skipWhitespace(rawNode, endOfLabel + 2) + } + if (rawNode[start] === '<') { + for (let index = start + 1; index < rawNode.length; index += 1) { + if (rawNode[index] === '\\') index += 1 + else if (rawNode[index] === '>') return { start: start + 1, end: index } + } + throw new Error(`markdown: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}`) + } + let depth = 0 + for (let index = start; index < rawNode.length; index += 1) { + const char = rawNode[index] + if (char === '\\') index += 1 + else if (char === '(') depth += 1 + else if (char === ')') { + if (depth === 0) return { start, end: index } + depth -= 1 + } else if (/\s/.test(char ?? '') && depth === 0) { + return { start, end: index } + } + } + return { start, end: rawNode.length } +} + +/** Locate one parsed destination in the original Markdown without reserializing it. */ +export function markdownDestination(source: string, node: MarkdownDestinationNode): MarkdownDestination { + const start = node.position?.start.offset + const end = node.position?.end.offset + if (start === undefined || end === undefined) { + throw new Error(`markdown: destination ${JSON.stringify(node.url)} has no source offsets`) + } + const range = destinationRange(source.slice(start, end), node.type) + const absolute = { start: start + range.start, end: start + range.end } + return { ...absolute, url: source.slice(absolute.start, absolute.end) } +} + /** * Extract every parsed code block with its info string, in document order. * @param source - Markdown source to scan. diff --git a/scripts/merge-translation-pairing.ts b/scripts/merge-translation-pairing.ts index dda2621a36..b14305bddb 100644 --- a/scripts/merge-translation-pairing.ts +++ b/scripts/merge-translation-pairing.ts @@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { mergeTranslationPairingRecords, + repositoryTranslationPairSource, resolveTranslationPairingConflicts, } from './translation-pairing-merge.ts' @@ -16,7 +17,7 @@ try { const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() if (args[0] === '--resolve') { if (args.length !== 1) throw new Error('--resolve takes no paths; it inspects the unmerged index') - const resolved = resolveTranslationPairingConflicts(root) + const resolved = resolveTranslationPairingConflicts(root, repositoryTranslationPairSource(root)) if (resolved.length === 0) { console.log('merge-translation-pairing: no unresolved pairing records') } else { @@ -36,6 +37,7 @@ try { readFileSync(ancestorPath, 'utf8'), readFileSync(currentPath, 'utf8'), readFileSync(otherPath, 'utf8'), + repositoryTranslationPairSource(root), ) writeFileSync(currentPath, result.record) } diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh index e5f0902750..1efaee200a 100755 --- a/scripts/prepare-ci-bubblewrap.sh +++ b/scripts/prepare-ci-bubblewrap.sh @@ -28,5 +28,5 @@ printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH" sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo 'apparmor userns knob absent — the functional probe decides' "$root/usr/bin/bwrap" --version -"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true +"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent -- true echo 'bubblewrap functional probe passed' diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index bacb6996ec..200263bb5a 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -223,10 +223,15 @@ describe('rewriteMarkdown', () => { ) }) - it('routes a pair switcher across locales while ordinary links stay in locale', () => { + it('routes switchers across locales and explicit locale siblings within their locale', () => { const { root, pages } = fixture() writeFileSync(join(root, 'docs/a.zh.md'), '# A\n') - const paired = pages.filter(page => page.source !== 'docs/a.md') + writeFileSync(join(root, 'docs/b.zh.md'), '# B\n') + const paired = pages.filter(page => page.source !== 'docs/a.md').map(page => ( + page.locale === 'root' && page.source === 'docs/b.md' + ? { ...page, source: 'docs/b.zh.md', sourceAliases: ['docs/b.md'] } + : page + )) paired.push( { locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'], @@ -237,7 +242,7 @@ describe('rewriteMarkdown', () => { route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1, }, ) - expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', { + expect(rewriteMarkdown('[English](a.md) [B](b.zh.md)\n', { locale: 'root', sourcePath: 'docs/a.zh.md', route: 'guide/a.md', @@ -245,6 +250,14 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', })).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n') + expect(rewriteMarkdown('[中文](a.zh.md) [B](b.md)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/guide/a.md', + pages: paired, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('[中文](../../guide/a.md) [B](../reference/b.md)\n') }) it('fails loud when a relative target is missing', () => { @@ -297,6 +310,37 @@ describe('docsPages locale routes', () => { } }) + it('projects the audited tutorial entry links from explicit locale index pages', () => { + const entries = [ + ['docs/user/develop/basic/config.md', '../framework/index.md'], + ['docs/user/develop/basic/publish.md', '../framework/index.md'], + ['docs/user/develop/basic/tool.md', './index.md'], + ['docs/user/develop/basic/tool.md', '../practice/index.md'], + ['docs/user/develop/framework/events.md', '../practice/index.md'], + ['docs/user/develop/framework/service.md', '../practice/index.md'], + ['docs/user/develop/practice/index.md', '../basic/index.md'], + ['docs/user/guide/index.md', '../develop/basic/index.md'], + ] as const + + for (const [englishSource, englishTarget] of entries) { + for (const locale of ['en', 'root'] as const) { + const source = locale === 'root' ? englishSource.replace(/\.md$/, '.zh.md') : englishSource + const target = locale === 'root' ? englishTarget.replace(/\.md$/, '.zh.md') : englishTarget + const page = docsPages.find(candidate => candidate.locale === locale && candidate.source === source) + expect(page, `${locale}:${source}`).toBeDefined() + expect(readFileSync(resolve(repositoryRoot, source), 'utf8')).toContain(`](${target})`) + expect(rewriteMarkdown(`[Entry](${target})\n`, { + locale, + sourcePath: source, + route: page!.route, + pages: docsPages, + repoRoot: repositoryRoot, + repositoryRef: 'abc123', + })).toBe(`[Entry](${englishTarget})\n`) + } + } + }) + it('indexes every subsystem page in both sides of the folder README', () => { const pages = globSync(join(repositoryRoot, 'docs/subsystems/*.md')) .map(page => basename(page)) @@ -305,11 +349,22 @@ describe('docsPages locale routes', () => { expect(pages.length).toBeGreaterThan(0) for (const readme of ['README.md', 'README.zh.md']) { const rows = readFileSync(join(repositoryRoot, 'docs/subsystems', readme), 'utf8') - const missing = pages.filter(page => !rows.includes(`| [${page}](${page}) |`)) + const missing = pages.filter((page) => { + const target = readme.endsWith('.zh.md') ? page.replace(/\.md$/, '.zh.md') : page + return !rows.includes(`| [${page}](${target}) |`) + }) expect(missing, `${readme} must carry one table row per subsystem page`).toEqual([]) } }) + it('places the shared todo fragment alias on the translated todo section', () => { + const catalog = readFileSync(resolve(repositoryRoot, 'docs/tool-catalog.zh.md'), 'utf8') + expect(catalog.match(/<\/a>/g)).toHaveLength(1) + expect(catalog).toContain( + '\n\n## `@deepseek-ai/dsh-tool-todo`', + ) + }) + it('projects every published subsystem page in Chinese', () => { const rootPages = docsPages.filter(page => ( page.locale === 'root' && page.route.startsWith('reference/subsystems/') diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 9150780c14..582a306df3 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -14,6 +14,11 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' +import { + isExternalOrAbsoluteMarkdownUrl, + markdownDestination, + splitMarkdownUrlTarget, +} from './markdown.ts' const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness' const root = resolve(import.meta.dirname, '..') @@ -35,11 +40,6 @@ interface Replacement { value: string } -interface DestinationRange { - start: number - end: number -} - type RewritableNode = Extract /** Inputs for rewriting one canonical Markdown page. */ @@ -65,94 +65,12 @@ function repoPath(absPath: string, repoRoot: string): string { return relative(repoRoot, absPath).split(sep).join('/') } -function isExternalOrSiteAbsolute(url: string): boolean { - return url.startsWith('#') - || url.startsWith('//') - || url.startsWith('/') - || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) -} - -function skipWhitespace(source: string, start: number): number { - let index = start - while (/\s/.test(source[index] ?? '')) index += 1 - return index -} - -function labelEnd(source: string): number { - const first = source.indexOf('[') - if (first === -1) return -1 - let depth = 0 - for (let index = first; index < source.length; index += 1) { - const char = source[index] - if (char === '\\') { - index += 1 - } else if (char === '[') { - depth += 1 - } else if (char === ']') { - depth -= 1 - if (depth === 0) return index - } - } - return -1 -} - -function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange { - const endOfLabel = labelEnd(rawNode) - if (endOfLabel === -1) { - throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`) - } - - let start: number - if (type === 'definition') { - const colon = rawNode.indexOf(':', endOfLabel + 1) - if (colon === -1) { - throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`) - } - start = skipWhitespace(rawNode, colon + 1) - } else { - if (rawNode[endOfLabel + 1] !== '(') { - throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`) - } - start = skipWhitespace(rawNode, endOfLabel + 2) - } - - if (rawNode[start] === '<') { - for (let index = start + 1; index < rawNode.length; index += 1) { - if (rawNode[index] === '\\') index += 1 - else if (rawNode[index] === '>') return { start: start + 1, end: index } - } - throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`) - } - - let depth = 0 - for (let index = start; index < rawNode.length; index += 1) { - const char = rawNode[index] - if (char === '\\') { - index += 1 - } else if (char === '(') { - depth += 1 - } else if (char === ')') { - if (depth === 0) return { start, end: index } - depth -= 1 - } else if (/\s/.test(char ?? '') && depth === 0) { - return { start, end: index } - } - } - return { start, end: rawNode.length } -} - // `#fragment` suffixes pass through verbatim. Generated cordis-surface // headings carry explicit `` anchors with the GitHub slug, so those // fragments resolve on the published site too; hand-written headings rely on // VitePress's own slugger, which differs from GitHub's for punctuation-heavy // text — hand-authored cross-page fragments should prefer plain-text headings // or explicit anchors. -function splitTarget(url: string): { path: string; suffix: string } { - const boundary = url.search(/[?#]/) - if (boundary === -1) return { path: url, suffix: '' } - return { path: url.slice(0, boundary), suffix: url.slice(boundary) } -} - function decodePath(path: string): string { try { return decodeURIComponent(path) @@ -239,8 +157,8 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const replacements: Replacement[] = [] const rewrite = (node: RewritableNode): void => { - if (isExternalOrSiteAbsolute(node.url)) return - const { path, suffix } = splitTarget(node.url) + if (isExternalOrAbsoluteMarkdownUrl(node.url)) return + const { path, suffix } = splitMarkdownUrlTarget(node.url) if (path === '') return const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) const targetPath = repoPath(absPath, options.repoRoot) @@ -257,16 +175,10 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) ? `${options.placeImage(absPath)}${suffix}` : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') - const start = node.position?.start.offset - const end = node.position?.end.offset - if (start === undefined || end === undefined) { - throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`) - } - const rawNode = source.slice(start, end) - const rawDestination = destinationRange(rawNode, node.type) + const destination = markdownDestination(source, node) replacements.push({ - start: start + rawDestination.start, - end: start + rawDestination.end, + start: destination.start, + end: destination.end, value: nextUrl, }) } diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 2d5d391fe9..4e98065719 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -42,7 +42,7 @@ node, bin_path, cwd, timeout_seconds = sys.argv[1:] pid, fd = pty.fork() if pid == 0: os.chdir(cwd) - os.execvpe(node, [node, bin_path, "web", "--host", "127.0.0.1", "--port", "0"], os.environ.copy()) + os.execvpe(node, [node, bin_path, "web", "--no-open", "--host", "127.0.0.1", "--port", "0"], os.environ.copy()) output = bytearray() ready_seen = False diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 27679d8fa2..54aa8c09f1 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -3,7 +3,8 @@ * readable from the repository rather than derived inside CI * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * - * The dsh family shares one version across its members and the workspace root: + * The dsh family shares one version across its publishable members, private + * package manifests, and the workspace root: * `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such * as `0.0.1-rc.1`). The vendored family has one version line per package, but * every release advances and publishes the complete family so the next release @@ -14,7 +15,7 @@ * the tag after the commit merges. CI never writes to the repository. */ -import { readFileSync, writeFileSync } from 'node:fs' +import { globSync, readFileSync, writeFileSync } from 'node:fs' import { join, matchesGlob } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' @@ -47,10 +48,20 @@ interface PlannedVersion { readonly from: string /** The version to write. */ readonly to: string - /** The tag this version publishes from, or undefined for the workspace root. */ + /** The tag this version publishes from, or undefined for a non-published manifest. */ readonly tag: string | undefined } +/** One private dsh package whose version follows the publishable family. */ +interface PrivateDshVersion { + /** Repository-relative manifest path. */ + readonly manifestPath: string + /** Package directory used in bump output. */ + readonly label: string + /** Current manifest version. */ + readonly version: string +} + /** * Split a version into its release numbers, discarding any prerelease segment. * @param version - the current version. @@ -236,14 +247,43 @@ function rootVersion(root: string): string { } /** - * Plan the dsh family's rewrite: one version for every member and the root. + * Discover private package manifests that share the dsh version without joining + * its publish set. + * @param root - repository root. + * @returns Private package manifests sorted by path. + */ +function privateDshVersions(root: string): PrivateDshVersion[] { + return globSync('packages/*/*/package.json', { cwd: root }) + .map(path => path.replaceAll('\\', '/')) + .sort() + .flatMap((manifestPath) => { + const parsed: unknown = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${manifestPath} is not a JSON object`) + } + const manifest = parsed as Record + if (manifest.private !== true) return [] + if (typeof manifest.version !== 'string') { + throw new Error(`${manifestPath} must declare a string version`) + } + return [{ + manifestPath, + label: manifestPath.slice(0, -'/package.json'.length), + version: manifest.version, + }] + }) +} + +/** + * Plan the dsh family's rewrite: one version for every publishable member, + * private package, and the root. * @param family - the dsh family. * @param root - repository root. * @param members - the family's members. * @param request - `major`, `minor`, `patch`, or an explicit version. * @returns The manifests to rewrite and the shared target version. */ -function planShared( +export function planShared( family: ReleaseFamily, root: string, members: readonly ReleaseMember[], @@ -259,13 +299,24 @@ function planShared( ] for (const member of members) { planned.push({ - manifestPath: join(member.directory, 'package.json'), + manifestPath: `${member.directory}/package.json`, label: member.directory, from: member.version, to: version, tag: family.tagFor({ ...member, version }), }) } + const publishableManifests = new Set(members.map(member => `${member.directory}/package.json`)) + for (const entry of privateDshVersions(root)) { + if (publishableManifests.has(entry.manifestPath)) continue + planned.push({ + manifestPath: entry.manifestPath, + label: entry.label, + from: entry.version, + to: version, + tag: undefined, + }) + } return { planned, version } } @@ -287,7 +338,7 @@ function planPerPackage( const tagged = lastTaggedVersion(family, member) const to = nextVendorVersion(member.version, tagged, prerelease) planned.push({ - manifestPath: join(member.directory, 'package.json'), + manifestPath: `${member.directory}/package.json`, label: member.directory, from: member.version, to, diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 22eb0b064a..d22db243d8 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -1,9 +1,12 @@ /** Release family discovery, publish order, tag naming, and the bump judgements. */ -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts' import { releaseFamily, type ReleaseMember } from './families.ts' -import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts' +import { compareVersions, nextVendorVersion, planShared, reachesPayload } from './bump.ts' /** * A release member standing in for a manifest on disk. @@ -16,12 +19,51 @@ function member(directory: string, name: string, manifest: Record): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-')) + roots.push(root) + write(join(root, 'apps/web/dist/index.html'), '
') + write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') + writeClientBuildRecord(root, environment) + return root +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + vi.unstubAllEnvs() +}) + describe('release families', () => { it('excludes private experimental packages from the dsh release', () => { const members = releaseFamily('dsh').members(resolve(import.meta.dirname, '../..')) expect(members.some(member => member.directory.startsWith('packages/experimental/'))).toBe(false) - expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-team') + expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-agent-team') + }) + + it('bumps private dsh packages without adding release tags', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-release-version-')) + roots.push(root) + write(join(root, 'package.json'), '{"version":"0.0.1"}\n') + write(join(root, 'packages/experimental/prototype/package.json'), '{"version":"0.0.1","private":true}\n') + write(join(root, 'packages/core/unselected/package.json'), '{"version":"0.0.1"}\n') + + const dsh = releaseFamily('dsh') + const published = member('packages/core/published', '@deepseek-ai/dsh-published') + const { planned } = planShared(dsh, root, [published], '0.0.2') + + expect(planned.map(entry => ({ path: entry.manifestPath, tag: entry.tag }))).toEqual([ + { path: 'package.json', tag: undefined }, + { path: 'packages/core/published/package.json', tag: 'dsh-v0.0.2' }, + { path: 'packages/experimental/prototype/package.json', tag: undefined }, + ]) }) it('names one tag for the whole dsh family and one per vendored package', () => { @@ -57,6 +99,23 @@ describe('release families', () => { expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/) }) + it('requires a current official client build only for dsh artifacts', () => { + const dsh = releaseFamily('dsh') + const vendor = releaseFamily('vendor') + const officialEnvironment = officialClientBuildEnvironment(resolve(import.meta.dirname, '../..')) + vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH) + const official = buildFixture(officialEnvironment) + const defaultBuild = buildFixture({}) + + expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow() + expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/) + expect(() => { dsh.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/) + expect(() => { vendor.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).not.toThrow() + + write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n') + expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/) + }) + it('publishes a dependency before its consumer, and orders ties by name', () => { const dsh = releaseFamily('dsh') const members = [ diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 7ce5566831..a847371631 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -11,6 +11,10 @@ import { globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { + officialClientBuildEnvironment, + readClientBuildRecord, +} from '../client-build-environment.ts' import { validateTarballPayload } from '../publication-payload.ts' /** @@ -111,6 +115,13 @@ export abstract class ReleaseFamily { /** Git tag prefix this family publishes from. */ abstract readonly tagPrefix: string + /** + * Assert that built artifacts match this release family's required profile. + * Families without environment-selected artifacts accept every build tree. + * @param _root - repository root containing generated artifacts. + */ + verifyBuildArtifacts(_root: string): void {} + /** * Discover this family's members. * @param root - repository root. @@ -311,6 +322,11 @@ class DshFamily extends ReleaseFamily { readonly patterns = ['packages/!(experimental)/*/package.json', 'apps/*/package.json'] as const readonly tagPrefix = 'dsh-v' + /** Require current artifacts from a complete official client build. */ + override verifyBuildArtifacts(root: string): void { + readClientBuildRecord(root, officialClientBuildEnvironment(root)) + } + /** * Require one version across the family, the way a single tag can name it. * @param members - this family's members. diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 5d2b9b4e64..3b68a1e49c 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -46,6 +46,7 @@ function main(): void { const root = process.cwd() const destination = resolve(root, values.out ?? DEFAULT_OUTPUT) const members = family.publishOrder(family.members(root)).order + family.verifyBuildArtifacts(root) family.verifyVersions(members) rmSync(destination, { recursive: true, force: true }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index dce448b2e0..8de5d1e095 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -344,6 +344,12 @@ describe('Node 24 lane ownership', () => { 'built-bin-smoke', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) + expect(subject.find(item => item.id === 'build')?.env).toEqual({ + DSH_BUILD_CLIENT_PROFILE: 'official', + }) + expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({ + DSH_BUILD_CLIENT_PROFILE: 'official', + }) expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of [ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f65fed97e..ee021782f9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' import { COVERAGE_PARTITIONS_ENV, @@ -176,6 +177,14 @@ function pnpmScript(id: string, script: string, options: Partial = {}): Ga } } +/** Build official client artifacts inside a CI aggregate without changing sibling gate environments. */ +function ciBuildGate(id = 'build', options: Partial = {}): Gate { + return pnpmScript(id, 'build', { + ...options, + env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' }, + }) +} + function pnpmExec(id: string, args: string[], options: Partial = {}): Gate { return { id, @@ -216,7 +225,7 @@ export function gatesForMode(selected: Mode): Gate[] { case 'ci-coverage': return coverageGates() case 'ci-snapshot': - return [pnpmScript('build', 'build'), snapshotGate()] + return [ciBuildGate(), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() case 'ci-consumers': @@ -287,7 +296,7 @@ function ciPrimaryGates(): Gate[] { // The prepared typecheck and build both drive Client tsc, while build also // repeats the Host contract pass. Wait for all three consumers so build // neither races tsbuildinfo nor replaces declarations while they are read. - pnpmScript('build', 'build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }), + ciBuildGate('build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -369,7 +378,7 @@ function runningNodeMajor(): number { function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ ...ciSharedStaticGates(), - ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], + ...options.ownsBuild ? [ciBuildGate()] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, ...options.ownsBuild @@ -388,7 +397,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { function ciArtifactGates(): Gate[] { return [ - pnpmScript('build', 'build'), + ciBuildGate(), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -403,8 +412,11 @@ function ciConsumerGates(): Gate[] { const builtTree = ['build'] const validatedBuild = ['built-package-invariants'] return [ - pnpmScript('build', 'build'), - pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + ciBuildGate(), + pnpmScript('node-compat', 'check:node-compat', { + label: 'Node compatibility', + env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' }, + }), pnpmScript('publint', 'publint', { needs: builtTree }), builtPackageInvariantsGate(builtTree), pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', { @@ -450,7 +462,7 @@ function webSnapshotGate(needs: string[]): Gate { function ciWindowsBlockingGates(): Gate[] { return [ - pnpmScript('windows-build', 'build', { label: 'build' }), + ciBuildGate('windows-build', { label: 'build' }), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), ] } @@ -470,7 +482,7 @@ function ciWindowsCompleteGates(): Gate[] { after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], })) return [ - pnpmScript('build', 'build'), + ciBuildGate(), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), ...coverage, ...observational, diff --git a/scripts/run-oxlint.spec.ts b/scripts/run-oxlint.spec.ts index 25245add32..85628382e8 100644 --- a/scripts/run-oxlint.spec.ts +++ b/scripts/run-oxlint.spec.ts @@ -16,6 +16,18 @@ describe('Oxlint invocation', () => { }) }) + it('uses location-preserving diagnostics in CI', () => { + expect(resolveOxlintInvocation(['.'], { CI: 'true', DSH_OXLINT_THREADS: '4' })).toEqual({ + args: ['.', '--format=unix', '--threads=4'], + env: { CI: 'true', DSH_OXLINT_THREADS: '4', GOMAXPROCS: '4' }, + }) + }) + + it('preserves an explicitly selected CI formatter', () => { + expect(resolveOxlintInvocation(['.', '--format', 'github'], { CI: 'true' }).args) + .toEqual(['.', '--format', 'github']) + }) + it.each(['0', '-1', '1.5', 'auto'])('rejects invalid worker bound %s', (value) => { expect(() => resolveOxlintInvocation(['.'], { DSH_OXLINT_THREADS: value })) .toThrow('DSH_OXLINT_THREADS must be a positive integer') diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts index 13ed84799f..bcbddb5011 100644 --- a/scripts/run-oxlint.ts +++ b/scripts/run-oxlint.ts @@ -10,6 +10,14 @@ function isFixInvocation(args: readonly string[]): boolean { return args.some(arg => FIX_FLAGS.has(arg)) } +function hasOutputFormat(args: readonly string[]): boolean { + return args.some(arg => + arg === '-f' + || arg.startsWith('-f=') + || arg === '--format' + || arg.startsWith('--format=')) +} + /** Complete Oxlint child-process arguments and environment. */ export interface OxlintInvocation { readonly args: readonly string[] @@ -23,8 +31,10 @@ export interface OxlintInvocation { * @returns the complete CLI arguments and child environment. */ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation { + const resolvedArgs = [...args] + if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=unix') const raw = env.DSH_OXLINT_THREADS - if (raw === undefined || raw === '') return { args: [...args], env: { ...env } } + if (raw === undefined || raw === '') return { args: resolvedArgs, env: { ...env } } const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`run-oxlint: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`) @@ -33,7 +43,7 @@ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.Pro throw new Error('run-oxlint: use DSH_OXLINT_THREADS instead of passing --threads directly.') } return { - args: [...args, `--threads=${raw}`], + args: [...resolvedArgs, `--threads=${raw}`], env: { ...env, GOMAXPROCS: raw }, } } diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index ddffa0c246..a4b4a2e7c5 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,47 +4,47 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI, served at `http://127.0.0.1:3080` by default. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n- Join
DeepSeek Harness Discord community.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[MIT](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI at `http://127.0.0.1:3080` by default and opens it in the default browser for a local launch. An SSH launch only prints the host URL because the SSH client or editor owns the local forwarded address. Pass `--no-open` to run the server without opening a browser. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` prepares the repository artifacts. `pnpm dsh web` uses those built artifacts without rebuilding.\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n- Join DeepSeek Harness Discord community.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[MIT](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
企微小助手入群问卷微信公众号
\"DeepSeek\"DeepSeek\"DeepSeek
\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令默认会在 `http://127.0.0.1:3080` 启动 Web UI,本机启动时还会用默认浏览器打开页面。通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。传入 `--no-open` 可仅运行服务器而不打开浏览器。详见 [Web UI 指南](docs/user/guide/index.zh.md)。\n\n\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` 会准备仓库产物。`pnpm dsh web` 会直接使用这些已构建产物,不会重新构建。\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
企微小助手入群问卷微信公众号
\"DeepSeek\"DeepSeek\"DeepSeek
\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.zh.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host Typert graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nRun the repository build separately before using these source-checkout demos:\n\n```sh\npnpm run build\n```\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host Typert graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\n`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nRun the repository build separately before using these source-checkout demos:\n\n```sh\npnpm run build\n```\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出目录通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通包只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host 包、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` 包及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新包只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host Typert 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务服务在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;包公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n从源码 checkout 运行这些演示前,请单独执行仓库构建:\n\n```sh\npnpm run build\n```\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型定义(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出目录通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.zh.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通包只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host 包、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` 包及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新包只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host Typert 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.zh.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)。\n\n`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。\n\n业务服务在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.zh.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.zh.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.zh.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;包公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n从源码 checkout 运行这些演示前,请单独执行仓库构建:\n\n```sh\npnpm run build\n```\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n\n\n### 逐字记录类型定义(`ts type-equiv`)\n\n[子系统](subsystems/README.zh.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL to the same counterpart so the switcher still resolves there.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL to the same counterpart so the switcher still resolves there.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, semantic link targets with exact query/fragment suffixes, and verbatim code blocks match one to one across the pair. When a relative document link targets the active bilingual corpus, the English side uses its `.md` path and the Chinese side uses its `.zh.md` path. A missing counterpart in that corpus is a pair-completeness error rather than a fallback; targets outside the active corpus keep the authored path. See [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), every ordinary relative document link uses its source side's target locale, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and semantic link targets with exact query/fragment suffixes apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING and BRAND_GUIDELINES documents, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. A generator that owns both sides, such as the Cordis subsystem-region generator, projects paired document paths to each output locale while keeping every other generated byte equal. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 Git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的 worktree 内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 YAML diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL,使切换行在该位置仍可访问。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest(元数据清单)中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.zh.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 Git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的 worktree 内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 YAML diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.zh.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL,使切换行在该位置仍可访问。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、保留原样 query/fragment 后缀的语义链接目标,以及逐字节一致的代码块在配对两侧一一对应。相对文档链接的目标属于活跃双语语料时,英文侧使用其 `.md` 路径,中文侧使用其 `.zh.md` 路径。该范围内缺少对侧属于配对完整性错误,不得回退;范围外的目标保留原路径。完整保持规则见 [translation-rules.md](translation-rules.zh.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、每条普通相对文档链接都使用源文件一侧对应的目标 locale,且结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外保留原样 query/fragment 后缀的语义链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest(元数据清单)中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.zh.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 与 BRAND_GUIDELINES 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。Cordis subsystem 区块生成器等同时拥有两侧输出的生成器,会把配对文档路径投影到各自 locale,同时保持其余生成字节一致。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. A README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" + "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, link locale, and semantic targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative document link MUST keep the same semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, the English side uses its `.md` path and the Chinese side uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. The language switcher remains the explicit cross-locale exception, and a README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" }, { "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。在 GitHub 以外位置渲染的 README 可以按 [README.md](README.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.zh.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接 locale 与语义目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对文档链接必须保持相同的语义目标和完全相同的 query/fragment 后缀。目标属于活跃双语语料时,英文侧使用其 `.md` 路径,中文侧使用其 `.zh.md` 路径;该范围内缺少对侧属于错误,范围外的目标保留原路径。外部 URL、图片与纯页内 fragment 保持不变。语言切换行仍是显式跨 locale 例外;在 GitHub 以外位置渲染的 README 可以按 [README.md](README.zh.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" }, { "role": "user", - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. Relative document links whose targets belong to that active corpus use the target sibling matching the source locale, while the structure signature normalizes `.md` and `.zh.md` siblings to one semantic target and retains the exact query/fragment suffix; the [localized bilingual links decision](2026-08-18-localized-bilingual-links.md) owns that refinement. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁能够发现。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync(文档同步门禁)强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 Git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 YAML diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两侧的 hash 均与记录匹配、中文侧和所有人工撰写的英文源都带语言切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、语言切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和所消费的响应。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest(元数据清单)通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁能够发现。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.zh.md)与 [doc-sync(文档同步门禁)强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.zh.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.zh.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 Git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 YAML diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两侧的 hash 均与记录匹配、中文侧和所有人工撰写的英文源都带语言切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。目标属于该活跃语料的相对文档链接使用与源文件 locale 相同的目标兄弟文件;结构签名则把 `.md` 与 `.zh.md` 兄弟文件规范化为同一个语义目标,并保留完全相同的 query/fragment 后缀;该细化规则由[双语文档链接本地化决策](2026-08-18-localized-bilingual-links.zh.md)负责。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.zh.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、语言切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和所消费的响应。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest(元数据清单)通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.zh.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/translation-brief.spec.ts b/scripts/translation-brief.spec.ts index 0e0bc8b24c..85a49a0179 100644 --- a/scripts/translation-brief.spec.ts +++ b/scripts/translation-brief.spec.ts @@ -215,6 +215,8 @@ describe('brief rendering', () => { expect(brief).toContain('## First-occurrence notes') expect(brief).toContain('agent(智能体)') expect(brief).toContain('首次出现 annotations attach to the document-wide first occurrence only') + expect(brief).toContain('targets in the active bilingual corpus use `.zh.md` for Chinese') + expect(brief).toContain('a missing in-scope counterpart is an error') expect(brief).toContain('verify-translation-pairing --write docs/foo.md') }) @@ -266,6 +268,8 @@ describe('brief rendering', () => { scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: [] }, }) expect(brief).toContain('exactly what the new Chinese states') + expect(brief).toContain('targets in the active bilingual corpus use `.md` for English') + expect(brief).toContain('targets outside the corpus keep the authored path') expect(brief).toContain('verify-translation-pairing --write docs/foo.md') }) diff --git a/scripts/translation-brief.ts b/scripts/translation-brief.ts index 9380974fa0..1c76bccc31 100644 --- a/scripts/translation-brief.ts +++ b/scripts/translation-brief.ts @@ -386,7 +386,7 @@ const ZH_TARGET_DIGEST = [ '- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.', '- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.', '- Code fences byte-identical to the English side, comments included; inline code spans verbatim.', - '- Relative links keep the `.md` target; only the switcher line links `.zh.md`.', + '- Repository-relative document links keep the same semantic target and exact query/fragment; targets in the active bilingual corpus use `.zh.md` for Chinese, a missing in-scope counterpart is an error, and targets outside the corpus keep the authored path. The switcher remains the cross-locale exception.', '- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.', '- 首次出现 annotations attach to the document-wide first occurrence only; later occurrences use the bare form, and an empty 首次出现 cell means never gloss.', '- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.', @@ -398,7 +398,7 @@ const EN_TARGET_DIGEST = [ '- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.', '- Write concise professional developer prose, not word-by-word gloss; terse stays terse.', '- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.', - '- Relative links keep the `.md` target; only the switcher line links `.zh.md`.', + '- Repository-relative document links keep the same semantic target and exact query/fragment; targets in the active bilingual corpus use `.md` for English, a missing in-scope counterpart is an error, and targets outside the corpus keep the authored path. The switcher remains the cross-locale exception.', '- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.', '- One physical line per paragraph; exactly one trailing newline.', ] diff --git a/scripts/translation-links.spec.ts b/scripts/translation-links.spec.ts new file mode 100644 index 0000000000..35efde120e --- /dev/null +++ b/scripts/translation-links.spec.ts @@ -0,0 +1,253 @@ +/** Regression coverage for locale-aware bilingual Markdown links. */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + normalizeTranslationMarkdownLinks, + rewriteTranslationLinkLocales, + translationLinkLocaleViolations, + type TranslationLinkContext, +} from './translation-links.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) removeFixtureSafely(root) +}) + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-translation-links-')) + roots.push(root) + mkdirSync(join(root, 'docs/section'), { recursive: true }) + mkdirSync(join(root, 'packages'), { recursive: true }) + writeFileSync(join(root, 'docs/guide.md'), '# Guide\n') + writeFileSync(join(root, 'docs/guide.zh.md'), '# 指南\n') + writeFileSync(join(root, 'docs/reference.md'), '# Overview\n') + writeFileSync(join(root, 'docs/reference.zh.md'), '# 概览\n') + writeFileSync(join(root, 'docs/unpaired.md'), '# Only\n') + writeFileSync(join(root, 'docs/section/index.md'), '# Section\n') + writeFileSync(join(root, 'docs/section/index.zh.md'), '# 章节\n') + writeFileSync(join(root, 'packages/outside.md'), '# Outside\n') + writeFileSync(join(root, 'packages/outside.zh.md'), '# 范围外\n') + return root +} + +function linkContext( + root: string, + sourcePath: string, + repositoryFileExists?: (repoPath: string) => boolean, +): TranslationLinkContext { + return { + repoRoot: root, + sourcePath, + isTranslationPairSource: path => path.startsWith('docs/'), + ...(repositoryFileExists === undefined ? {} : { repositoryFileExists }), + } +} + +function expectUnchangedLinkInput(root: string, input: string): void { + const context = linkContext(root, 'docs/guide.md') + expect(translationLinkLocaleViolations(input, context)).toEqual([]) + expect(rewriteTranslationLinkLocales(input, context)).toEqual({ content: input, rewritten: 0 }) + expect(normalizeTranslationMarkdownLinks(input, context)).toBe(input) +} + +describe('translation link locale validation', () => { + it('rejects a Chinese link to the English sibling with an exact diagnostic', () => { + const root = fixture() + expect(translationLinkLocaleViolations( + '# 指南\n\n正文。\n\n[概览](reference.md?view=full#overview)\n', + linkContext(root, 'docs/guide.zh.md'), + )).toEqual([{ + sourcePath: 'docs/guide.zh.md', + line: 5, + url: 'reference.md?view=full#overview', + expectedUrl: 'reference.zh.md?view=full#overview', + }]) + }) + + it('rewrites an encoded exact filename without changing its query or fragment suffix', () => { + const root = fixture() + const input = '[概览](reference%2Emd?view=full&mode=all#overview)\n' + expect(translationLinkLocaleViolations( + input, + linkContext(root, 'docs/guide.zh.md'), + )[0]).toMatchObject({ + url: 'reference%2Emd?view=full&mode=all#overview', + expectedUrl: 'reference.zh.md?view=full&mode=all#overview', + }) + expect(rewriteTranslationLinkLocales(input, linkContext(root, 'docs/guide.zh.md'))).toEqual({ + content: '[概览](reference.zh.md?view=full&mode=all#overview)\n', + rewritten: 1, + }) + }) + + it('encodes each exact path segment with only RFC 3986 unreserved characters', () => { + const root = fixture() + const input = '[保留](a%29%23%3Fb%2Emd?view=full#section)\n' + const repositoryFiles = new Set(['docs/a)#?b.md', 'docs/a)#?b.zh.md']) + expect(rewriteTranslationLinkLocales( + input, + linkContext(root, 'docs/guide.zh.md', path => repositoryFiles.has(path)), + )).toEqual({ + content: '[保留](a%29%23%3Fb.zh.md?view=full#section)\n', + rewritten: 1, + }) + }) + + it('accepts the target-locale sibling and an out-of-scope target with its own sibling', () => { + const root = fixture() + expect(translationLinkLocaleViolations( + '[paired](reference.zh.md) [outside](../packages/outside.md)\n', + linkContext(root, 'docs/guide.zh.md'), + )).toEqual([]) + }) + + it('does not fall back when an active target is missing its locale sibling', () => { + const root = fixture() + expect(translationLinkLocaleViolations( + '[missing](unpaired.md)\n', + linkContext(root, 'docs/guide.zh.md'), + )[0]).toMatchObject({ expectedUrl: 'unpaired.zh.md' }) + }) + + it('requires English sources to use the English sibling', () => { + const root = fixture() + expect(translationLinkLocaleViolations( + '[Reference](reference.zh.md)\n', + linkContext(root, 'docs/guide.md'), + )[0]).toMatchObject({ + url: 'reference.zh.md', + expectedUrl: 'reference.md', + }) + }) + + it('does not infer an index page from a directory target', () => { + const root = fixture() + const input = '[Section](section/)\n' + expect(translationLinkLocaleViolations(input, linkContext(root, 'docs/guide.zh.md'))).toEqual([]) + expect(rewriteTranslationLinkLocales(input, linkContext(root, 'docs/guide.zh.md'))) + .toEqual({ content: input, rewritten: 0 }) + }) + + it('exempts the language switcher target explicitly', () => { + const root = fixture() + expect(translationLinkLocaleViolations( + '# 指南\n\n[English](guide.md) | 中文\n', + linkContext(root, 'docs/guide.zh.md'), + ['guide.md'], + )).toEqual([]) + }) + + it('does not exempt an ordinary body link to the counterpart', () => { + const root = fixture() + const markdown = '# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.md)\n' + expect(translationLinkLocaleViolations( + markdown, + linkContext(root, 'docs/guide.zh.md'), + ['guide.md'], + )).toEqual([{ + sourcePath: 'docs/guide.zh.md', + line: 5, + url: 'guide.md', + expectedUrl: 'guide.zh.md', + }]) + expect(rewriteTranslationLinkLocales( + markdown, + linkContext(root, 'docs/guide.zh.md'), + ['guide.md'], + ).content).toBe('# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.zh.md)\n') + }) + + it('uses the selected content plane for target existence without deriving scope from siblings', () => { + const root = fixture() + const staged = new Set(['docs/reference.md', 'docs/reference.zh.md']) + expect(translationLinkLocaleViolations( + '[概览](reference.md)\n', + linkContext(root, 'docs/guide.zh.md', path => staged.has(path)), + )).toHaveLength(1) + staged.delete('docs/reference.zh.md') + expect(translationLinkLocaleViolations( + '[概览](reference.md)\n', + linkContext(root, 'docs/guide.zh.md', path => staged.has(path)), + )).toHaveLength(1) + staged.delete('docs/reference.md') + expect(translationLinkLocaleViolations( + '[概览](reference.md)\n', + linkContext(root, 'docs/guide.zh.md', path => staged.has(path)), + )).toEqual([]) + }) +}) + +describe('translation link rewriting and normalization', () => { + it('rewrites only the destination while preserving the suffix and title', () => { + const root = fixture() + const input = '[概览](reference.md?view=full&mode=all#overview "reference.md title")\n' + expect(rewriteTranslationLinkLocales( + input, + linkContext(root, 'docs/guide.zh.md'), + )).toEqual({ + content: '[概览](reference.zh.md?view=full&mode=all#overview "reference.md title")\n', + rewritten: 1, + }) + }) + + it('rewrites link definitions without changing their labels', () => { + const root = fixture() + expect(rewriteTranslationLinkLocales( + '[概览][ref]\n\n[ref]: "title"\n', + linkContext(root, 'docs/guide.zh.md'), + ).content).toBe('[概览][ref]\n\n[ref]: "title"\n') + }) + + it('uses only the first duplicate reference definition', () => { + const root = fixture() + expect(translationLinkLocaleViolations( + '[概览][ref]\n\n[ref]: reference.zh.md\n[ref]: reference.md\n', + linkContext(root, 'docs/guide.zh.md'), + )).toEqual([]) + }) + + it('does not treat an image-only definition as a document link', () => { + const root = fixture() + const input = '![preview][asset]\n\n[asset]: reference.zh.md#overview\n' + expectUnchangedLinkInput(root, input) + }) + + it.each([ + '\n', + 'https://example.com/reference.md\n', + ])('leaves GFM autolink source unchanged: %s', (input) => { + const root = fixture() + expectUnchangedLinkInput(root, input) + }) + + it('normalizes only paired locale paths and retains other bytes', () => { + const root = fixture() + const english = '[Reference](reference.md#overview) [Outside](../packages/outside.md)\n' + const chinese = '[Reference](reference.zh.md#overview) [Outside](../packages/outside.md)\n' + expect(normalizeTranslationMarkdownLinks( + english, + linkContext(root, 'docs/guide.md'), + )).toBe(normalizeTranslationMarkdownLinks( + chinese, + linkContext(root, 'docs/guide.zh.md'), + )) + }) + + it('retains authored query bytes during normalization', () => { + const root = fixture() + const escaped = '[Reference](reference.md?x=1&y=2#overview)\n' + const literal = '[Reference](reference.zh.md?x=1&y=2#overview)\n' + expect(normalizeTranslationMarkdownLinks( + escaped, + linkContext(root, 'docs/guide.md'), + )).not.toBe(normalizeTranslationMarkdownLinks( + literal, + linkContext(root, 'docs/guide.zh.md'), + )) + }) +}) diff --git a/scripts/translation-links.ts b/scripts/translation-links.ts new file mode 100644 index 0000000000..af7c991fd4 --- /dev/null +++ b/scripts/translation-links.ts @@ -0,0 +1,328 @@ +/** Locale-aware resolution and byte-preserving rewrites for bilingual Markdown links. */ + +import { existsSync, statSync } from 'node:fs' +import { posix, resolve } from 'node:path' +import type { Nodes } from 'mdast' +import { + isExternalOrAbsoluteMarkdownUrl, + markdownDestination, + parseMarkdown, + splitMarkdownUrlTarget, + visitMarkdown, + type MarkdownDestination, +} from './markdown.ts' + +/** Repository and source document used to resolve one relative link. */ +export interface TranslationLinkContext { + /** Absolute repository root. */ + repoRoot: string + /** Repository-relative Markdown source path. */ + sourcePath: string + /** Whether an English Markdown path belongs to the active bilingual corpus. */ + isTranslationPairSource: (sourcePath: string) => boolean + /** Selected content plane; defaults to regular files in the working tree. */ + repositoryFileExists?: (repoPath: string) => boolean +} + +/** One relative document link whose target uses the wrong locale sibling. */ +export interface TranslationLinkLocaleViolation { + sourcePath: string + line: number + url: string + expectedUrl: string +} + +/** Result of rewriting wrong-locale relative document links. */ +export interface TranslationLinkRewriteResult { + content: string + rewritten: number +} + +interface TranslationPairTarget { + source: string + zh: string +} + +interface ResolvedTranslationLink { + pair: TranslationPairTarget + targetPath: string + suffix: string + expectedPath: string + expectedUrl: string + locale: 'en' | 'zh' +} + +interface Replacement { + start: number + end: number + value: string +} + +type LinkNode = Extract + +/** Offset of the one top-level switcher link immediately following the H1. */ +export function languageSwitcherLinkOffset( + tree: Nodes, + markdown: string, + acceptedTargets: string | readonly string[], +): number | undefined { + if (tree.type !== 'root') return undefined + const accepted = new Set(typeof acceptedTargets === 'string' ? [acceptedTargets] : acceptedTargets) + const headingIndex = tree.children.findIndex(node => node.type === 'heading' && node.depth === 1) + if (headingIndex < 0) return undefined + for (const node of tree.children.slice(headingIndex + 1)) { + if (node.type === 'heading') return undefined + if (node.type !== 'paragraph' || node.position === undefined) continue + const start = node.position.start.offset + const end = node.position.end.offset + if (start === undefined || end === undefined) continue + const authored = markdown.slice(start, end) + if (!/^(?:English \| \[中文\]\([^\n]+\)|\[English\]\([^\n]+\) \| 中文)$/.test(authored)) continue + const links = node.children.filter((child): child is Extract => child.type === 'link') + if (links.length === 1 && accepted.has(links[0]?.url ?? '')) { + return links[0]?.position?.start.offset + } + } + return undefined +} + +/** Whether the tree carries its canonical top-level language switcher. */ +export function hasLanguageSwitcher( + tree: Nodes, + markdown: string, + acceptedTargets: string | readonly string[], +): boolean { + return languageSwitcherLinkOffset(tree, markdown, acceptedTargets) !== undefined +} + +function decodePath(path: string): string { + try { + return decodeURIComponent(path) + } catch { + return path + } +} + +function worktreeFileExists(repoRoot: string, repoPath: string): boolean { + try { + const path = resolve(repoRoot, repoPath) + return existsSync(path) && statSync(path).isFile() + } catch { + return false + } +} + +function repositoryFileExists(context: TranslationLinkContext, repoPath: string): boolean { + return context.repositoryFileExists?.(repoPath) ?? worktreeFileExists(context.repoRoot, repoPath) +} + +function repositoryRelativePath(path: string): string | undefined { + const normalized = posix.normalize(path) + if (normalized === '' || normalized === '.' || normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized)) { + return undefined + } + return normalized +} + +function resolveRepositoryTarget( + rawPath: string, + context: TranslationLinkContext, +): string | undefined { + const decoded = decodePath(rawPath) + const exact = repositoryRelativePath(posix.join(posix.dirname(context.sourcePath), decoded)) + if (exact === undefined) return undefined + return repositoryFileExists(context, exact) ? exact : undefined +} + +function translationPairTarget(targetPath: string, context: TranslationLinkContext): TranslationPairTarget | undefined { + const source = targetPath.endsWith('.zh.md') + ? targetPath.replace(/\.zh\.md$/, '.md') + : targetPath.endsWith('.md') ? targetPath : undefined + if (source === undefined || !context.isTranslationPairSource(source)) return undefined + const zh = source.replace(/\.md$/, '.zh.md') + return { source, zh } +} + +function encodePathSegment(segment: string): string { + return encodeURIComponent(segment).replace(/[!'()*]/g, character => ( + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + )) +} + +function relativeExpectedPath( + context: TranslationLinkContext, + expectedPath: string, + rawPath: string, +): string { + const relative = posix.relative(posix.dirname(context.sourcePath), expectedPath) + const encoded = relative.split('/').map(encodePathSegment).join('/') + return rawPath.startsWith('./') && !encoded.startsWith('.') ? `./${encoded}` : encoded +} + +function expectedLocalePath( + rawPath: string, + locale: 'en' | 'zh', + context: TranslationLinkContext, + expectedPath: string, +): string { + if (locale === 'zh' && rawPath.endsWith('.md') && !rawPath.endsWith('.zh.md')) { + return rawPath.replace(/\.md$/, '.zh.md') + } + if (locale === 'en' && rawPath.endsWith('.zh.md')) return rawPath.replace(/\.zh\.md$/, '.md') + return relativeExpectedPath(context, expectedPath, rawPath) +} + +function resolveTranslationLink( + url: string, + context: TranslationLinkContext, + authoredUrl: string, +): ResolvedTranslationLink | undefined { + if (isExternalOrAbsoluteMarkdownUrl(url)) return undefined + const { path } = splitMarkdownUrlTarget(url) + const authored = splitMarkdownUrlTarget(authoredUrl) + if (path === '') return undefined + const targetPath = resolveRepositoryTarget(path, context) + if (targetPath === undefined) return undefined + const pair = translationPairTarget(targetPath, context) + if (pair === undefined) return undefined + const locale = context.sourcePath.endsWith('.zh.md') ? 'zh' : 'en' + const expectedPath = locale === 'zh' ? pair.zh : pair.source + return { + pair, + targetPath, + suffix: authored.suffix, + expectedPath, + expectedUrl: `${expectedLocalePath(authored.path, locale, context, expectedPath)}${authored.suffix}`, + locale, + } +} + +function hasExpectedLocale(resolved: ResolvedTranslationLink): boolean { + return resolved.targetPath === resolved.expectedPath +} + +function replacementFor(destination: MarkdownDestination, value: string): Replacement { + return { start: destination.start, end: destination.end, value } +} + +function authoredExternalTarget(markdown: string, node: LinkNode): string { + const start = node.position?.start.offset + const end = node.position?.end.offset + if (start === undefined || end === undefined) { + throw new Error(`translation-links: external link ${JSON.stringify(node.url)} has no source offsets`) + } + const raw = markdown.slice(start, end) + if (node.type === 'definition' || raw.startsWith('[')) return markdownDestination(markdown, node).url + if (raw.startsWith('<') && raw.endsWith('>')) return raw.slice(1, -1) + return raw +} + +function applyReplacements(markdown: string, replacements: Replacement[]): string { + let output = markdown + for (const replacement of replacements.sort((left, right) => right.start - left.start)) { + output = output.slice(0, replacement.start) + replacement.value + output.slice(replacement.end) + } + return output +} + +function visitDocumentLinkNodes( + markdown: string, + skipTargets: readonly string[], + visitor: (node: LinkNode) => void, +): void { + const tree = parseMarkdown(markdown) + const switcherOffset = languageSwitcherLinkOffset(tree, markdown, skipTargets) + const referencedIdentifiers = new Set() + const visitedDefinitions = new Set() + visitMarkdown(tree, (node) => { + if (node.type === 'linkReference') referencedIdentifiers.add(node.identifier) + }) + visitMarkdown(tree, (node) => { + if (node.type === 'link' && node.position?.start.offset === switcherOffset) return + if (node.type === 'link') { + visitor(node) + } else if (node.type === 'definition' + && referencedIdentifiers.has(node.identifier) + && !visitedDefinitions.has(node.identifier)) { + visitedDefinitions.add(node.identifier) + visitor(node) + } + }) +} + +function visitResolvedDocumentLinks( + markdown: string, + context: TranslationLinkContext, + skipTargets: readonly string[], + visitor: (node: LinkNode, destination: MarkdownDestination, resolved: ResolvedTranslationLink) => void, +): void { + visitDocumentLinkNodes(markdown, skipTargets, (node) => { + if (isExternalOrAbsoluteMarkdownUrl(node.url)) return + const destination = markdownDestination(markdown, node) + const resolved = resolveTranslationLink(node.url, context, destination.url) + if (resolved !== undefined) visitor(node, destination, resolved) + }) +} + +/** Return one violation per wrong-locale link or link definition. */ +export function translationLinkLocaleViolations( + markdown: string, + context: TranslationLinkContext, + skipTargets: readonly string[] = [], +): TranslationLinkLocaleViolation[] { + const violations: TranslationLinkLocaleViolation[] = [] + visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => { + if (hasExpectedLocale(resolved)) return + violations.push({ + sourcePath: context.sourcePath, + line: node.position?.start.line ?? 0, + url: destination.url, + expectedUrl: resolved.expectedUrl, + }) + }) + return violations +} + +/** Rewrite wrong-locale document links without reserializing surrounding Markdown. */ +export function rewriteTranslationLinkLocales( + markdown: string, + context: TranslationLinkContext, + skipTargets: readonly string[] = [], +): TranslationLinkRewriteResult { + const replacements: Replacement[] = [] + visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => { + if (hasExpectedLocale(resolved)) return + replacements.push(replacementFor(destination, resolved.expectedUrl)) + }) + return { content: applyReplacements(markdown, replacements), rewritten: replacements.length } +} + +/** Normalize only paired-document locale paths while retaining every other byte and URL suffix. */ +export function normalizeTranslationMarkdownLinks( + markdown: string, + context: TranslationLinkContext, + skipTargets: readonly string[] = [], +): string { + const replacements: Replacement[] = [] + visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => { + replacements.push(replacementFor( + destination, + `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`, + )) + }) + return applyReplacements(markdown, replacements) +} + +/** Semantic target of one authored inline link or referenced definition. */ +export function semanticTranslationLinkNodeTarget( + node: LinkNode, + markdown: string, + context: TranslationLinkContext, +): string { + if (isExternalOrAbsoluteMarkdownUrl(node.url)) return authoredExternalTarget(markdown, node) + const destination = markdownDestination(markdown, node) + const resolved = resolveTranslationLink(node.url, context, destination.url) + return resolved === undefined + ? destination.url + : `dsh-translation-target:${resolved.pair.source}${resolved.suffix}` +} diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index 5f1ddd4fc0..e004c935d7 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -48,6 +48,44 @@ export interface GitIndexBlob { content: Buffer } +/** Every stage-zero path currently present in the Git index. */ +export function gitIndexPaths(root: string): Set { + const paths = new Set() + const entries = runGit(root, ['ls-files', '--stage', '-z'], 'listing Git index paths') + .toString('utf8') + .split('\0') + .filter(Boolean) + for (const entry of entries) { + const match = /^\d+ [0-9a-f]+ ([0-3])\t([\s\S]+)$/.exec(entry) + if (!match?.[1] || match[2] === undefined) throw new Error('git ls-files --stage returned a malformed entry') + if (match[1] === '0') paths.add(match[2]) + } + return paths +} + +/** + * Paths visible to a custom merge driver from the current index plus every + * merge head Git advertises through `GITHEAD_` environment entries. + * + * Git invokes custom drivers before it writes clean additions from the other + * heads into stage zero. The explicit post-conflict resolver has no GITHEAD + * entries and therefore uses the already-merged index alone. + */ +export function gitMergeInputPaths(root: string, environment: NodeJS.ProcessEnv = process.env): Set { + const paths = gitIndexPaths(root) + const heads = Object.keys(environment) + .flatMap(key => /^GITHEAD_([0-9a-f]{40})$/.exec(key)?.[1] ?? []) + .sort() + for (const head of heads) { + const files = runGit(root, ['ls-tree', '-r', '--name-only', '-z', head], `listing merge-head ${head} paths`) + .toString('utf8') + .split('\0') + .filter(Boolean) + for (const file of files) paths.add(file) + } + return paths +} + /** * Read one path from the Git index without consulting working-tree bytes. * diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 32924416a6..7293989c2e 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -6,6 +6,7 @@ import { mkdtempSync, mkdirSync, readFileSync, + rmSync, symlinkSync, writeFileSync, } from 'node:fs' @@ -15,8 +16,8 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts' import { - mergeTranslationPairingRecords, - resolveTranslationPairingConflicts, + mergeTranslationPairingRecords as mergeTranslationPairingRecordsWithScope, + resolveTranslationPairingConflicts as resolveTranslationPairingConflictsWithScope, } from './translation-pairing-merge.ts' import { renderTranslationPairingRecord, @@ -35,6 +36,27 @@ interface Fixture { root: string } +function mergeTranslationPairingRecords( + root: string, + metaPath: string, + ancestorRecord: string, + currentRecord: string, + otherRecord: string, +) { + return mergeTranslationPairingRecordsWithScope( + root, + metaPath, + ancestorRecord, + currentRecord, + otherRecord, + () => true, + ) +} + +function resolveTranslationPairingConflicts(root: string): string[] { + return resolveTranslationPairingConflictsWithScope(root, () => true) +} + afterEach(() => { for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) @@ -252,6 +274,19 @@ describe('translation pairing merge composition', { timeout: 15_000 }, () => { )).toThrow('pairing record escapes the repository') }) + it('rejects a pairing record excluded from the active corpus', () => { + const fixture = createFixture(false) + + expect(() => mergeTranslationPairingRecordsWithScope( + fixture.root, + 'docs/guide.i18n.yaml', + '', + '', + '', + () => false, + )).toThrow('docs/guide.i18n.yaml is excluded from the active bilingual documentation corpus') + }) + it('merges the owner blobs named by three valid records', () => { const fixture = createFixture(false) git(fixture, ['config', 'merge.default', 'text']) @@ -271,6 +306,48 @@ describe('translation pairing merge composition', { timeout: 15_000 }, () => { expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh))) }) + it('accepts locale-specific paths to the same paired document', () => { + const fixture = createFixture(false) + write(fixture.root, 'docs/reference.md', '# Overview\n') + write(fixture.root, 'docs/reference.zh.md', '# 概览\n') + git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md']) + const source = baseSource.replace('Alpha base.', '[Reference](reference.md#overview)') + const zh = baseZh.replace('甲基础。', '[参考](reference.zh.md#overview)') + const ancestor = record(fixture.root, 'docs/guide.md', source, zh) + const current = record(fixture.root, 'docs/guide.md', source, zh) + const other = record(fixture.root, 'docs/guide.md', source, zh) + rmSync(join(fixture.root, 'docs/reference.md')) + rmSync(join(fixture.root, 'docs/reference.zh.md')) + + expect(mergeTranslationPairingRecords( + fixture.root, + 'docs/guide.i18n.yaml', + ancestor, + current, + other, + ).zhContent.toString('utf8')).toBe(zh) + }) + + it('rejects a clean merge whose Chinese link uses the English sibling', () => { + const fixture = createFixture(false) + write(fixture.root, 'docs/reference.md', '# Overview\n') + write(fixture.root, 'docs/reference.zh.md', '# 概览\n') + git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md']) + const source = baseSource.replace('Alpha base.', '[Reference](reference.md)') + const zh = baseZh.replace('甲基础。', '[参考](reference.md)') + const ancestor = record(fixture.root, 'docs/guide.md', source, zh) + const current = record(fixture.root, 'docs/guide.md', source, zh) + const other = record(fixture.root, 'docs/guide.md', source, zh) + + expect(() => mergeTranslationPairingRecords( + fixture.root, + 'docs/guide.i18n.yaml', + ancestor, + current, + other, + )).toThrow('docs/guide.zh.md:5 clean merge uses "reference.md"; expected "reference.zh.md"') + }) + it('merges a generated source without an English language switcher', () => { const fixture = createFixture(false) const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, generatedBaseZh) @@ -414,6 +491,49 @@ describe('translation pairing merge composition', { timeout: 15_000 }, () => { expectMergedPair(fixture) }) + it('sees a paired link target added by the other branch', () => { + const fixture = createFixture() + commitPair(fixture, baseSource, baseZh, 'base') + git(fixture, ['switch', '-c', 'current']) + commitPair(fixture, currentSource, currentZh, 'current guide') + git(fixture, ['switch', 'master']) + record( + fixture.root, + 'docs/guide.md', + baseSource.replace('Beta base.', '[Reference](reference.md#overview)'), + baseZh.replace('乙基础。', '[参考](reference.zh.md#overview)'), + ) + record( + fixture.root, + 'docs/reference.md', + '# Reference\n\nEnglish | [中文](reference.zh.md)\n\nOverview.\n', + '# 参考\n\n[English](reference.md) | 中文\n\n概览。\n', + ) + git(fixture, ['add', '.']) + git(fixture, ['commit', '-m', 'other guide and target']) + git(fixture, ['switch', 'current']) + installFixtureRuntime(fixture.root) + git(fixture, [ + 'config', + 'merge.dsh-translation-pairing.driver', + 'scripts/merge-translation-pairing-driver.sh %O %A %B %P', + ]) + + const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], { + encoding: 'utf8', + env: fixture.env, + }) + + expect(merge.status, merge.stderr).toBe(0) + expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('') + expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toContain( + '[Reference](reference.md#overview)', + ) + expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toContain( + '[参考](reference.zh.md#overview)', + ) + }) + it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => { const fixture = createFixture() const records = createDivergedPair(fixture) diff --git a/scripts/translation-pairing-merge.ts b/scripts/translation-pairing-merge.ts index 856c909091..7f0f25114b 100644 --- a/scripts/translation-pairing-merge.ts +++ b/scripts/translation-pairing-merge.ts @@ -7,6 +7,7 @@ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' import { GIT_COMMAND_MAX_BUFFER, gitBlobHash, + gitMergeInputPaths, readGitIndexBlob, runGit, storeGitBlob, @@ -14,12 +15,17 @@ import { import { isTranslationScopeFile, languageSwitcherTargets, - linksTo, parseTranslationMarkdown, + parseTranslationPairingManifest, requiresSourceLanguageSwitcher, + translationPairSourcePredicate, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' +import { + hasLanguageSwitcher, + translationLinkLocaleViolations, +} from './translation-links.ts' import { parseTranslationPairingRecord, renderTranslationPairingRecord, @@ -162,20 +168,62 @@ function loadRecordOwners( } } -function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void { - const sourceTree = parseTranslationMarkdown(source.toString('utf8')) - const zhTree = parseTranslationMarkdown(zh.toString('utf8')) +function assertMergedPairStructure( + root: string, + paths: TranslationPairPaths, + source: Buffer, + zh: Buffer, + isTranslationPairSource: (sourcePath: string) => boolean, +): void { + const sourceText = source.toString('utf8') + const zhText = zh.toString('utf8') + const sourceTree = parseTranslationMarkdown(sourceText) + const zhTree = parseTranslationMarkdown(zhText) + const indexFiles = gitMergeInputPaths(root) + const repositoryFileExists = (path: string): boolean => indexFiles.has(path) const sourceSwitcherTargets = languageSwitcherTargets(paths.source) const zhSwitcherTargets = languageSwitcherTargets(paths.zh) - if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, zhSwitcherTargets)) { + if (requiresSourceLanguageSwitcher(paths.source) + && !hasLanguageSwitcher(sourceTree, sourceText, zhSwitcherTargets)) { throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`) } - if (!linksTo(zhTree, sourceSwitcherTargets)) { + if (!hasLanguageSwitcher(zhTree, zhText, sourceSwitcherTargets)) { throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`) } + const localeViolations = [ + ...translationLinkLocaleViolations(sourceText, { + repoRoot: root, + sourcePath: paths.source, + isTranslationPairSource, + repositoryFileExists, + }, zhSwitcherTargets), + ...translationLinkLocaleViolations(zhText, { + repoRoot: root, + sourcePath: paths.zh, + isTranslationPairSource, + repositoryFileExists, + }, sourceSwitcherTargets), + ] + if (localeViolations.length > 0) { + const violation = localeViolations[0] + if (violation === undefined) throw new Error('translation locale violation disappeared') + throw new Error(`${violation.sourcePath}:${violation.line} clean merge uses ${JSON.stringify(violation.url)}; expected ${JSON.stringify(violation.expectedUrl)}`) + } const divergences = translationStructureDiff( - translationStructureSignature(sourceTree, zhSwitcherTargets), - translationStructureSignature(zhTree, sourceSwitcherTargets), + translationStructureSignature(sourceTree, zhSwitcherTargets, { + repoRoot: root, + sourcePath: paths.source, + isTranslationPairSource, + repositoryFileExists, + markdown: sourceText, + }), + translationStructureSignature(zhTree, sourceSwitcherTargets, { + repoRoot: root, + sourcePath: paths.zh, + isTranslationPairSource, + repositoryFileExists, + markdown: zhText, + }), ) if (divergences.length > 0) { throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`) @@ -212,19 +260,23 @@ export function mergeTranslationPairingRecords( ancestorRecord: string, currentRecord: string, otherRecord: string, + isTranslationPairSource: (sourcePath: string) => boolean, ): TranslationPairingMergeResult { const normalizedMeta = normalizeMetaPath(root, metaPath) if (!isTranslationScopeFile(normalizedMeta)) { throw new Error(`${normalizedMeta} is outside the active bilingual documentation corpus`) } const paths = translationPairPathsFromMeta(normalizedMeta) + if (!isTranslationPairSource(paths.source)) { + throw new Error(`${normalizedMeta} is excluded from the active bilingual documentation corpus`) + } assertDefaultTextMerge(root, paths) const ancestor = loadRecordOwners(root, 'ancestor', ancestorRecord, paths) const current = loadRecordOwners(root, 'current', currentRecord, paths) const other = loadRecordOwners(root, 'other', otherRecord, paths) const sourceContent = mergeBlobTriplet(root, paths.source, ancestor.source, current.source, other.source) const zhContent = mergeBlobTriplet(root, paths.zh, ancestor.zh, current.zh, other.zh) - assertMergedPairStructure(paths, sourceContent, zhContent) + assertMergedPairStructure(root, paths, sourceContent, zhContent, isTranslationPairSource) const sourceHash = storeGitBlob(root, sourceContent) const zhHash = storeGitBlob(root, zhContent) return { @@ -236,6 +288,16 @@ export function mergeTranslationPairingRecords( } } +/** Read the repository manifest and return its active bilingual-source predicate. */ +export function repositoryTranslationPairSource(root: string): (sourcePath: string) => boolean { + const path = 'scripts/translation-pairing.manifest.json' + const content = readGitIndexBlob(root, path)?.content ?? readFileSync(join(root, path)) + const manifest = parseTranslationPairingManifest( + content.toString('utf8'), + ) + return translationPairSourcePredicate(manifest) +} + function unmergedSidecars(root: string): Map { const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8') const records = new Map() @@ -289,7 +351,10 @@ function assertUneditedSidecar( * @param root - Repository root with an in-progress merge-like operation. * @returns Repository-relative sidecar paths resolved and staged. */ -export function resolveTranslationPairingConflicts(root: string): string[] { +export function resolveTranslationPairingConflicts( + root: string, + isTranslationPairSource: (sourcePath: string) => boolean, +): string[] { const resolutions: { path: string; record: string }[] = [] const failures: { path: string; reason: string }[] = [] for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) { @@ -307,6 +372,7 @@ export function resolveTranslationPairingConflicts(root: string): string[] { ancestorRecord, currentRecord, otherRecord, + isTranslationPairSource, ) const paths = translationPairPathsFromMeta(metaPath) if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) { diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index efddff2bbe..7bc072a953 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -1,11 +1,16 @@ /** Regression tests for bilingual snapshots, corpus scope, and structure. */ import { execFileSync, spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts' +import { + gitBlobHash, + gitIndexPaths, + readGitIndexBlob, + storeGitBlob, +} from './translation-pairing-git.ts' import { parseTranslationPairingRecord, renderTranslationPairingRecord, @@ -13,21 +18,44 @@ import { } from './translation-pairing-record.ts' import { blobHash, + isTranslationPairingManifestExcluded, isTranslationScopeFile, languageSwitcherTargets, - linksTo, pairAnchorOfArgument, parseTranslationMarkdown, parseTranslationPairingCliArgs, parseTranslationPairingManifest, partitionGeneratedRegions, requiresSourceLanguageSwitcher, + translationPairSourcePredicate, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' +const fixturePairSource = (): boolean => true + function signature(markdown: string) { - return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md') + return translationStructureSignature( + parseTranslationMarkdown(markdown), + 'counterpart.zh.md', + { + repoRoot: process.cwd(), sourcePath: 'counterpart.md', + isTranslationPairSource: fixturePairSource, markdown, + }, + ) +} + +function fixtureSignature( + root: string, + sourcePath: string, + markdown: string, + switcherTarget: string, +) { + return translationStructureSignature( + parseTranslationMarkdown(markdown), + switcherTarget, + { repoRoot: root, sourcePath, isTranslationPairSource: fixturePairSource, markdown }, + ) } function gitSupportsObjectFormat(format: 'sha256'): boolean { @@ -106,6 +134,26 @@ describe('translation pairing snapshots', () => { } }) + it('lists exact index files without treating a directory prefix as one entry', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-')) + try { + execFileSync('git', ['init', '--quiet', root], { + env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' }, + }) + mkdirSync(join(root, 'docs'), { recursive: true }) + writeFileSync(join(root, 'docs/reference.md'), '# Reference\n') + writeFileSync(join(root, 'docs/reference.zh.md'), '# 参考\n') + execFileSync('git', ['-C', root, 'add', 'docs']) + + expect(gitIndexPaths(root)).toEqual(new Set([ + 'docs/reference.md', + 'docs/reference.zh.md', + ])) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-')) try { @@ -119,11 +167,16 @@ describe('translation pairing snapshots', () => { describe('translation pairing manifest', () => { it('accepts an exclusions-only manifest', () => { - expect(parseTranslationPairingManifest(JSON.stringify({ + const manifest = parseTranslationPairingManifest(JSON.stringify({ excluded: ['docs/generated/'], - }))).toEqual({ + })) + expect(manifest).toEqual({ excluded: ['docs/generated/'], }) + expect(isTranslationPairingManifestExcluded('docs/generated/page.md', manifest)).toBe(true) + expect(translationPairSourcePredicate(manifest)('docs/generated/page.md')).toBe(false) + expect(translationPairSourcePredicate(manifest)('docs/guide.md')).toBe(true) + expect(translationPairSourcePredicate(manifest)('packages/example/guide.md')).toBe(false) }) it.each([ @@ -156,16 +209,44 @@ describe('translation pairing switchers', () => { it('accepts only the canonical public URL for an absolute switcher', () => { const targets = languageSwitcherTargets('python/sdk/README.zh.md') - const canonical = parseTranslationMarkdown( - '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)', - ) - const wrongPath = parseTranslationMarkdown( - '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)', - ) + const canonicalMarkdown = '# README\n\nEnglish | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)\n' + const canonical = parseTranslationMarkdown(canonicalMarkdown) + const wrongMarkdown = '# README\n\nEnglish | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)\n' + const wrongPath = parseTranslationMarkdown(wrongMarkdown) - expect(linksTo(canonical, targets)).toBe(true) - expect(translationStructureSignature(canonical, targets).links).toEqual([]) - expect(linksTo(wrongPath, targets)).toBe(false) + expect(translationStructureSignature(canonical, targets, { + repoRoot: process.cwd(), + sourcePath: 'python/sdk/README.md', + isTranslationPairSource: fixturePairSource, + markdown: canonicalMarkdown, + }).links).toEqual([]) + expect(translationStructureSignature(wrongPath, targets, { + repoRoot: process.cwd(), + sourcePath: 'python/sdk/README.md', + isTranslationPairSource: fixturePairSource, + markdown: wrongMarkdown, + }).links).toEqual([ + 'https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md', + ]) + }) + + it('excludes only the header switcher from the structural links', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-translation-switcher-')) + try { + writeFileSync(join(root, 'guide.md'), '# Guide\n') + writeFileSync(join(root, 'guide.zh.md'), '# 指南\n') + const markdown = '# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.md)\n' + expect(translationStructureSignature( + parseTranslationMarkdown(markdown), + languageSwitcherTargets('guide.md'), + { + repoRoot: root, sourcePath: 'guide.zh.md', + isTranslationPairSource: fixturePairSource, markdown, + }, + ).links).toEqual(['dsh-translation-target:guide.md']) + } finally { + rmSync(root, { recursive: true, force: true }) + } }) }) @@ -201,6 +282,9 @@ describe('translation scope discovery', () => { 'CONTRIBUTING.md', 'CONTRIBUTING.zh.md', 'CONTRIBUTING.i18n.yaml', + 'BRAND_GUIDELINES.md', + 'BRAND_GUIDELINES.zh.md', + 'BRAND_GUIDELINES.i18n.yaml', 'apps/cli/README.md', 'future/subtree/readme.md', 'packages/example/README.zh.md', @@ -215,6 +299,7 @@ describe('translation scope discovery', () => { it.each([ 'packages/example/guide.md', 'packages/example/CONTRIBUTING.md', + 'packages/example/BRAND_GUIDELINES.md', 'examples/tutorial.md', 'website/reference.md', 'packages/example/README.txt', @@ -230,6 +315,81 @@ describe('translation scope discovery', () => { }) describe('translation structural signature', () => { + it('retains external GFM autolinks without parsing inline-link syntax', () => { + const markdown = '\n' + expect(signature(markdown).links).toEqual(['https://example.com/reference.md']) + }) + + it('retains exact authored bytes for ordinary external link targets', () => { + const escaped = signature('[External](https://example.com/?x=1&y=2)\n') + const literal = signature('[External](https://example.com/?x=1&y=2)\n') + expect(escaped.links).toEqual(['https://example.com/?x=1&y=2']) + expect(translationStructureDiff(escaped, literal)).toEqual([ + 'link target #1 diverges between the pair: "https://example.com/?x=1&y=2" vs "https://example.com/?x=1&y=2"', + ]) + }) + + it('treats target-locale siblings as one semantic link target', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-')) + try { + writeFileSync(join(root, 'reference.md'), '# Reference\n') + writeFileSync(join(root, 'reference.zh.md'), '# 参考\n') + const sourceMarkdown = '[Reference](reference.md?view=full#section)\n' + const counterpartMarkdown = '[参考](reference.zh.md?view=full#section)\n' + const source = fixtureSignature(root, 'guide.md', sourceMarkdown, 'guide.zh.md') + const counterpart = fixtureSignature(root, 'guide.zh.md', counterpartMarkdown, 'guide.md') + expect(translationStructureDiff(source, counterpart)).toEqual([]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('includes reference-style document links but excludes image-only definitions', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-')) + try { + writeFileSync(join(root, 'reference.md'), '# Reference\n') + writeFileSync(join(root, 'reference.zh.md'), '# 参考\n') + const markdown = [ + '[Reference][doc]', + '', + '![Preview][asset]', + '', + '[doc]: reference.md', + '[asset]: reference.zh.md', + '', + ].join('\n') + expect(translationStructureSignature( + parseTranslationMarkdown(markdown), + 'guide.zh.md', + { + repoRoot: root, sourcePath: 'guide.md', + isTranslationPairSource: fixturePairSource, markdown, + }, + ).links).toEqual(['dsh-translation-target:reference.md']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('compares the first duplicate reference definition that CommonMark resolves', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-')) + try { + for (const name of ['reference', 'different', 'other']) { + writeFileSync(join(root, `${name}.md`), `# ${name}\n`) + writeFileSync(join(root, `${name}.zh.md`), `# ${name} zh\n`) + } + const sourceMarkdown = '[Reference][ref]\n\n[ref]: reference.md\n[ref]: other.md\n' + const counterpartMarkdown = '[参考][ref]\n\n[ref]: different.zh.md\n[ref]: other.zh.md\n' + const source = fixtureSignature(root, 'guide.md', sourceMarkdown, 'guide.zh.md') + const counterpart = fixtureSignature(root, 'guide.zh.md', counterpartMarkdown, 'guide.md') + expect(translationStructureDiff(source, counterpart)).toEqual([ + 'link target #1 diverges between the pair: "dsh-translation-target:reference.md" vs "dsh-translation-target:different.md"', + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('accepts matching list kinds, starts, and item counts', () => { const source = signature('3. One\n4. Two\n\n- A\n- B\n') const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n') diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 930764034b..d4b96a1d4b 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -12,6 +12,11 @@ import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' +import { + languageSwitcherLinkOffset, + semanticTranslationLinkNodeTarget, + type TranslationLinkContext, +} from './translation-links.ts' /** Complete opening marker line: `` (slug captured). */ const GENERATED_REGION_BEGIN_LINE = /^$/ @@ -126,6 +131,7 @@ export interface TranslationPairingManifest { const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i const ROOT_CONTRIBUTING_ARTIFACT = /^contributing(?:\.md|\.zh\.md|\.i18n\.yaml)$/i +const ROOT_BRAND_GUIDELINES_ARTIFACT = /^brand_guidelines(?:\.md|\.zh\.md|\.i18n\.yaml)$/i const NON_SOURCE_DIRECTORIES = new Set([ 'node_modules', 'lib', @@ -181,6 +187,7 @@ export function isTranslationScopeFile(file: string): boolean { return !file.startsWith('.agents/notes/archived/') && !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file) || ROOT_CONTRIBUTING_ARTIFACT.test(file) + || ROOT_BRAND_GUIDELINES_ARTIFACT.test(file) || file.startsWith('.agents/notes/') || file.startsWith('docs/') || file.startsWith('python/')) @@ -213,6 +220,22 @@ export function parseTranslationPairingManifest(content: string): TranslationPai return { excluded: excludedField(record) } } +/** Whether a manifest entry excludes one exact file or a directory subtree. */ +export function isTranslationPairingManifestExcluded( + file: string, + manifest: TranslationPairingManifest, +): boolean { + return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) +} + +/** Build the active bilingual-source predicate shared by every link consumer. */ +export function translationPairSourcePredicate( + manifest: TranslationPairingManifest, +): (sourcePath: string) => boolean { + return sourcePath => isTranslationScopeFile(sourcePath) + && !isTranslationPairingManifestExcluded(sourcePath, manifest) +} + /** * Normalize one CLI pair argument to its English anchor path: any of the * pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare @@ -309,18 +332,6 @@ export function languageSwitcherTargets(counterpart: string): string[] { return [basename(counterpart), `${PUBLIC_REPOSITORY_BLOB_ROOT}${counterpart}`] } -/** Whether the tree contains a link to any accepted target. */ -export function linksTo(tree: Nodes, targets: string | readonly string[]): boolean { - const accepted = new Set(typeof targets === 'string' ? [targets] : targets) - let found = false - const visit = (node: Nodes): void => { - if (node.type === 'link' && accepted.has(node.url)) found = true - if ('children' in node) for (const child of node.children) visit(child) - } - visit(tree) - return found -} - /** Generated English sources cannot carry a switcher without making their generator stale. */ export function requiresSourceLanguageSwitcher(source: string): boolean { return ![ @@ -347,11 +358,21 @@ export function requiresSourceLanguageSwitcher(source: string): boolean { export function translationStructureSignature( tree: Nodes, switcherTargets: string | readonly string[], + linkContext: TranslationLinkContext & { markdown: string }, ): TranslationStructureSignature { - const acceptedSwitchers = new Set( - typeof switcherTargets === 'string' ? [switcherTargets] : switcherTargets, - ) + const switcherOffset = languageSwitcherLinkOffset(tree, linkContext.markdown, switcherTargets) const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] } + const definitions = new Map>() + const collectDefinitions = (node: Nodes): void => { + if (node.type === 'definition' && !definitions.has(node.identifier)) { + definitions.set(node.identifier, node) + } + if ('children' in node) for (const child of node.children) collectDefinitions(child) + } + collectDefinitions(tree) + const linkTarget = (node: Extract): string => ( + semanticTranslationLinkNodeTarget(node, linkContext.markdown, linkContext) + ) const visit = (node: Nodes): void => { switch (node.type) { case 'heading': @@ -369,8 +390,17 @@ export function translationStructureSignature( : `bullet:items=${node.children.length}`) break case 'link': - if (!acceptedSwitchers.has(node.url)) sig.links.push(node.url) + if (node.position?.start.offset !== switcherOffset) { + sig.links.push(linkTarget(node)) + } break + case 'linkReference': { + const definition = definitions.get(node.identifier) + if (definition !== undefined) { + sig.links.push(linkTarget(definition)) + } + break + } default: // Every other node kind is prose or a container, not part of the signature. break diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index 65160896c7..96ddc98991 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -58,6 +58,10 @@ describe('translation prompt rendering', () => { expect(rendered).toContain('Markdown emphasis markers do not create a word boundary') expect(rendered).toContain('Never invent responsibility merely to avoid a passive construction') expect(rendered).toContain('Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety') + expect(rendered).toContain('Chinese output uses its `.zh.md` path') + expect(rendered).toContain('belongs to the active bilingual corpus') + expect(rendered).toContain('a missing counterpart in that corpus is an error') + expect(rendered).toContain('exact query/fragment suffix') expect(rendered).toContain('Return exactly three raw XML sections') }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3c7038937c..8b580750bd 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -302,24 +302,24 @@ "source": "packages/schedule/schedule/src/types.ts" }, { - "doc": "docs/subsystems/team.md", + "doc": "docs/subsystems/agent-team.md", "symbol": "TeamMemberSnapshot", - "source": "packages/experimental/team/src/types.ts" + "source": "packages/experimental/agent-team/src/types.ts" }, { - "doc": "docs/subsystems/team.md", + "doc": "docs/subsystems/agent-team.md", "symbol": "TeamMessageSnapshot", - "source": "packages/experimental/team/src/types.ts" + "source": "packages/experimental/agent-team/src/types.ts" }, { - "doc": "docs/subsystems/team.md", + "doc": "docs/subsystems/agent-team.md", "symbol": "TeamMessageSource", - "source": "packages/experimental/team/src/types.ts" + "source": "packages/experimental/agent-team/src/types.ts" }, { - "doc": "docs/subsystems/team.md", + "doc": "docs/subsystems/agent-team.md", "symbol": "TeamTaskSnapshot", - "source": "packages/experimental/team/src/types.ts" + "source": "packages/experimental/agent-team/src/types.ts" }, { "doc": "docs/subsystems/commands.md", @@ -685,11 +685,21 @@ "symbol": "SessionReferenceInput", "source": "packages/context/session-reference/src/types.ts" }, + { + "doc": "docs/subsystems/session-reference.md", + "symbol": "FileReferenceCandidate", + "source": "packages/context/file-reference/src/types.ts" + }, { "doc": "docs/subsystems/session-reference.md", "symbol": "SessionReferenceCandidate", "source": "packages/context/session-reference/src/types.ts" }, + { + "doc": "docs/subsystems/session-reference.md", + "symbol": "SessionReferenceMentionCandidate", + "source": "packages/context/session-reference/src/types.ts" + }, { "doc": "docs/subsystems/session-reference.md", "symbol": "PreparedReferencedMessage", diff --git a/scripts/types/client-build-environment/index.d.ts b/scripts/types/client-build-environment/index.d.ts new file mode 100644 index 0000000000..db7b9f24fe --- /dev/null +++ b/scripts/types/client-build-environment/index.d.ts @@ -0,0 +1,7 @@ +/** Build-time values that bundlers replace before client code reaches a browser. */ +declare const process: { + readonly env: { + readonly NODE_ENV?: string + readonly [name: `DSH_CLIENT_${string}`]: string | undefined + } +} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 707d979f23..02d0e66c0e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -70,12 +70,14 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' }, 'packages/client/ui-commands': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model-selection': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, @@ -96,6 +98,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, + 'packages/context/file-reference': { kind: 'indirect', reason: 'The discovery seam and grammar delegate model guidance to the composed provider.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, 'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, @@ -130,6 +133,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/settings/settings-file': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' }, 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model-facing use a value authorizes.' }, 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model-facing behavior.' }, + 'packages/credentials/authorization': { kind: 'none', reason: 'A configuration-time conversation with a human; no flow, notice, or prompt reaches a model request.' }, 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' }, 'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' }, 'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' }, diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 9bfc1a1d73..5b2b4052a4 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -10,9 +10,14 @@ * See `docs/i18n/README.md` for the owning contract. */ -import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, globSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { basename, join, resolve, sep } from 'node:path' -import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts' +import { + gitBlobHash, + gitIndexPaths, + readGitIndexBlob, + storeGitBlob, +} from './translation-pairing-git.ts' import { parseTranslationPairingRecord, renderTranslationPairingRecord, @@ -20,17 +25,23 @@ import { } from './translation-pairing-record.ts' import { languageSwitcherTargets, - linksTo, parseTranslationMarkdown, parseTranslationPairingCliArgs, parseTranslationPairingManifest, partitionGeneratedRegions, requiresSourceLanguageSwitcher, + isTranslationPairingManifestExcluded, isTranslationScopeFile, TRANSLATION_SCOPE_GLOB_EXCLUDES, + translationPairSourcePredicate, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' +import { + hasLanguageSwitcher, + normalizeTranslationMarkdownLinks, + translationLinkLocaleViolations, +} from './translation-links.ts' const root = resolve(import.meta.dirname, '..') let request: ReturnType @@ -43,6 +54,7 @@ try { const listMode = request.mode === 'list' const writeMode = request.mode === 'write' const indexMode = request.input === 'index' +const indexFiles = indexMode ? gitIndexPaths(root) : undefined const contentCache = new Map() @@ -50,15 +62,17 @@ const contentCache = new Map() function readRepositoryFile(file: string): Buffer | undefined { if (contentCache.has(file)) return contentCache.get(file) const content = indexMode - ? readGitIndexBlob(root, file)?.content - : existsSync(join(root, file)) ? readFileSync(join(root, file)) : undefined + ? indexFiles?.has(file) ? readGitIndexBlob(root, file)?.content : undefined + : existsSync(join(root, file)) && statSync(join(root, file)).isFile() + ? readFileSync(join(root, file)) + : undefined contentCache.set(file, content) return content } /** Whether one path exists in the selected content plane. */ function repositoryFileExists(file: string): boolean { - return readRepositoryFile(file) !== undefined + return indexMode ? indexFiles?.has(file) === true : readRepositoryFile(file) !== undefined } /** Discover source Markdown and pairing sidecars before applying the corpus predicate. */ @@ -74,6 +88,7 @@ if (manifestContent === undefined) { throw new Error('scripts/translation-pairing.manifest.json is missing from the selected content plane') } const manifest = parseTranslationPairingManifest(manifestContent.toString('utf8')) +const isTranslationPairSource = translationPairSourcePredicate(manifest) /** * An excluded entry ending in `/` excludes the whole directory. The trailing @@ -82,7 +97,7 @@ const manifest = parseTranslationPairingManifest(manifestContent.toString('utf8' * manifest must keep their trailing slash. */ function isExcluded(file: string): boolean { - return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) + return isTranslationPairingManifestExcluded(file, manifest) } // Enumerate the scope once: the whole corpus, or exactly the named pairs' @@ -230,40 +245,83 @@ for (const source of [...pairAnchors].sort()) { continue } - // Generated regions are language-invariant: the exact same generator output - // (markers included) must appear in both sides, in the same order. The - // structural signature below compares the region content again as part of - // the whole document; this dedicated check exists to name the divergence - // precisely and to reject a region grammar violation on either side. + const sourceText = sourceContent.toString('utf8') + const zhText = zhContent.toString('utf8') + const sourceSwitcherTargets = languageSwitcherTargets(source) + const zhSwitcherTargets = languageSwitcherTargets(zh) + for (const violation of [ + ...translationLinkLocaleViolations(sourceText, { + repoRoot: root, + sourcePath: source, + isTranslationPairSource, + repositoryFileExists, + }, zhSwitcherTargets), + ...translationLinkLocaleViolations(zhText, { + repoRoot: root, + sourcePath: zh, + isTranslationPairSource, + repositoryFileExists, + }, sourceSwitcherTargets), + ]) { + errors.push(`${violation.sourcePath}:${violation.line}: link target ${JSON.stringify(violation.url)} uses the wrong locale; expected ${JSON.stringify(violation.expectedUrl)}`) + state.set(source, 'out-of-sync') + } + + // Generated regions must remain byte-identical after paired document paths + // are normalized to one semantic target. The structural signature below + // compares their contents again as part of the whole document; this named + // check rejects any prose, ordering, code, marker, or non-locale URL drift. let sourceRegions: { regions: string[]; stripped: string } let zhRegions: { regions: string[]; stripped: string } try { - sourceRegions = partitionGeneratedRegions(sourceContent.toString('utf8')) - zhRegions = partitionGeneratedRegions(zhContent.toString('utf8')) + sourceRegions = partitionGeneratedRegions(sourceText) + zhRegions = partitionGeneratedRegions(zhText) } catch (error) { errors.push(`${source} ↔ ${zh}: ${error instanceof Error ? error.message : String(error)}`) state.set(source, 'out-of-sync') continue } - if (sourceRegions.regions.length !== zhRegions.regions.length - || sourceRegions.regions.some((region, index) => region !== zhRegions.regions[index])) { - errors.push(`${source} ↔ ${zh}: generated regions differ between the pair — regenerate (the generator writes both sides byte-identically)`) + const normalizedSourceRegions = sourceRegions.regions.map(region => normalizeTranslationMarkdownLinks(region, { + repoRoot: root, + sourcePath: source, + isTranslationPairSource, + repositoryFileExists, + })) + const normalizedZhRegions = zhRegions.regions.map(region => normalizeTranslationMarkdownLinks(region, { + repoRoot: root, + sourcePath: zh, + isTranslationPairSource, + repositoryFileExists, + })) + if (normalizedSourceRegions.length !== normalizedZhRegions.length + || normalizedSourceRegions.some((region, index) => region !== normalizedZhRegions[index])) { + errors.push(`${source} ↔ ${zh}: generated regions differ beyond paired-document locale paths — regenerate both sides`) state.set(source, 'out-of-sync') } - const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8')) - const zhTree = parseTranslationMarkdown(zhContent.toString('utf8')) - const sourceSwitcherTargets = languageSwitcherTargets(source) - const zhSwitcherTargets = languageSwitcherTargets(zh) - if (!linksTo(zhTree, sourceSwitcherTargets)) { + const sourceTree = parseTranslationMarkdown(sourceText) + const zhTree = parseTranslationMarkdown(zhText) + if (!hasLanguageSwitcher(zhTree, zhText, sourceSwitcherTargets)) { errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) } - if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, zhSwitcherTargets)) { + if (requiresSourceLanguageSwitcher(source) && !hasLanguageSwitcher(sourceTree, sourceText, zhSwitcherTargets)) { errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) } for (const divergence of translationStructureDiff( - translationStructureSignature(sourceTree, zhSwitcherTargets), - translationStructureSignature(zhTree, sourceSwitcherTargets), + translationStructureSignature(sourceTree, zhSwitcherTargets, { + repoRoot: root, + sourcePath: source, + isTranslationPairSource, + repositoryFileExists, + markdown: sourceText, + }), + translationStructureSignature(zhTree, sourceSwitcherTargets, { + repoRoot: root, + sourcePath: zh, + isTranslationPairSource, + repositoryFileExists, + markdown: zhText, + }), )) { errors.push(`${source} ↔ ${zh}: ${divergence}`) } diff --git a/tsconfig.base.client.json b/tsconfig.base.client.json index a6efecbe2d..9222c69f26 100644 --- a/tsconfig.base.client.json +++ b/tsconfig.base.client.json @@ -6,6 +6,7 @@ "compilerOptions": { "jsx": "react-jsx", "lib": ["ES2024", "DOM", "DOM.Iterable"], - "types": [] + "typeRoots": ["./scripts/types", "./node_modules/@types"], + "types": ["client-build-environment"] } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f97108033..8d8d5e1433 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -83,11 +83,15 @@ "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], + "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"], "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], "@deepseek-ai/dsh-compaction/checkpoint": ["./packages/compaction/compaction/src/checkpoint.ts"], "@deepseek-ai/dsh-compaction/types": ["./packages/compaction/compaction/src/types.ts"], + "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], + "@deepseek-ai/dsh-file-reference/types": ["./packages/context/file-reference/src/types.ts"], + "@deepseek-ai/dsh-session-reference/types": ["./packages/context/session-reference/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], @@ -98,6 +102,8 @@ "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], + "@deepseek-ai/dsh-experimental-agent-team/invariant": ["./packages/experimental/agent-team/src/invariant.ts"], + "@deepseek-ai/dsh-experimental-tool-agent-team/invariant": ["./packages/experimental/tool-agent-team/src/invariant.ts"], "@deepseek-ai/dsh-*/invariant": [ "./packages/core/*/src/invariant.ts", "./packages/prompt/*/src/invariant.ts", @@ -196,11 +202,13 @@ "@deepseek-ai/dsh-client-test-runtime/invariant": ["./packages/test-support/client-runtime/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], + "@deepseek-ai/dsh-client-ui-brand-official": ["./packages/client/ui-brand-official/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-input-trigger": ["./packages/client/ui-input-trigger/src"], + "@deepseek-ai/dsh-client-ui-reference": ["./packages/client/ui-reference/src"], "@deepseek-ai/dsh-client-ui-commands": ["./packages/client/ui-commands/src"], "@deepseek-ai/dsh-client-ui-model-selection": ["./packages/client/ui-model-selection/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], @@ -227,6 +235,8 @@ "@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"], "@deepseek-ai/dsh-sdk-protocol": ["./packages/sdk/protocol/src"], "@deepseek-ai/dsh-sdk-jsonrpc-server": ["./packages/sdk/server/src"], + "@deepseek-ai/dsh-experimental-agent-team": ["./packages/experimental/agent-team/src"], + "@deepseek-ai/dsh-experimental-tool-agent-team": ["./packages/experimental/tool-agent-team/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", "./packages/prompt/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 8eb1983094..b5874b6fde 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -26,6 +26,8 @@ // Client package outside packages/client with a local CSS module face. "packages/extensions/ui-cordis/src/css-modules.d.ts", "packages/client/tsdown.client.ts", + "scripts/client-build-environment.ts", + "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], @@ -62,6 +64,7 @@ { "path": "./packages/test-support/client-runtime" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, + { "path": "./packages/client/ui-brand-official" }, { "path": "./packages/client/ui-conversation" }, { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, @@ -70,6 +73,7 @@ { "path": "./packages/client/ui-input-trigger" }, { "path": "./packages/client/ui-commands" }, { "path": "./packages/client/ui-skill" }, + { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, { "path": "./packages/client/ui-directory-picker-browse" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 28d61a7054..f37b98ea57 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -40,6 +40,7 @@ "apps/web/tests/cold-blank-session.e2e.ts", "apps/web/tests/stats-paged-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", + "apps/web/tests/rail-search-expand.e2e.ts", "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", @@ -49,6 +50,8 @@ "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/message-feedback-layout.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", + "apps/web/tests/reference-composer.e2e.ts", + "apps/web/tests/markdown-wide-table.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", @@ -105,6 +108,7 @@ "packages/*/*/tests/**/*.client.spec.ts", "packages/*/*/tests/**/*.client.spec.tsx", "packages/client/tsdown.client.ts", + "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" @@ -150,6 +154,7 @@ { "path": "./packages/settings/settings-file" }, { "path": "./packages/credentials/credentials" }, { "path": "./packages/credentials/credentials-local" }, + { "path": "./packages/credentials/authorization" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, @@ -175,6 +180,8 @@ { "path": "./packages/feedback/command-feedback" }, { "path": "./packages/schedule/schedule" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/file-reference" }, + { "path": "./packages/context/file-reference-local" }, { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/interaction/user-questions" }, @@ -199,6 +206,7 @@ { "path": "./packages/terminal/terminal" }, { "path": "./packages/terminal/terminal-bash" }, { "path": "./packages/shell/tool-bash-persistent" }, + { "path": "./packages/shell/tool-pwsh-persistent" }, { "path": "./packages/terminal/tool-terminal" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-python" }, @@ -269,8 +277,8 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, - { "path": "./packages/experimental/team" }, - { "path": "./packages/experimental/tool-team" }, + { "path": "./packages/experimental/agent-team" }, + { "path": "./packages/experimental/tool-agent-team" }, { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-worker-thread" }, { "path": "./packages/workflow/tool-workflow" }, diff --git a/vitest.config.ts b/vitest.config.ts index b255083cb8..bc34d7c186 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,6 +57,10 @@ const windowsUnsupportedCoveragePackages = process.platform === 'win32' const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + // The koffi-backed Win32 table (Toolhelp32/GetProcessTimes/taskkill) + // executes only on win32; its decision logic is unit-pinned on every + // host through the injected-internals suites. + 'packages/subprocess/subprocess-local/src/windows-inspector.ts', ] : []