docs(i18n): re-translate RFC batch with the prompt-v4 pipeline

146 篇 RFC 译文按 v4 基线(#348)重出:v4 模板+术语表、金标
few-shot、三段协议、切换行后处理;全量机械核对零异常(一处
task id 术语违规已修)。三篇超长 RFC(code-mode 已入,web-seam/
agent-scope/sandbox/cds-core 仍在长文档通道产出)随后补。
This commit is contained in:
ZiyaZhang
2026-07-22 03:07:36 -07:00
parent 839b88a53a
commit 8ea5cdd894
292 changed files with 2819 additions and 2820 deletions
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-content-block-vocabulary.md: 9414bda624fa6e5fc7e9b11b7a738d32b269af6b
2026-06-11-content-block-vocabulary.zh.md: 32308a5fe58f3a2e3c402982b7067811b9698218
2026-06-11-content-block-vocabulary.zh.md: 764791cbef65c2031b8337af4f4cb6835e9d312a
@@ -1,4 +1,4 @@
# RFC:由 dsh-llm 有的提供方无关内容块词汇
# RFC:由 dsh-llm 有的提供方无关内容块词汇
Status: implemented
@@ -10,19 +10,19 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
## 决策
行持有词汇:消息是类型化内容块(`text``reasoning``tool-call``tool-result`的数组,其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式也用于所有「字符串化」字段的类型定义`MessageSource``FinishReason``TurnTrigger``TurnEndReason`)。流式输出原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)映射成本留在适配器中,正是它该的地方。
主拥有词汇:消息是类型化内容块的数组`text``reasoning``tool-call``tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式所有「字符串化」字段提供类型`MessageSource``FinishReason``TurnTrigger``TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该的地方。
会话内上下文注入(`context/message``steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新 role,因此适配器负担。实适配器验证已确认渲染方式当前 DeepSeek 行为下有效;如果未来某提供方出现不匹配,应在该适配器内处理,而非引入新的规范 role
会话内上下文注入(`context/message``steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。实适配器验证已确认渲染方式符合当前 DeepSeek 行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色
## 曾考虑的替代方案
- **镜像 DeepSeek/OpenAI chat-completions 结构**:对第一个提供方零映射成本,但对富内容(推理reasoning)、作为结构化块的工具结果)处理起来别扭
- **原样采用 Anthropic Messages 块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。
- **镜像 DeepSeek/OpenAI chat-completions 结构**:对第一个提供方零映射成本,但对富内容(推理结构化块形式的工具结果)处理不便
- **原样采用 Anthropic Messages 块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。
## 后果
- 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。
- 多模态块只有在适配器、UI 上下文压缩(context compaction)三方协同支持才会回归;见[移除 image 内容块 RFC](../simplification/2026-07-04-drop-image-content-block.md)。
- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的变体](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[惰性请求旋钮](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。
- 每个适配器都承担翻译成本;首批真实适配器已验证了流式输出协议,后续新适配器应继续在适配器本地测试中证其提供方特有的映射。
- 跨包边界的 ID 使用品牌类型(`CallId``SessionId``AgentId`零运行时成本的名义类型。
- 多模态块只有在适配器、UI 上下文压缩(context compaction)三方协同支持才会回归;见 [drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md)。
- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。
- 每个适配器都承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中证其提供方特有的映射。
- 跨包package边界的 ID 使用品牌类型(`CallId``SessionId``AgentId`——零运行时开销的名义类型。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-custom-schema-dsl.md: 4c4d572b15d5e474e00e99fc5e7dd89240251c63
2026-06-11-custom-schema-dsl.zh.md: 31af594846b6b1bc8ce983b7b9d4ddd956a560ff
2026-06-11-custom-schema-dsl.zh.md: eca0428d46ba3b3a4beac0cf9e8f02a9fa198388
@@ -1,4 +1,4 @@
# RFC:使用自定义类型化工具 schema DSL 替代 schemastery
# RFC:使用自定义类型化 tool-schema DSL 替代 schemastery
Status: implemented
@@ -6,18 +6,18 @@ Status: implemented
## 问题
工具参数必须以标准 JSON Schema 形式传递给模型,同时让工具作者在 `execute(args)` 中获得类型推导而无需类型断言。schemastery 已用于插件配置,但工具作者 API 需要的是逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。
工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。
## 决策
在 dsh-tools 中实现一个小型自定义 DSL:`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型(required 键为必选,其余通过 `?` 真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将它们串联起来`defineTool()``ToolRegistry.register()` 仍接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具就是这样注册
在 dsh-tools 中实现一个小型自定义 DSL:`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型(required 键为必选,其余通过 `?` 标记为真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将三者串联`defineTool()``ToolRegistry.register()`接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具正是以此方式注册。
## 曾考虑的替代方案
**schemastery**(已 vendor用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。
**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验转换,而非 JSON Schema **生成**,因此会增加间接层却无法干净地产出协议格式(wire format)。
## 后果
- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。
- DSL 意保持小巧(string/number/boolean/object/array、enum、default、嵌套 properties/items)。相对完整 JSON Schema 的缺口(union、format、约束)在真实工具提出需求之前暂不补。
- `InferArgs` 映射在一次早期可选性 bug 之后已有类型层面的回归测试
- DSL 意保持小巧(string/number/boolean/object/array、enum、default、嵌套 properties/items)。相对完整 JSON Schema 的缺口(union、format、constraint)在真实工具提出需求之前暂不补
- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-dev-invariants-over-deep-readonly.md: dc89b5b66d02bde2f4fe2794e04b76ecdeac62ae
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 02beb323df84cd442611f0c52b3c56734d6d0554
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 2c3c9e221b42f4b45216d09e825a41b1be3bcee9
@@ -1,4 +1,4 @@
# RFC:源拥有的会话不可变性与开发模式不变式
# RFC:源拥有的会话不可变性与开发模式不变式
Status: implemented
@@ -6,55 +6,55 @@ Status: implemented
## 问题
会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实间关系的检查。如果将二者混为一体放进一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。
会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实间关系的检查。如果将二者混为一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。
会话日志是回放、请求重建、持久化用户可见历史的持久真源。会话包以外的代码必须能检视历史,但不能保留一个可以事后改写的引用;从调用方接的输入也不能继续连接到调用方拥有的可变对象
会话日志是回放、请求重建、持久化用户可见历史的持久真源。会话包package)外部的代码必须能检视历史,但不能保留一个可在之后改写历史的引用;从调用方接的输入也不能继续连接到调用方拥有的可变对象。
单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。
TypeScript readonly 类型不构成充分的运行时边界。它们在程序运行时消失,一次类型转换即可绕过,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。
TypeScript readonly 类型不充分的运行时边界。它们在程序运行时消失,类型转换可以绕过它们,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。
## 决策
职责在一个始终开启的存储边界与可选的开发断言之间分离。
职责在始终启用的存储边界与可选的开发断言之间分离。
### Session 拥有不可变历史
`Session` 仅在一次递归遍历完成无损 JSON 快照后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切离记录,因此校验和存储不可能从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
`Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切离记录,因此验证与存储不从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者收同一记录,`session.events` 返回一份冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验、快照与冻结边界。
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验、快照与冻结边界。
这一保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
### 派生请求保持
### 派生请求保持
`deriveMessages()` 将已记录的表面事件投影为离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。请求组装因此可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
`deriveMessages()` 将已记录的表面事件投影为离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
### 不变式插件检查关系
`dsh-invariants` 是一个纯监听的开发插件。它不冻结记录,没有配置;dispose 仅移除其断言。它检查需要踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent 状态转换、主体正确的作用域分发,以及 agent loop 构建的请求与从其会话日志前缀重建的请求之间的等价性。
`dsh-invariants` 是一个纯监听的开发插件。它不冻结记录,没有配置;dispose(资源释放)仅移除其断言。它检查需要踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。
当插件附加到已有或已播种的会话时,它回放不可变日志以重建踪状态。这使得在轮次中间进行热重载是安全的,同时不赋予插件对会话存储的所有权。
当插件附加到已有或已播种的会话时,它回放不可变日志以重建踪状态。这使得在轮次中热重载是安全的,同时不赋予插件对会话存储的所有权。
## 曾考虑的替代方案
### 全面的 deep-readonly 类型
[被否决的不可变公表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公开的日志和消息表面全面应用递归 readonly 类型。这能提供编辑器反馈,但不能提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。
[被否决的不可变公表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公日志和消息表面应用递归 readonly 类型。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。
### 仅在开发模式冻结
在安装了不变式插件时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终开启,而更昂贵的关系检查保持为可选的开发支持。
不变式插件安装时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用,而开销更大的关系检查保持为可选的开发支持。
### 仅在派生消息时克隆
`deriveMessages()` 保护最常见的请求路径,但 `session.events` 的其他读者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,不是替代品。
`deriveMessages()` 保护最常见的请求路径,但 `session.events` 的其他读者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
## 后果
-被接受的实时或种子会话事件在任何观察者收之前,都已从调用方拥有的输入中离并深度不可变。
-被接受的实时或种子会话事件在任何观察者收之前,都已从调用方拥有的输入中离并深度不可变。
- `session.events` 暴露稳定的不可变快照,而非私有的增长数组。
- 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为;dispose 或省略该插件不会削弱日志不可变性。
- `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。
- 运行时边界在每条被接受的事件上承担一次递归快照与冻结的开销;后续读者和缓存投影复用已拥有的不可变记录。
- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读者和缓存投影复用已拥有的不可变记录。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-event-sourced-sessions.md: 04ff974826ffbc9052c7eb9f5794bc16557241f9
2026-06-11-event-sourced-sessions.zh.md: a3fec18445673bc2368333413f9802f9822b9c89
2026-06-11-event-sourced-sessions.zh.md: 12d6aedcc32b4d93f0dbe010854bcd3a0721703f
@@ -1,28 +1,28 @@
# RFC:事件溯源的会话与派生消息历史
Status: implemented
[English](2026-06-11-event-sourced-sessions.md) | 中文
Status: implemented
## 问题
MVP 要求严格的基于事件的 trace、logging 系统,session 完全可回放。
MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统,session 完全可回放
## 决策
`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志,是唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`);原始流分片被记入日志以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威来源。回放fork = 用已有日志初始化一个新会话。
`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志,是唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`);原始流分片被记以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威依据。回放/fork = 用已有日志初始化一个新会话。
追加操作是同步的(热路径从不阻塞 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。
追加操作是同步的(热路径从不阻塞 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。
顺序契约:agent loop(智能体循环)先追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。
## 曾考虑的替代方案
**可变消息数组 + 事件作通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身是状态,分歧在结构上不可能发生。
**可变消息数组 + 事件作通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身是状态,分歧在结构上不可能发生。
## 后果
- 回放、trace 与遥测在结构上得到保证,而非事后附加。
- 持久化仍是插件关注点;内存存储随 dsh-session 一起发布
- 回放、追踪与遥测在结构上得到保证,而非事后附加。
- 持久化仍是插件关注点;内存存储随 dsh-session 一起提供
- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。
- 派生成本随日志长度增长——压缩(未来插件)是预期的缓解手段,而非日志变更。
- 派生成本随日志长度增长压缩(未来插件)是预期的缓解手段,而非日志变更。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-microkernel-event-taxonomy.md: c66968257a5a6304f187ccb5b9a162aa143e608d
2026-06-11-microkernel-event-taxonomy.zh.md: 86750f6ece488e735f2c14f759db7c9eef360828
2026-06-11-microkernel-event-taxonomy.zh.md: 7becf5872aee16fe21b4acbbc61920ed91c40f44
@@ -6,26 +6,26 @@ Status: implemented
## 问题
产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩(context compaction)、沙箱、权限、UI、持久化、MCP、skill(技能)都必须能以插件形式编写,无需修改核心。
产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩(context compaction)、沙箱、权限、UI、持久化、MCP、skill(技能)都必须能以插件形式编写,无需修改核心。
## 决策
纯 Cordis 事件分类体系taxonomy)。循环的扩展 seam 是带有明确分发模式的类型化事件
纯 Cordis 事件分类体系。agent loop(智能体循环的扩展 seam 是带类型的事件,具有明确分发模式:
- **waterfall(瀑布式事件)**around-middleware):插件可变换、否决或包装:`agent/prompt-submit``agent/request``agent/step-result``agent/turn-continuation``tools/pre-execute``tools/execute``tools/post-execute``llm/stream``system-prompt/assemble`
- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时都会运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。
- **parallel**await 扇出):每个监听器都必须获得独立执行机会:`session/flush` 持久性检查点。
- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。
- **waterfall(瀑布式事件)**around-middleware):插件可变换、否决或包装:`agent/prompt-submit``agent/request``agent/step-result``agent/turn-continuation``tools/pre-execute``tools/execute``tools/post-execute``llm/stream``system-prompt/assemble`
- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。
- **parallel**await 扇出):每个监听器都必须获得独立执行机会:`session/flush` 持久性检查点。
- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。
事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且身可替换——它之外的任何代码都不得依赖它。
事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且身可替换——外部不得依赖它。
## 曾考虑的替代方案
**专用中间件栈(koa-compose 风格)** **插件插入其中的显式阶段状态机**:两者都需要重新实现分发、dispose(资源释放)重载语义,而 Cordis 原生事件系统已经提供了这些;作为 Cordis effect,监听器天然获得 HMR(热模块替换) dispose 能力。
**专用中间件栈(koa-compose 风格)****显式阶段状态机(插件向其中插入阶段)**:两者都需要重新实现 Cordis 原生事件系统已提供的分发、dispose(资源释放)重载语义;作为 Cordis effect,监听器天然获得 HMR(热模块替换) dispose 能力。
## 后果
- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持新)。
- HMR dispose 免费获得:监听器和注册都是 Cordis effect。
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。
- 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何 seam 的 steering(中途引导)不会被搁置(有回归测试保障)。
- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持新)。
- HMR dispose 无需额外工作:监听器和注册均为 Cordis effect。
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。
- 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering(中途引导)永远不会被搁置(有回归测试保障)。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-runtime-arg-validation.md: 6da117643166d304bee1d368a314cc1602cac828
2026-06-11-runtime-arg-validation.zh.md: fb67568da3bfc7f1f8fcc0429b6c68193e04693a
2026-06-11-runtime-arg-validation.zh.md: 5f41ff99a2d56f39ff8d61191d92dc782f163c82
@@ -6,19 +6,19 @@ Status: implemented
## 问题
`defineTool`[自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是编译期对一个运行时值的声明:这个值以模型生成的 JSON 形式到达,没有任何机制强制模型遵守 schema因此,一次格式错误的调用(缺少必键、声明为数字的位置传入字符串、枚举值超出集合)会以「仅有类型之名」的状态`execute`。工具体要么在错误形状上崩溃(产生一条模型无法据以行动的通用堆栈跟踪),要么更糟静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。
`defineTool`[自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON没有任何机制强制模型遵守 schema因此畸形调用(缺少必键、声明为数字的位置传入字符串、枚举值超出集合)会以「仅名义类型化」的状态`execute`。工具函数体要么在错误形状上崩溃(产生模型无法据以自我修正的通用堆栈跟踪),要么更糟——静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。
## 决策
`validateArgs(spec, args): string[]`一个运行时值解释 `SchemaSpec`,返回人类可读的违规列表(空 = 合法),且是全函数(不抛出异常)。`defineTool` 在调用类型化的工具体之前运行它;如果存在违规,则抛出 `ToolArgsError``code: 'INVALID_ARGS'`,消息列出违规项),注册表既有的 execute-waterfall catch 将其转为模型可读取并据以自我修正的 `isError` 结果。
`validateArgs(spec, args): string[]` 对运行时值解释一个 `SchemaSpec`,返回可读的违规列表(空数组 = 合法),且是全函数(不抛出异常)。`defineTool` 在调用类型化函数体之前运行它;存在违规抛出 `ToolArgsError``code: 'INVALID_ARGS'`,消息列出违规项),注册表既有的 execute-waterfall(瀑布式事件)catch 将其转为模型可读取并据以自我修正的 `isError` 结果。
校验器严格镜像 `schemaSpecToJsonSchema` 的语义遍历相同结构、执行相同规则:顶层必须是非数组对象;必键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items``object`/`array` 属性仅做类型检查;`enum` 是成员判定。原始注册的(MCP)工具不受影响它们自行校验输入。
校验器严格镜像 `schemaSpecToJsonSchema` 的语义——遍历相同结构、执行相同规则:顶层必须是非数组对象;必键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items``object`/`array` 属性仅做类型检查;`enum` 是成员资格检查。原始注册的(MCP)工具不受影响——它们自行校验输入。
## 后果
- 模型在自身格式错误的调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。
- 校验器与 `InferArgs` 必须保持一致;一[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。
- `ToolArgsError` 目前是一个`code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,不影响读取 `.message` 的调用方。
- 模型在自身畸形调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。
- 校验器与 `InferArgs` 必须保持一致;一[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。
- `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,不影响读取 `.message` 的调用方。
- 校验开销相对于一次模型调用可忽略不计。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-structured-error-taxonomy.md: 2baf88a1f942215e79561e565f455276c80178c4
2026-06-11-structured-error-taxonomy.zh.md: f4762c4e92fb94c5bdbccc5f9a61e1496dc4c66d
2026-06-11-structured-error-taxonomy.zh.md: 90b0fdd7f6c8b4f0565c6538c2ddd4cb31680c38
@@ -6,21 +6,21 @@ Status: implemented
## 问题
错误跨越服务边界时只是裸字符串。工具错误被扁平化为一个文本块——name、code 和 stack 全部丢失——导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型得到的反馈也不如本可以获得的那样可操作。非 Error 的 throw 退化更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对一个通用基类做 `instanceof`
故障跨越 seam 时只是裸字符串。工具错误被扁平化为一个文本块name、code 和 stack 全部丢失),导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型得到的反馈也不如本可以那样具有可操作。非 Error 的 throw 退化更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对其进行通用的 `instanceof` 判断
## 决策
`dsh-llm`(叶子包package,所有其他包都已依赖它——不引入新的依赖边)中建立一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` `cause`式传递`name` 默认为子类名。`isHarnessError`服务边界处做类型收窄。
`dsh-llm`(叶子包,所有其他包都已依赖它不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause``name` 默认为子类名。`isHarnessError` seam 处做类型收窄。
- `LlmError``ToolArgsError`dsh-tools)和 `InvariantError`dsh-invariants)现在继承该基类,保留各自既有的 code。
- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存日志,供重试/沙箱插件和回放使用。面向模型的文本块不变。
- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError``code: 'UNKNOWN'`,原始值通过 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件已暴露 `code`)。
- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。
- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError``code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。
## 后果
- 错误端到端链路上可被机器路由:插件可以 `error.code` 分支,而对 message 做子串匹配。
- 一个基类被广泛导入,但它位于所有包已依赖的包中,代价是一条 import 语句,而非一条新的依赖边。
- `deriveMessages` 不会将 `error` 字段呈现到模型历史中——模型仍然看到文本块;结构化字段服务于代码逻辑和回放。
- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类加了跨服务边界的路由元数据,不改变面向模型的文本。
- 错误端到端机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。
- 一个基类被广泛导入,但它位于所有包已依赖的包中,代价是一条 import 语句,而非新的依赖边。
- `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。
- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类加了跨 seam 的路由元数据,不改变面向模型的文本。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-tool-schemas-in-prompt-assembly.md: 443e6f20115e5a76001b4466c2d756675adbd886
2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 2d23d6fd0ead17fbeed3feaf3cec864813ef47e4
2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 03624b98fabdedf691f3fb448cc5f37ba5a649ef
@@ -1,4 +1,4 @@
# RFC:工具 schema 属于系统提示词组装的一部分
# RFC:工具 schema 系统提示词组装的一部分
Status: implemented
@@ -6,18 +6,18 @@ Status: implemented
## 问题
在协议格式(wire format)层面,工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。从架构角度看,「模型被告知它能做什么」是一个内聚的关注点:提示词段落工具列表由同一批插件贡献组装而成,并在同一时刻被消费。
在协议格式(wire format)层面,工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。然而从架构角度看,「模型被告知它能做什么」是一个统一的关注点:提示词段落工具列表由相同的插件贡献组装,并在同一时刻被消费。
## 决策
`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema(工具注册表自动贡献一个提供方)。agent loop(智能体循环)每消费一 assembly;适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall(瀑布式事件)是模型前置信息的唯一拦截点——工具过滤(ToolSearch / 渐进式披露)是一次 assembly 写,与提示词编辑无异。
`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema(工具注册表自动贡献一个提供方)。agent loop(智能体循环)每个步骤消费一 assembly;适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall(瀑布式事件)是模型预先获知的所有信息的唯一拦截点工具过滤(ToolSearch / 渐进式披露)是一次 assembly 写,与提示词编辑无异。
## 曾考虑的替代方案
**循环分别向工具注册表和提示词服务查询**——将一个内聚的关注点拆到两个 seam 上;任何想塑造「模型被告知什么」的拦截(工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 改写
**循环工具注册表和提示词服务分别查询**将一个统一的关注点拆到两个 seam 上;任何想影响「模型被告知什么」的拦截(工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 重写即可完成
## 后果
- 一条 waterfall 统管模型的常驻上下文;plan 模式等插件可以在一个监听器中同时替换提示词文本和可见工具。
- assembly 接口通过声明合并实现可扩展(无需无类型的 `extras` 包——扩展即声明合并)。
- schema 出现在提示词服务中」有轻微的概念意外感,本文 package README 对此做了说明。
- assembly 接口通过声明合并实现可扩展(没有无类型的 `extras` 包——扩展即声明合并),为未来的槽位预留空间
- schema 放在「提示词服务中略有概念上的意外感,已在本文 package README 中加以说明。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-13-capability-seams.md: e9d417dbd2bafcaece39601b12dbb310feb1e19b
2026-06-13-capability-seams.zh.md: e5d9be803f71ef3b85f79ed483dab64612a27fc7
2026-06-13-capability-seams.zh.md: c569f3df083ec48bd05e6be2d3a1e5875fde362f
@@ -1,32 +1,32 @@
# RFC:能力 seam——接口/实现/消费方
Status: implemented
# RFC:能力 seam——接口/实现/消费方
[English](2026-06-13-capability-seams.md) | 中文
Status: implemented
## 问题
harness 具有可替换的能力:前是 bash 执行,未来会有沙箱/远程执行器和替代模型提供方。一项能力三个关注点,它们以不同速率、出于不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面什么编程)。将三者打包在一个 package 中会耦合这些变化速率把本地执行器换成沙箱执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。
harness 具有可替换的能力:前是 bash 执行,未来会有沙箱/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面什么编程)。将三者捆绑在一个包(package中会耦合这些变化速率——把本地执行器换成沙箱执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。
这与「运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已经用 service + `inject` 回答了(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。那套机制是必要的,但不决定 package 边界;本 RFC 决定。
这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过 service + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。机制是必要的,但不决定包的边界;本 RFC 决定的是包的边界
## 决策
一项可替换的能力拆为**三个 package**
一项可替换的能力**三个包**构成
1. **接口**一个抽象 service 加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis(例如 `dsh-bash``BashExecutor``BashRunResult``BashTask`)。
2. **实现**一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、spill-file 截断)。沙箱/远程后端是实现同一接口的兄弟 package
3. **消费方**模型和插件看到的东西(例如 `dsh-tool-bash``bash`/`bash_output`/`bash_kill` 工具 schema)。消费方 `inject` 接口 key,从不导入实现类型。
1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis(例如 `dsh-bash``BashExecutor``BashRunResult``BashTask`)。
2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、溢出文件截断)。沙箱/远程后端是实现同一接口的兄弟
3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash``bash`/`bash_output`/`bash_kill` 工具 schema)。消费方 `inject` 接口,从不导入实现类型。
实现与消费方随后独立演进:沙箱执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。
实现与消费方由此独立演进:沙箱执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。
当各部分确实属于同一关注点时,分并非强制:LLM seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现 package。不要预防性拆分只有一种可设想的实现和一个消费方的能力保持为一个 package,直到第二出现。
当各部分确实属于同一关注点时,分并非强制:LLM(大语言模型) seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现。不要预防性拆分——如果一项能力只有一种可设想的实现和一个消费方,就保持为一个,直到第二出现。
## 曾考虑的替代方案
- **合并为一个 package**:否决因为它重新耦合了拆分所要分离的三种变化速率(这正是拆分的全部意义)。
- **`@cordisjs/plugin-capability`**:完全不同的维度。它是一个权限/能力*安全*服务(带继承的命名权限,通过 `ctx.capability.test` 对会话进行检测),是延后的权限沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,**不是**替换实现的机制。混淆这两个「能力」正是本 RFC 所指出的陷阱。
- **单一合并包**:否决因为它重新耦合了三分设计本要分离的三种变化速率(这正是拆分的意义所在)。
- **`@cordisjs/plugin-capability`**这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,**不是**替换实现的机制。混淆这两个「能力」概念正是本 RFC 所指出的陷阱。
## 后果
每项能力多出更多 package 和更多样板代码(一 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions"Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中;bash 三件套是参考模板。何时合并、何时拆分是一个判断性决策,架构文档已做说明——本 RFC 记录的是*为什么*默认选择拆分。
每项能力需要更多包和更多样板代码(一 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本管理,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions"Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中;bash 三件套是参考模板。何时合并、何时拆分是一个判断问题,架构文档对此有详细说明——本 RFC 记录的是*为什么*默认选择拆分。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-13-twin-llm-adapters.md: 4efefcf4e2f6b1d60567ba3bfed7ae1ea53a7a4e
2026-06-13-twin-llm-adapters.zh.md: 806eea84ff3e6c4de988348964429ccaea27f4ba
2026-06-13-twin-llm-adapters.zh.md: 6cabd95c5361afdea5b33ffdee34a5acb6026f7f
@@ -1,4 +1,4 @@
# RFC:以两个 LLM 适配器作为设计验证孪生
# RFC:以两个 LLM 适配器作为设计验证孪生
Status: implemented
@@ -6,22 +6,22 @@ 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.md))。如果词汇针对单适配器定义,就有可能将该适配器的特异行为烘焙进「中立」契约唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——届时泄漏已代价高昂。
## 决策
从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现:
从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建
- `dsh-llm-deepseek`:手写 `fetch` + SSE 解析,直 DeepSeek API。
- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai`有自己的事件词汇)访问同一端点
- `dsh-llm-deepseek`:手写 `fetch` + SSEServer-Sent Events解析,直接对接 DeepSeek API。
- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai`访问同一端点(该库有自己的事件词汇)。
它们强制执行的规则是:**凡 StreamChunk 词汇无法同时为两个实现表达的东西,都是核心词汇的 bug**——立即暴露,而非等到下一个提供方才发现。这对孪生确定了现已记录在 `dsh-llm/src/types.ts``StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程原始 JSON 字符串,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由库封装的适配器暴露出来的,单一手写适配器会将其掩盖
二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生确定了现已记录在 `dsh-llm/src/types.ts``StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏
## 曾考虑的替代方案
- **单一适配器**:代码更少、e2e 成本减半,但「提供方无关」的声明无验证;词汇会默默编码 DeepSeek-via-fetch 的假设。
- **mock 第二适配器**:更便宜,但不会触及真实提供方的协议格式(wire format)怪癖,因此证明力有限。孪生是真实对真实。
- **单一适配器**:代码更少、e2e 成本减半,但「提供方无关」的声明无验证;词汇会默默编码 DeepSeek-via-fetch 的假设。
- **mock 第二适配器**:更便宜,但不会触及真实提供方的协议格式(wire format)怪癖,因此证明力有限。孪生是真实对真实的验证
## 后果
孪生使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理模式下的表现——换来的是 seam 中立性的持续验证和第二份实现示例。两者都使用 `apiKey``baseURL``models`;手写适配器暴露 `thinking`/`reasoningEffort`pi-ai 适配器暴露一个 `reasoning` 级别。未来一致性测试套件可以通过一份取代性 RFC 论证退役其中一个适配器。
孪生使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理reasoning模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey``baseURL``models`;手写适配器暴露 `thinking`/`reasoningEffort`pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件可以通过后续 RFC 论证退役其中一个适配器。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-14-session-persistence.md: 4078487b71862791dd25cf2afcfc03255cfeb0be
2026-06-14-session-persistence.zh.md: 801632b56d2056b36dbf7869f5114d74599bd5d5
2026-06-14-session-persistence.zh.md: a44ef9647ca4003bc81023a54b7e9b5945003b02
@@ -1,4 +1,4 @@
# RFC:会话持久化——基于有 `SessionEvent` 的抽象服务
# RFC:会话持久化作为基于有 `SessionEvent` 的抽象服务
Status: implemented
@@ -6,31 +6,31 @@ Status: implemented
## 问题
会话此前存在于内存中。示例插件 `session-jsonl.ts`(在两个 examples 目录中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose 时 fire-and-forget 地排空缓冲区),没有列表功能,也没有格式版本控制。没有任何东西能把磁盘上的历史会话重新注入一个活跃的 agent,因此持久恢复(继续昨天的任务)、持久 fork以及 ACP `session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都不可能实现。
会话此前存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入活跃的 agent(智能体)中,因此持久恢复("继续昨天的任务")、持久 fork 以及 ACPAgent Client Protocol`session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。
[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM 历史。持久化必须忠于这一:直接持久化有的 `SessionEvent`,不引入需要来回转换的并行持久化消息类型。后端也必须可替换——当前文件存储,将来是数据库存储——统一在一个接口之后。
[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠于这一设计:直接持久化有的 `SessionEvent`,不引入需要来回转换的并行"持久化消息"类型。后端也必须可替换——当前文件存储,以后用数据库存储——统一在一个接口之后。
## 决策
持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md)`dsh-bash` 模板),而非循环或核心逻辑:
1. **接口**`dsh-session-persistence``ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是有的 `SessionEvent``{ type, seq, time, data }`),逐字复用,无转换类型。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字保留**包括 `assistant/chunk`**)。
1. **接口**`dsh-session-persistence``ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是有的 `SessionEvent``{ type, seq, time, data }`),原样复用,无转换类型。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字保留**包括 `assistant/chunk`**)。
以下关键选择记录于此,因为它们是持久的、有争议的、且出人意料的:
以下关键选择记录于此,因为它们是持久的、有争议的、且出人意料的:
- **规范持久日志逐字保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载`events[i].seq === i` 要求日志*连续*;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。未来可以将过滤 chunk 的投影作为带独立重编号的派生视图,但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写 `turn/end` 之前的事件永不重写,且循环在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end`这些合成结果使恢复后的 provider transcript 保持有效。只有不完整的最后一条记录会被丢弃;如果在最后一个真实 `turn/end` 或之前出现解析错误或序号间隙,则视为损坏,该会话不可加载。
- **文件后端为规范实现,数据库后端为验证的可替换方案。** `SessionEvent` 1:1 映射一行 `(session_id, seq, type, time, data)``append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口不变opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 套件——因此契约以相同的语义(惰性物化、加载时关闭中断轮次、连续 seq)约束两个后端,一次表达在文件字节上,一次表达在行上。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,通过新的只读属性 `session.header` 附加到 `Session`——永远不 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件 seed/fork 会话时可以免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因为是死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。)
- **`ctx.agents.create()` `ctx.agents.resume()` 是异步工厂;resume 还额外跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop 不会硬注入 `sessionPersistence`(那会让非持久化的演示永远挂起);当 `sessionPersistence` 不存在时,`resume` 以明确的错误拒绝。
- **规范持久日志逐字保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验 `events[i].seq === i` 要求日志*连续*;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写 `turn/end` 的事件永不重写,且循环在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end`。合成结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射一行 `(session_id, seq, type, time, data)``append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,通过新的只读属性 `session.header` 附加到 `Session`——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。)
- **`ctx.agents.create()` `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那会让非持久化的演示永远挂起);当 `sessionPersistence` 不存在时,`resume` 以明确的错误拒绝。
## 曾考虑的替代方案
上述每个关键选择在陈述时已记录了被否决的替代方案:**过滤 chunk 的规范日志**Codex 的 `policy.rs`状)——破坏连续 seq 契约;**截断崩溃的轮次**——静默销毁长时间自主运行的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**——会让非持久化的演示永远挂起。
上述每个关键选择在陈述记录了被否决的替代方案:**过滤 chunk 的规范日志**Codex 的 `policy.rs`式)破坏连续 seq 契约;**截断崩溃的轮次**静默销毁长时间自主运行的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
格式版本控制:header 携带一个 `version``load` 拒绝任何非当前版本(不做迁移——预发布的会话格式固定 `SESSION_FORMAT_VERSION = 0`,按 AGENTS.md 的预发布立场吸收形状变动)。坦率地说:仅追加 + 刷写对部分尾部写入(加载时容忍)是健壮的,但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是将来更强的选项。
格式版本控制:header 携带一个 `version``load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
## 后果
新增两个包(package),以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`[ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部建立在既有的事件溯源日志之上,后端在一个接口替换。可复用的 `runPersistenceContract` 套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字保留
新增两个包(package),以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`[ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-15-turn-enclosure-invariant.md: bf0789f21ba7bd928e023bb5fd844d9ec78c4bc1
2026-06-15-turn-enclosure-invariant.zh.md: 9f6f69b923ed7feff51122c4e4040bff3c9db8ea
2026-06-15-turn-enclosure-invariant.zh.md: 644e7b975e024286d5307f586f509c2b4a9f21ea
@@ -1,4 +1,4 @@
# RFC:每个会话事件必须包含在一个轮次内
# RFC:每个会话事件都封闭在一个轮次内
Status: implemented
@@ -6,37 +6,37 @@ Status: implemented
## 问题
持久化的会话持久化后端(在一个配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`load` 会用一个合成的 `turn/end {kind:'interrupted'}` 将其关闭,同时保留该轮次的真实事件(见[会话持久化](2026-06-14-session-persistence.md))。这种恢复只有在没有任何*合法的*持久事件位于轮次之外(即上一个 `turn/end` 与下一个 `turn/start` 之间的间隙)时才是良定义的,否则这类事件会被入下一个轮次的中断关闭中。
持久化的会话持久化后端(在配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`load` 会用一个合成的 `turn/end {kind:'interrupted'}` 将其关闭,同时保留该轮次的真实事件(见[会话持久化](2026-06-14-session-persistence.md))。这种恢复只有在没有任何*合法的*持久事件位于轮次之外(即上一个 `turn/end` 与下一个 `turn/start` 之间的间隙)时才是良定义的,否则这类事件会被入下一个轮次的中断关闭中。
假设并不成立。有两条路径在轮次之外记录了事件:
这一假设并不成立。有两条路径在任何轮次之外记录了事件:
1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` **之前**追加 `user/message`,导致一个轮次自身的提示词落在前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。
2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的实调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,经常发生在 agent **空闲**两个轮次之间)时。
1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` **之前**追加 `user/message`——于是一个轮次自身的提示词落在前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。
2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的实调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,而这经常发生在 agent **空闲**(轮次之间)时。
对于情况 2,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并**恢复时丢弃**——注入的上下文虽然已持久化到磁盘,但重新加载被静默丢失。情况 1 单独来看是无害`user/message` 之后总是紧跟它触发的轮次),但使「什么可以出现在轮次之外」这条规则变得模糊。
情况 2,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并**恢复时丢弃**——注入的上下文已持久写入磁盘,但重新加载被静默丢失。情况 1 本身无害(`user/message` 之后总会跟着它触发的轮次),但使「什么可以出现在轮次之外」这条规则变得模糊。
## 决策
**每个会话事件都位于一个轮次内部**——在一个 `turn/start` 与其匹配的 `turn/end` 之间。具体而言:
**每个会话事件都位于一个轮次内部**:在 `turn/start` 与其匹配的 `turn/end` 之间。具体而言:
- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非在其之前。因此,这些消息一经记录,`turn/end` 就已被承诺,而现有的 finalizer 保证了这一点
- agent **运行中**调用 `agent.inject()` 时,`context/message` 追加到已打开的轮次中(行为不变)。
- agent **空闲时**调用 `agent.inject()`系统`context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}``context/message``turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`
- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而维护一个私有计数器,因此空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。
- `dsh-invariants` 插件在开发模式下**强制执行**该不变式:在没有打开轮次追加 `user/message``context/message``steering/message` 会抛出 `InvariantError`
- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非之前。因此,一旦这些消息记录,就欠下一个 `turn/end`,既有的 finalizer 保证它被写入
- agent **运行中**调用 `agent.inject()` 时,`context/message` 追加到已打开的轮次中(行为不变)。
- agent **空闲时**调用 `agent.inject()``context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}``context/message``turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`
- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。
- `dsh-invariants` 插件在开发环境中**强制执行**该不变式:在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出 `InvariantError`
可序列化性不变式在同一源码边界强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一统一管控,而非由下游恰好在监听的某个后端发现。
可序列化性不变式在同一源码边界强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。
## 曾考虑的替代方案
**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条可检查的生产端规则优于一更宽松的边界扫描逻辑,后者需要同时推理部分轮次*和*轮次间的散落事件。
**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条单一、可检查的生产端规则优于一更宽松的边界扫描后者需要同时推理部分轮次*和*轮次间的散落事件
## 后果
轮次现在是*唯一的*持久/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:一个被中断的最终轮次被关闭(用合成的 `turn/end {interrupted}`),其真实事件保留,且完全不存在将轮次间上下文混入其中的风险,因为不再有轮次间上下文。`scanLog` 保持简单(至多一个可能未关闭的最终轮次,永远没有散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后得以存活。
轮次现在是*唯一的*持久/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:被中断的最终轮次被关闭(用合成的 `turn/end {interrupted}`),其真实事件得以保留,且零风险将轮次间上下文混入其中,因为不存在轮次间上下文。`scanLog` 保持简洁(最多一个可能未关闭的最终轮次,绝无散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后依然存活。
代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行,且推导出的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 本就纯粹按事件类型推导,因此渲染结果不变`injection` 触发器是一个新的磁盘词汇值;与每一个 `SessionEventMap`/`TurnTriggerMap` 的新增一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`),这对任何断言旧顺序的代码可观测——agent loop 自身的测试是唯一的此类消费方。
代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行;派生的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 已经纯粹按事件类型派生,因此渲染结果完全相同`injection` 触发器是一个新的磁盘词汇值;与每 `SessionEventMap`/`TurnTriggerMap` 的新增一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`),这对任何断言旧顺序的代码可观测——agent loop 自身的测试是唯一的此类消费方。
该规则有意采用生产端强制执行 + 开发模式检查的方式,而非读取端容忍:未来的后端(SQLite/WAL)可以免费继承同样干净的边界,而在轮次外记录事件的插件会在开发模式下大声失败,而非在下次重新加载时静默丢失数据。
该规则有意采用生产端强制、开发环境检查的方式,而非读取端容忍的方式:未来的后端(SQLite/WAL无需额外工作即可继承同样干净的边界,而在轮次外记录事件的插件会在开发环境中大声失败,而非在下次重新加载时静默丢失数据。
轮次内检测到的失败在 `turn/end` 之前记录。后的 flush 失败没有合法的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。
轮次内检测到的失败在 `turn/end` 之前记录。后的 flush 失败没有有效的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-17-filesystem-capability-seam.md: c502ae712de22e97661192057d4410c7c55ea044
2026-06-17-filesystem-capability-seam.zh.md: 01a4318237ebbd29fe3effa3f8528b6e4a5132d6
2026-06-17-filesystem-capability-seam.zh.md: 4e544e24c548a52bde254886a65ff2fdb559a986
@@ -1,42 +1,42 @@
# RFC:文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具
Status: implemented
[English](2026-06-17-filesystem-capability-seam.md) | 中文
Status: implemented
## 问题
harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read``write``edit` 直接使用 `node:fs`,面向模型的工具包将同时拥有文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。
harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read``write``edit` 直接使用 `node:fs`,面向模型的工具包将同时承担文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。
这把三个独立变化的关注点耦合在了一起:
1. 文件系统契约:插件可以请求哪些操作。
2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目范围的文件系统。
2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目作用域的文件系统。
3. 消费方接口:面向模型的 `read` / `write` / `edit` schema 与结果格式化。
没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,也会搅动工具 schema、演示和提示词引导。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。
如果没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,工具 schema、演示和提示词引导也会被迫变动。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。
我们需要文件系统工具在成为公开包接口之前,以与 bash 相同的能力 seam 形态落地。
我们需要文件系统工具在成为公开包package接口之前,以与 bash 相同的能力 seam 形态落地。
## 决策
文件系统访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md)
文件系统访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md)
1. `@deepseek-ai/dsh-fs``packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。
2. `@deepseek-ai/dsh-fs-local``packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。
3. `@deepseek-ai/dsh-tool-fs``packages/fs/tool-fs`)通过 `ctx.fs` 提供面向模型的 `read``write``edit` 工具,并作为执行器分发 `fs/*` 事件。
3. `@deepseek-ai/dsh-tool-fs``packages/fs/tool-fs`)通过 `ctx.fs` 提供面向模型的 `read``write``edit` 工具,分发 `fs/*` 事件的执行器
消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。
读后写/编辑与已观察状态策略是第四个包 `@deepseek-ai/dsh-fs-policy``packages/fs/fs-policy`),通过 `fs/*` 事件门而非 `ctx.fs` 方法贡献;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 RFC 确立了三包 seam;策略从提供方基类拆出的决策 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md),其作为事件门插件(而非方法服务)实现 [event-gate RFC](2026-06-26-file-context-as-event-gate.md)。本文已更新为描述最终落地的四包形态。
读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy``packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` ;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 RFC 确立了由三个包构成的 seam;策略从提供方基类拆出的决策 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门插件(而非方法服务)实现的方式由 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。
第一个后端意仅限本地:`dsh-fs-local` 针对宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口后提供沙箱、远程、虚拟或项目范围的文件系统。
第一个后端意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口后提供沙箱、远程、虚拟或项目作用域的文件系统。
第一个消费方意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read``write``edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监或更高层的项目操作,只要所需能力存在于 `ctx.fs` 上,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。
第一个消费方意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read``write``edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监或更高层的项目操作,只要 `ctx.fs`存在所需能力,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。
读后写/编辑与已观察状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。
读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。
## 包拓扑
@@ -47,11 +47,11 @@ harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local`
consumer interface implementation
```
`@deepseek-ai/dsh-fs` 仅依赖 `cordis` 来自 `@deepseek-ai/dsh-llm` 的仓库级 `HarnessError` 基类。它声明 `ctx.fs` 键、抽象 `FileSystem` 服务、后端消费方共享的词汇类型、文件系统错误词汇,以及 `fs/*` 策略事件词汇。它不持有已观察状态存储,也不持有 owner 推导形态;事件传递一个不透明的 `object` actor,提供方从不读取它,`dsh-fs-policy` 插件在这些事件之上拥有 owner 推导形态和已观察状态存储。
`@deepseek-ai/dsh-fs` 仅依赖 `cordis` 加上来自 `@deepseek-ai/dsh-llm` 的仓库级 `HarnessError` 基类。它声明 `ctx.fs` 键、抽象 `FileSystem` 服务、后端消费方共享的词汇类型、文件系统错误词汇,以及 `fs/*` 策略事件词汇。它不持有观测状态存储,也不持有 owner 推导形态;事件传递一个不透明的 `object` actor,提供方从不读取它,`dsh-fs-policy` 插件在这些事件之上拥有 owner 推导形态和观测状态存储。
`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs``cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有已观察状态存储新鲜度是后端铸造、策略插件记录的版本令牌。
`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs``cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有观测状态存储——新鲜度是后端铸造、策略插件记录的版本令牌。
`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs``@deepseek-ai/dsh-tools``@deepseek-ai/dsh-system-prompt``cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs``node:path``@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs`
`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs``@deepseek-ai/dsh-tools``@deepseek-ai/dsh-system-prompt``cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs``node:path``@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs`
`tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read``write``edit`)。它注入 `fs`,从不导入实现包。
@@ -59,23 +59,23 @@ harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local`
`@deepseek-ai/dsh-fs` 拥有一个语义文件系统服务。它比 `readFile` / `writeFile` 更高层,这样 `tool-fs` 就不必重新实现路径解析、版本管理、文本解码、二进制拒绝、分页、原子替换、符号链接行为或字面编辑语义。
该接口盖以下语义操作:
该接口盖以下语义操作:
- 将模型/插件提供的路径解析为后端定义的目标。
- 在不读取文件内容的情况下获取目标元数据。
- 获取目标元数据而不读取文件内容
- 从目标读取有界的 UTF-8 文本页。
- 创建或替换一个 UTF-8 文本文件。
- 通过字面替换编辑一个已存在的 UTF-8 文本文件。
- 通过字面替换编辑一个已的 UTF-8 文本文件。
提供方 seam 还承载策略所依赖的新鲜度钩子,但已观察状态存储和 owner 推导位于 `dsh-fs-policy` 插件中,而非 `ctx.fs` 上:
提供方 seam 还携带策略所依赖的新鲜度钩子——但观测状态存储和 owner 推导位于 `dsh-fs-policy` 插件中,而非 `ctx.fs` 上:
- 后端为每个目标铸造一个不透明的 `version` 令牌(在 `stat` 每次读取/变更结果中)。
- `writeText`/`editText` 接受一个可选的版本期望:省略它则执行无条件的裸提供方变更提供它则在后端的原子临界区内守护变更。
- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录已观察版本,以从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。
- 后端为每个目标铸造一个不透明的 `version` 令牌(在 `stat` 以及每次读取/变更结果中)。
- `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更提供它则在后端的原子临界区内守护变更。
- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。
授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都记录目标的版本,后续的写入/编辑只要文件仍处于该版本被授权——因此对第 100-150 行的窗口读取可以授权对第 120 行的编辑。已观察状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>``dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。)
授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都记录目标的版本,后续的写入/编辑只要文件仍处于该版本被授权——因此对第 100-150 行的窗口读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>``dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。)
路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目范围的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。
路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。
解析后的目标必须至少暴露三个概念:
@@ -83,19 +83,19 @@ harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local`
- 不透明的 `targetKey`,用于过期守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
读取和变更结果必须包含一个不透明的文件 `version`。本地后端可以使用 mtime/size 或类 hash 令牌;远程后端可以使用修订 id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。
读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类 hash 令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 流式传输相同的文本语义用于大文件。二者都负责常规文件检查;有界行/输出处理不是它们的职责——行窗口、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界行/输出处理不是它们的职责——行窗口、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。
已观察状态记录不在 `ctx.fs` 上:成功读取后执行器发出 `fs/observed``dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。
观测状态记录不在 `ctx.fs` 上:成功读取后执行器发出 `fs/observed``dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。
全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已存在的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并已存在时以 `FS_NOT_OBSERVED` 拒绝(这是策略为未观 owner 使用的路径);`replaceIfVersion` 仅在目标处于已观察版本时替换,否则 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的已观察状态选择提供哪个期望。
全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`这是策略为未观 owner 使用的路径);`replaceIfVersion` 仅在目标处于观测版本时替换,否则 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。
字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此针对旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地组合。
字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。
策略插件(而非 `ctx.fs`)对先前观进行门控:`edit` 要求 owner 有先前观(否则 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传`editText`。在策略插件缺席时,`ctx.fs` 单独是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。
策略插件(而非 `ctx.fs`)对先前观进行门控:`edit` 要求 owner 有先前观(否则 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传给 `editText`。在策略插件缺席时,`ctx.fs` 本身是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。
文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码 `FS_NOT_FOUND``FS_NOT_TEXT``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_NOT_REGULAR_FILE``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。)
文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND``FS_NOT_TEXT``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_NOT_REGULAR_FILE``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。)
## 工具消费方行为
@@ -105,7 +105,7 @@ harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local`
- `read`:检查一个 UTF-8 文本文件并返回带行号的内容与分页引导。
- `write`:创建或完全替换一个 UTF-8 文本文件。
- `edit`:通过替换字面文本更新一个已存在的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。
- `edit`:通过替换字面文本更新一个已的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。
每个工具遵循相同的执行形态:
@@ -114,47 +114,47 @@ harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local`
3. 将结果格式化为面向模型的 `ContentBlock[]`
4. 让抛出的后端/工具错误流经 `ToolRegistry.execute()`,由其转换为 `isError` 工具结果。
该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()``ToolRegistry.schemas()` 流入正常的提示词组装路径;无需改 agent loop。
该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()``ToolRegistry.schemas()` 流入正常的提示词组装路径;无需改 agent loop(智能体循环)
工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` schema 不会仅因后端变化而改变。
工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` schema 不会仅因后端变化而改变。
默认部署要求在用 `write``edit` 更新已存在文件之前先 `read``tool-fs` 不通过检查名为 `read` 的工具是否运行过来实现这一点:它分发 `fs/write-intent`/`fs/edit-intent` 事件(将执行上下文作为不透明 actor 传递),`dsh-fs-policy` 插件推导 owner、对先前观进行门控并提供版本期望。任何窗口读取都能授权后续的写入/编辑,只要文件未变。用 `write` 创建新文件不要求先前观
默认部署要求在用 `write``edit` 更新已文件之前先 `read``tool-fs` 不通过检查是否运行过名为 `read` 的工具来实现这一点:它分发 `fs/write-intent`/`fs/edit-intent` 事件(将执行上下文作为不透明 actor 传递),`dsh-fs-policy` 插件推导 owner、对先前观进行门控并提供版本期望。任何窗口读取都能授权后续的写入/编辑,只要文件未变。用 `write` 创建新文件不要求先前观
根插件通过组合各工具的注册辅助函数来注册完整套件。它注入 `fs``tools``systemPrompt`
## 测试
测试遵循包边界,而非仅覆盖用户可见的工具:`dsh-fs` 中的服务 seam`dsh-fs-local` 中通过 `ctx.fs` 接口的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件版本守护写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs`针对真实本地提供方的消费方接口( mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有 `dsh-fs-policy` 两种情况下集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`已观察状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。
测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件版本守护写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs`基于真实本地提供方的消费方接口( mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 情况下进行集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。
本仓库曾踩过的防御性模式类别被直接固定:
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only`'wx'``0o600`)临时文件暂存,失败时清理,最后原子 rename。这与 bash spill-file 规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限以及已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。
- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个已观察状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit`读守护,通过一个路径的过期写入可通过另一个路径检测到。
- **并发/过期竞争。** 两个并发写入/编辑操作针对同一目标确定性地结算:一个成功,另一个 `FS_STALE_VERSION` 拒绝成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。
- **HMR 安全与 dispose。** 释放后端的 fiber 会撤回 `ctx.fs` 提供方;后续提供方启动时没有继承状态。
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only`'wx'``0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。
- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的过期写入可通过另一个路径检测到。
- **并发/过期竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。
- **HMR(热模块替换)安全与 dispose(资源释放)。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续提供方以无继承状态启动
## 曾考虑的替代方案
- **面向模型的工具直接使用 `node:fs`**:工具包将同时拥有执行策略、路径解析、原子写入、文本解码和编辑语义,耦合了「问题」一节所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。
- **单一合并包 `dsh-fs-tools`**seam 之前的形态;出于与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。
- **已观察状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观策略,因此提供方保留版本令牌和可选的版本守护变更。
- **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。
- **单一合并包 `dsh-fs-tools`**seam 之前的形态;与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。
- **观测状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观策略,因此提供方保留版本令牌和可选的版本守护变更。
## 后果
**`cwd` 可能被误认为沙箱。** 本地后端的基目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。
**`cwd` 可能被误认为沙箱。** 本地后端的基目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。
**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露示元数据,而不要求消费方理解宿主路径。
**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露示元数据,而不要求消费方理解宿主路径。
**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义。这会重新制造本 RFC 试图避免的耦合。
**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义重新制造本 RFC 试图避免的耦合。
**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地结算:一个赢,另一个得到 `FS_STALE_VERSION`
**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`
**已观察状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内;split-fs-seam RFC 随后确立沙箱/远程后端不应继承面向模型的观策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、已观察状态和先读后编辑门控,通过 `fs/*` 事件实现
**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内split-fs-seam RFC 随后确立沙箱/远程后端不应继承面向模型的观策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控
**`resolve` 后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再作为单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端而言这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变独立请求,使单次 `read`两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观契约不变。
**`resolve` 后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变独立请求,使单次 `read`两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观契约不变。
**已观察状态持久化被推迟。** 已观察状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观可回放。
**观测状态持久化被推迟。** 观测状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观可回放。
**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs``dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。
**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs``dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。
**包拆分的代价前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改包接口代价更高。
**包拆分的成本前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改包接口代价更高。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-agent-lifecycle-and-ownership-seams.md: a70e7db8d809efd68ae770995795fc7b3d1b83d2
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: ac42a09c70e9570d3def0f0bd056bd571b923315
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 3fb68336c56b5296f18b3587ea42399a05362733
@@ -1,12 +1,12 @@
# RFCAgent 生命周期与所有权 seam
Status: implemented
[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文
Status: implemented
## 问题
ACPAgent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的不同症状:插件可以通过 `ctx.agents` 创建或恢复 agent,但无法独立拥有 dispose(资源释放)单个 agent长时间运行的 bash 任务在执行器内部也没有稳定的所有者。ACP 在断开连接时中止并等待 agent,却无法注销该会话的 agent`session/cancel` 无法取消已队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。
ACPAgent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的症状:插件可以通过 `ctx.agents` 创建或恢复 agent(智能体),但无法独立拥有 dispose(资源释放)单个 agent,而长时间运行的 bash 任务在执行器也没有稳定的所有者。ACP 在断时中止并等待 agent,却无法注销该会话的 agent`session/cancel` 无法取消已队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。
## 决策
@@ -14,33 +14,33 @@ ACPAgent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se
### 1. 队列感知的 `Agent.cancel(reason?)`
`cancel()` 是唯一的公开停止原语。它清除已排队的输入和 steering(中途引导)输入中止正在行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已队的提示词在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲 agent 的 cancel 不设置标记。
`cancel()` 是唯一的公开停止原语。它清除已入和 steering(中途引导)输入中止正在行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已队的 prompt 在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲状态的 cancel 不设置标记。
### 2. `AgentHandle` 异步释放器
`ctx.agents.create`/`resume` `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法拆除。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享一个 memoized 的拆除程:停止循环、等待静默与 flush 完成、分离 agent 和会话,然后回收其 scope。注册表条目分离后 ID 即可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。
`ctx.agents.create`/`resume` `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法将其拆除。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享一个 memoize 的拆除程:停止循环、等待静默与刷写完成、分离 agent 和会话,然后解除其 scope。ID 在注册表条目分离后变为可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。
拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放先停止循环并等待 `agent.done`,再分离会话。如果使用兄弟 effect它们会并发释放,可能在关闭 flush 之前移除 append 钩子。释放通知被隔离,不会中断拆除链。
拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放先停止循环并等待 `agent.done`然后再分离会话。使用兄弟 effect会并发释放,可能在关闭刷写之前移除 append 钩子。释放通知被隔离,不会中断拆除链。
### 3. Bash 所有者令牌置于 seam 中
### 3. Bash seam 中的所有者令牌
后台任务的所有权执行器持有`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保在任务上,使得这道围栏在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。
后台任务的所有权属于执行器。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保在任务上,使得这道隔离在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。
## 验证
- ACP 断开连接或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。
- 在已队的提示词启动前取消,能阻止该提示词运行或吸收下一条提示词
- ACP 断或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。
- 在已队的 prompt 启动前取消,能阻止该 prompt 运行或吸收下一条 prompt
- 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。
- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 演示无需显式管理 handle。
- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 演示无需显式管理 handle。
## 会话所有者令牌在存活 agent 中唯一
bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。
bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。
## 曾考虑的替代方案
- **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam:否决。一条读取路径即可,无需冗余 API。
- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时兄弟 effect 并发释放`Promise.all`),store 有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`
- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect`Promise.all`),store 有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`
- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。
## 后果
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-session-surface.md: 31297166b735468147850a81d7fd43a8fa30a1e8
2026-06-18-session-surface.zh.md: 9e2933a1e564b3dbb7719d55b5264f670c3b833f
2026-06-18-session-surface.zh.md: 159aefc10261ac5380701f46c3d4a367674940b1
@@ -1,22 +1,22 @@
# RFCSession surface——基于事件日志的链表,用于 LLM 消息推导
Status: implemented
# RFC会话 surface——基于事件日志的链表,用于 LLM 消息派生
[English](2026-06-18-session-surface.md) | 中文
Status: implemented
## 问题
事件日志是权威数据源,但历史操此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源记录,且每次新增操都要修改 `deriveMessages()`
事件日志是权威数据源,但历史操此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源信息,且每次新增操都要反复修改 `deriveMessages()`
## 决策
新增一个 **surface**:一条从事件日志派生、带缓存的链表,由「surface 节点」(产出 LLM 消息的那部分事件)组成,通过事件日志中的 `surfaceOp` 标记维护。
新增一个 **surface**:一条派生的、缓存的链表,由「surface 节点」(事件中产出 LLM(大语言模型)消息的子集)组成,通过事件日志中的 `surfaceOp` 标记维护。
### `SessionEvent` 上的两个顶层字段
### `SessionEvent` 新增两个顶层字段
每个 `SessionEvent` 新增两个可选字段(与 `seq`/`time`属结构元数据):
每个 `SessionEvent` 获得两个可选字段(结构性元数据,`seq`/`time`):
- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。
- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
### SurfaceOp:两种操作
@@ -27,45 +27,45 @@ export type SurfaceOp =
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
1. **Append**:在尾部追加一个新节点。`user/message``assistant/message``tool/result``context/message``steering/message` 使用此操作。agent loop 在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。
1. **Append**:在尾部追加一个新节点。`user/message``assistant/message``tool/result``context/message``steering/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。
2. **Replace**:移除从 `start``end`(两端含)的节点,并在其位置插入一个新节点。`start``end` 都必须是当前 surface 上有效的 surface 节点 seq`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
2. **Replace**:移除从 `start``end`(两端含)的节点,并在其位置插入一个新节点。`start``end` 都必须是当前 surface 上有效的 surface 节点 seq`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
### SurfaceManager:基于增量,而非全量重建
`SurfaceManager` 类(`Session` 私有实现)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前事件不会改变;种子日志只是在首次访问时折叠的初始增量。
`SurfaceManager` 类(`Session` 私有)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前事件不会改变;种子日志只是在首次访问时折叠的初始增量。
无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。
`deriveMessages()` 在存在 surface 标记时使用 surface否则回退到既有的线性扫描(向后兼容)。
`deriveMessages()` 在存在 surface 标记时使用 surface对没有标记的会话回退到既有的线性扫描(向后兼容)。
### 持久化
新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何改:`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs``surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。
新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何改`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs``surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。
### 崩溃恢复
`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result` 闭事件。这些闭事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建的 surface 有效。
`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result`事件。这些闭事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建的 surface 有效。
### 不变式
开发模式不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。
开发模式下的不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。
每个 surface 可达事件都必须携带 `surfaceOp`,否则它从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此要求`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。无效种子在预发布格式策略被拒绝而非升级。
每个 surface 可达事件都必须携带 `surfaceOp`,否则它从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。
## 曾考虑的替代方案
- **逐插件的 `agent/request` 包装**(surface 之前的历史操模式):监听器排序脆弱,不留持久化的变更记录,且每次新增操作都要修改核心 `deriveMessages()`
- **逐插件的 `agent/request` 包装**(surface 之前的历史操模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改
- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。surface 是双向链表,端点自然以节点 seq 命名,单节点替换(`start === end`)在闭区间语义下读起来更自然。
- **脏标记触发全量重建**而非增量处理:在会话生命周期内为 O(N²)——每次单事件追加都要重新扫描所有先前事件。
- **脏标记全量重建**替代增量处理:在会话生命周期内为 O(N²)每次单事件追加都要重新扫描所有先前事件。
## 后果
- **`packages/core/session`**:新增 `surface.ts``SurfaceManager`)、新类型(`SurfaceOp``SurfaceIntent`)、`SessionEvent` 上的新字段、修改 `append()`(第三个必参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一推导路径)、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。
- **`packages/core/agent-loop`**:所有 surface 可达的追加传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。
- **`packages/core/session`**:新增 `surface.ts``SurfaceManager`)、新类型(`SurfaceOp``SurfaceIntent`)、`SessionEvent` 新字段、修改 `append()`(第三个必参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一派生路径)、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。
- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。
- **`packages/session-persistence/session-persistence-sqlite`**`events` 表新增两个可空 TEXT 列(`source_event_seqs``surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。
- **`packages/support/invariants`**surface 相关验证规则。
- **`packages/session-persistence/session-persistence-jsonl`**:无需改。
- **`packages/support/invariants`**surface 相关验证规则。
- **`packages/session-persistence/session-persistence-jsonl`**:无需改
- **`packages/session-persistence/session-persistence`**:抽象接口不变。
Surface 是未来历史操的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点取代该范围在 surface 上的位置,而插件自身的跟踪事件(如 `compaction/start``compaction/end`不进入 surface。回放确定性地保留这一决策。
Surface 是未来历史操的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start``compaction/end`)不进入 surface。回放确定性方式保留该决策。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-shared-persistence-write-coordinator.md: 3fc30dc2e1382fd983d050433123f46a2cd0ed19
2026-06-18-shared-persistence-write-coordinator.zh.md: 6c8ccef8dd5603d837dd0a9884adf9c1cd8a17d4
2026-06-18-shared-persistence-write-coordinator.zh.md: 38e900d38cc48317836717ddeda5323cf97df993
@@ -1,44 +1,44 @@
# RFC:共享持久化写入协调器
Status: implemented
[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
Status: implemented
## 问题
`dsh-session-persistence-jsonl``dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但二者的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、串行化 flush 链、HMR(热模块替换)种子注入,以及 dispose(资源释放)排空。纯粹的种子前缀冲突与可串行化守卫已迁入 seam 包;剩余的编排仍然正确性密集的,并且相同的修复被应用了两次。代码级 diff 表明两个后端在**所有**这些逻辑上是逐字节一致或同算法的:四个 map`states`/`buffers`/`chains`/`inits`)、`installWritePath``initFor``onCreated` 的四种分支、`flush``drain``serialize``adopt``adoptLivePrefix``assertVersion`,以及 `create`/`append`/`load` 骨架。唯一不同的只有存储原语(写字节 vs. INSERT 行)。
`dsh-session-persistence-jsonl``dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、序列化的 flush 链、HMR(热模块替换)种子注入 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然正确性要求很高,且同样的修复被应用了两次。代码级 diff 表明两个后端在**全部**这些逻辑上要么字节相同、要么算法相同:四个 map`states`/`buffers`/`chains`/`inits`)、`installWritePath``initFor``onCreated` 的四种分支、`flush``drain``serialize``adopt``adoptLivePrefix``assertVersion`,以及 `create`/`append`/`load` 骨架。唯一的差异在于存储原语(写字节 vs. INSERT 行)。
## 决策
将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。
将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险——「协调器不得迫使非常规后端与继承层级搏斗」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险——「协调器不得非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
### 钩子接口(`PersistenceBackend<TornMarker>`
六个方法(五个必需 + 一个可选生命周期钩子)——协调器与存储之间唯一的 seam:
六个方法(五个必需 + 一个可选生命周期钩子)——协调器与存储之间唯一的 seam:
- `name`后端标签,用于 dispose 失败时的 `AggregateError`
- `loadStored(id)`按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucketSQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 实现创建冲突探测。
- `loadLive(id, cwd)`读取**限定于 `cwd`** 的已存储前缀。**刻意区别于 `loadStored`**HMR live-adoption 只能接管与活跃会话**相同 cwd** 的持久化日志;同 id 但不同 cwd 的日志是冲突而非恢复。合并这两个方法会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`
- `appendBatch(meta, events, isMaterialized)`持久追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与第一个事件批次必须一起提交——崩溃发生在二者之间时不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`使崩溃修复持久化:截断撕裂尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。
- `list()`列出所有已存储的元数据。
- `close?()`可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,确保 close 失败不会掩盖排空错误。
- `name`——后端标签,用于 dispose 失败时的 `AggregateError`
- `loadStored(id)`——按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucketSQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 进行创建碰撞探测。
- `loadLive(id, cwd)`——读取**限定于 `cwd`** 的已存储前缀。** `loadStored` 有意区分**HMR live-adoption 只能接管与存活会话处于**同一 cwd** 的持久化日志;同 id 但不同 cwd 的日志是碰撞而非恢复。合并二者会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`
- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。
- `list()`——列出所有已存储的元数据。
- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,因此 close 失败不会掩盖排空错误。
### 不透明的撕裂标记
### 不透明的 torn marker
保持 seam 干净的唯一设计选择:崩溃修复中的「撕裂尾部在哪里」token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session``interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的标记类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者碰巧都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较**折叠在钩子内部**,因此返回的标记已经是 `number | undefined`;如果不做这折叠,协调器就必须了解字节长度。
保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session``interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者恰好都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠**在钩子内部**,因此返回的 marker 已经是 `number | undefined`;如果不做这折叠,协调器就必须了解字节长度。
## 测试
共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract``tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、冲突、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLiteschema 版本、`scanRows`、事务回滚)。每个真实后端有一个 through-coordinator 的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture 钩子),确保协调器的撕裂标记修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生撕裂标记,因此无法触达该分支。
共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract``tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、碰撞、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试规格缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLiteschema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture(测试前置数据)钩子),确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker,因此无法触达该分支。
## 曾考虑的替代方案
- **后端继承的基类**否决,改用组合后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
- **更宽的钩子面**每个候选钩子都被折叠掉:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与第一个事件批次原子提交);没有单独的创建冲突探测(它就是 `loadStored(id) !== undefined`);`list()` 也不经协调器透传(列举不需要任何编排)。
- **后端继承的基类**——否决,改用组合后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍完全不使用协调器、直接实现抽象服务。
- **更宽的钩子面**——每个候选钩子都被折叠掉:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与首批事件原子提交);没有单独的创建碰撞探测( `loadStored(id) !== undefined`);`list()` 也不经协调器透传(列举不需要任何编排)。
## 后果
协调器增加了一层间接和一个不透明的撕裂标记,但将此前每个后端重复的正确性密集编排集中到一处。其钩子面保持窄小:冲突检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,无需复制事件-缓冲区-flush 生命周期。
协调器增加了一层间接和一个不透明的 torn marker,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,无需复制事件-缓冲区-flush 生命周期。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-branded-ids.md: f6d066857d8904ae5343f12310266663806a0ae2
2026-06-20-branded-ids.zh.md: 14f82c395cdf43e2f5df5b2317dda3d45c595a64
2026-06-20-branded-ids.zh.md: 80c158e598f3007416d31a89a6704a759798e44e
@@ -1,30 +1,30 @@
# RFC:在所有应当使用品牌类型的位置推行 Branded ID
Status: implemented
# RFC:在所有应有之处使用 branded ID
[English](2026-06-20-branded-ids.md) | 中文
Status: implemented
## 问题
harness 已经为三个标识符打上了品牌类型`CallId``packages/llm/llm/src/brand.ts`)、`SessionId``packages/core/session/src/types.ts`)和 `AgentId``packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零成本的 cast 工厂函数`dsh-brand` 还声明了治理策略:*"品牌类型用于跨包边界且可能被混淆的 id并非每个 string 都需要品牌类型。"* 这条策略是正确的;问题在于它只落实了一半。两缺口使得结构相同但语义不同」的 string 今天仍能通过类型检查。
harness 已经为三个标识符做了 brand 处理`CallId``packages/llm/llm/src/brand.ts`)、`SessionId``packages/core/session/src/types.ts`)和 `AgentId``packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包package `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*"Branding 用于跨包边界且可能被混淆的 id不是每个 string 都需要 brand。"* 这条策略是正确的;问题在于它只落实了一半。两缺口使得结构相同但语义错误的 string 今天仍能通过类型检查
**缺口 1bash seam 中未打品牌的 ID。** `BashTask.id` 以及所有 executor/tool 边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型通过 `task_id` 返回该值,因此混淆 task id 和 session id 既类型正确的,也是可达
**缺口 1bash seam 中未 brand 的 ID。** `BashTask.id` 以及所有执行器/工具边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型通过 `task_id` 返回该值,因此混淆 task id 和 session id 既类型正确可达。
bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string``BashExecSpec.owner: string | undefined``packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是拥有者 agent `session.header.id``callerToken = (exec) => exec.agent?.session.header.id` `packages/bash/tool-bash/src/index.ts`——即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的bash owner-token 别名漏洞」
bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string``BashExecSpec.owner: string | undefined``packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)`session.header.id``callerToken = (exec) => exec.agent?.session.header.id`位于 `packages/bash/tool-bash/src/index.ts`即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的"bash owner-token alias hole"
**缺口 2:既有品牌类型的侵蚀。** `CallId``SessionId``AgentId` 在注册表 map、公开查找参数、ACP 会话踪和持久化协调器中退化为裸 string。在查找边界丢弃品牌类型,等于废掉了它的核心保护
**缺口 2:既有 brand 的侵蚀。** `CallId``SessionId``AgentId` 在注册表 map、公开查找参数、ACP 会话踪和持久化协调器中退化为裸 string。在查找边界丢弃 brand 会使其主要保护失效
## 决策
纯类型变更。品牌类型是零成本 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵既有的「并非每个 string 都需要策略。
纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵既有的"不是每个 string 都需要"策略。
- **为 bash task id 打品牌。** 在 `packages/bash/bash/src/types.ts`*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂函数,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。品牌原语放在无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 依赖它就能为自己的 id 打品牌——永远不需要为了获取 `Branded`引入 `dsh-llm`(或 `dsh-session`。将品牌贯穿 `BashTask.id``BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时一次性为计数器输出打品牌),以及 `dsh-tool-bash` 的校验/访问控制面(`validateTaskId` 返回 `BashTaskId``task_id` 在模型 string 到达的 tool 边界处打品牌)。
- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(拥有该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`来获取 `Branded`。将其贯穿 `BashTask.id``BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId``task_id` 在模型 string 到达的工具边界处被 brand)。
- **铸造独立的 `OwnerToken` 品牌。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined``dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`cast 为 `OwnerToken`——这是两套词汇交汇的唯一位置。bash seam 永远不导入 `dsh-session`。(理由见下一节。)
- **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined``dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 不导入 `dsh-session`。(理由见下一节。)
- **阻止品牌侵蚀。** 将既有品牌传播到缺口 2 列出的 `Map` 键类型和公开方法参数:`Map<SessionId, Session>``get(id: SessionId)``Map<AgentId, Agent>``Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械最大的部分,也是让*既有*品牌在查找处真正发挥作用(而非仅在结构体字段上标注)的关键。
- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数`Map<SessionId, Session>``get(id: SessionId)``Map<AgentId, Agent>``Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。
示意形状(工厂模式与现有三个品牌完全一致):
示意形状(工厂模式与已有的三个 brand 完全一致):
```ts ignore-check
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -46,24 +46,24 @@ export function OwnerToken(id: string): OwnerToken {
### 为什么不把 `owner` 类型标注为 `SessionId`
executor 将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。
执行器将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。
## 不在范围内 / 可能的扩展
遵循「并非每个 string 都需要品牌类型」策略,刻意保持窄范围。以下每项都是合理的未来品牌候选,附推迟理由而非承诺:
遵循"不是每个 string 都需要 brand"的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附推迟理由而非承诺:
- **`ModelId`**`GenerateOptions.model``LlmService` 适配器注册表键)——一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个品牌,仅为控制本 RFC 的影响范围而暂不纳入。
- **`ToolName`**`ToolRegistry` 键)——由作者定义、人类可读,且很少与其他 id 混淆;候选强度最弱,可能不值得打品牌
- **`ErrorCode`**`HarnessError.code`——封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要加强类型,用 string 字面量联合类型比品牌更合适。
- **数值序号**——轮次号、步骤号和事件 `seq` 是 `number` 而非 `string``Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体为它们打品牌,但它们是位置序号、很少跨边界传递,收益低。
- **带校验的构造**——品牌工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界对畸形输入抛异常的 `SessionId.parse()` / `isValid()` 伴生函数确实是缺口,但它是一项*运行时行为*变更,有自己的设计问题(什么算「畸形」?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型改动
- **`ModelId`**`GenerateOptions.model``LlmService` 适配器注册表键)一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个 brand,仅为控制本 RFC 的影响范围而暂不纳入。
- **`ToolName`**`ToolRegistry` 键)由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand
- **`ErrorCode`**`HarnessError.code`:一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。
- **数值序号**轮次号、步骤号和事件 `seq` 是 `number` 而非 `string``Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益低。
- **带校验的构造**brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算"格式错误"?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型变更
## 验证
`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿 executor、本地实现和面向模型的 tool,且未引入 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用应的品牌类型而非裸 `string`;来自提供方、ACP 和模型的原始输入通过品牌工厂进入,而非散落的 cast。
`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿执行器、本地实现和面向模型的工具,且未添加 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用应的 brand 而非裸 `string`;来自提供方、ACP 和模型的原始输入通过 brand 工厂进入,而非散落的 cast。
## 后果
- **两个面的机械性改动。** 传播品牌类型涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观测地是纯类型——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);即使该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
- **品牌类型不做校验。** 品牌类型是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是格式良好的 string,就和以前一样能通过类型检查。本 RFC 不关闭这个缺口(见不在范围内)——它只阻止传入错误*类别*的 id 这一类错误。
- **在哪里停下仍是判断题。** 为 `BashTaskId` 打品牌而不为 `ToolName`,为 `OwnerToken` 打品牌而不为 `ModelId`,是对哪些 string可能被混淆的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。
- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);如果该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查。本 RFC 不关闭这个缺口(见"不在范围内")——它只阻止传入错误*类别*的 id 这错误。
- **"在哪里停下"仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName`,为 `OwnerToken` 加但不为 `ModelId`,是对哪些 string"可能被混淆"的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-extract-example-app-packages.md: 3a0a0f5d4b329afed72bd3c00bf880989e24fe54
2026-06-20-extract-example-app-packages.zh.md: 9de9f79369ebad387778a0418b75dfde96b285a7
2026-06-20-extract-example-app-packages.zh.md: 8945f0c97727479c9e847711a96fc5d18118e09e
@@ -1,57 +1,57 @@
# RFC:将示例应用提取为 package
Status: implemented
# RFC:将示例应用提取为独立包
[English](2026-06-20-extract-example-app-packages.md) | 中文
Status: implemented
## 问题
示例目录本应是*薄*的:只包含演示的可变接线,而非演示的机制本身。在次变更之前它是的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示还需要`logger` + `hmr`)、三个共享 YAML 片段的嵌套引`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),以及每个示例各自`agent-loop`/持久化/系统提示词配置。真正的应用——每个 agent 都需要的服务主干——散在叶子配置和那些 include 中。
示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在次变更之前它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需`logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身`agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散在叶子配置和那些 include 中。
叶子配置还拥有一个耦合的前门。ACP 要求 stdout 纯净,通过 `session/new` 创建 agentstdio 需要控制台 logger 和一个预创建的 `main`。防止错误组合的唯一手段是行文中的警告,而三个 `start.ts` 文件重复 Loader 引导和生命周期代码。
叶子配置还拥有一个耦合的前门。ACPAgent Client Protocol要求 stdout 纯净,通过 `session/new` 创建 agentstdio 需要一个控制台 logger 和一个预创建的 `main`。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复 Loader 引导和生命周期代码。
## 决策
每个示例现在**基本上是对一个 app package 的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**app 包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM 适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。
每个示例现在**主要是对一个应用包(package的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**应用包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM(大语言模型)适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。
- **`@deepseek-ai/dsh-agent-spine-demo`**[packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合无提供方、无执行器、 UI 的主干,并转发 loop 的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为这个包组合的是主干而非扩展;替换 loop 意味着提供另一个 bundle。
- **`@deepseek-ai/dsh-stdio-demo`**[packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**[packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以加插件,但安全的组合现在是默认产物。
- **`start.ts` 已移除。** 每个 app 包暴露一个 `bin``dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(如 `dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和 fail-loud 守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享 app bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个的自执行组合,基于这些辅助函数加上自身特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍排除在覆盖率之外(自执行 CLI 入口,与旧的 `start.ts` 类似),由 keyless Loader 路径测试驱动。
- **每个叶子 `cordis.yml` 精简**后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个 app 条目承载 app 的配置(模型、系统提示词、持久化根目录——作为 app 包自身的 `Config` 暴露,由 app 将每个值路由到接线的目标位置:stdio 路由到预创建的 agentacp 路由到 bridge 插件)。
- **echo-agent 折叠到 `dsh-stdio-demo`**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。
- **`base.yml``base-core.yml``acp-agent/acp-tail.yml` 退役**——它们共享的主干现在位于 `dsh-agent-spine-demo`
- **`@deepseek-ai/dsh-agent-spine-demo`**[packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。
- **`@deepseek-ai/dsh-stdio-demo`**[packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**[packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以加插件,但安全的组合现在是默认产物。
- **`start.ts` 已移除。** 每个应用包暴露一个 `bin``dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(`dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个精简的自执行组合,基于这些辅助函数加上其应用特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍排除在覆盖率之外(自执行 CLI(命令行界面)入口,与旧的 `start.ts` 性质相同),由 keyless Loader 路径测试驱动。
- **每个叶子 `cordis.yml` 精简**后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个承载应用配置的 app 条目(模型、系统提示词、持久化根目录——以应用包自身的 `Config` 形式暴露,由它将各值路由到应用接线的目标位置:stdio 路由到预创建的 agentacp 路由到 bridge 插件)。
- **echo-agent 折叠到 `dsh-stdio-demo`**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。
- **`base.yml``base-core.yml``acp-agent/acp-tail.yml` 退役**——它们共享的主干现在位于 `dsh-agent-spine-demo`
`bash-local` 和 LLM 适配器保持为**叶子选择**bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器可以在不触碰 app 的情况下替换进来
`bash-local` 和 LLM 适配器仍然是**叶子选择**bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器无需触碰应用即可替换
### 实现修正:`hmr` 保留为叶子条目
提案将 `hmr` 列入 stdio app 内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包在两方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付:
提案最初`hmr` 列入 stdio 应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo`中会在两方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付:
1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃 `loader` 服务的情况下会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,无法在进程内的单元/覆盖率测试层运行。
1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃 `loader` 服务会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。
2. 进程内测试层(vitest)甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。
关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患——在 ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧——因此将它留在叶子不会损失耦合论证所关注的安全性。**logger**(真正的耦合)保持内置:stdio app 包含它,ACP app 省略它。
关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧因此将它留在叶子不会损失耦合论证所关注的安全性。**logger**(真正的耦合)保持内置:stdio 应用包含它,ACP 应用省略它。
## 曾考虑的替代方案
### 为什么不继续用共享 YAML include 来接线?
### 为什么不继续用共享 YAML include 来管理接线?
旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水只能在三个 `start.ts` 文件中复。包将「ACP app 绝不向 stdout 输出日志」从文警告变成产物的属性:叶子中没有可以写错的 logger 条目。
旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水一直在三个 `start.ts` 文件中复。包将「ACP 应用绝不向 stdout 输出日志」从文警告变成产物的属性:叶子中不存在可以写错的 logger 条目。
## 验证
- 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。
- `demo:echo``demo:repl``demo:acp` 调用 app 包的 bin。
- 每个新包有 README 和逐文件 100% 覆盖率;每个 app 包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状失败
- ACP 回放 transcript 保持不变,因为插件集和加载顺序未改变。
- `demo:echo``demo:repl``demo:acp` 调用应用包的 bin。
- 每个新包有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障
- ACP 回放 transcript(文本记录)保持不变,因为插件集和加载顺序未改变。
## 后果
- **裸插件树教学。** echo-agent 内联 `cordis.yml` 曾一次展示所有插件;主干现在藏在 bundle 后面,因此查看完整树意味着打开 `dsh-agent-spine-demo`app 包的 README 承担了这部分教学职责。
- **多了一层间接。** 「这个演示加载了什么?」变成了读一个 package,而非扫一份 YAML
- **裸插件树教学。** echo-agent 内联 `cordis.yml` 曾一次展示所有插件;主干现在藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`应用包的 README 承担了这教学职责。
- **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了读一个
## 相关
- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo``base*.yml` 文件被删除,将 `base.yml` 重命名为无提供方核心便不再有意义。
- 建立在[能力 seam](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分之上——后端和展示层保持为叶子选择;主干是共享 bundle。
- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle`ui` app 特有的前门)。
- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo``base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。
- 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。
- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle`ui`应用特有的前门)。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-package-hierarchy.md: faf5815222b20699a32f1af489e625ba3e891230
2026-06-20-package-hierarchy.zh.md: 4b71cd41826e727eea19d305635c6485e18393c2
2026-06-20-package-hierarchy.zh.md: 118367f2655bffbd270f259df4ade37a70dcb2d7
@@ -1,4 +1,4 @@
# RFC:将包package重组为模块化层级结构
# RFC:将包重组为模块化层级结构
[English](2026-06-20-package-hierarchy.md) | 中文
@@ -6,13 +6,13 @@ Status: implemented
## 问题
`packages/` 原先是扁平的:18 个包全部位于 `packages/<name>/`一个包的位置无法体现它是核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。package README 带着 `FIXME(package-hierarchy)``scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来都同等基础。
`packages/` 原先是扁平的:18 个包package全部位于 `packages/<name>/`从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)``scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。
这不仅是外观问题。因为每个顶层包看起来都属于同一个公开接口,未来移除更难;发布/lint/文档脚本不得不通过注释或手工维护的静态列表来编码意图,而从布局直接读取。
这不仅是外观问题。由于每个顶层包看起来都属于同一个公开接口,未来移除更加困难,而 publish/lint/doc 脚本不得不通过注释或手工维护的静态列表来编码意图,而不是从布局直接读取。
## 决策
按模块角色分组,统一 `packages/<group>/<pkg>/` 两层深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略,不是包重命名。
按模块角色将包分组,统一放在 `packages/<group>/<pkg>/` 深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略的调整,不是包重命名。
```text
packages/
@@ -44,32 +44,32 @@ packages/
### 放置决策
- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/``llm/llm``bash/bash``session-persistence/session-persistence`),实现和消费方作为扁平兄弟。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2,workspace glob 保持简洁的 `packages/*/*`,一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(目录名唯一,使 first-on-disk-wins 无歧义)。
- **`session` 留在 `core/`;持久化自成一族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族(`session-persistence/`),与 `llm/``bash/` 对称,而非嵌套在 `core/session/` 下。
- **`agent-loop``core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环随产品发布,因此与核心主干同。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`因此循环仍可替换。
- **`invariants``ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试回放适配器)一起放在 `support/` 中。`acp``ui/` 的唯一成员,因为它是真正的产品接口(编辑器驱动的 ACP 桥接),在结构上不同于 readline 演示辅助工具。
- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/``llm/llm``bash/bash``session-persistence/session-persistence`),实现和消费方作为扁平兄弟并列。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2这使 workspace glob 保持简洁的 `packages/*/*`并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(唯一的目录名使 first-on-disk-wins 无歧义)。
- **`session` 留在 `core/`;持久化独立成族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族(`session-persistence/`),与 `llm/``bash/` 对称,而非嵌套在 `core/session/` 下。
- **`agent-loop``core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环交付,因此与核心主干同。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`所以循环仍可替换。
- **`invariants``ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取出来以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试回放适配器)一起放在 `support/` 中。`acp``ui/` 的唯一成员,因为它是真正的产品接口(编辑器驱动的 ACP 桥接), readline 演示辅助工具在结构上截然不同
### 去重包清单
### 去重包列表
清单此前在五重复枚举。统一的深度 2 布局使大部分可以被推导出来
列表此前在五个地方重复枚举。统一的深度 2 布局使大部分可以被推导:
- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选路径)映射所有包,取代逐包条目。根 `tsconfig.json` 复用该源映射,并携带显式 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选包含 `/*/`,朴素的正则注释剥离器会误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手剥离注释。)
- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`
- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest 生成这些引用留作后续工作(见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。
- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选)映射所有包,取代逐包条目。根 `tsconfig.json` 复用该源映射,并携带显式 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手剥离注释。)
- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`
- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest(元数据清单)生成这些引用留作后续工作(见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。
### 新增的护栏
两道 doc-sync/hygiene 门禁保结构及其引用正确,使本次重组所需的人工检查不必再次手动重复:
两道 doc-sync/hygiene 门禁保结构及其引用保持正确,使本次重组所需的手动检查无需日后重复:
- `scripts/verify-package-paths.ts` 标记 Markdown 或 `.ts` 注释/字符串中的 `packages/<path>` 引用如果该路径无法解析**且**某段命名了一个真实存在的包,则视为指向已移动包的陈旧路径。如果路径命名的包在任何地方都不存在(前瞻性提案),则不报错;因此该门禁 proposed/implemented/rejected 统一适用。
- `scripts/check-workspace-constraints.ts` 断言 `packages/<group>/<pkg>` 形状:分组目录不 `package.json`,没有包扁平地位于根层或嵌套更深。分组名称保持开放——新分组无需修改门禁;只有深度 2 的形状是固定的。
- `scripts/verify-package-paths.ts` 标记 Markdown 或 `.ts` 注释/字符串中的 `packages/<path>` 引用如果该引用无法解析**且**某个路径段命名了一个真实存在的包,指向已移动包的陈旧路径。如果路径命名的包在任何地方都不存在(前瞻性提案),则不予标记,因此该门禁 proposed/implemented/rejected 统一适用。
- `scripts/check-workspace-constraints.ts` 断言 `packages/<group>/<pkg>` 形状:分组目录不 `package.json`没有包扁平地位于根层或嵌套更深。分组名称保持开放——添加新分组无需修改门禁;只有深度 2 的形状是固定的。
## 曾考虑的替代方案
- **第三层(每个族下设 `adapters/`/`impls/`)**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`,一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。
- **第三层(每个族下设 `adapters/`/`impls/`)**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。
- **将持久化嵌套在 `core/session/` 下**:否决。存储后端构成一个平行的能力族,与 `llm/``bash/` 对称,而会话日志本身属于核心产品 API。
- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口`acp``ui/` 的唯一成员,因为编辑器确实在驱动它。
- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口;`acp``ui/` 的唯一成员,因为编辑器实际驱动它。
## 后果
本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种动在发布前是可接受的(遵循 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约;而且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需再做额外的结构编辑。
本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种动在发布前是可接受的(依据 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需额外的结构编辑。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-21-mandatory-app-attribution-headers.md: 4fa773e089b3a4b682e42269a66d85aeaf5c18f6
2026-06-21-mandatory-app-attribution-headers.zh.md: 3660b8e7de977c01a19f9ed9ac9e73409f69706a
2026-06-21-mandatory-app-attribution-headers.zh.md: 42cc396b5719adb2a2d71e0e9cf0d3554c5533a5
@@ -1,84 +1,84 @@
# RFC:对提供方请求强制携带 `User-Agent` 归属标识
Status: implemented
[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文
Status: implemented
## 问题
LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只部分做到了这一点:手写的 DeepSeek 适配器发送一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以静默地遗漏归属标识,而库封装的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。
LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。
直接触发来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 通过 `HTTP-Referer`展示名/分类头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的确头部集当作通用标准采纳,然后将提供方特的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。
直接触发因素来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer`上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的确头部集当作通用标准采纳,然后将提供方特的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。
## 调研
- **OpenRouter 的机制是提供方特的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title``X-OpenRouter-Categories` 追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。
- **在 agent 工具领域`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACPAgent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。
- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。
- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份标识,说明它用于互操作性报告和分析,并说用户代理应当SHOULD在每个请求中发送它除非被配置为不发送。这是唯一直接匹配「哪个产品在发出这个 HTTP 请求」的标准头部。
- **OpenRouter 的机制是提供方特的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title``X-OpenRouter-Categories` 追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。
- **在 agent 工具生态中`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACPAgent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。
- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特有的附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。
- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份,说明它用于互操作性报告和分析,并说用户代理*应当*在每个请求中发送它除非被配置为不发送。这是唯一直接对应「哪个产品在发出 HTTP 请求」的标准头部。
- **`Referer` 是标准的,但 OpenRouter 的 `HTTP-Referer` 不是标准字段。** RFC 9110 第 10.1.3 节将 `Referer` 定义为获取目标 URI 的来源 URI,并用大量篇幅讨论隐私限制。OpenRouter 则要求 `HTTP-Referer`,将其用作应用 URL 标识符。该名称和含义是 OpenRouter 特有的,尽管它形似标准 `Referer` 头部的 CGI 环境变量形式。
- **`From` 是标准的,但不适合作为强制默认。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理应当SHOULD发送它以便服务器联系运营者,但非机器人代理不应在没有用户显式配置的情况下发送它,因为存在隐私和安全策略顾虑。harness 可以后续支持运营者联系方式,但不得凭空造或全局强制要求。
- **请求体中的 `user``metadata` 字段不是应用归属。** 某些模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特的 body schema,要么不保证能通过 OpenAI 兼容网关转发。它们不能替代静态的应用身份头部。
- **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试客户端,但除非应用显式提供产品归属层,否则它们不会将 harness 标识为应用。
- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai``StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此库封装的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式wire format契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。
- **`From` 是标准的,但不适合作为强制默认。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理*应当*发送它以便服务器联系运营者,但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空造或全局强制要求。
- **请求体中的 `user``metadata` 字段不是应用归属。** 部分模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特的 body schema,要么不保证能通过 OpenAI 兼容网关透传。它们不能替代静态的应用身份头部。
- **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试客户端,但除非应用显式提供产品归属层,否则它们不能标识 harness 为应用。
- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai``StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。
## 决策
在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于库封装的适配器,库的头部钩子入同一个 mock 服务器断言)。
在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子入同一个 mock 服务器断言)。
本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories` 是 OpenRouter 特的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,带自己的隐私/产品决策、测试和文档。在之前,即使请求指向 OpenRouter,也只发送本 RFC 的共享 `User-Agent` 归属。
本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories` 是 OpenRouter 特的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,带自己的隐私/产品决策、测试和文档。在之前,即使请求指向 OpenRouter,也只发送本 RFC 定义的共享 `User-Agent` 归属。
提供方无关的身份由 `dsh-llm``packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值:
提供方无关的身份由 `dsh-llm``packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值:
- `User-Agent` 的产品令牌`deepseek-harness`(与 RFC 之前的线路值及仓库/组织身份保持连续性)
- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量
- 应用 URL`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 阻塞发布,直到该仓库实际存在
- `User-Agent` 的产品 token`deepseek-harness`(与 RFC 之前的线路值及仓库/组织身份保持连续性)
- 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量
- 应用 URL`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布
默认值是强制的且非空。白标部署向 `attributionHeaders(identity)` 传入自己的 `AppIdentity`——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
默认值是强制的且非空。白标部署通过`attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
线路映射(`attributionHeaders`;代码中头部名称小写——HTTP 字段名在线路上不区分大小写):
线路映射(`attributionHeaders`;代码中头部名称小写——HTTP 字段名在线路上不区分大小写):
| 目标 | 映射 |
|---|---|
| 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 |
| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档记录了等效契约,否则不发送 OpenRouter 专用头部。 |
| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档了等效契约,否则不发送 OpenRouter 特有头部。 |
| OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`。 |
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特 RFC 接受额外头部。不以类推方式复用 `HTTP-Referer`。 |
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 RFC 接受额外头部。不要类比复用 `HTTP-Referer`。 |
端点检测不属于本 RFC,因为此处不接受任何端点特映射。如果后续落地 OpenRouter 支持,检测必须是显式的:要么是专的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名。
端点检测不本 RFC 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名
## 验证
已落地的契约:
- `dsh-llm``LlmAdapter` 作者记录了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。
- `dsh-llm``LlmAdapter` 作者文档化了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。
- 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。
- `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。
- `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。
- 本 RFC 下没有适配器发送 OpenRouter 特的归属头部(`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`)。
- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户稳定标识符。
- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现行为。
- 本 RFC 下没有适配器发送 OpenRouter 特的归属头部(`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`)。
- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户稳定标识符。
- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现行为。
## 曾考虑的替代方案
**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer``X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特的产品功能,不是本 RFC 试图标准化的提供方无关模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在第一个共享归属辅助函数中。
**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer``X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特的产品功能,不是本 RFC 试图标准化的提供方无关模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在个共享归属辅助函数中。
**所有地方都发 OpenRouter 头部。** 否决。这会把一份自定义 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的字段。还有风险 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。
**所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。
**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/分类,不帮助 OpenRouter 等网关构建应用排名。
**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。
**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态产品身份,且可安全地在每个请求上发送。
**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态产品身份,且可安全地在每个请求上发送。
**仅配置 opt-in 的归属。** 否决。默认关闭的设置正是适配器持续漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。
**仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。
**以产品命名的令牌`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` 令牌,因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 连续性胜出:它是提供方已经从本代码库看到的身份,与组织/仓库身份和包作用域一致,且在展示文案承载产品名的同时保持线路归属稳定。
**以产品命名的 token`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` token,因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 连续性胜出:它是提供方从本代码库已经看到的身份,与组织/仓库身份和包 scope 一致,且在展示文案承载产品名的同时保持线路归属稳定。
## 后果
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混通用 SDK 流量的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混通用 SDK 流量的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在创建之前该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,使其不会在未解决的情况下发版(见 `docs/development.md` 标记语义)。
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在创建之前该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;pi-ai 封装的适配器依赖 pi-ai 继续遵守 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件变红。这对抽象层是有益的压力:一个无法设置强制头部的提供方适配器无法完整实现 harness 的 LLM 契约。
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。
**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 才能实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。
**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-26-file-context-as-event-gate.md: 6e78e2df5f7969b5ed9b74c0b597e2fcacbe8e82
2026-06-26-file-context-as-event-gate.zh.md: b0806fd3e1d61a9bdaf20a728dbfcfc945013b78
2026-06-26-file-context-as-event-gate.zh.md: d69ccdbcea4b14dbd0291cf69af0bf7d5f3fadfc
@@ -1,4 +1,4 @@
# RFC:将 `dsh-fs-policy` 改为事件门插件,而非方法接口
# RFC:将 `dsh-fs-policy` 改为事件门插件,而非方法接口
Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题
[split-fs-seam RFC](../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`
[split-fs-seam RFC](../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` 而失败
这把三件本应可分离的事情耦合在了一起:
@@ -14,11 +14,11 @@ Status: implemented
2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。
3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。
因为工具调用 `fileContext` 方法,移除策略层是一个破坏性变更,而非优雅地失去一个*附加功能*。策略对工具的运行是承重的,而非可选的收紧。
由于工具调用的是 `fileContext` 方法,移除策略层是一个破坏性变更,而非优雅地失去一个*附加*能力。策略对工具的运行是承重的,而非可选的收紧。
## 决策
反转控制流。**`dsh-tool-fs` 成为执行器,直接调用 `ctx.fs`****`dsh-fs-policy` 成为门 + 记录插件**,通过事件参与,不通过工具调用的方法,也不注册 `ctx.fileContext` 服务。
反转控制流。**`dsh-tool-fs` 成为执行器,直接调用 `ctx.fs`****`dsh-fs-policy` 成为门 + 记录插件**,通过事件参与,不通过工具调用的方法,也不注册 `ctx.fileContext` 服务。
```text
tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs;
@@ -31,22 +31,22 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who
provider dsh-fs-local local implementation of ctx.fs
```
该模型是叠加式的:裸 `ctx.fs` 执行原子、无约束的文本 I/O,而 `dsh-fs-policy` 在其上叠加观测状态、读后才能编辑、以及版本守卫。因此移除策略后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了在服务边界保持策略可选,而非作为正常部署姿态。
该模型是叠加式的:裸 `ctx.fs` 执行原子、无约束的文本 I/O,而 `dsh-fs-policy` 叠加观测状态、读后编辑和版本守卫。因此移除策略后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 以及 `tools`/`systemPrompt`
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` `tools`/`systemPrompt`
## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` stat
## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` stat
`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的变更临界区检测陈旧:
`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧
- "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`
- "你读到的版本还是最新的吗"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一原子锁中。`dsh-fs-policy``vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`
- "你读到的版本是否仍为最新"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一原子锁中完成`dsh-fs-policy``vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`
这是刻意的设计。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,那么该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在两者之间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区既无竞态又额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最读取"的保证由 CAS *实现*`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。
这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区既无竞态又额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最近一次读取"的保证由 CAS *实现*`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。
## 提供方契约变更:版本守卫变为可选
为使裸提供方不受约束,其两个变更操作上的版本守卫变为**可选**——则守卫,则无条件:
为使裸提供方不受约束,其两个 mutation 上的版本守卫变为**可选**——传入则守卫,省略则无条件执行
```ts ignore-check
// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
@@ -62,17 +62,17 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion
// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior)
```
`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个变更操作共享一个对称形状(`expected?`:省略 = 无守卫,提供 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能的"无守卫"情况是新增的,且它是裸提供方的默认行为。无论哪种情况,变更操作仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会出现文件撕裂);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`为"此刻无法编辑该目标"保留一个统一的编辑失败码
`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的"无守卫"情况是新增的,且它是裸提供方的默认行为。无论哪种情况,mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`保持一个统一的编辑失败码表示"此刻无法编辑该目标"
## 事件词汇(归属 `dsh-fs`
## 事件词汇( `dsh-fs` 拥有
事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦契约所要求的`dsh-tool-fs` 是事件发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。
事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦契约所`dsh-tool-fs` 是发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——而非面向模型的概念(行窗口、行号渲染页脚不会泄漏到此层)。
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号渲染后的页脚不会泄漏到此层)。
**两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 返回,因此在默认部署中它占据该槽位;一个注册更早或使用 `prepend` 的监听器会代该策略。权限、审计和沙箱关注点仍在可组合的 `tools/execute` waterfall 上。
**两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会代该策略。权限、审计和沙箱关注点仍在可组合的 `tools/execute` waterfall 上。
actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其监听器将 `object` actor 窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它**不**拥有策略层的运行时 owner 结构。
actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其监听器将 `object` actor 窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它**不**拥有策略层的运行时 owner 结构。
```ts
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
@@ -106,66 +106,66 @@ interface Events {
}
```
`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由 loop 分发且无 `this`),而非服务绑定的 waterfall(如 `llm/stream`)。分发是 `dsh-tool-fs` 插件,它不是一个服务。
`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由循环分发且无 `this`),而非服务绑定的 waterfall(如 `llm/stream`)。分发是 `dsh-tool-fs` 插件,它不是一个服务。
## 工具契约(`dsh-tool-fs`
工具保留其面向模型的 schema`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何"后端"要求如此的措辞应改为说 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。
工具保留其面向模型的 schema`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称"后端"要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。
`dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts``buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为工具拥有了读取操作。这些读取渲染类型和辅助函数入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。
`dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts``buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。
`dsh-tool-fs` 是一个注册全部三个工具(`read`/`write`/`edit`)的单根插件,与 `dsh-tool-bash` 对齐。它注入 `fs`(加 `tools`/`systemPrompt`),从不注入 `fileContext`。(最初的提案还将每个工具作为 `/read`/`/write`/`/edit` 子路径插件暴露,以支持聚焦部署;实现时放弃——没有消费方需要单工具部署,且子路径发布迫使引入定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理,而同级的工具包都不需要这些。每工具的注册辅助函数(`applyReadTool`/`applyWriteTool`/`applyEditTool`保留为根插件组合的内部模块。)
`dsh-tool-fs` 是一个注册全部三个工具(`read`/`write`/`edit`)的单根插件,与 `dsh-tool-bash` 相同。它注入 `fs`(加 `tools`/`systemPrompt`),从不注入 `fileContext`。(最初的提案还将每个工具作为 `/read`/`/write`/`/edit` 子路径插件暴露,聚焦部署使用;实现时放弃——没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理。每工具的注册辅助函数(`applyReadTool`/`applyWriteTool`/`applyEditTool`仍作为根插件组合的内部模块保留。)
`stat` 预算通过让 waterfall 惰性产出期望值来最小化——裸默认返回 `undefined`(无守卫),从不 stat
通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat
- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读取后的确认 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑虚假地 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。**工具内零 stat**无论是否有 `dsh-fs-policy`。
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。**两种情况下工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。
- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读确认 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`**工具内零 stat**
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。
工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,这样 `dsh-fs-policy` 就能推导其观测状态的 owner。工具不知道策略插件是否存在:它总是在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,以便 `dsh-fs-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在:它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做守卫,因此抛异常的监听器会在变更已成功后报告失败。异步或可失败的观测需要另一事件契约。
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器会在 mutation 已成功后报告失败。异步或可失败的观测需要另一事件契约。
## 策略插件契约(`dsh-fs-policy`
`dsh-fs-policy` 是一个插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个用于 HMR(热模块替换)的 disposer(资源释放))。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`结构化的 owner 推导(将事件中不透明的 `object` actor 窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR)。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
- `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。
- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。
- `fs/observed` 监听器:`record(owner, key, version)`。
一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着" owner 在此版本观测过目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:变更操作将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose(资源释放)时丢弃所有状态(HMR 安全)。
一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着" owner 在此版本观测过目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外部世界。这正是 `dsh-tool-fs` 移除方法耦合的关键。
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。
## 裸提供方行为(无 `dsh-fs-policy`
这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 `dsh-fs-policy`。是工具不再耦合于策略方法服务后存在的无约束提供方下限。 `dsh-fs-policy` 缺席时,每个 `fs/*` waterfall 落入其 `undefined` 默认值,`fs/observed` 无监听器:
这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 `dsh-fs-policy`。是工具不再耦合于策略方法服务后存在的无约束提供方下限。 `dsh-fs-policy` 不存在时,每个 `fs/*` waterfall 落入其 `undefined` 默认值,`fs/observed` 无监听器:
- **read** 不变(它从不需要策略;只是 emit 了一个现在无人听的 `fs/observed`)。
- **write** 无条件 create-or-overwrite`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无读取前置要求,无版本检查。
- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 不带版本守卫或读取前置要求即进行匹配重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。
- **read** 行为不变(它从不需要策略;只是 emit 了一个现在无人听的 `fs/observed`)。
- **write** 无条件 create-or-overwrite`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无读要求,无版本检查。
- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 版本守卫、无先读要求地匹配重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。
两个变更操作仍然是原子的(后端的 per-target 锁是无条件的)。简单地*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、读后才能编辑、以及版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身不变
两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、读后编辑和版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身无需任何变更
## 取代
## 取代关系
本 RFC 修正——而非撤销——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变的是**工具与策略层之间的耦合方式**:一个强制方法服务变成了插件拥有的事件门fs I/O + 读取窗口从 `fileContext` 上移到了 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
本 RFC 修正——而非推翻——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变的是**工具与策略层之间的耦合方式**:强制方法服务变插件拥有的事件门fs I/O + 读取窗口从 `fileContext` 上移 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
## 验证
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读取的 edit 均成功;有策略时,未读取的 edit 返回 `FS_NOT_OBSERVED`,未读取的 overwrite 被 `createIfAbsent` 门控。策略做出决策后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上均为 read 一次 `stat`、write 或 edit 零次 `stat`。面向模型的 schema 逐字节不变,因此快照不变。
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`、write 或 edit 零次 `stat`。面向模型的 schema 逐字节不变,因此快照不变。
## 曾考虑的替代方案
- **保留 `ctx.fileContext` 作为路径内方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具不加载策略层就无法运行,使策略对基本操作是承重的,而非可选的收紧。
- **策略侧版本检查**`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的变更临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃没有消费方需要单工具部署,且子路径发布迫使引入定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理,而同级的工具包都不需要这些;每工具的注册辅助函数保留为根插件组合的内部模块。
- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重的,而非可选的收紧。
- **策略侧版本检查**`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留
## 后果
- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过三个事件保持窄小并在每个事件上记录 default-thunk 语义来缓解。
- **策略事件放在存储 seam 中。** `dsh-fs` 获得了两个版本决策事件一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不面向模型的概念,因此 seam 不沾染行窗口/观测策略类型 agent/session owner 结构。
- **单策略占位,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能特性。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。
- **移除读取后的确认 stat** 使后续*有守卫*的编辑在读写竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,从不是正确性漏洞;提供方锁仍阻止基于错误版本的写入。
- **裸提供方不做读后写/编辑检查,也不做版本检查。** 不加载 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何有文件。这正是保持工具独立于策略服务的意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;这不是发布 fs 工具的配置预期姿态。
- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。
- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。
- **单策略占位,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。
- **移除读确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。
- **裸提供方不做读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何有文件。这正是保持工具独立于策略服务的意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期姿态。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 72aae03361cbc088cf64f3548a43ac6253eb21eb
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 5c708cb6bfed28b2164cbd1d0b1c7368bf3e1d07
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: f661199048b7eaa359f792e96ac52baf8cd61fdf
@@ -1,4 +1,4 @@
# RFCbash seam 上 stdin 与额外 env
# RFCbash seam 上支持 stdin 与额外 env
Status: implemented
@@ -6,28 +6,28 @@ Status: implemented
## 问题
钩子子系统运行外部钩子命令的方式与 Claude Code 和 Codex 相同:一个钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT``PLUGIN_ROOT`……)读取上下文。harness 在 `ctx.bash` 能力 seam 背后已经有一个完善的命令行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组 kill、输出截断/溢出处理和凭证擦除。将它复用于钩子执行,意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前没有写入 stdin 或设置额外 env 的能力。本 RFC 添加这两输入。
钩子子系统 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT``PLUGIN_ROOT`……)读取上下文。harness 已经`ctx.bash` 能力 seam 后面有一个完善的命令行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 RFC 添加这两输入。
`stdin``env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境中的凭证由 `dsh-bash-local` 的子进程环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。
`stdin``env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。
## 决策
`BashExecRequest`面向模型/插件请求)和 `BashExecSpec``run`/`start` 实际执行的解析后规格)上**同时**添加 `stdin?: string``env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿:`resolve()` 原样传递,`run()`/`start()`它们传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。
`BashExecRequest`(模型/插件请求)和 `BashExecSpec``run`/`start` 所作用的已解析 spec)上**同时**添加 `stdin?: string``env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿它们`resolve()` 原样传递,`run()`/`start()`传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。
三个刻意的选择:
三个有意为之的选择:
1. **面向模型工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。
1. **模型工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使名称看起来像凭证。这是正确的,因为擦除的职责很窄:阻止 harness 自身 *ambient* `process.env` 中的凭证泄漏到命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非 ambient 密钥),因此擦除不对它的约束。`childEnv(extra?)` 的分层为 `scrub(process.env)``ENV_OVERRIDES`面向模型`TERM=dumb` 等)→ `extra`,后者优先。
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使键名与凭证同形。这是正确的,因为擦除的职责很窄:阻止 harness 的*环境* `process.env` 凭证泄漏到被 spawn 的命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非环境中的秘密),因此擦除不构成对它的约束。`childEnv(extra?)` `scrub(process.env)``ENV_OVERRIDES`对模型友好`TERM=dumb` 等)→ `extra` 的顺序分层,后者优先。
3. **`stdin`/`env` 在解析后规格上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——这是一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。
3. **`stdin`/`env`解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。
`dsh-bash-local` 仅在提供了字节时才创建 stdin 管道;否则 fd 0 保持 `/dev/null`维持原有行为。它写入字节后关闭管道。如果子进程未读取退出导致 `EPIPE`,则忽略该错误,因为命令退出状态和输出决定结果。
`dsh-bash-local` 仅在有字节需要写入时才创建 stdin 管道;否则 fd 0 仍为 `/dev/null`保持先前行为。它写入字节后关闭管道。子进程未读取退出时产生的 `EPIPE` 被忽略,因为命令退出和输出决定结果。
## 曾考虑的替代方案
**可配置的 ambient 密钥擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的 ambient 保护。
**可配置的环境秘密擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的环境保护。
## 后果
钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子专属变量,保留其进程组管理、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一入口。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。
钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-event-domain-semantics.md: e05c238c52052454d3e01e82767cddd9af316a9d
2026-06-30-event-domain-semantics.zh.md: a5453824183aa3f71486b5dbd24ed8c056d9c854
2026-06-30-event-domain-semantics.zh.md: 9048679ec7a7992852cce76bf43269f5499da792
@@ -1,4 +1,4 @@
# RFC:事件域语义——session 是事实日志,agent 是时表面
# RFC:事件域语义——session 是事实日志,agent 是运行时表面
Status: implemented
@@ -6,34 +6,34 @@ Status: implemented
## 问题
harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着分类体系的增长,三个事件域之间的界限变得模糊:
harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着分类体系的增长,三个事件域之间的界限变得模糊:
- `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。
- `agent/*` 承载实时运行时信号,向插件传递 `Agent` 句柄。
- `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。
- `tools/*` 承载工具注册表与执行 seam。
两个问题促使我们明确固定这些语义。第一,若干轮次/步骤边界同时持久的 `SessionEvent``turn/start``turn/end``step/start``step/end`镜像的 `agent/*` emit`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`两种形式存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要一个统一、有文档的订阅表面插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须无需阅读循环代码就能判断应该监听会话事件还是 agent 事件,以及为什么
两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent``turn/start``turn/end``step/start``step/end`**和**镜像的 `agent/*` emit`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要**一个**连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听 session 事件还是 agent 事件,以及原因
这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code Codex 桥接的基础。
这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code Codex 桥接的基础。
## 决策
**三个域,各司其职,一条边界规则。**
**三个域,各司其职,一条边界规则统一**
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录):想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。
- **`agent/*`——实时运行时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request``agent/step-result``agent/turn-continuation`)可修改或否决,以及瞬态 emit`agent/status``agent/error``agent/created`/`agent/disposed``agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤**边界**不在此——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途引导(`steering/message`)同理。
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录):想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request``agent/step-result``agent/turn-continuation`)可变更或否决瞬态 emit`agent/status``agent/error``agent/created`/`agent/disposed``agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤**边界**不在此——它们是持久的 session 事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导`steering/message`)同理。
- **`tools/*`——工具注册表与执行 seam。**
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中`session/event` 读取——不会被镜像为 `agent/*` emit。
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中`session/event` 读取——不会被镜像为 `agent/*` emit。
**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`——被**移除**。没有生产消费方需要在边界处持有活的 `Agent`ACP 桥接从 `session/event``turn/end``agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们根本没有消费方);turn 镜像在 ui-stdio 迁移后随之移除——见[移除边界镜像事件 RFC](../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 桥接从 `session/event``turn/end``agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 ui-stdio 迁移后随之移除见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。
## 后果
- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end``closeTurn` 仅追加 `turn/end``Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;acceptance 或内部校验失败仍会在边界进入日志之前逃逸。
- 之前通过已移除 emit 观察边界的测试现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们固定的行为(边界序、步骤计数)不变;只是读取的流切换到了权威的那一个。那些测试「抛出异常的 turn 边界 emit 监听器」的用例被删除,因为该代码路径不存在(没有 emit 可供抛出)。按照 [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md),行为与其测试一迁移(或一消亡)。
- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部 dispatch 校验在日志推前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表已提交的边界,该边界欠一个后续 `step/end`
- 本 RFC 的完整实现[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`
- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end``closeTurn` 仅追加 `turn/end``Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。
- 之前通过已移除 emit 观察边界的测试现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界序、步骤计数)不变;只是读取的源移到了规范源。那些测试「抛出异常的 turn 边界 emit 监听器」的用例被删除,因为该代码路径不存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一迁移(或一消亡)。
- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表已提交的欠一个后续 `step/end` 的边界
- 完整实现[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`
- Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-02-fs-per-session-cwd.md: 00643955d918dff87241f4b240bb7e6774d21a0b
2026-07-02-fs-per-session-cwd.zh.md: ca37e1f41af53c43151ac82e1be1b76eaafdb97e
2026-07-02-fs-per-session-cwd.zh.md: 73176cde3747a2eb8c03aadbf3f419bf27173d70
@@ -1,34 +1,34 @@
# RFC文件系统路径解析基于调用方的会话 cwd
Status: implemented
# RFC相对文件系统路径调用方的会话 cwd 解析
[English](2026-07-02-fs-per-session-cwd.md) | 中文
Status: implemented
## 问题
ACP 桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd``dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 相关工作,以及 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录行,会话 B 中的在 B 的项目目录行——一个服务器进程,N 个工作区。
ACPAgent Client Protocol桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd``dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)`session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录行,会话 B 中的在 B 的项目目录行——一个服务器进程,N 个工作区。
文件系统路径解析使用的是插件加载时的单一 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
## 决策
将调用方的会话 cwd 透传到路径解析,与 `dsh-tool-bash``workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。
将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash``workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。
- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>``opts.cwd` 是相对 `path` 解析基准;绝对 `path` 忽略它;省略 `opts.cwd` 使用后端自身的默认值。使用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。
- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)``config.cwd`调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。
- `dsh-tool-fs``read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数获取会话 cwd`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 一致),并传给 `resolve`。非 agent/无 header 的调用方返回 `undefined`,后端应用其默认值。
- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>``opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 使用后端自身的默认值。用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。
- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)``config.cwd`作为调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。
- `dsh-tool-fs``read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。
## 曾考虑的替代方案
### 为什么由调用方提供 cwd(而非提供方)
### 为由调用方(而非提供方)提供 cwd
提供方 seam 不依赖 `dsh-agent``dsh-session`它是一个文本存储后端,沙箱或远程实现同样满足该接口,而它们没有「agent 会话」的概念。工具已经接收 `ToolExecution``exec`),其中携带 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包边界处显式优于隐式」的约定:基目录作为显式参数到达提供方并由其执行,而非让提供方越界去读取它不应知的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。
提供方 seam 不依赖 `dsh-agent``dsh-session`——它是一个文本存储后端,沙箱或远程实现同样满足该接口,而这些实现没有「agent 会话」的概念。工具已经接收 `ToolExecution``exec`),其中携带 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包package边界处显式优于隐式」的约定:基目录作为显式参数传入,提供方据此行动,而非让提供方越界去读取它不应知的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。
默认值只存在于**一个**地方提供方的 `config.cwd``sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会制造一个提供方本来会自行选择的基目录。
默认值只存在于**一个**地方——提供方的 `config.cwd``sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本自行选择的基目录。
## 后果
- 在 ACP 演示中,fs 工具 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目文件夹,两类工具都在该目录下作。
- `FsTarget` 的标识不变:`targetKey`然是解析后绝对路径的 realpath,因此 observed-state 键控符号链接标识不受影响——正确的 per-session cwd 产生的 key 与 bash 目标一致
- 在 ACP 演示中,fs 工具 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下作。
- `FsTarget` 的标识不变:`targetKey`解析后绝对路径的 realpath,因此 observed-state 键控符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key
- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。
- 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-02-result-time-applied-hunk-diffs.md: 81ab8b9827ddaec39d63e2a3f8fbb864085a9ac9
2026-07-02-result-time-applied-hunk-diffs.zh.md: 3914bb872025d7116a047dfaf3455f527e35879a
2026-07-02-result-time-applied-hunk-diffs.zh.md: 2914c4242c4246ede8588967ed36b3f6c725c607
@@ -1,20 +1,20 @@
# RFC:结果时刻的 applied-hunk diff 用于文件变更
Status: implemented
[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文
Status: implemented
## 问题
[带标签的渲染意图联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时CALL time提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string``new_string` 没有周围行,而一次 `replace_all` 如果触及五个分散位置,仍然渲染为一对片段。
[tagged render-intent union](2026-07-02-tool-render-intent-union.md) `dsh-tool-fs` 的 write/edit 在调用时提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string``new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。
驱动 `claude-agent-acp` 自身的 ACP 桥接层可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原地*展示变更(而非浮动片段)的关键。我们的工具止步于调用时片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。
在对接 `claude-agent-acp` 自身的 ACPAgent Client Protocol bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中**原位**显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。
障碍在于一个 seam 边界:`presentResult(args, result)`**`args` + 面向模型的 `result``{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放都会运行,因此必须具回放确定性且不能做 I/O。它看不到文件的变更前/后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数 + 版本,没有文本。因此无法计算、也无法传递 applied hunk 给 presenter。
障碍在于一个 seam 边界:`presentResult(args, result)`**`args` + 面向模型的 `result``{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放都会运行,因此必须具回放确定性且不能做 I/O。它看不到文件的后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数版本,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。
## 决策
新增一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来承载 applied-hunk diff。
添加一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来携带 applied-hunk diff。
### 1. 工具结果上的 `meta` 通道(core
@@ -24,38 +24,38 @@ Status: implemented
type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
```
`meta` 是工具自有的 `unknown`core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。
`meta` 是工具自有的 `unknown`core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。
这是通用形态("工具附加持久化的结果展示"),而非 fs 专用——任何工具都可以使用。
这是通用形态("工具附加持久化的结果展示"),而非 fs 特有的——任何工具都可以使用。
### 2. 工具计算 hunk;后端返回变更前/后文本fs
### 2. 工具计算 hunk;后端返回 before/afterfs
按照[能力-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**:
按照 [capability-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**:
- `dsh-fs` 扩展 `FsEditOutcome`,增加 `{ before: string; after: string }`;扩展 `FsWriteOutcome`,增加 `{ before: string | null; after: string }``before: null` ⇒ 新建文件,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。
- `dsh-tool-fs` 将上下文 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换 pending 卡片:建或无变化的覆写回退参数推导的文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染错误信息。
- `dsh-fs` `FsEditOutcome` 扩展为包含 `{ before: string; after: string }`,将 `FsWriteOutcome` 扩展为包含 `{ before: string | null; after: string }``before: null` 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。
- `dsh-tool-fs` 将上下文 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:建或无变化的覆写回退到由参数推导的文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染错误信息。
### 3. 桥接层渲染 `diff` 结果卡片
### 3. Bridge 渲染 `diff` 结果卡片
`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`桥接层结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中**替换**调用时的内容,因此结果 diff **取代**调用时片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,结果 diff)与 `claude-agent-acp` 完全一致。
`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中**替换**调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,结果 diff)与 `claude-agent-acp` 完全一致。
## 曾考虑的替代方案
**手写或 vendor diff 算法。** 上下文 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。仓库的 vendor 策略适用于框架源码,而非每个叶子工具。
**手写或 vendor diff 算法。** 上下文 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。仓库的 vendor 策略适用于框架源码,而非每个叶子工具
## 后果
`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:从日志读回,从不重新计算。代价:覆写操作在内存中同时持有变更前和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。
`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘格式词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:从日志读回,从不重新计算。代价:覆写操作在内存中同时持有旧文本和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。
## 非目标
- **实时增量 diff 流式输出。** hunk 在变更完成后一次性计算;没有逐键 diff。
- **对二进制/非 UTF-8 覆写做 diff。** 此类文件的 `before` 为 `null`(没有文本 diff 基础);写入仍然成功,结果渲染文件 diff`oldText: null`)而非上下文 hunk。
- **重命名/移动 diff。** 仅单个已解析路径内容 diff。
- **限制覆写 diff 基础的大小。** 覆写操作将整个旧文件读入内存以计算上下文 hunk已持有的新内容之上),因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。后续优化可以设定预读上限,超过阈值时回退到文件/无上下文 diff;以 `TODO(overwrite-diff-bound)` 标记在读取位置
- **实时增量 diff 流式输出。** hunk 在变更完成后一次性计算;没有逐键 diff。
- **对二进制/非 UTF-8 覆写做 diff。** 此类文件的 `before` 为 `null`(没有文本 diff 基础);写入仍然成功,结果渲染文件 diff`oldText: null`)而非上下文 hunk。
- **重命名/移动 diff。** 仅单个已解析路径内容 diff。
- **限制覆写 diff 基础的大小。** 覆写操作将整个旧文件读入内存以计算上下文 hunk(加上已持有的新内容),因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。未来的改进可以设定预读上限,超过阈值时回退到文件/无上下文 diff在读取位置以 `TODO(overwrite-diff-bound)` 跟踪
## 相关
- 补齐了[带标签的渲染意图联合类型](2026-07-02-tool-render-intent-union.md)中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。
- 建立在[文件系统能力 seam](2026-06-17-filesystem-capability-seam.md)变更前/后文本是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)之上
- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。
- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。
- 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。
- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-02-tool-render-intent-union.md: 8256f09f9c297658627d0c3d9e99ee1c5424b254
2026-07-02-tool-render-intent-union.zh.md: ed46bf0a8bea1cbfbce4287e5dc48be21c8d8fb9
2026-07-02-tool-render-intent-union.zh.md: 35bd775545c9131a20da9e7f7424506e4554eb4b
@@ -1,4 +1,4 @@
# RFC:用于工具调用展示的标签 render-intent 联合类型
# RFC:用于工具调用展示的标签 render-intent 联合类型
Status: implemented
@@ -6,17 +6,17 @@ Status: implemented
## 问题
工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI(编辑器的工具调用卡片)中的渲染方式,返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的大杂烩**:调用侧有 `title``kind``rawInput``content``locations``terminal`;结果侧有 `title``content``terminal``ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分含混不清:
工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI(编辑器的工具调用卡片)中如何渲染,返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有一个可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的集合**:调用侧有 `title``kind``rawInput``content``locations``terminal`;结果侧有 `title``content``terminal``ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分模糊不清:
- 调用侧和结果侧的 `terminal` 字段重叠,bridge 需要将一个 `content` 块、一个 `terminal` 块和 `rawInput` 按调用拼接在一起,靠临时条件逻辑缝合
- 哪些组合是*合法的*没有文档:一个设置了 `terminal` 的调用如果同时设置了 `content`,含义是「卡片上方的描述」;一个 generic 调用如果设置了 `terminal`毫无意义但类型允许。类型允许无意义的状态。
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content`*LLM*`ContentBlock[]` 词汇(text/image),工具字面上无法请求一个 diff。
- 调用侧和结果侧的 `terminal` 字段重叠,bridge 需要将每次调用的 `content` 块、`terminal` 块和 `rawInput` 临时条件逻辑拼接在一起
- 哪些组合是*合法的*没有文档说明:一个设置了 `content``terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的*LLM(大语言模型)*`ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示
`packages/core/tools/src/index.ts`有的 `FIXME(tool-presentation)`了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的标签联合类型),而不是一堆可选字段由 bridge 拼接。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以标签 render-intent 联合类型的形式回归」。这个门槛现已达到:两个生产族(`dsh-tool-bash``dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。
`packages/core/tools/src/index.ts`有的 `FIXME(tool-presentation)`了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产族(`dsh-tool-bash``dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。
## 决策
用一个**以 `card` 为标签的可辨识联合类型**替代可选字段大杂烩。工具为每次调用/结果声明一个渲染意图;bridge 标签分发。
用一个**以 `card` 为标签的可辨识联合类型**替代可选字段集合。工具为每次调用/结果声明一个渲染意图;bridge 根据标签分发。
```ts ignore-check
type FileLocation = { path: string; line?: number }
@@ -34,41 +34,41 @@ interface GenericResultView { card: 'generic'; title?: string; content?: Content
interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string }
```
`card` 在每个变体上都是**必填**的:一个真正的判别字段,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比编译错误更糟。添加变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。
`card` 在每个变体上都是**必填**的——真正的判别,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何需要新的 bridge 代码来渲染,因此一个插件添加被 bridge 静默丢弃的变体,比编译错误更糟糕。新增变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。
### 为什么标签联合类型优于字段大杂烩
### 为什么标签联合类型优于字段集合
- **无效状态变得不可表。** generic 卡片不能携带终端输出;terminal 卡片不能携带 diff。旧的大杂烩允许所有这些组合。
- **bridge 按分支分发而非拼接。** 每种卡片一个分支,各自精确产出该卡片所需的协议格式(wire format),而非调五个交互关系未文档化的可选字段。
- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`bridge 出 ACP `{type:'diff', path, oldText, newText}` `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这是本次重设计解锁的能力。
- **无效状态变得不可表。** generic 卡片不能携带终端输出;terminal 卡片不能携带 diff。旧的字段集合允许所有这些组合。
- **bridge 分发而非拼接。** 每种卡片一个分支,各自精确产出该卡片所需的协议格式(wire format),而非调五个交互关系未文档化的可选字段。
- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`bridge 出 ACP `{type:'diff', path, oldText, newText}` `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这是本次重设计解锁的能力。
### 生产映射
### 生产映射
- `dsh-tool-fs` read → `generic``kind:'read'`,附带一个 follow-along `location`);write → `diff``oldText:null`);edit → `diff``oldText:old_string || null``newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` Read/Write/Edit 分支逐字段对应。
- `dsh-tool-fs` read → `generic``kind:'read'`,附带一个 follow-along `location`);write → `diff``oldText:null`);edit → `diff``oldText:old_string || null``newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` Read/Write/Edit 分支逐字段对应。
- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` 和 `bash_output`/`bash_kill` → `generic`。
- `dsh-tool-todo` → `generic`。
### 终端回退的归属
`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**bridge 在无能力路径上将 `output` 包裹围栏代码块),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的 capability 门控行为。
`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹围栏代码块),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。
### 纯函数性保持不变
`presentCall`/`presentResult` 仍然是 `args`以及 `presentResult` result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件样式`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string``new_string`
`presentCall`/`presentResult` 仍然是 `args``presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string``new_string`
## 相对路径显示标题
`claude-agent-acp` 将文件卡片标题路径相对于会话 cwd 做相对化处理(`toDisplayPath`显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`同时保持 `locations[]`/`diff.path` **原始**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args,无法看到会话 cwd,因此相对化发生在 **bridge**——bridge 已经将会话 cwd 传入工具调用渲染(与它用于解析 terminal 卡片标题的 cwd 相同)。bridge 仅对标题做相对化,通过对已知 `locations[0].path`/`diffs[0].path` 子串精确结构化替换实现——对文件卡片类型通用,从不特判工具名。
`claude-agent-acp` 将文件卡片标题中的路径相对于会话 cwd 做缩短处理(`toDisplayPath`——显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`——同时保持 `locations[]`/`diff.path` **原始路径**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args,无法访问会话 cwd,因此这一相对化处理发生在 **bridge**bridge 已经将会话 cwd 传入工具调用渲染逻辑(与它用于解析 terminal 卡片标题的 cwd 相同)。bridge 仅对标题做相对化,方式是对已知 `locations[0].path`/`diffs[0].path` 子串精确结构化替换——对所有文件卡片类型通用,从不针对工具名做特殊处理
## 曾考虑的替代方案
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其结论明确推迟到两个真实工具和两个真实消费方存在后再做这个联合类型,而该门槛现已达到
- **可合并扩展的联合类型**`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟。
- **保留可选字段大杂烩**:即「问题」一节所剖析的现状:无效状态可表、字段交互文档、且完全无法请求 diff 卡片。
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做联合类型,该条件现已满足
- **可合并扩展的联合类型**`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟
- **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表、字段交互文档、且完全无法请求 diff 卡片。
## 后果
新的渲染意图 bridge switch 处编译中断变更——这是有意为之:渲染代码必须卡片种类存在之前就位。无效的卡片/字段组合现已不可表bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。
新的渲染意图会在 bridge switch 处引发编译中断——这是有意为之:渲染代码必须先于卡片种类存在。无效的卡片/字段组合现已不可表bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。
## 非目标
@@ -76,7 +76,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
## 相关
- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做标签 render-intent 联合类型」)中的推迟决定。该门槛现已达到;本 RFC 即那个联合类型。
- [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:该 RFC 增加了一个持久化的 `meta` 通道,使 write/edit 在结果时`DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 点一个,或新建文件的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。
-`ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view`_meta` terminal 卡片约定和 capability 门控不变;仅 harness 侧的展示类型改变)。
- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 RFC 即那个联合类型。
- [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时`DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。
-`ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。
- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-03-filesystem-directory-listing-seam.md: bb8d9c4deda18b320b85d548bbd5bcb32f1c1d72
2026-07-03-filesystem-directory-listing-seam.zh.md: a0332fe6cec576ad1c5b4722e2decb87344aeee1
2026-07-03-filesystem-directory-listing-seam.zh.md: ccc5ca67f58537134da5c5484b3d527ba84fe8d3
@@ -1,53 +1,53 @@
# RFC:为文件系统 seam 添加直接目录列举能力
Status: implemented
[English](2026-07-03-filesystem-directory-listing-seam.md) | 中文
Status: implemented
## 问题
`@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam,本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前,它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经够,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。
`@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam,本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前,它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经够,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。
直接的压力来自 skill 加载:读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录包含 `<name>/SKILL.md``<name>.md` 仍需要目录枚举。如果`dsh-skill` 中添加目录列举,要么保留一个直接的 Node 依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。
直接的压力来自 skill(技能)加载:读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录包含 `<name>/SKILL.md``<name>.md` 仍需要目录枚举。如果`dsh-skill` 中添加目录列举,要么保留 Node 的直接依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。
本决策只添加提供方能力,不引入面向模型的 `ls`/`list` 工具,也不改变 skill 发现逻辑。那些消费方需要独立的 UX、提示词和策略决策。
本决策只添加提供方能力,不涉及面向模型的 `ls`/`list` 工具 skill 发现机制的变更。那些消费方需要独立的 UX、prompt 与策略决策。
## 决策
`@deepseek-ai/dsh-fs` 中添加 `FileSystem.listDir(target, signal?)`
`listDir` 仅列举一目录。它以稳定的名称顺序返回直接子项,包含:
`listDir` 仅列举一目录。它以稳定的名称顺序返回直接子项,包含以下字段
- `name`:子项的 basename
- `type``file``directory``other`
- `target`:已解析的子项 `FsTarget`
- `version`:可用时提供的轻量元数据
- `size`:可用时提供的常规文件大小。
- `name`:子项的 basename
- `type``file``directory``other`
- `target`:已解析的子项 `FsTarget`
- `version`:可用时返回的轻量元数据
- `size`:可用时返回的常规文件大小。
它从不读取文件内容。递归遍历、glob 匹配、分页、搜索、文件监听和面向模型的渲染均有意不在范围内。
本地后端通过 `readdir({ withFileTypes: true })``resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的提示词/列表输出稳定,并提前缀缓存复用率。
本地后端通过 `readdir({ withFileTypes: true })``resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的 prompt/列表输出稳定,并提前缀缓存复用率。
损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。列举目录或解析/探测子项元数据时遇到权限或后端 I/O 故障以结构化的 `FsError` 码使整个列举失败:
损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。列举目录或解析/探测子项元数据时遇到权限或后端 I/O 故障,则以结构化的 `FsError` 错误码使整个列举失败:
- `FS_NOT_FOUND`:目标不存在
- `FS_NOT_DIRECTORY`:目标存在但不是目录
- `FS_PERMISSION_DENIED`:权限不足
- `FS_IO_ERROR`:其他后端 I/O 故障
- `FS_NOT_FOUND`:目标不存在
- `FS_NOT_DIRECTORY`:目标存在但不是目录
- `FS_PERMISSION_DENIED`:权限不足
- `FS_IO_ERROR`:其他后端 I/O 故障
- `FS_ABORTED`:调用被中止。
## 曾考虑的替代方案
**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其提示词、schema 和渲染契约与提供方原语无关
**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其 prompt、schema 和渲染契约与提供方原语相互独立
**让每个消费方自行枚举目录。** 否决。这会 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。
**让每个消费方自行枚举目录。** 否决。这会 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。
**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根目录发现只需要直接子项,简单的单列举是未来消费方可以安全组合的最小后端契约。
**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根发现只需要直接子项,简单的单列举是未来消费方可以安全组合的最小后端契约。
**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target,因此解析子项时遇到的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。
**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target,因此解析子项时的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。
## 后果
每个文件系统后端现在必须多实现一个提供方原语。这是 harness 尚未发布时有意为之的基础工作,但也意味着未来的沙箱/远程后端需要定义等价的直接子项列举行为。
该能力仍然面向提供方。在消费方落地之前,ACP/模型会话仍需使用 `bash` 等既有工具来列举目录。没有面向模型的 `listdir` 工具是预期行为,而非接线遗漏
该能力仍停留在提供方层面。在消费方落地之前,ACPAgent Client Protocol/模型会话仍需使用 `bash` 等既有工具来列举目录。缺少面向模型的 `listdir` 工具是预期行为,而非接线错误
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: fce9d555c8843b99fdbfa7b652b46d0b88053935
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 5af4fca19649d4ee458eaa6a23ae7374abdd89e4
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 93a639a2ddac33cb1ceac57101cb6185fe6034ad
@@ -1,72 +1,72 @@
# RFC提示词变量与工具指导归属
Status: implemented
# RFCPrompt 变量与工具指导归属
[English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文
Status: implemented
## 问题
组装后的系统提示词四个缺陷,同属一类:harness 已经掌握的事实在别处被手工重述,然后漂移。
组装后的系统提示词存在四个缺陷,同属一类:harness 已的事实在别处被手工重述,然后漂移。
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。
**工具指导是叶子 YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml``examples/acp-agent/cordis.yml``systemPrompt` 字符串——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs``dsh-tool-web` `ctx.systemPrompt.section()` 贡献的方式持有各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml``examples/acp-agent/cordis.yml``systemPrompt` 字符串——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs``dsh-tool-web`通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。
**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「使用 read 工具……」再读到「你是 coding-agent」——与身份优先的惯例Claude Code、Codex)相反,且 section 流水线之外形成了第二条组合路径。
**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are coding-agent」——与 identity-first 约定Claude Code、Codex)相反,且 section 流水线之外第二条组合路径。
**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题同族`PromptSection.name` 文档写着"(diagnostics / dedup)",但重复项被静默接受。
**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题:`PromptSection.name` 文档标注为 "(diagnostics / dedup)",但重复项被静默接受。
## 决策
**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包的 prompt section。harness 出处 → 静态的 `harness:identity` section。部署角色行为 → 部署的 persona。
**一条原则:prompt 中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包package的 prompt section。harness 来源标识 → 静态的 `harness:identity` section。部署角色行为 → 部署的 persona。
### 组装上下文
`SystemPrompt.assemble(context)` 接受一个可 merge 扩展的 `AssembleContext``dsh-system-prompt` 声明用于 scoped routing 的可选 `scope` 选择器,而 `dsh-agent` 通过 declaration-merge 将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖环)。循环在每一步调用 `assembleContextFor(agent)`,使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall(瀑布式事件)也会收到它,监听可据此按 agent 过滤或扩展。
`SystemPrompt.assemble(context)` 接受一个可合并扩展的 `AssembleContext``dsh-system-prompt` 声明可选 `scope` 选择器用于 scoped 路由,而 `dsh-agent` 通过声明合并将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖环)。循环在每个步骤调用 `assembleContextFor(agent)`,使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall(瀑布式事件)也接收它,监听可据此按 agent 过滤或扩展。
### 提示词变量
### Prompt 变量
插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝未知的 own-property 引用、注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被再次扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。
插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。
`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`= `options.model`)和 `cwd`= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。
`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`= `options.model`)和 `cwd`= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。
### Persona 作为 order-0 section
`dsh-system-prompt` 有 order 为 `-100``harness:identity` 和 order 为 `0`配置 `deployment:persona`,因此两者在替换循环时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)``agent/pre-step` 因此测量用于压缩(compaction)的确切提示词。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 分段为:identity `-100`、persona `0`、工具指导 `100199`
`dsh-system-prompt` 有 order 为 `-100``harness:identity` 和 order 为 `0` 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)``agent/pre-step` 因此测量的正是用于压缩(compaction)的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100199`
### 工具指导归属
每个工具的语义和选择指导放在工具描述中。Prompt section 承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的描述已包含完整契约。部署 persona 只包含角色和行为。
每个工具的语义和选择指导放在工具 description 中。prompt section 承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的 description 包含完整契约。部署 persona 只包含角色和行为。
### Subagent 对话历史描述符
`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`fork 设为 `true``dsh-tool-subagent` 根据该标志派生工具描述和 prompt 参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一事实。提供方生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。
`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`fork 设为 `true``dsh-tool-subagent` 根据该标志派生工具和 prompt 参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。
## 曾考虑的替代方案
- **循环自行组合一行身份文本**——在必须保持精简的那个包里硬编码面向模型的行文("plugins, not loop changes",且在 section 流水线之外成第二条组合路径。(身份确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
- **通过 `agent/request` waterfall 注入模型名称**——提示词文本在两处组合,且 `agent/pre-step``fullSystemPrompt` 会遗漏它,导致压缩(compaction测量的提示词与模型实际看到的不一致。
- **在每个 persona 中手写模型名称**——与上方一行的 `model:` 键重复,配置修改后默失实——正是本 RFC 要治的病。
- **宽松插值(未知引用保留原样或替换为空)**——一个拼写错误 `{{modle}}`(或一个空洞)会被送到模型,直到 transcript(文本记录)审查才有人注意到
- **在配置中逐实例手写 subagent 措辞**——面向模型的行文重新回到每个部署 × 每个实例,又是同一个病。** provider 名称匹配措辞**——`providerName` 本身是配置,重命名 provider 后会静默拿到错误的措辞。
- **在 `apply` 时解析 provider(加载顺序要求)** **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**——provider 生命周期事件的替代方案;均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。
- **循环自行组合一行 identity 文本**在必须保持精简的那个包("用插件,不改循环")中硬编码面向模型的行文,且在 section 流水线之外成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
- **通过 `agent/request` waterfall 注入模型名称**prompt 文本在两处组合,且 `agent/pre-step``fullSystemPrompt` 会遗漏它,导致 compaction 测量的 prompt 与模型实际看到的不一致。
- **在每个 persona 中手写模型名称**与上方一行的 `model:` 键重复,配置修改后默失实正是本 RFC 要治的病
- **宽松插值(未知引用保留原样或替换为空)**一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现
- **在配置中为每个 subagent 实例编写措辞**面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。
- **在 `apply` 时解析 provider(加载顺序要求)** **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。
## 不在范围内
- 更多变量(`date`平台、git 状态)——注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。
- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化)——推迟到 session-cwd 方案重新讨论时。
- 更多变量(`date`platform、git 状态)注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。
- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化)推迟到 session-cwd 方案重新讨论时。
## 交付的不变式
- coding-agent 提示词通过一条组装路径渲染identity、带插值模型名的 persona,然后是 fs/bash/web 指导。
- coding-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。
- fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。
- 未知、无值、格式错误或不平衡的变量引用会指 section 并抛出异常;重复的 section、变量和工具注册也会抛出异常。
- 快照回放与提示词无关:它按轮次和步骤索引已录的 chunk 流,不比较发出的请求。
- 未知、无值、格式错误或不平衡的变量引用会指 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 快照回放与 prompt 无关:它按轮次和步骤索引已录的 chunk 流,不比较发出的请求。
## 后果
- 组装后的提示词中每个事实现在恰好有一个归属方,叶子 YAML 中手的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。
- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词中的声明在该步骤就会过时;如果一个插件在那里**提供**模型(options.model 未设置——循环文档记载的回退路径),变量在渲染时无值,含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,且正是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。
- 当一个已绑定的 provider 不在位(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求只是缺少它。这是诚实的状态——替代方案是一个描述或执行都不可信的已注册工具。
- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望大声暴露的撰写错误。
- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,届时再添加。
- 组装后的 prompt 中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。
- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,prompt 对该步骤的声明就会过时;如果一个插件在那里**提供**模型(options.model 未设置——循环文档记载的回退路径),变量在渲染时无值,`{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。
- 当一个已绑定的 provider 不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。
- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们**希望**大声暴露的撰写错误。
- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,再添加。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-reconstructable-requests.md: 0978cd8760c6a0420be1bf0a3baf6b50c1a04a13
2026-07-05-reconstructable-requests.zh.md: 82f9cf085db3d6cd408e54a8e7cf99082d848176
2026-07-05-reconstructable-requests.zh.md: a4864c9e795ccc0da2cbbf0ca4d17a90c029858c
@@ -1,4 +1,4 @@
# RFC:每个 LLM 请求都可从会话日志重建
# RFC:每个 LLM(大语言模型)请求都可从会话日志重建
Status: implemented
@@ -6,50 +6,50 @@ Status: implemented
## 问题
请求流水线此前不保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。
请求流水线未能保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。
快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只追加、从不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型必须看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。
快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只追加不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型需要看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。
## 决策
### 原则
**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何持有日志的人都能逐字节重建。精确的范围明:保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对已记录区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。
**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何持有日志即可逐字节重建请求。精确的范围明:保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3
### 机制
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 写(压缩的 `replace`——`SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,其中的消息是共享的深度冻结:通过投影修改已记录的历史是不可表达的(会抛异常),取代了旧的次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统提示词变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader``diffHeader``applyHeaderDelta` 是纯编解码器。每个循环实例在首次请求时写入一个快照以锚定进程边界。Delta 是优化:写入方验证往返等价性,对不可表达的变更(如纯工具重排序)回退到完整快照。
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader``diffHeader``applyHeaderDelta` 是纯编解码器。每个循环实例在首次请求时写入一个快照以锚定进程边界。delta 是优化:写入方验证往返等价性,对无法表达的变更(如纯工具重排序)回退到完整快照。
一步重建 prompt 组装。实例的第一步中,`agent/session-prefix` 用仅限请求的开场消息扩展一个冻结的空种子;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后在消息快照紧接 `step/start` 之前接收组合后的前缀。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠的 header 事件(前缀唯一的持久化归属),从前缀、快照和 header 构建 `GenerateOptions`深度冻结它,同时保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。
个步骤重建 prompt 组装。实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后接收组合后的前缀,消息在 `step/start` 之前立即被快照。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`对其深度冻结保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。
**`step/start` 是重建边界。** 一从该序列之前的事件派生消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。Header 重建折叠该步骤自身的 `request/header*` 事件,或在无新 header 写入时沿用前一次折叠结果。
**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。header 重建通过该步骤自身的 `request/header*` 事件折叠,或在无新 header 写入时沿用前一次折叠结果。
**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建而非监听器顺序。带密钥的 e2e 要求首次请求之后出现正数的 cache-read token;逐步 usage 是生产信号,header 变更或压缩表现为下一步 cache-read 下降。
**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。
### MiniCode 形态:采纳,但溯源箭头反转
与 MiniCode 一样,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同的是,事件日志仍是真源,因为它拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志派生的消息和 header 折叠结果,使每个请求都可独立检查。
与 MiniCode 相同,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同,事件日志仍是真源,因为它同时拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志推导的消息和 header 折叠结果,使每个请求都可独立检查。
## 曾考虑的替代方案
- **客户端作为真源**(照搬 MiniCode):在日志之外出现第二个生效的真相——两者漂移而无人察觉;见上节。
- **客户端作为真源**(照搬 MiniCode):在日志之外多出一个运行时真相——两者漂移而无人察觉;见上节。
- **镜像日志的有状态传输客户端**:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑面,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。
- **逐次调用的请求标量**(每次 `agent/request` 分发时传入一个可自由修改的配置):监听器可以零记账地逐次切换 model,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。
- **检测并报告**(比较连续请求,发现分歧时警告):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。
- **事件驱动组装**(仅在变更信号时重新渲染):存在信号遗漏的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步渲染加值比较在零信号纪律下仍然健壮
- **Header 事件上的叙事字段**delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件派生——每个事实只有一个归;快照携带 reason 是因为锚点的成因无法从数据本身派生
- **逐次调用的请求标量**一个可自由变异的配置传给每次 `agent/request` 分发):监听器可以零记账地逐次切换 model,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。
- **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。
- **事件驱动组装**(仅在变更信号时重新渲染):存在信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步渲染加值比较在零信号纪律下即可稳健工作
- **Header 事件上的叙事字段**delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件推导——每个事实只有一个归宿;快照携带 reason 是因为锚点的成因无法从数据推导
## 后果
- 一个无法由日志解释的请求不可能被意外构造——无论是循环还是监听器;修改已构建的请求会抛异常;每 header 变更都是一个持久的、可 diff 的日志事件。
- 在建议通道之间做选择是变更频率决策,而本设计稳定的那个成为结构性的`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且**不可能**在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()``tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每都是持久的 `context/message`,付出一次代价后即享受前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了所有当前更新模式)。
- 在提供方处仍需全价的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一不同)。提供方自身的 reasoning-content 排除由服务端管理。
- 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每 header 变更都是持久的、可 diff 的日志事件。
- 在建议通道之间做选择是变更频率决策,而本设计使稳定的那个在结构上成为默认`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且**不可能**在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()``tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每都是持久的 `context/message`,付出一次代价后即前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。
- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一快照不同)。提供方自身的 reasoning-content 排除由服务端管理。
- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。
- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema:主导项),加上真正变更时的 delta——相对 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。
- 快照 golden 文件变更一次(每 transcript 增加其 header 事件);写文件系统的 fixture 以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只往返 cwd 无关的参数路径。
- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特的额外项(reasoning 选项、额外 body 参数)应归属何处。
- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema:主导项),加上真正变更时的 delta——相对 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。
- 快照 golden 文件变更一次(每 transcript(文本记录)增加其 header 事件);写文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只 cwd 无关的参数路径做往返
- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特的额外项(reasoning 选项、额外 body 参数)应归属何处。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-subagent-provider-lifecycle-events.md: 6d711f2a6d8496a8a229ec63d86dd89816efb6f8
2026-07-05-subagent-provider-lifecycle-events.zh.md: 45eedcfdfa834722000c9f2c15e3955ced791c2f
2026-07-05-subagent-provider-lifecycle-events.zh.md: f412b031644c14ca70caae3efc72eefa9ce2c2ac
@@ -1,36 +1,36 @@
# RFCSubagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed`
Status: implemented
[English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文
Status: implemented
## 问题
[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 使 `dsh-tool-subagent` 从其提供方**派生**面向模型的措辞:`SubagentProvider.inheritsParentContext`spawn/ACP 为 `false`fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),从而让 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在**工具注册时**就已固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。
[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) `dsh-tool-subagent` 从其提供方**派生**面向模型的措辞:`SubagentProvider.inheritsParentContext`spawn/ACP 为 `false`fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在**工具注册时**固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。
如果在工具插件的 `apply` 时刻解析提供方,就会产生隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这要求行不通,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不等待激活完成:一个延迟到达的后端可能导致工具 fiber 失败,即使它在配置中列在前面也是如此。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。
如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()`等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。
## 决策
注册表将提供方的成员变作为类型化事件广播,消费方镜像这些事件而非假设顺序:
注册表将提供方的成员变作为类型化事件广播,消费方镜像这些事件而非假设顺序:
- **`subagent/provider-added(provider)`**:一个提供方在 `ctx.subagents` 注册表中变为可解析。在注册时发出。
- **`subagent/provider-removed(name)`**:一个提供方离开注册表(其插件 fiber 被 dispose——卸载或 HMR 重载)。从注册的 disposer 中发出。
- **`subagent/provider-removed(name)`**:一个提供方离开注册表(其插件 fiber 被 dispose(资源释放)——卸载或 HMR(热模块替换)重载)。从注册的 disposer 中发出。
`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞当提供方离开时注销工具在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不可能对模型撒谎。这里**刻意不留**任何需要文档化的加载顺序要求:事件使顺序问题消失,而非将其钉死。
`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞——当提供方离开时注销工具,并在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不对模型撒谎。这里有意**不留**任何需要文档化的加载顺序要求:事件顺序问题消失,而非将其钉死。
这些事件还补全了该 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn``fork``acp`)在其上共存;一个内容会被其他插件用来派生状态的注册表,应当以类型化事件广播成员变,而非要求轮询或依赖加载顺序。
这些事件还完善了 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn``fork``acp`)在其上共存;一个其他插件从中派生状态的注册表,应当以类型化事件广播成员变,而非要求轮询或依赖加载顺序。
## 曾考虑的替代方案
- **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"会声称一个 Loader 并不提供的顺序保证。
- **重试查找(轮询直到提供方出现)**:最终收敛,但在框架已有的机制(effect 注册 + disposal)之外自行发明了一套私有就绪协议;而且它无法感知提供方**离开**,因此 HMR 会一个措辞描述已 dispose 后端的工具滞留
- **仅在 section 中放置 subagent 措辞,在组装时延迟解析**:同样能容忍任意加载顺序,但 tool-choice 引导移出了描述,与 prompt-variables RFC 立的归属规则相矛盾(每个工具的语义和使用时机属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。
- **根据提供方名称而非提供方对象确定措辞**`providerName` 本身是配置,重命名提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。
- **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"这一要求声称了 Loader 并不存在的顺序保证。
- **重试查找(轮询直到提供方出现)**:最终收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它无法感知提供方**离开**,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。
- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但 tool-choice 引导移出了**描述**,与 prompt-variables RFC 立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。
- **根据提供方名称而非提供方对象确定措辞**:`providerName` 本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。
## 后果
- 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。
- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录,不会饿死后续镜像或扰乱拆卸流程。`start()`在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)[生产者/消费者映射](../../../event-producer-consumer.md)。
- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处发的工具——工具注册表的 `tools/change` 事件确保 prompt 组装保持最新
- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 实例命名了不同的提供方但相同的 `toolName`者都会等待,先到达的提供方触发注册;第二注册仅在**其**提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一爆炸半径;工具注册表的重名拒绝机制仍是最终兜底
- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)[生产者/消费者映射](../../../event-producer-consumer.md)。
- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处发的工具——工具注册表的 `tools/change` 事件发出会保持 prompt 组装的时效性
- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`者都会等待,先到达的提供方注册;第二注册仅在**其**提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-06-timeout-deadline-library.md: 9906aa7cce40ffd7b5f7082199d05edb8d0a54b2
2026-07-06-timeout-deadline-library.zh.md: ef99a1a051f3cedbe5a2770e5bbeeebe716745c4
2026-07-06-timeout-deadline-library.zh.md: dd1a60f9d7b91575b32e1cc85b3800cf823b6a6d
@@ -1,22 +1,22 @@
# RFC:共享的超时/截止时间原语,hard-kill 留给各能力自行实现
[English](2026-07-06-timeout-deadline-library.md) | 中文
# RFC:共享的超时/截止时间原语,硬终止留给各能力自行实现
Status: implemented
[English](2026-07-06-timeout-deadline-library.md) | 中文
## 问题
超时处理在各个承载工具的能力之间逐渐分化,而这种分化并非表面的——同一套逻辑被三种方式各自重新实现,每种都带着自己微妙的正确性负担。
超时处理在各个承载工具的能力之间逐渐分化,而这种分化并非表面的同一套逻辑被三种方式重新实现,各自带有微妙的正确性负担。
- **bash**[packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器——用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器——各自调用同一个 `kill()` 闭包,该闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut``aborted`各自独立锁存。
- **web_fetch**[packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手工搭建*的超时:构造一个 `AbortController`,接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因——因为 reader 只抛出裸 `AbortError`
- **web_search**[packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)**完全没有超时**`WebSearchRequest`[packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本 RFC 中保持无超时——见「后果」。)
- **bash**[packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut``aborted`)独立锁存。
- **web_fetch**[packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手*的超时:构造一个 `AbortController``setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因因为 reader 只抛出裸 `AbortError`
- **web_search**[packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)**完全没有超时**`WebSearchRequest`[packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。)
每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「取消」——而融合原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 舞步就是证据)。与此同时,各能力执行的*终止*作不可归约地不同:bash 杀的是 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止的是进程内的 `fetch`undici 拆 socket)。不存在一种单一机制能停止所有这些工作
每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「取消」而融合原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 处理就是证据)。与此同时,各能力执行的*终止*作不可归约地不同:bash 杀死一个 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止一个进程内的 `fetch`undici 拆 socket)。不存在一个能停止所有能力工作的单一机制
## 决策
`@deepseek-ai/dsh-timeout` 位于 `packages/util/`(与 `dsh-brand` 同级),拥有超时的*计时与分类*这一半;*终止*那一半——hard kill——留在各能力的实现中。它是一个纯函数库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何东西、不持有跨调用状态、不发射事件。刻意不设中央「超时服务」——那样的服务必须知道如何停止每个能力的工作而这正是微内核要排除在共享层之外的知识,也是 Codex `ExecExpiration` 作用域仅限于 exec 族所示范的。
`@deepseek-ai/dsh-timeout` 位于 `packages/util/`(与 `dsh-brand` 同级),负责超时的*计时与分类*这一半;*终止*那一半——硬终止——留在各能力的实现中。它是一个纯函数库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何东西、不持有跨调用状态、不发射事件。这里刻意不设中央「超时服务」,因为那样的服务必须知道如何停止每个能力的工作——而这正是微内核要排除在共享层之外的知识,也是 Codex `ExecExpiration`于 exec 族所示范的原则
### 库的对外接口
@@ -57,42 +57,42 @@ export function deadline(
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined
```
`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的无超时哨兵,用于后端有的后台任务;外部提示经 `clampTimeout`必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。提供方将超时原因译为 seam 特定的结果。`timeoutOf(signal, code)` 通过 code 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力的超时。
`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的无超时哨兵,用于后端有的后台任务;外部提示经 `clampTimeout`必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。提供方将超时原因译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。
### 分
### 职责划
| 关注点 | 负责方 |
|---|---|
| 校验请求提示并钳位 default/max | `dsh-timeout``clampTimeout`——纯算术加共享的正有限请求契约 |
| 校验请求提示并钳位默认值/最大值 | `dsh-timeout``clampTimeout`纯算术加共享的正有限请求契约 |
| 启动定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout``deadline` |
| 清除定时器 | `dsh-timeout``[Symbol.dispose]` |
| 中止后分类首个 abort reason | `dsh-timeout``timeoutOf` |
| 中止后首个 abort reason 进行分类 | `dsh-timeout``timeoutOf` |
| **实际终止工作** | 各能力的实现 |
| default/max *值* | 各能力的配置 |
| 默认值/最大值*数值* | 各能力的配置 |
| 超时 `code` 字符串 | 各能力(`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT` |
信号只*通知*;终止始终是监听的职责,而监听因能力而异。bash 自写 `addEventListener('abort', kill)`,因为 OS 进程活在本运行时之外,没有别的东西会杀它;web `d.signal` 交给 `fetch`undici 拆 socket。这也是文件 read/write/edit 不接受 **`timeoutMs`** 的原因:本地系统调用多只能尽力中止,超时无法强制 `fsync`/`rename` 停下,加一个超时等于引入一个违反「显式优于隐式」的隐式默认值。两个参考 agent 出于同样的理由都不给文件 I/O 设超时。
信号只*通知*;终止始终是监听的职责,而监听因能力而异。bash 自行编写 `addEventListener('abort', kill)`,因为 OS 进程存在于本运行时之外,没有别的东西会杀它;web `d.signal` 交给 `fetch`undici 拆 socket。这也是文件读/写/编辑**不接受** `timeoutMs` 的原因:本地系统调用多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,添加超时将是一个违反「显式优于隐式」的隐式默认值。两个参考 agent 出于同样的原因对文件 I/O 设超时。
### 各能力如何消费
### 各能力如何消费该库
- **web_fetch**——工具层保持校验并转发;提供方手工搭建的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被提供方自有的 `deadline`/`timeoutOf` 取代。上游信号已预先中止时仍立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。
- **bash**——`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。
- **web_fetch**工具层保持校验并转发;提供方手的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。
- **bash**`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。
## 后果
- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,seam 类型 `BashRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。
- `SpawnSpec.timeoutMs` `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残保留:`runBash` 不再拥有定时器执行器拥有分类逻辑后,它们无处被读取。这是与字面提案形状(向 `runBash` 传 `timeoutMs: 0`)的唯一偏差;在逐文件覆盖率门禁下,一个始终为 0 且无读取的字段死代码。
- web_fetch 去掉了自建的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。
- `AbortSignal.any` `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。
- `SpawnSpec.timeoutMs` `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残保留:由于 `runBash` 不再拥有定时器执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无读取的字段在逐文件覆盖率门禁下属于死代码。
- web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。
- `AbortSignal.any` `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。
不在本 RFC 范围内,列出以标明边界:`web_search` 可以在其 tool-schema/快照覆盖率规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,hard kill 仍是各能力自己的事。
以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其 tool-schema/snapshot 覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。
## 曾考虑的替代方案
**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核理由否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查)——这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 作用域仅限于 exec 族,正是因为它驱动的 kill`killpg`)是进程族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。
**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查)这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。
**每个工具各自实现超时,不共享代码(前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手工搭建的 controller/reason 逻辑正是未来每个网络/进程工具都要重新推导的,而融合 + `signal.reason` 恢复是容易出错的部分。Claude Code 容忍完全重复;本仓库有一统一的共享中止通道(每次 `execute` 上的 `exec.signal`),使一个小型共享原语严格更干净,因此成本/收益不同。
**每个工具各自实现超时,不共享代码(前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复是容易出错的部分。Claude Code 容忍完全重复;本仓库有一统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使一个小型共享原语严格更,因此成本/收益不同。
**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在 deadline 时 resolve *工具调用*的 promise,而不停止底层工作——子进程或 fetch socket 会泄漏。发信号并要求能力监听,才能强制一条真的终止路径存在。这与「dispose 必须达到静止态,而非仅仅请求它」的防御性规则一致。
**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不停止底层工作——子进程或 fetch socket 会泄漏。发信号并要求能力监听,才能强制一条真的终止路径存在。这与「dispose 必须达到静止态,而非仅仅请求它」的防御性规则一致。
**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了自建定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径不变。
**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径保持不变。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-07-tool-call-timeout-policy.md: 0e69c8504dd34dfc4427bf1f3865a80be93eff9d
2026-07-07-tool-call-timeout-policy.zh.md: d842d0a3c811e7502f4d45baba49010a06c5f1a5
2026-07-07-tool-call-timeout-policy.zh.md: 2f4eca6eff9d135aad3fe538b887402a42ac6f84
@@ -1,24 +1,24 @@
# RFC:工具调用超时策略作为插件
Status: implemented
[English](2026-07-07-tool-call-timeout-policy.md) | 中文
Status: implemented
## 问题
[超时/截止时间 RFC](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` 转发给调用的实现,而部署策略来决定预算。
[超时/截止时间 RFC](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` 转发给调用的实现,而部署策略来决定预算。
与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 执行命令钩子,而非通过 `ctx.tools.execute()``bash` 模型工具通过同一后端复用前台执行、后台启动、后台轮询和钩子调用。一步到位地所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。
与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 执行命令钩子,而非通过 `ctx.tools.execute()``bash` 模型工具通过同一后端复用前台执行、后台启动、后台轮询和钩子调用。一步到位地所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。
## 决策
工具调用超时是一项仅适用于面向模型的工具执行的策略,由三部分组成:
工具调用超时是仅适用于面向模型的工具执行的策略,由三部分组成:
- `@deepseek-ai/dsh-timeout`是拥有 `deadline()``timeoutOf()` 的共享库。
- `@deepseek-ai/dsh-timeout` 仍是拥有 `deadline()``timeoutOf()` 的共享库。
- `@deepseek-ai/dsh-tools``tools/pre-execute``tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`
- `@deepseek-ai/dsh-timeout-policy` 从注册表读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。
执行流水线
执行流水线如下
```text
ctx.tools.execute(exec)
@@ -30,17 +30,17 @@ ctx.tools.execute(exec)
-> tools/post-execute
```
默认行为是保守的:未声明 `timeoutMs` 的工具不会从该插件收到 `TOOL_TIMEOUT` 截止时间
默认行为是保守的:未声明 `timeoutMs` 的工具不会从该插件收到 `TOOL_TIMEOUT` 截止信号
### `tools/execute` 环绕 seam
`@deepseek-ai/dsh-tools` 声明了一个 `tools/execute` waterfall,其基础 `next()`「分发并规范化」的 thunk即同一个内部 `try`/`catch`将抛出的工具错误(或未知工具错误)转换为 `isError``ToolExecutionResult`。监听器接收 `(exec, next)`:调用 `next()` 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整流水线仍`execute` 的外层 try/catch 内,因此抛出异常的监听器会变成 `isError` 结果,永远不会导致轮次失败。
`@deepseek-ai/dsh-tools` 声明了一个 `tools/execute` waterfall,其基础 `next()`带规范化的分发 thunk——即同一个内部 `try`/`catch`,将抛出的工具错误(或未知工具错误)转换为 `isError``ToolExecutionResult`。监听器接收 `(exec, next)`:调用 `next()` 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整流水线仍`execute` 的外层 try/catch 内,因此抛出异常的监听器会变成 `isError` 结果,而非轮次失败。
catch 是基础 `next()` 而非 waterfall 之外的东西这一点是关键:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为正常的错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`
catch 是基础 `next()`而非 waterfall 之外的东西这一点至关重要:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为普通错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`
### `timeout-policy` 插件
该插件是 `@deepseek-ai/dsh-timeout-policy`位于 `packages/timeout/` 分组中,是一个零配置的函数/命名空间插件(`name` / `inject` / `apply`)。每个工具的预算声明在工具自身,而非插件`ToolDefinition` 携带可选的 `timeoutMs`,由拥有该工具的插件从自身配置中设置。例如 `dsh-tool-web``fetchTimeoutMs` / `searchTimeoutMs`(默认 30000)解析到 `web_fetch` / `web_search` 的定义上:
该插件是 `@deepseek-ai/dsh-timeout-policy`,一个零配置的函数/命名空间插件(`name` / `inject` / `apply`,位于 `packages/timeout/`。每个工具的预算声明在工具自身,而非插件:`ToolDefinition` 携带一个可选的 `timeoutMs`,由拥有该工具的插件从自身配置中设置。例如 `dsh-tool-web``fetchTimeoutMs` / `searchTimeoutMs`(默认 30000)解析到 `web_fetch` / `web_search` 的定义上:
```yaml
- id: timeout-policy
@@ -52,11 +52,11 @@ catch 是基础 `next()` 而非 waterfall 之外的东西,这一点是关键
searchTimeoutMs: 30000
```
超时声明在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止时间信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。
超时在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。
信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的参数,使用共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的文档惯用法——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始信号,使 `tools/post-execute` 永远不会看到插件的(可能已中止的)截止时间信号。
信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的惯用方式——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始,使 `tools/post-execute` 永远不会看到插件的(可能已中止的)截止信号。
`timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止时间代码(作用域,使嵌套的外层截止时间读取为普通取消),以及结构化工具结果错误代码。其替换结果为:
`timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止代码(作用域,使嵌套的外层截止为普通取消)结构化工具结果错误代码。其替换结果为:
```ts ignore-check
function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
@@ -68,44 +68,44 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
}
```
这是一个协作式截止时间。它不会通过工具 promise 竞速来杀死任意工作;工具或其调用的能力必须遵 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 的含义是「此工具 `exec.signal` 协作式的」,插件 README 将此作为契约声明
这是一个协作式截止。它不会通过竞争工具 promise 来杀死任意工作;工具或其调用的能力必须遵 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 意味着「此工具 `exec.signal` 协作」,插件 README 将此作为契约。
可重建性不需要新的会话事件`TOOL_TIMEOUT` 是该调用最终面向模型的 `tool/result`,因此现有会话日志已经记录了下一次模型请求所看到的内容和结构化 `{ name, code }` 错误。
无需新的会话事件来保证可重建性:`TOOL_TIMEOUT` 是该调用最终面向模型的 `tool/result`,因此现有会话日志已经记录了下一次模型请求所的内容和结构化 `{ name, code }` 错误。
### 现有工具适配
`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent 的形`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent 的形`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
`dsh-web-fetch-local` 保留一个配置的提供方级 `timeoutMs`作为直接调用 `ctx.web.fetch()` 的调用方和配置错误部署的大资源兜底;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜。
`dsh-web-fetch-local` 保留一个配置级别的 `timeoutMs` 作为大型资源兜底,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜
`bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background``dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。
`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们要么只能尽力而为,要么没有必要。
`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们而言要么只能尽力而为,要么没有必要。
未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对类工具造成问题,bash seam 可以后续添加调用方有截止时间的模式;不在本次范围内。
未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对类工具造成问题,bash seam 可以后续添加调用方有截止模式;不在本次范围内。
## 曾考虑的替代方案
**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该守卫要求每个匹配项注册一个面向模型的工具。插件不注册任何工具——它是 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制一个误导性的启动条目。包为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。
**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包package为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。
**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。对 web 类工具而言它不够好,因为每个新的支持超时的工具都必须自行选择校验、上限语义、文档、快照和分类。插件集中了策略和分类,同时让每个工具的 schema 专注于业务输入。
**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。
**立即将所有超时策略移出 bash-local。** 长期更干净bash-local 将为纯子进程执行器,所有调用方拥有自己的截止时间。作为第一步不合适,因为钩子直接调用 `ctx.bash` bash 模型工具前台/后台语义,这与工具调用生命周期不同。保留 `BASH_TIMEOUT` 维持了这些路径的稳定,同时工具调用超时在更简单的工具上验证自身
**立即将所有超时策略移出 bash-local。** 长期来看更干净——bash-local 将为纯子进程执行器,所有调用方自行管理截止时间。作为第一步不合适,因为钩子直接调用 `ctx.bash` bash 模型工具前台/后台语义与工具调用生命周期不同。保留 `BASH_TIMEOUT` 维持了这些路径的稳定,同时工具调用超时在更简单的工具上先行验证。
**为所有工具使用全局默认预算。** 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明预算使采纳成为有意的行为。
**为所有工具使用全局默认预算。** 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明预算使采纳成为有意的行为。
**暴露面向模型的 `timeout_ms` 覆盖参数。** Claude Code 的 `WebFetch`/`WebSearch` 和 Codex 的 web 工具将超时排除在模型调用形之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 `timeout-policy` 引入 schema/参数剥离规则。Web 超时仅作为部署策略。
**暴露面向模型的 `timeout_ms` 覆盖参数。** Claude Code 的 `WebFetch`/`WebSearch` 和 Codex 的 web 工具将超时排除在模型调用形之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 `timeout-policy` 引入 schema/参数剥离规则。Web 超时仅作为部署策略。
**让 `timeout-policy` 自行匹配工具参数。** 类似「当 `bash.run_in_background` 为 true 时禁用超时」的规则引擎会使策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来避此问题。
**让 `timeout-policy` 自行匹配工具参数。** 诸如「当 `bash.run_in_background` 为 true 时禁用超时」之类的规则引擎会策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来避此问题。
**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`post 监听器可以分类并替换。这不可行,因为截止时间的生命周期跨越两个独立的 waterfall:需要 call-id 映射、在每 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。
**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`post 监听器可以分类并替换。这样做的问题是截止时间的生命周期跨越两个独立的 waterfall:需要 call-id 映射、在每 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。
**使用 `Promise.race` 非协作工具强制超时。** 否决,原因与超时库 RFC 相同:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。
**使用 `Promise.race` 非协作工具强制超时。** 与超时库 RFC 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。
## 后果
- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是窄的包装注册表分发,而非替代 pre 门禁或 post 结果策略基础 `next()` 是「分发并规范化」,因此包装器永远不会看到原始的工具抛出。
- 多个 `tools/execute` 监听器通过普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。
- 按声明加入是一个有意的配置错误风险:工具可以声明 `timeoutMs` 但不遵 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作web 工具在已转发信号的工具上证了这一模式。
- 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。
- 与字面提案的偏差,按已实现 RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`Cordis 会忽略后者)逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置)而非在插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在「## 决策」中描述。
- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。
- 多个 `tools/execute` 监听器普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。
- 按声明加入是一个有意的配置风险:工具可以声明 `timeoutMs` 但不遵 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上证了这一模式。
- 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。
- 与字面提案的偏差,按 implemented-RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`Cordis 会忽略后者)逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置)而非在插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文「决策」一节中描述。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-08-agent-scope-contexts.md: f238d58d90413d36e81b34c1a2c94e1291e889de
2026-07-08-agent-scope-contexts.zh.md: dfade19709ccbf206054f414882ecff114ac1e44
2026-07-08-agent-scope-contexts.zh.md: 0f4de12e782fc72d3761b6d46cd953ab4650654d
@@ -6,25 +6,25 @@ Status: implemented
## 问题
一个应用需要在多个 agent(智能体)之间共享基础设施,同时让每个 agent 拥有自己的工具、prompt 贡献、策略和监听器。共享的适配器、持久化和用户界面属于部署层面;而一个人设、工具变体或监听器往往只属于某一个 agent。
一个应用需要在多个 agent(智能体)之间共享基础设施,同时让每个 agent 拥有自己的工具、提示词贡献、策略和监听器。共享的适配器、持久化和用户界面属于部署层面;而 persona、工具变体或监听器往往只属于某一个 agent。
为每个 agent 建立独立的服务图会重复共享基础设施。一个全局注册图则有相反的问题:某个 agent 的专属贡献可能泄漏到无关的 agent 中。贡献者需要一种普通的注册机制,既能决定谁看到项贡献,又能决定何时清理它。
为每个 agent 建立独立的服务图会重复共享基础设施。使用一个全局注册图则有相反的问题:某个 agent 特有的贡献可能泄漏到无关的 agent 中。贡献者需要一种普通的注册机制,既能决定谁可以看到项贡献,又能决定何时清理它。
该机制还需要一个发布边界。agent 在其本地世界完整之前不得变为可见,拆除过程必须保留该世界直到最终工作停止。
该机制还需要一个发布边界。agent 在其本地世界构建完成之前不得变为可见,拆除时也必须保留该本地世界直到最终工作停止。
## 决策
每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有贡献的上下文进行注册;感知作用域的服务将部署全局注册与恰好一个匹配的 agent 层合;操作从其真实 agent 选择该层;该层在 agent 完整的已发布生命周期内存在。
每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的 context 进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合;操作从其真实 agent 选择该层;该层在 agent 完整发布生命周期内存在。
Cordis 是 SDK 底层的插件框架。Cordis **上下文(context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../cordis-primer.md)对框架有更详细的说明。
Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../cordis-primer.md)对框架有更详细的说明。
对大多数贡献者而言,完整契约是四条规则:
对大多数贡献者而言,完整契约是四条规则:
| 问题 | 规则 |
|---|---|
| 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通注册 API |
| 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,使用所属服务的合并规则 |
| 哪些作用域监听器会运行? | 无作用域监听器加上为该操作 agent 注册的监听器 |
| 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通注册 API |
| 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,所属服务的合并规则 |
| 哪些作用域监听器会运行? | 无作用域监听器加上为该操作所属 agent 注册的监听器 |
| 该层存在多久? | setup 在发布前完成;dispose 保留该层直到工作达到静止 |
作用域是扁平的。解析永远不会遍历父级或兄弟作用域,生命周期所有权也不意味着注册继承。
@@ -43,20 +43,20 @@ flowchart LR
agentBLayer --> agentBView
```
缺失的交叉边就是隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。
缺失的交叉边隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。
配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 解释了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有独立的 `persona``toolFilter``maxDepth` 功能。
配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona``toolFilter``maxDepth` 功能。
### 注册来源决定可见性与清理
通过普通插件上下文进行的注册是部署全局的,随该插件 dispose。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域 dispose。
通过普通插件 context 进行的注册是部署全局的,随该插件一起 dispose(资源释放)。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域一起 dispose。
| 注册来源 | 默认可见性 | 随谁 dispose |
|---|---|---|
| 普通插件上下文 | 每个符合条件的 agent 视图 | 注册插件 |
| 普通插件 context | 每个符合条件的 agent 视图 | 注册插件 |
| `agent.ctx` | 仅该 agent 的视图 | agent 作用域 |
工具、prompt 段落与变量、工具限制、守卫作用域事件监听器都采用此契约。名的本地值通常对该 agent 遮蔽同名全局值;每个所属服务自行记录例外与合并行为。
工具、提示词段落与变量、工具限制、守卫以及作用域事件监听器都遵循此契约。名的本地值通常对该 agent 遮蔽同名全局值;所属服务文档会说明例外与合并行为。
普通贡献者的模式是在 agent setup 期间注册完整的本地世界:
@@ -89,35 +89,35 @@ await handle.dispose()
ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone
```
setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其契约仅限组合:通过强制转换或内部注册表调用来驱动或发布正在构建的 agent 是不受支持的
setup 接收一个完整的受信 Cordis context,因此可以组合普通插件和服务。其契约仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建的 agent。
### 操作选择视图
注册来源与操作主体是两个独立的事实。通过 `agent.ctx` 调用服务决定的是新注册归属何处,并不将后续读取绑定到该 agent。
工具查找与执行接收其服务的 agent。prompt 组装接收正在构建请求的 agent 的组装上下文。事件分发接收其领域主体。这使共享服务实例可在多个 agent 间复用,同时让每个操作的视图保持显式。
工具查找与执行接收其服务的 agent。提示词组装接收正在构建请求的 agent 的组装上下文。事件分发接收其领域主体。这使共享服务实例可在多个 agent 间复用,同时让每个操作的视图保持显式。
只有采纳了作用域契约的服务才会解析 agent 层。`agent.ctx` 不会自动改变任意 Cordis 服务调用的行为。
### 作用域事件将路由与事件数据分离
关于 Agent A 的事件通常到达无作用域监听器和 A 作用域监听器,而不到达 B 作用域监听器。没有 agent 主体的事件到达无作用域监听器。
关于 Agent A 的事件通常到达无作用域监听器和 A 作用域监听器,而不到达 B 作用域监听器。没有 agent 主体的事件到达无作用域监听器。
在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。
`{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。
`{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。
### 创建最后发布,dispose 最后撤销
`ctx.agents.create()``resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。
可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。
可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。
如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;所有失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。
如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。
`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。
`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。
调用方的 Cordis 上下文和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。
调用方的 Cordis context 和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。
```mermaid
flowchart TB
@@ -139,25 +139,25 @@ flowchart TB
## 安全与权限是非目标
agent 作用域组合的是受信的同进程注册。它不沙箱化插件不定义父到子的权限格不在创建时冻结授权也不保证子级不能做超出父级的事。
agent 作用域组合的是受信的同进程注册。它不沙箱化插件不定义父到子的权限格不在创建时冻结授权也不保证子级不能做超出父级的事。
父级可以拥有一个可见工具比自身更的子级,因为生命周期所有权不捐赠也不封顶注册。持有 Cordis 上下文的插件同样运行在同一进程中,可以直接调用可用服务。
父级可以拥有一个可见工具比自身更广的子级,因为生命周期所有权不赠予也不限制注册。持有 Cordis context 的插件同样运行在同一进程中,可以直接调用可用服务。
需要非升保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式未来授权 API以及通用的能力/输出/终止标签均不在本决策范围内。
需要非升保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式未来授权 API以及通用的能力/输出/终止标签均不在本决策范围内。
## 曾考虑的替代方案
被否决的设计要么将可见性与清理分离,要么只覆盖一注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。
被否决的设计要么将可见性与清理分离,要么只覆盖一注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。
### 向每注册传递 agent 选项
### 向每注册传递 agent 选项
类似 `tools.register(definition, { agent })` 的 API 在每个注册表中重复作用域管道,允许可见性所有权与清理所有权漂移。通过 `agent.ctx` 注册使两个事实跟随同一个 Cordis 效果所有者
类似 `tools.register(definition, { agent })` 的 API 在每个注册表中重复作用域管道,允许可见性所有权与清理所有权漂移。通过 `agent.ctx` 注册使两个事实跟随同一个 Cordis effect owner
### 过滤事件但保持注册表全局
监听器过滤阻止错误的钩子运行,但无法限定工具 schema、可执行查找、prompt 段落、变量或其他已注册数据的作用域。agent 本地组合仍需临时的全局变更。
监听器过滤可以阻止错误的钩子运行,但无法限定工具 schema、可执行查找、提示词段落、变量或其他已注册数据的作用域。agent 本地组合仍需临时的全局变更。
### 为每个 agent 创建一个服务图
### 为每个 agent 创建独立的服务图
所需的视图是共享部署服务加上一个本地注册层。每 agent 一个图会重复适配器,并使共享持久化、提供方注册表和应用启动复杂化。
@@ -167,6 +167,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件,
## 后果
贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作选择真实 agentdispose 返回的 handle。从观察者角度看 setup 是原子的,teardown 保留本地行为直到工作停止。
贡献者使用一种熟悉的模式:通过插件 context 注册共享行为,通过 `agent.ctx` 注册本地行为,在操作选择真实 agentdispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。
代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等于权限,subagent 组合控制作为独立功能存在,而非隐藏作用域语义
代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等于权限,subagent 组合控制作为独立功能存在,而非隐藏作用域语义。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-14-acp-agent-client-protocol.md: 0bb2a2f2e307b8f23a3a9ca98edb2a2d3b5df0a8
2026-06-14-acp-agent-client-protocol.zh.md: 19744cb9f4d4b675586d4327a1da423fdea21b77
2026-06-14-acp-agent-client-protocol.zh.md: 7bb0e066572150c9c8fc0b94de1d5bf2d69527ee
@@ -1,4 +1,4 @@
# RFCACPAgent Client Protocol)支持——从外部编辑器驱动编码 agent
# RFCAgent Client ProtocolACP)支持——从外部编辑器驱动编码 agent
[English](2026-06-14-acp-agent-client-protocol.md) | 中文
@@ -6,54 +6,54 @@ Status: implemented
## 问题
harness 最初通过 readline 循环暴露 agent(智能体)。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成状态、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的情况下取消某个对话。ACP 将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
harness 最初通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACPAgent Client Protocol将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
桥接层必须保持 harness 既有的职责边界。它不能依赖具体的 agent loop(智能体循环)绕过工具注册表在编辑器中执行 shell 命令,发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
## 决策
`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk``AgentSideConnection`(基于 stdin/stdout),仅编接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不改 agent loop,也不是能力 seam 的实现。
`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk``AgentSideConnection`(基于 stdin/stdout),仅编接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不改 agent loop,也不是能力 seam 的实现。
桥接层实现以下稳定的会话路径:
- `initialize` 协商协议版本,声明支持 text 与 `resource_link` prompt,并声明 `loadSession`
- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回组合支持的配置选项。
- `session/load` 在构造 agent 之前,先用持久化元数据校验请求的 cwd在异步恢复期间留 id;将 user/assistant/tool 事件作为 ACP update 回放并报告恢复后的 config-option fold
- `session/prompt` 接受 text 和 resource link,拒绝不支持或空的内容,每个会话只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误 turn 拒绝 RPC;其他关闭 turn 的原因通过一个全覆盖的 ACP stop-reason codec 映射。
- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的 prompt,并声明 `loadSession` 能力
- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回组合支持的配置选项。
- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间留 id,将用户/助手/工具事件作为 ACP update 回放并报告恢复后的 config-option 折叠结果
- `session/prompt` 接受文本和 resource link,拒绝不支持或空的内容,每个会话同时只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。
- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的 prompt。
工具调用的呈现仍由工具自身负责。工具的 `presentCall``presentResult` 返回 `generic``terminal``diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控 `_meta.terminal_info``_meta.terminal_output``_meta.terminal_exit` 约定harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境变量清理、任务归属和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中没有硬编码工具名分支。
工具调用的展示仍由工具自身负责。工具的 `presentCall``presentResult` 返回 `generic``terminal``diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控约定 `_meta.terminal_info``_meta.terminal_output``_meta.terminal_exit`harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境清洗、所有权和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中无需硬编码工具名分支。
权限处理是[用户审批 seam](2026-07-06-approval-seam.md) 上的一个 answerer,而非 ACP 中「每次工具调用都询问」策略。一个带有 call id 的、针对桥接层所属 agent 的 `approval/request`,会变该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。非本桥接层的请求或无 call id 的请求委托路径;answerer 缺失或失败时保持 fail-closed。决定是否询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。
权限处理是 [user-approval seam](2026-07-06-approval-seam.md) 上的一个 answerer,而非 ACP 中「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id `approval/request`,会变该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 保持 fail-closed。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。
`ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。出厂`workspace-write``danger-full-access` 预设各自捆绑一个沙箱模式与一审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在 open turn 期间的切换立即追加;idle 状态下的切换在响应中叠加,并在下一次 `agent/prompt-submit` 时锚定,位于请求组装之前。在此之前它仅存于内存,因此崩溃后恢复的是持久化的 fold。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 仍为连接级。
`ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。已发布`workspace-write``danger-full-access` 预设各自捆绑一个沙箱模式与一审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在开放轮次中的切换立即追加;空闲时的切换叠加在响应中,并在下一次 `agent/prompt-submit` 时锚定到开放轮次之前的请求组装阶段。在此之前它仅存于内存,因此崩溃后恢复的是持久化的折叠结果。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 保持连接级
桥接层还提供基于 ACP 的 `UserInteractionProvider``ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述自定义回答覆盖语义均被保留。
桥接层还提供基于 ACP 的 `UserInteractionProvider``ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述自定义回答覆盖语义均被保留。
生命周期归属是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt并行 dispose 每个 handle等待循环静默持久化刷,然后移除记录。流通知失败被隔离,消失的客户端无法破坏 agent turn。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt并行 dispose 所有 handle等待循环静默持久化刷,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md)package README 是运维契约。
精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md)package README 是操作契约。
## 曾考虑的替代方案
**在 `tools/execute` 前置一个监听器,对每个 ACP 所属调用都询问权限**:否决。这会权限策略硬编码 UI 桥接层,即使没有策略要求也会询问,且无法服务执行开始后才产生的审批请求。共享的用户审批 seam 将机制、询问策略和 UI answerer 分离。
**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会权限策略硬编码 UI 桥接层,即使没有策略要求也会询问,且无法服务执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。
**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、idle 观察和 dispose `dsh-agent` 上的接口级归属操作;UI 插件不需要依赖规则例外。
**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放`dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。
**通过 ACP `terminal/*` 执行 bash**:否决。那会把执行移到 harness 之外,绕过其沙箱、凭证清、任务归属、cwd 解析会话日志。终端元数据仅用于呈现
**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清、任务所有权、cwd 解析会话日志。终端元数据仅用于展示
**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的接口。
**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。
**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用归属范围,且与协议传输竞争。应用组合拥有 stdout 纯净性。
**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。
## 后果
编辑器可以通过一条 ACP 连接创建、加载、prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 每会话配置的持久真源。工具呈现与人工回答通道仍是可扩展的插件契约,而非 ACP 特有行为。
编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。
桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托,以及 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。
桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。
idle 状态下的 config 选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到 open turn 之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件 turn 封闭且回放安全的代价。
空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。
## 验证
ACP 测试套件覆盖内存协议编解码、创建/加载回放、精确的 prompt 结算、取消竞、不支持的内容、工具呈现、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/dispose 静默,以及 HMR(热模块替换)清理。快照 built-bin 测试验应用组合,真实 API 的 e2e 在无 key 时自动跳过。
ACP 测试套件覆盖内存协议编解码、创建/加载回放、精确的 prompt 结算、取消竞、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放静默,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-14-acp-multi-session.md: b96557d2d94711adb2183aa4f5dd8debf39c1de8
2026-06-14-acp-multi-session.zh.md: 263e292e7161b789b8e06772deb0d2bc896f8de6
2026-06-14-acp-multi-session.zh.md: 6a9f5e8162d46ed8719164247e22b8b9c5d26c61
@@ -1,39 +1,39 @@
# RFC:在单连接上多路复用并发 ACP 会话
Status: implemented
# RFC:在单连接上多路复用并发 ACP 会话
[English](2026-06-14-acp-multi-session.md) | 中文
Status: implemented
## 问题
一个 ACPAgent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上持多个活跃对话。如果桥接层只允许单活跃会话,就不得不额外启动进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台任务 id 绝不能跨越会话边界。
一个 ACPAgent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。
## 决策
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引, agent 作用域的回调使用。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待生效的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造重复的 agent;不同 id 可以并发加载。
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,用于 agent 作用域的回调。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造重复的 agent;不同 id 可以并发加载。
每个 `session/event``agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end`时结算;来自已取消的前轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到条记录,只调用该 agent 的队列感知取消路径。
每个 `session/event``agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end`时结算;来自已取消的前轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到条记录,只调用该 agent 的队列感知取消路径。
权限归属使用同一个反向索引。ACP `approval/request` 应答器向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值折叠该会话自身的事件,待生效的空闲切换存储在该记录上,直到下一轮次将其锚定。
权限归属使用同一个反向索引。ACP `approval/request` 应答器向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话的 session id。`bash_output``bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不授予访问权。归属信息存储在执行器任务,因此工具插件重载不会擦除它。
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output``bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息执行器任务一起存储,因此工具插件重载不会擦除它。
连接拆除时清空活跃 map,将每个待结算的 prompt 以取消状态结算,并并行 dispose 所有 `AgentHandle`。每个句柄停止并等待其循环结束,在仍挂载时刷新会话注销 agent,然后移除会话。拆除操作被 memoize 并在客户端断开与插件 dispose 之间共享。
连接拆除时清空活跃 map,将每个待处理的 prompt 以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话注销 agent移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。
## 曾考虑的替代方案
**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。
**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。
**每会话一个 `ctx.extend()`**:否决。子上下文本身不创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话的归属记录;agent 生命周期由 `AgentHandle` 拥有
**每会话 `ctx.extend()`**:否决。子上下文本身不创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话拥有的记录;agent 生命周期由 `AgentHandle` 管理
**以 agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是应当在插件重载后存活的跨边界标识
**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是跨边界的标识,应当在插件重载后仍然存活。
## 后果
N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不为每个会话添加一监听器,因此在长连接期间避免了监听器扇出。
N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不为每个会话添加一监听器,从而避免了长连接期间监听器扇出。
桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于推迟状态。
桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于延期状态。
## 验证
多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。
多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-15-code-mode.md: c64b6d6d8442e60240fa6c849833385ed50d71ff
2026-06-15-code-mode.zh.md: f94ef61dae180bad5ec604b5c7f9552eeecff593
2026-06-15-code-mode.zh.md: e9ae74f6629f3e34a0e97f0fa532764c70095bba
@@ -1,132 +1,132 @@
# RFCCode Mode——模型针对工具注册表编写 TypeScript
Status: implemented
[English](2026-06-15-code-mode.md) | 中文
Status: implemented
## 问题
在注册表的原生呈现方式,agent loop(智能体循环)将每个可见能力作为 JSON Schema 函数定义广播`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每个**中间 `tool-result` 都在下一次请求时重新进入模型上下文。
在注册表的原生呈现方式,agent loop(智能体循环)将每个可见能力 JSON Schema 函数定义的形式通告给模型`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每个**中间 `tool-result`在下一次请求时重新进入模型上下文。
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都把整个中间结果拖回上下文,无论模型是否需要。
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单观察:LLM(大语言模型)写代码发出工具调用更擅长,因为它们见过数百万行真实代码,而见过的人造工具调用 trace 相对很少。模型不再每步发出一工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只取回它打印或返回的内容——而非所有中间结果。
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单观察:LLM(大语言模型)写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。
工具呈现属于拥有工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位Node `worker_threads` 提供独立隔离区、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 有的信任模型(§信任姿态)。
工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 有的信任模型(§信任姿态)。
## 决策
三项决策,各自在下方独立小节中展开:
1. **Code Mode 是 `ToolRegistry``dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 放入系统提示词)`'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其规范贡献;协作式 prompt 组装的结果仍具权威性,请求头日志记录的正是该返回的呈现。
1. **Code Mode 是 `ToolRegistry``dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 系统提示词)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。
2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`core 消费 seam 的先例见 `agent-loop``dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行启动一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——不需要 unsafe-acknowledgement 标志——因为 harness 已经提供`dsh-bash-local`,后者以严格**更**的环境权限执行模型编写的任意 shell 命令。
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付`dsh-bash-local`,后者以严格**更**的环境权限执行模型编写的任意 shell 命令。
### 注册表拥有模式
`ToolRegistry` 获得一个 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 切换`tools: { mode: code }`——无需改代码,遵循 no-hardcoded-tunables 约定。
`ToolRegistry` 获得一个 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式`tools: { mode: code }`无需改代码,遵循 no-hardcoded-tunables 约定。
**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接 prompt 提供方和组装 waterfall 仍各自负责自己的贡献。
**与 `toolOrder` 的交互,预先明:** 如果配置的 `systemPrompt.toolOrder` 命名了原生能力,`mode: 'code'` 下会拒绝所有组装,因为些名称不在该模式的协议校验范围内。这是正确行为,不是 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。
**与 `toolOrder` 的交互,预先明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。
**SDK prompt 段**`'code'``'both'` 下,tool-guidance order band 中的惰性 `tools:sdk`落为作用域内可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**SDK prompt 段。**`'code'``'both'` 下,tool-guidance order band 中的惰性 `tools:sdk`为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**组装所有权。** `run_code``tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。作用域内`tools:sdk`可以在分发前遮蔽全局默认值,监听器可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议可行;没有恢复 pass 会覆盖有意的组合。
**组装所有权。** `run_code``tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**代码生成。** `jsonSchemaToTs()``defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc并将不支持的构造降级为 `unknown`。SDK 带引号的对象键暴露工具,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
**代码生成。** `jsonSchemaToTs()``defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
### run_code 工具与分发桥
### run_code 工具与分发桥
`'code'``'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以便分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是归一化的外层结果。其 `execute(args, exec)`
`'code'``'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`
1. **构建绑定。** 一个 run 作用域的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 归一化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变执行身份,并遍历完整的工具流水线。
2. **运行程序**`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 作用域的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后没有子调用可以追加。
1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变执行标识,并遍历完整的工具流水线。
2. **运行程序**`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。
**子调用的 `additionalContext` 被省略。**`run_code` 期间注入它会破坏父调用/结果的邻性,而一个程序可以产生多个上下文。支持它需要一个复数通道或循环级别的子分发缓冲区。
**子调用的 `additionalContext` 被省略。**`run_code` 期间注入它会破坏父调用/结果的邻性,而一个程序可以产生多个 context。支持它需要一个复数通道或循环级别的子分发缓冲区。
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要工具的并发安全元数据。
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。
**呈现。** `run_code`渲染意图按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,内容为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。
**呈现。** `run_code` render intent 按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。
### 可观测性:`tool/code-dispatch`
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具身份、归一化参数和结果摘要。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具标识、规范化参数和结果摘要。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开`run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
### code-runtime seam
`packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`,仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service``super(ctx, 'codeRuntime')`)加词汇:
`packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`,仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service``super(ctx, 'codeRuntime')`)加词汇:
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出, `error` 字段解析`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端 rejection
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——正交的结果按[防御性模式](../../../defensive-patterns.md)独立报告;超时的 run 不是异常,abort 不是超时。
- 两个只读的后端描述符,仅供信息参考不用于门控`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来`'process'``'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时。
- 两个只读的后端描述符,仅供信息参考而非门禁判定`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来`'process'``'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此原生模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 后替换实现,配对相应的 SDK 生成器。
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 后替换实现,配对相应的 SDK 生成器。
### worker 线程运行时
### worker-thread 运行时
`@deepseek-ai/dsh-code-runtime-worker``packages/code-runtime/` 组的第二个包。每次 `run()`
`@deepseek-ai/dsh-code-runtime-worker``packages/code-runtime/` 组的第二个包package。每次 `run()`
1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes``node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。Strip-only 模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理任何其他程序错误一样自我正。语法级别的失败永远不会 spawn worker。
2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化不跨 run 共享状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。
3. **在 bootstrap 中执行**:剥离类型后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await``return` 可用,程序的完成值即为 run 的 `value`structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。
4. **通过消息端口桥接绑定**worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }``{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__``constructor``toString` 的绑定是普通自有属性,不会产生原型链冲突。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是敌对的,因为对端运行的是模型代码。
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 限制总经过时间,包括未完成的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。
6. **Dispose 至静默**:服务自身的 disposal 终止进行中的 worker 并*等待*它们退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。
1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes``node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我正。语法级失败不会 spawn worker。
2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。
3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await``return` 可用,程序的完成值即为 run 的 `value`structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。
4. **通过 message port 桥接绑定**worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }``{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__``constructor``toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。
6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。
### 信任姿态
worker 运行时提供的是封闭隔离,而非安全边界:模型代码可以触及 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门,并额外提供空环境、堆限制、独立隔离区和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
worker 运行时提供的是隔离,而非安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
### 模型看到的内容
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时 catch 被 reject 的工具调用,并仅返回或打印应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
## 后果
切换到 `'code'` 的部署必须更新任何仅原生`toolOrder`。组装监听器负责维护任何被重写的协议面的完整性。子分发保持序列化,桥接不会传播逐调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。
切换到 `'code'` 的部署必须更新任何仅限 native `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,桥不传播每次调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。
## 测试
- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、敌对绑定流量、空环境、structured-clone 回退、输出上限和 disposal 至静默。一个 built-package 测试在纯 Node 下运行 worker 入口。
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、作用域可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 归一化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、生成的文件和精选的回答。
- **快照:** `code-mode-turn``both-mode-turn` fixture(测试前置数据)固定 SDK 段落、头部工具列表、分发事件和结果卡片。
- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、结果文件和策展后的回答。
- **快照:** `code-mode-turn``both-mode-turn` fixture(测试前置数据)固定 SDK 段、请求头工具列表、分发事件和结果卡片。
## 曾考虑的替代方案
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,依赖监听器顺序。模型提供哪些工具、以何种表示,是注册表的单一关注点:原生 schema 和 SDK 是同一可见存储的两种投影。
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,依赖监听器顺序。模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一可见存储的两种投影。
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立隔离区、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
**原生工具调用做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志表面替换很容易添加,但仍每次调用付出一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
**原生工具调用做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志表面替换成本低,但仍每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
**循环中的并行原生分发。** 往返开销的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策使两者兼容:当元数据就绪时,原生并行分发和工具绑定并行化一起解锁。
**循环中的并行原生分发。** 往返成本的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发和工具绑定并行化一起解锁。
**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash``read``edit`)作为原生调用已经是理想的,强每次编辑都通过程序会加重常见场景负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用而不强加。
**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash``read``edit`)作为原生调用已经是最优的,强每次编辑都通过程序会常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用而不强加于人
**工具可见性层(此工具原生,彼工具 code)。** 推迟:它需要工具元数据和 `'native' | 'code' | 'both'`具备的呈现拆分,且其设计依赖于模型在 `'both'` 下如何分配使用的证据。
**工具可见性层(此工具 native,彼工具 code-only)。** 推迟:它需要工具元数据和 `'native' | 'code' | 'both'`提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。
**SDK 中的消毒标识符别名**`my-tool``my_tool`Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名冲突逻辑;模型处理 `tools["my-tool"](…)` 没有问题
**SDK 中的清洁化标识符别名**`my-tool``my_tool`Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`
**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。MVP 否决:跨调用状态对会话日志不可见,破坏了每个请求是日志纯函数可重建性保证;每次 run 全新保持了这一点。内核风格后端在未来仍可通过 seam 表达,配合自己的日志方案。
**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。MVP 否决:跨调用状态对会话日志不可见,破坏了每个请求是日志纯函数」这一可重建性保证;每次 run 全新保持了这一点。内核风格后端在未来仍可通过同一 seam 表达,配合自己的日志方案。
## 风险
**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于现有 bash 工具,封闭隔离超过它,门使用相同的 seam。需要更的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计扩展跟踪,而非本设计的 TODO。
**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,隔离程度超过它,门使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计扩展跟踪,而非本设计的 TODO。
**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数后,且 `amaro`/`sucrase` 是 API 变时的即插即用替代品。可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。
**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数后,且 `amaro`/`sucrase` 是 API 变时的直接替代品。可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。
**SDK 的 prompt 开销,尤其在 `'both'` 下。** `.d.ts`与它补充的原生 schema 相当`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话开销mode 是部署的;本 RFC 不做无条件节省的声明。何时偏好哪种模式的量化指导明确上线后学习。
**SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts`与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本mode 是部署的;本 RFC 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。
**注册表范围增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts``code-mode.ts``schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底形状的东西都在 `ctx.codeRuntime`
**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts``code-mode.ts``schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 后。
**Structured-clone 值可超出 JSON。** 因此工具绑定在分发前对参数做 JSON 归一化,确保每执行的调用都可以被记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。
**Structured-clone 值可超出 JSON。** 因此工具绑定在分发前对参数做 JSON 规范化,确保每执行的调用都可记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的相同并发安全元数据绑定。
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。
**预算计量读取事件循环,而非标志** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending dispatch 无法暂停它」)对敌对程序是承重的。两侧都有单元测试(带 pending decoy dispatch 的热循环在 `computeMs` 死亡;idle-on-slow-binding 存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过。
**预算计量读取事件循环,而非 flag** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)对恶意程序是承重的。两侧都有单元测试(带 pending 诱饵分发的热循环在 `computeMs` 死亡;在慢绑定上空闲的程序存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-17-filesystem-tool-schemas.md: c2d3aa679599b1129a19b9082b9254ecf3103f12
2026-06-17-filesystem-tool-schemas.zh.md: b13274c41da244d7d2a5fe6ff2064d8d5e0a9b42
2026-06-17-filesystem-tool-schemas.zh.md: cd504a37d5ee25f6634b651d30099afe5acd6495
@@ -1,24 +1,24 @@
# RFC:文件系统工具 schema——面向模型的读/写/编辑形状
Status: implemented
# RFC:文件系统工具 schema——面向模型的读/写/编辑接口形状
[English](2026-06-17-filesystem-tool-schemas.md) | 中文
Status: implemented
## 问题
[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam`ctx.fs`)、包拆分(`dsh-fs``dsh-fs-local``dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及 read-before-write/edit 检查所依赖的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) 两份 RFC 随后将该策略`ctx.fs`到了 `dsh-fs-policy` 插件的 `fs/*` 事件门上。第一版文件系统工具交付剩余的决策是面向模型的 schema 表面:模型在 `read``write``edit` 中看到哪些参数。
[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam`ctx.fs`)、包package拆分(`dsh-fs``dsh-fs-local``dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFC 后来将其`ctx.fs` `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read``write``edit` 中看到哪些参数。
schema 应足够小,`dsh-tool-fs` 的首次实现中完成;同时又足够稳定,使未来的本地/远程/沙箱文件系统后端不会引起面向模型的接口变动。它还应避免从参考系统照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有表面
schema 应足够小,以便`dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有接口
## 决策
`@deepseek-ai/dsh-tool-fs`第一版文件系统工具套件中暴露以下三个面向模型的工具:
`@deepseek-ai/dsh-tool-fs`首个文件系统工具套件中暴露以下三个面向模型的工具:
| Tool | 我们的 schema | Claude Code | OpenCode | 说明 | 纳入原型 |
| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype |
|---|---|---|---|---|---|
| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | 仅文件;`offset` 从 1 开始;首次实现不支持图片/PDF/多模态。 | 是 |
| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | 创建或覆写 UTF-8 文本。在默认 fs-policy 下,更新已有文件需要先有一次观测;新建文件则不需要。 | 是 |
| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | 字面字符串替换;默认要求唯一匹配;在默认 fs-policy 下需要先有一次观测(任何窗口化的 read 都算)。 | 是 |
| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES |
| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES |
| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES |
schema 使用 snake_case 字段名(`file_path``old_string``new_string``replace_all`),与 Claude Code 及现有 DeepSeek Harness 工具 schema 示例保持一致。消费方包将这些面向模型的名称转换为 `ctx.fs` 调用和 `fs/*` 事件分发。
@@ -26,47 +26,47 @@ schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string`
### `read`
`read`一个 UTF-8 文本文件并返回带行号的内容。
`read`一个 UTF-8 文本文件并返回带行号的内容。
参数:
- `file_path: string`——必填。要读取的路径,由 `ctx.fs` 解析。
- `offset?: number`——可选。返回的第一行,从 1 开始。默认为第一行。
- `limit?: number`——可选。返回的最大行数。默认值上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。
- `limit?: number`——可选。返回的最大行数。默认值上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。
首次实现的非目标
首次实现不涉及的内容
- 不支持 PDF `pages` 参数。
- 不支持图片或多模态文件读取。
- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的未来工具。
- PDF `pages` 参数。
- 图片或多模态文件读取。
- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的后续工具。
### `write`
`write` 创建或完替换一个 UTF-8 文本文件。
`write` 创建或完替换一个 UTF-8 文本文件。
参数:
- `file_path: string`——必填。要写入的路径,由 `ctx.fs` 解析。
- `content: string`——必填。要写入的完整 UTF-8 文本内容。
在默认 fs-policy 下,用 `write` 更新已有文件需要同一执行上下文对该文件有过一次先前观测(read/write/edit);`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的 stale guard 提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。
在默认 fs-policy 下,使`write` 更新已有文件需要同一执行上下文先前对该文件有过一次观测(read/write/edit);`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的 stale guard 提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。
schema 不将 `expected_hash``expected_version``create_only` 暴露为面向模型的参数。stale-version 检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。
schema 不将 `expected_hash``expected_version``create_only` 为面向模型的参数暴露。过期版本检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。
### `edit`
`edit` 通过替换字面文本来更新一个已有的 UTF-8 文本文件。
`edit` 通过替换字面文本来更新已有的 UTF-8 文本文件。
参数:
- `file_path: string`——必填。要编辑的路径,由 `ctx.fs` 解析。
- `old_string: string`——必填。要替换的字面文本。首次实现中空字符串无效。
- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配
- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配内容
- `replace_all?: boolean`——可选。默认为 false。为 false 时,`old_string` 必须恰好匹配一处。
`edit` 要求同一执行上下文对该文件有过一次先前观测(任何窗口化的 read 都算——授权依据是版本新鲜度,而非全文查看要求),或该上下文对该文件有过先前的 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 强制执行。
`edit` 要求同一执行上下文先前对该文件有过一次观测(任何窗口化的 read 都算——授权基于版本新鲜度,而非全文查看要求),或该上下文先前对该文件做过 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 负责执行。
首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,后端可以自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。
首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。
## 结果形状
@@ -74,17 +74,17 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为
默认原生投影:
| Tool | `tool-fs` 消费的结构化 `ctx.fs` 结果 | 默认模型投影 |
| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection |
|---|---|---|
| `read` | 返回的行、返回行数、总行数、目标显示路径、文件版本、部分视图标志 | 带行号的文本加分页脚注 |
| `write` | create/update 操作、目标显示路径、新文件版本 | 简洁的 create/update 成功文本 |
| `edit` | 替换次数、replace-all 标志、目标显示路径、新文件版本 | 简洁的 edit 成功文本 |
| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer |
| `write` | create/update operation, target display path, new file version | concise create/update success text |
| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text |
结构化结果不重复模型参数(如 `file_path``old_string``content`),除非后端已将其解析为新信息(如 `displayPath``targetKey` 或新版本)。token 感知的截断属于模型投影的职责,不属于后端规范结果。
结构化结果不重复模型参数(如 `file_path``old_string``content`),除非后端已将其解析为新信息(如 `displayPath``targetKey` 或新版本)。面向 token 的截断属于模型投影的职责,而非后端规范结果的一部分
## 延后
## 延后事项
以下内容被明确排除在首文件系统 schema 之外:
以下内容被明确排除在首文件系统 schema 实现之外:
- 面向模型的 `expected_hash``expected_version``create_only` 参数。
- 目录列表、glob、grep 和搜索工具。
@@ -95,18 +95,18 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为
## 测试
schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。
schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。
## 曾考虑的替代方案
- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。
- **camelCase 参数名(OpenCode 风格)**snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开表面
- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。stale 检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。
- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。
- **camelCase 参数名(OpenCode 风格)**snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开接口
- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。过期检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。
## 后果
**首版 schema 有意小于 Claude Code。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非初始 schema 上叠加重载。
**首版 schema 有意小于 Claude Code** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非初始 schema 重载。
**v1 没有显式的面向模型 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:stale 检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非来自模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非通过模型提供的版本字段。
**v1 没有显式的面向模型 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:过期检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。
**命名成为公开表面** 一旦发布,将 `file_path` 改为 `filePath` `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选 snake_case 并将其视为稳定的面向模型契约。
**命名成为公开接口** 一旦发布,将 `file_path` 改为 `filePath``old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选 snake_case并将其视为稳定的面向模型契约。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-acp-terminal-and-tool-rendering.md: cab89aa690c2068399ea5429a9c467410c744ce8
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 5a545b7a53f0814bc0e6071430c367047dc83f7f
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 4047c493e63ac23f758de718616fd1f4bb29f7d4
@@ -1,48 +1,48 @@
# RFC丰富的 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
Status: implemented
# RFC ACP bash 渲染——通过 `_meta` 约定实现终端卡片
[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文
Status: implemented
## 问题
ACP 桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 展示](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
ACPAgent Client Protocol桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格输出和退出状态;纯文本丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。
参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格输出和退出状态;纯文本丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。
## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create`
ACP 规范有一个*客户端侧*终端子协议:agent 调用客户端的 `terminal/create`传入 `{ command, args, cwd, env }`,由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境变量清洗、后台任务所有权、按会话的 cwd)。执行路由到编辑器会绕过所有这些机制,并将执行分裂为两个后端。
ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`传入 `{ command, args, cwd, env }`,由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。
研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理:
- **`claude-agent-acp`**`tools.ts``acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` `_meta.terminal_info.{ terminal_id, cwd }`;输出/退出通过 `tool_call_update``_meta.terminal_output.{ terminal_id, data }` `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。
- **`codex-acp`**`CodexToolCallMapper.ts``TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量)发送,由同一个 `_meta.terminal_output` 能力选择。
- **`claude-agent-acp`**`tools.ts``acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` `_meta.terminal_info.{ terminal_id, cwd }`;输出退出通过 `tool_call_update``_meta.terminal_output.{ terminal_id, data }` `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。
- **`codex-acp`**`CodexToolCallMapper.ts``TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量),由同一个 `_meta.terminal_output` 能力选择。
Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。它将能力声明为 `clientCapabilities._meta.terminal_output = true``_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范——但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一途径
Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。客户端通过 `clientCapabilities._meta.terminal_output = true` 声明此能力`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式
## 决策
保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。
1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。
2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data``exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash``bash` 返回该结构(cwd 来自解析后的工作目录;输出 + 退出从运行结果解析)。
3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会**替换**调用的 content 集合,因此重围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。
4. **退出标记从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态标记`_meta.terminal_exit.{exit_code,signal}`)会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。dispose 不受影响:没有新资源需要清理,因为桥接层从未创建客户端侧终端。
2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data``exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash``bash` 返回该结构(cwd 来自解析后的工作目录;输出退出从运行结果解析)。
3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会**替换**调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。
4. **退出信息从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态(`_meta.terminal_exit.{exit_code,signal}`确实会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。
## 曾考虑的替代方案
- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境变量清洗、后台任务所有权和按会话的 cwd,并将执行分裂为两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
- **通过事件 schema 传结构化退出**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,在同一文件中共同演进由往返测试守护。
- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
- **通过事件 schema 传结构化退出信息**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进由往返测试守护。
## 后果
- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端永远不会变差。如果 ACP 日后标准化了 agent 执行的终端,迁移到该标准并移除约定键。
- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对所有其他客户端的契约,绝不退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。
- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,迁移到该标准并移除约定键。
- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。
- **terminalId 冲突。** 从每次调用的 `callId` 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。
- **退出从渲染文本解析。** 退出标记通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传结构化退出(纯 `presentResult` seam 看不到结构化退出)。解析是标记发出的精确逆操作,位于同一文件中;一个往返测试固定了这对关系,标记格式变更如果破坏解析就会使测试套件失败。如果标记将来需要与退出标记的需求分歧,改为在 result 事件上暴露结构化退出。
- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方也会需要的丰富度。
- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,位于同一文件中;往返测试固定了这对关系,标记格式变更破坏解析测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。
- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。
## 不在范围 / 非目标
## 超出范围 / 非目标
文本块基线仍无能力声明时的默认行为。两个后续工作有意不在此处构建,各自需要独立 RFC**实时增量流式传输**`_meta.terminal_output_delta`在分片到达时发送,需要 `dsh-bash`增量输出 seam,以及**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片`grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。
文本块基线仍无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 RFC**实时增量流式传输**在分片到达时发出 `_meta.terminal_output_delta`,需要 `dsh-bash`新增增量输出 seam**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片`grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-compaction-capability-seam.md: 31b06905924a07a7f0c2af427d8868585966f1a7
2026-06-18-compaction-capability-seam.zh.md: ef71b3df39f02221f1bd25beb5e026cb056b95a0
2026-06-18-compaction-capability-seam.zh.md: 1675484ed65e5cd890f420d4bdd1e16e2a95b2ef
@@ -6,37 +6,37 @@ Status: implemented
## 问题
长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口——模型随即在响应中途截断(`max-tokens`)或质量退化。**压缩(compaction**缓解手段:用一段简洁的摘要替换一较早的历史,保持近期上下文完整。
长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口模型随即截断响应`max-tokens`)或性能退化。**上下文压缩(context compaction** 是对此的缓解手段:用一段简洁的摘要替换一较早的历史,保持近期上下文完整。
[会话 surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而建的基础设施:它是事件日志之上的链表,带有一个 `surfaceOp: { op: 'replace', start, end }` 操作,专门用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。
[session surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而建的基础设施:一条建立在事件日志之上的链表,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。
两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——这些与*何时*压缩、*压缩哪段*彼此独立变化。第二,`SurfaceEventType` 封闭的,只有五种事件类型(`user/message``assistant/message``tool/result``context/message``steering/message`);只有它们可以携带 `surfaceOp`。因此一个专`compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上 `surfaceOp`invariants 插件在运行时也会拒绝。
两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——它们独立于*何时*以及*压缩哪段范围*而变化。第二,`SurfaceEventType` 封闭五种事件类型(`user/message``assistant/message``tool/result``context/message``steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专`compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上附加 `surfaceOp`invariants 插件在运行时也会拒绝。
## 决策
### 压缩是一个能力 seam,接口与实现分离
按照[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进:
遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进:
1. **接口**`@deepseek-ai/dsh-compact`一个抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()``compactRegion()` 声明为**抽象方法**——契约阐述压缩*做什么*,而非*怎么做*。
2. **实现**`@deepseek-ai/dsh-compact-basic`一个具体的 `BasicCompactService`,拥有完整算法——token 估算(每 token 字符数——`charsPerToken` 配置,默认 4——加逐块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是兄弟包(或覆两个 protected 估算/摘要钩子的子类)。
1. **接口**`@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()``compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。
2. **实现**`@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,拥有完整算法——token 估算(每 token 字符数,即 `charsPerToken` 配置,默认 4,加上每块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 进行摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是同级包(或覆两个 protected 估算/摘要钩子的子类)。
3. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 RFC 范围之外,以便 seam 先稳定下来。
### 契约依赖 `dsh-session` 和 `dsh-llm`——有意的偏离
### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离
能力 seam RFC 规定接口包「只依赖 cordis(对 `dsh-bash` 成立,其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义 `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约无法表达。
能力 seam RFC 规定接口包"仅依赖 cordis"(对 `dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义*在* `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约无法表达。
这不是耦合异味——而是契约的领域本身。「只依赖 cordis的指导原则本来就是「接口依赖契约真正命名的东西,绝不依赖实现的简写。`dsh-session``dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。
这不是耦合异味而是契约的领域所在。"仅 cordis"的指导原则一直是"接口依赖契约真正需要命名的东西,绝不依赖实现"的简写。`dsh-session``dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。
### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端
### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端
早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,只有 `estimateContentTokens()``summarize()` 抽象。这会契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端不得不与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——接口保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()``summarize()``protected` 钩子,子后端可以覆而无需重新实现遍历——但这种分层是后端的私有关注,不是契约的。
早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法, `estimateContentTokens()``summarize()` 抽象。这会契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——并让接口保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()``summarize()``protected` 钩子,子后端可以覆而无需重新实现遍历——但是后端的私有关注,不是契约的。
`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)`**必**参数(而非最初的全可选形)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)以及轮次的 abort signal,因此可选性只会在 seam 处引入隐藏默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件在那里选择实际模型。
`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)`**必**参数(而非最初的全可选形)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)轮次的 abort signal,因此可选性只会在 seam 处引入隐藏默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件在那里已经选择实际模型。
### 自动压缩运行在 `agent/pre-step`一个专用的 surface 变更 seam
### 自动压缩在 `agent/pre-step` 运行——一个专用的 surface 变更 seam
压缩会变更会话 surface,因此在步骤开启之前、消息派生之前运行。`agent/request` 仍然是调用配置变换,永远不需要在 surface 变更后重建历史。
压缩会变更 session surface,因此在步骤开启之前、消息派生之前运行。`agent/request` 保持为调用配置变换,无需在 surface 变更后重建历史。
解决方案是一个专用的循环 seam:**`agent/pre-step`**`@mode serial`),由循环在系统组装*之后*、步骤开启(`step/start`*之前*触发:
@@ -48,29 +48,29 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction
request = waterfall agent/request ⟵ pure request transform (hooks, model switch)
```
循环在 `agent/pre-step` 之后派生一次消息。在 `step/start` 之前运行使压缩记录落在任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且 serial 的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。
循环在 `agent/pre-step` 之后派生一次消息。在 `step/start` 之前运行使压缩记录位于任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且串行的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。
### 保留是轮次无关的;工具配对平衡是唯一的结构守卫
自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:一个工具密集的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,surface 在*一轮之内*就会增长。单独一轮就可能超出窗口(失控轮次」)——而在下一次模型调用溢出之前能挽救它的唯一时机,就是下一步的 `pre-step` 检查点。如果压缩限制在轮次的第一步(或更糟,逐字保留整个进行中的轮次),恰好重新打开了压缩存在的意义所要堵住的那个缺口:harness 会在最需要压缩的时候崩溃。
自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:工具密集的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`因此 surface 在*一轮之内*就会增长。单独一轮就可能超出窗口("失控轮次"),而在下一次模型调用溢出之前唯一能挽救的时机是下一步的 `pre-step` 检查点。如果压缩限制在轮次的第一步(或更糟,逐字保留整个进行中的轮次),恰好重新打开了压缩存在的意义所要堵住的缺口:harness 会在最需要压缩崩溃。
`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小尾部完整 surface 单元,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到截断处工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧 surface 位置有新的序号。`compactRegion` 拒绝将工具调用与其结果拆的边界。进行中的轮次不享特殊保留。
`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧 surface 位置有新的序号。`compactRegion` 拒绝将工具调用与其结果拆的边界。进行中的轮次不享特殊保留。
因此失控轮次的压缩方式与任何其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持逐字。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`,待该步骤关闭后重试。
因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`并在该步骤关闭后重试。
**单单元溢出不在范围内,这是有意** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点如粘贴的 `user/message`——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地命名了这个边界,而非掩盖
**单单元溢出不在范围内,这是有意为之** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点如粘贴的 `user/message`——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地指出这一点,而非掩盖。
### 头部锚定:一个自动检查点,始终在头部
自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,使自动检查点始终只有一个。因此 `shadowedRange` 是位置性的而非数值序区间:一个新的摘要序号可能占据旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。
自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,因此只保留一个自动检查点。`shadowedRange` 因此是位置性的而非数值序区间:一个新的摘要序号可能占据旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。
### 近似收敛不变式
`resolveConfig` 校验数值参数但**不**基于假想的摘要长度不变式拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显的推理 token 消耗,模型可能输出不可预测大小的摘要。`maxTokens` 是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能保留尾部推过预算)——这恰好是上面声明的范围外关注点,而非抖动 bug。
`resolveConfig` 校验数值参数但**不**基于虚构的摘要长度不变式拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能保留尾部推过预算)这恰好是上范围外关注点,而非抖动 bug。
### Surface 替换:`compact/*` 事件仅存于日志;一条 `user/message` 承载摘要
### Surface 替换:`compact/*` 事件仅存于日志;一条 `user/message` 承载摘要
由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的节点*以及*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件:
由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的节点**簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件:
```
compact/start → log-only. Acquires the lock.
@@ -81,47 +81,47 @@ user/message → surfaceOp { op:'replace', start, end }. THE surface mutatio
compact/end → log-only. Releases the lock (carries `error` on a recoverable failure).
```
`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。
`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。
### 检查点框架 + 增量合并(后端私有)
基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 承诺一条替换 user 消息承载可能带框架的摘要。
基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 承诺一条替换 user 消息承载可能带框架的摘要。
### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败分类
### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败分类
`compact/start … compact/end` 括号的合理性,按实际承担的工作排序:
`compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序:
1. **可检测的崩溃孤儿 + 来源记录**(首要)。摘要生成是一次慢模型调用,在 `compact/start` *之后*持久化。摘要生成中途崩溃会留下一个没有匹配 `compact/end``compact/start`——一个可检测的孤儿。最后释放锁(而非最先释放)将崩溃窗口从*静默损坏*转为可检测的孤儿。
2. **防止并发压缩。** 如果当前轮次持有一个未匹配的 `compact/start``compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是一个重入绊线——抛出的「already in progress」信号意味着真正的 bug。)
1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢模型调用,持久化`compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end``compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转为可检测的孤儿。
2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start``compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是重入绊线——抛出"already in progress"表示真正的 bug。)
两种失败路径,均有文档记录:
- **崩溃**(循环在摘要生成中途死亡):一个悬空的 `compact/start`没有关闭者。因为 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,所以完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。
- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`surface 不受影响,模型调用继续使用完整历史。
- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,因此完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。
- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`surface 保持不变,模型调用完整历史继续
`compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。
**核心会话修复保持对压缩无感知——这是有意** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。因为仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。
**核心 session 修复保持对压缩无感知——这是有意为之** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。
## 曾考虑的替代方案
- **完整算法作为接口的具体方法**只有估算/摘要抽象)——早期草案;否决,因为它契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。
- **压缩运行`agent/request` waterfall(瀑布式事件)上**——早期方案;否决,因为它强制双重派生,且交给监听器上下文结构上无法压缩。专用的 `agent/pre-step` seam 使分层在构造上正确。
- **完整算法作为接口的具体方法**估算/摘要抽象)——早期草案;否决,因为它契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。
- **在 `agent/request` waterfall(瀑布式事件)上执行压缩**——早期方案;否决,因为它强制双重派生,且监听器上下文交给了结构上无法压缩的对象。专用的 `agent/pre-step` seam 从构造上使分层正确。
- **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。
- **教导核心轮次修复`compact/*`**——否决:仅日志的孤儿是惰性的,而一个为每个未来 `xxx/start … xxx/end` 插件对打补丁的核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。
- **教导核心轮次修复识 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。
## 后果
- **新包**`packages/compact/compact`(接口)和兄弟包 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。
- **新循环 seam**`agent/pre-step``@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。
- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end``SurfaceEventType` **不受影响**。这些是会话事件而非 cordis `Events`,因此事件分类门禁无需新增条目。
- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在,直接复用。
- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩将高序列号的替换节点放在旧范围的*位置*,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次包含不变式原样复用。
- **接线**`dsh-compact-basic``examples/coding-agent``cordis.yml` 中加载,使 seam 在真实演示中交付(此前未在任何地方加载)。
- **新包**`packages/compact/compact`(接口)和同级的 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。
- **新循环 seam**`agent/pre-step``@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。
- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end``SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。
- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在并被复用。
- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩将高序替换节点放在旧范围的*位置*,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次封闭不变式原样复用。
- **接线**`dsh-compact-basic``examples/coding-agent``cordis.yml` 中加载,使 seam 在真实演示中生效(此前它未被任何地方加载)。
## 测试
- **单元测试:** 真实 Loader 和 invariant 插件覆盖整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。
- **循环测试:** 测试固定每步在 `turn/start` `step/start` 之间有一次 awaited 的 `agent/pre-step`;在那里的 surface 变更落在步骤之外,并出现在单次派生的请求中。
- **带密钥 e2e:** 真实模型和 bash 会话在降低限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。
- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用回放仍是后续工作。
- **单元测试:** 使用真实 Loader 和 invariant 插件覆盖整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。
- **循环测试:** 测试固定每步在 `turn/start` `step/start` 之间有一次 awaited 的 `agent/pre-step`;在该处的 surface 变更落在步骤之外,并出现在单次派生的请求中。
- **带密钥 e2e:** 真实模型和 bash 会话在降低限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。
- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用回放仍是后续工作。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-21-subagent-capability-seam.md: 2bed84cd9166e8aa1ad5fa65b3afa44b8a842045
2026-06-21-subagent-capability-seam.zh.md: a99d0fe894dca485452dd266752785a26815bb8a
2026-06-21-subagent-capability-seam.zh.md: a5917c14141dd06c14b4f45c5f6e4703f0eb661f
@@ -1,74 +1,74 @@
# RFCSubagent 能力 seam
Status: implemented
[English](2026-06-21-subagent-capability-seam.md) | 中文
> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外 `dsh-subagent-acp` 后端([其 RFC](2026-06-22-acp-subagent-backend.md))。
Status: implemented
> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`[其 RFC](2026-06-22-acp-subagent-backend.md))。
## 问题
harness 有一个长期搁置的 subagent seam:一个 agent 将工作委派给另一个 agent。意图`Agent`/`AgentLoop` 接口中勾勒[packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):创建选项引用父 agent(fork = 用父会话的事件日志子会话播种spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅统一工作。本 RFC 实现 seam;上方横幅列出了已交付的内容。
harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智能体)将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案[packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 RFC 实现了这个 seam;上方横幅列出了已交付的内容。
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。我们预见的传输方式:
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACPAgent Client Protocol)。我们预见的传输方式:
- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于已有的 agent 工厂几乎零成本);
- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外「启动子 agent、发送提示词、流式更新、取消」形态
- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。
## 曾考虑的替代方案
### 为什么不用 bash seam 的形
### 为何不采用 bash seam 的形
bash seam[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**每个实现以唯一名称注册,调用方按名称选取。这与 **LLM 适配器注册表**`LlmService.registerAdapter`同构,而非单服务的 bash 执行器。seam 仍然是三包结构(接口 / 实现 / 消费方);唯一不同的轴是「单实现 vs. 多实现」。
bash seam[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多实现」这个维度不同
## 决策
### 三包 seam
### 由三个包构成的 seam
增包`packages/subagent/`
建包(package`packages/subagent/`
| 包 | 角色 |
|---|---|
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService``ctx.subagents`)、`SubagentProvider``SubagentRun`、请求/结果/能力词汇`subagent/*` 事件 |
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService``ctx.subagents`)、`SubagentProvider``SubagentRun`、请求/结果/能力词汇、`subagent/*` 事件 |
| `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent |
| `@deepseek-ai/dsh-subagent-fork` | 实现:以父会话日志快照为种子的进程内子 agent |
| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent |
| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 |
| `@deepseek-ai/dsh-subagent-mock` | 支撑:脚本化的提供方,用于通过真实加载路径测试 seam |
| `@deepseek-ai/dsh-subagent-mock` | 辅助:用于通过真实加载路径测试 seam 的脚本化提供方 |
| `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 |
### 基本原语:异步 `start → SubagentRun`
### 原语:异步 `start → SubagentRun`
提供方暴露 `start(request) → Promise<SubagentRun>`。完成发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()` 取消剩余工作并等待静。启动失败时清理部分资源,不发出生命周期事件。`start` 传输无关`spawn`命名全新进程内后端。
提供方暴露 `start(request) → Promise<SubagentRun>`。完成发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()`(资源释放)取消剩余工作并等待静。启动失败时清理部分资源,不发出生命周期事件。`start` 传输方式无关;`spawn`指代全新进程内后端。
### 两类可选能力,两种发现方式
- **启动时特性**`outputSchema``depthLimit``toolFilter``persona`)挂在静态 `provider.capabilities` 描述符上。服务在委派之前检查每一项请求的特性,提供方不支持则**大声拒绝**`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略」。它们必须在 run 存在之前检查,这就是为什么不能做成运行时方法。
- **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续交互)是 `SubagentRun` 上的**可选方法**。方法的存在即是能力,TypeScript 窄化即是发现机制:消费方不经窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要一个单独的 flags 对象来保持同步。
- **启动时特性**`outputSchema``depthLimit``toolFilter``persona`)挂在静态 `provider.capabilities` 描述符上。服务在委派**之前**检查每个被请求的特性,如果提供方不支持则**大声拒绝**`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些特性必须在 run 存在之前检查,因此不能是运行时方法。
- **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。
### Fork 与 fresh 是独立后端,而非一个 flag
全新子 agent fork 子 agent 是独立的提供方,而非请求上的 flag。`dsh-subagent-spawn` 启动隔离的子 agent`dsh-subagent-fork` 仅包含已完成父轮次的平衡前缀为种子。进行中的轮次被排除,因为其 subagent 调用尚无结果,无法构成有效的回放历史。
全新子 agent fork 子 agent 是独立的提供方,而非请求中的一个 flag。`dsh-subagent-spawn` 启动隔离的子 agent`dsh-subagent-fork` 用一个平衡前缀初始化子 agent,该前缀仅包含已完成父轮次。进行中的轮次被排除,因为其 subagent 调用尚无结果,无法构成有效的回放历史。
### 子 agent 隔离与父日志
每个 subagent 运行在自己的 **`Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 保持传输无关。
每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关。
### 同步收集(第一版)
### 同步收集(版)
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在 `finally` 中 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出。这个前台消费方不使用 run 的可选 steering 方法。
### 提供方选择是配置,不面向模型
`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选其中一个本版 schema 中没有 provider/type 参数。
`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选其中一个——本版 schema 中没有 provider/type 参数。
## 测试
seam 通过真实的 Cordis Loader/export 路径测试,这能捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e。
seam 通过真实的 Cordis Loader/export 路径测试,这能捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状错误。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试
## 后果
- **递归。** 若无限制,进程内子 agent 能看到委派工具并递归。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有它们的确切语义和安全限制
- **阻塞父轮次。** 同步收集在子 agent 的整个持续期间保持父 agent 的 `runStep` 打开。这对第一版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该重新设计将统一 subagent bash 的长时运行工具处理**(一个 sub-agent 和一个长时间运行的 `bash` 后台任务面临相同的「模型启动了一个慢操作,之后如何收集结果」问题,应共享一套机制而非各自发明)。
- **实时进度。** 本版仅暴露生命周期事件最终结果;逐分片的子→父更新流推迟到后台重新设计。
- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;第一版不声明这两项能力,子 agent 在自己的进程中自给自足
- **递归。** 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`ACP 声明这两项能力为关闭状态,并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界
- **阻塞父轮次。** 同步收集在子 agent 的整个持续时间内保持父 agent 的 `runStep` 打开。这对版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该设计将统一 subagent bash 的长时运行工具处理**(一个 subagent 和一个长时间运行的 `bash` 后台任务面临相同的问题——「模型启动了一个慢操作,之后如何收集结果」——应共享一套机制而非各自发明)。
- **实时进度。** 本版仅暴露生命周期事件最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理
- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;版不声明这两项能力,子 agent 在自己的进程中自行服务
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-22-acp-subagent-backend.md: 7eb03ddf68f54c29524944e7b8bc801eb1724fe6
2026-06-22-acp-subagent-backend.zh.md: 6f7b95318a2c714fea43a584ba49da00a7c12040
2026-06-22-acp-subagent-backend.zh.md: 249f5a5ebf18d42f3d83d2159f6c2bcb52a245c3
@@ -1,57 +1,57 @@
# RFCACP subagent 后端(进程外委派)
Status: implemented
[English](2026-06-22-acp-subagent-backend.md) | 中文
Status: implemented
## 问题
subagent seam[seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent 作为同一个 Cordis 上下文上的第二个 `Agent` 运行——开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义正是还要支持通过协议到达的进程外子 agent,以证明这层抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACPAgent Client Protocol)客户端。
subagent seam[seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在**同一个** Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的**进程外**子 agent,以证明抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACPAgent Client Protocol)客户端。
## 决策
`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个**派生的子进程**中,以 ACP *客户端*身份驱动。它是现有服务端桥接 `@deepseek-ai/dsh-acp`ACP *agent*)的方向反转孪生体:桥接**应答** `initialize`/`newSession`/`prompt`;本后端**调用**它们并**实现** `Client` 回调(`sessionUpdate``requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程对话
`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个**派生的子进程**中,以 ACP *客户端*身份驱动。它是现有服务端桥接 `@deepseek-ai/dsh-acp`ACP *agent*)的方向反转孪生体:桥接**应答** `initialize`/`newSession`/`prompt`;本后端**调用**它们并**实现** `Client` 回调(`sessionUpdate``requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程通信
### 每次运行启动新进程
### 每次运行启动新进程
每次 `start` 都 spawn 一个新子进程,运行恰好一个 ACP 会话(`initialize``newSession``prompt`),`dispose` 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。
每次 `start` 都 spawn 一个新子进程,运行恰好一个 ACP 会话(`initialize``newSession``prompt`),`dispose` 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。
### 最小客户端桩
### 最小客户端桩
客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费——后端累积 `agent_message_chunk` 文本为结果输出,在本次实现中忽略其余内容(思考、工具调用卡片),仅呈现子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝每个提示,`allow` 通过第一个 allow 形态的选项批准)——本次实现不将任何提示呈现给人类。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍是未来工作,如 seam RFC 所述。
客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam RFC 所述。
### 无启动时能力
提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),且本次实现未实现 `outputSchema`。服务在 `start` 运行之前就会拒绝需要上述任何能力的请求。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`
提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),本阶段也未实现 `outputSchema`如果请求需要其中任何一项,服务在 `start` 运行前即拒绝。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`
### StopReason 映射
ACP `StopReason` → harness `SubagentStopReason``end_turn``completed``max_tokens``max-tokens``refusal``refusal``cancelled``aborted``max_turn_requests``error`(无对等语义——任务未完成)、未知→`error`。spawn/传输/RPC 失败解析为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 永远不会因子 agent 级别失败 reject。
ACP `StopReason` → harness `SubagentStopReason``end_turn``completed``max_tokens``max-tokens``refusal``refusal``cancelled``aborted``max_turn_requests``error`(无对等语义任务未完成)、未知→`error`。spawn/传输/RPC 失败解析为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 子 agent 级别失败时从不 reject。
### 安全:清洗子进程环境
子 agent 是独立进程,因此会继承环境变量。凭证形态的环境变量(`/KEY|SECRET|TOKEN/i`)默认**不**转发——父 harness 自身的密钥不得隐式泄到派生进程中(与 bash 执行器采用的策略相同)。子 agent **自**的凭证(它需要模型密钥)通过 `config.env` **显式**提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 不会。子进程 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞争,使错误命令解析为 `error` 而非以未处理错误崩溃父进程。
子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认**不**转发——父 harness 自身的密钥不得隐式泄到派生进程中(与 bash 执行器采用的策略相同)。子 agent **自**的凭证(它需要模型密钥)通过 `config.env` **显式**提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 不会。子进程 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。
## 测试
- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`写入 `proof.txt`,父进程验证该文件。
- **快照缺口:** 每个 ACP 子 agent 是独立进程拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock-server 覆盖已有`TODO(acp-subagent-replay)` 跟踪父 agent 对回放中子 agent 的回放支持。
- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`写入 `proof.txt`,父进程验证该文件。
- **快照缺口:** 每个 ACP 子 agent 是独立进程拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。
## 曾考虑的替代方案
### 为何继续使用 SDK 0.25.1
后端仅需 `ClientSideConnection``ndJsonStream``PROTOCOL_VERSION` 和客户端协议类型,0.25.1 均已支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,不会改善本后端,因此升级作为独立变更保留。
后端只需要 `ClientSideConnection``ndJsonStream``PROTOCOL_VERSION` 和客户端协议类型,0.25.1 全部支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,不会改善本后端,因此升级作为独立变更保留。
### 为何不使用持久子进程?
持久进程池(跨运行复用热子进程)是一项性能优化,推迟到未来工作——它引入会话生命周期和崩溃恢复的复杂,本次实现不需要;每次 `start` spawn 新子进程与进程内「每次运行一个子 agent」的形态一致。
持久进程池(跨运行复用热子进程)是一项性能优化,推迟到后续工作。它增加了会话生命周期和崩溃恢复的复杂,本阶段不需要;每次 `start` spawn 新子进程与进程内「每次运行一个子 agent」的形态一致。
## 后果
每次运行都要付出一个新子进程的开销spawn + `initialize` + `newSession`)。父 agent 仅呈现子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示永远不会到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥通过 `config.env` 显式提供。
每次运行都要付出一个新子进程的代价spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥通过 `config.env` 显式提供。
## 未来提供方
## 后续提供方
同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam RFC 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-25-ask-user-question.md: 06673233038d10214f8de3d1f29766d43b575442
2026-06-25-ask-user-question.zh.md: 01d1284dba3622984d5403f94e3edd0ba02583b6
2026-06-25-ask-user-question.zh.md: a036220fd54e3f634ab4be80a45964b076d3fd2d
@@ -1,51 +1,51 @@
# RFCask-user 提问能力
Status: implemented
[English](2026-06-25-ask-user-question.md) | 中文
Status: implemented
## 问题
agent(智能体)有时仅凭模型推理(inference)无法安全地继续:它需要人类选择路径、确认有风险或默认的操作,或提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这打断正常的工具调用循环:agent 没有结构化的暂停手段,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的方式
agent(智能体)有时仅凭模型推理(inference)无法安全地继续执行:它需要人类选择路径、确认有风险或默认的操作,或提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这打断正常的工具调用循环:agent 没有结构化的暂停方式,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的途径
这是一个面向用户的能力,但它也跨越了包(package)边界。模型的工具需要一套提供方无关的请求词汇;每个 UI 面需要决定如何展示和收集答案;agent loop(智能体循环)应保持不变,因为工具调用本身已具备正确的异步形
这是一个面向用户的能力,但它也跨越了包(package)边界。面向模型的工具需要一套提供方无关的请求词汇;每个 UI 面需要决定如何展示和收集答案;agent loop(智能体循环)应保持不变,因为工具调用本身已具备正确的异步形
## 决策
引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与模型消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之:向人类提问是一种由 UI 支撑的产品能,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品面提供收集答案的具体 provider。工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。
引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与面向模型消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之:向人类提问是一种由 UI 支撑的产品能,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品面提供收集答案的具体 provider。工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。
模型的请求词汇有意与产品研 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })``id` 按问题提供并在结果中回传,使批量请求可以路由而不依赖问题文本。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。
面向模型的请求词汇有意与产品研 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })``id` 按问题提供并在结果中回传,使批量请求无需依赖问题文本即可路由`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。
provider 返回 `{ answers: [{ id, selected, custom? }] }``selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖所有已选选项,`selected` 为空。
Provider 返回 `{ answers: [{ id, selected, custom? }] }``selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER``ASK_ABORTED`、ACP 取消或会话路由缺失等失败会以机器路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能区分「用户取消」与通用抛出异常。
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER``ASK_ABORTED`、ACPAgent Client Protocol取消或会话路由缺失等失败会以机器路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能区分「用户取消」与一般的抛出异常。
## UI 映射
`dsh-stdio-demo` 的包内 readline 模块逐题渲染每个问题,在下一行示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序逐题询问,合并为一个答案对象返回。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个 prompt 占用 stdin。
`dsh-stdio-demo` 的包内 readline 模块渲染每个问题,在下一行示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序询问,为一个答案对象整体解析。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个提示占用 stdin。
`dsh-acp` 为 ACPAgent Client Protocol会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由到对应会话,并为每个问题调用 ACP `unstable_createElicitation`带会话作用域的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation 的情况都会为结构化的 `UserInteractionError`
`dsh-acp` 为 ACP 会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由出去,并为每个问题调用 ACP `unstable_createElicitation`带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation都会为结构化的 `UserInteractionError`
ACP 映射有意使用 elicitation 而非 `session/request_permission``request_permission` 仍保留给独立的权限门禁:它是围绕工具执行的 yes/no 或策略式授权协议。`ask_user_question` 是一个通用的信息收集工具,支持可选的自由格式答案,因此 ACP 表单 elicitation 是更贴合的协议。bridge 的会话路由与未来的权限门禁共享,但用户意图不同。
## 曾考虑的替代方案
**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user prompt 到达,而非作为需要答案的那次操作的结果。
**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user prompt 到达,而非作为需要答案的那次操作的结果。
**核心拥有 ask-user 相关包。** 最初实现将 seam 和模型工具分别放在 `packages/core``packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction``dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包结构与产品边界一致:应用和 bridge 提供人类答案的 provider,stdio 应用选择性加载模型工具。
**核心拥有 ask-user 包。** 最初实现将 seam 和面向模型工具分别放在 `packages/core``packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction``dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包的划分与产品边界一致:应用和 bridge 提供人类答案的 providerstdio 应用选择性加载面向模型工具。
**ACP `session/request_permission`。** 权限请求是围绕工具执行的授权;`ask_user_question` 是带可选自由格式答案的信息收集。将权限用于通用提问会混淆两个不同的产品概念,并使未来的权限门禁更难推理。
**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。新增一个循环特例会重复这一异步形,并迫使每个循环实现都了解一个 UI 关注点。
**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。添加新的循环特殊分支会重复这一异步形,并迫使每个循环实现都了解一个 UI 关注点。
## 后果
ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应`dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。
ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应限制`dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。
特性赋予模型一个强大的暂停原语,因此提示词引导很重要。工具描述告诉模型提问要简洁尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型提问要简洁尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
`dsh-user-interaction``dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和模型工具。`acp-agent` 默认只保留 `userInteraction` seam/providerACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载模型工具。
`dsh-user-interaction``dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和面向模型工具。`acp-agent` 默认只保留 `userInteraction` seam/providerACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型工具。
## 测试
单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value``recommended``allow_custom``desc` 的验证)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。
单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value``recommended``allow_custom``desc`)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-29-todo-write-tool.md: 69f81cf6fd93df63ce53bb82c97dbac16dbbd486
2026-06-29-todo-write-tool.zh.md: a687f5ab4bc5b9fcd5583ca4aac2857ab4c3f513
2026-06-29-todo-write-tool.zh.md: eb3c6fb8a9ddc7d26e4a620761c96a8d35ddf469
@@ -1,4 +1,4 @@
# RFC`todo_write` 工具——将模型任务列表建模为事件溯源的会话状态
# RFC`todo_write` 工具——将模型任务列表为事件溯源的会话状态
Status: implemented
@@ -6,59 +6,59 @@ Status: implemented
## 问题
harness 为模型提供了 bash 和 subagent 工具,没有任何方式记录结构化的任务列表。todo 列表服务于两个同等重要的目的:引导模型规划多步骤工作并保持当前任务明确(最多一个 in_progress,有未完成工作时恰好一个),以及为人类提供实时进度清单。ACPAgent Client Protocol)协议原生 `plan` sessionUpdate,编辑器(Zed)已经在渲染它,但 bridge 从未发出过。调研的每个参考编码 agent(智能体)实现claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能; harness 什么都没有。
harness 为模型提供了 bash 和 subagent 工具,没有办法记录结构化的任务列表。todo 列表两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为人类提供实时进度清单。ACPAgent Client Protocol)协议原生支持 `plan` sessionUpdate,编辑器(Zed)已渲染它,但 bridge 从未发出过。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能; harness 此前没有。
## 决策
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其全量列表状态新的 `todo/write` `SessionEventMap` 变体存在事件溯源的会话日志上。stdio UI 和 ACP bridge 都从既有的 `session/event` 渲染——ACP bridge 将列表映射为 `plan` sessionUpdate。
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其列表状态作为新的 `todo/write` `SessionEventMap` 变体存在事件溯源的会话日志上。stdio UI 和 ACP bridge 均从现有的 `session/event` 渲染ACP bridge 将列表映射为 `plan` sessionUpdate。
### 全量替换,三态 status
### 整列表替换,三态 status
模型每次调用发送**完整**列表;新列表替换旧列表(回放时 last-write-wins)。这是 claude-code V1、opencode 和 codex `update_plan` 共同使用的形,也是模型训练最多的形——没有逐项 id,没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`:与 codex `update_plan` 相同的三元组,且关键的是**与 ACP `PlanEntryStatus` 完全一致**因此 bridge 1:1 映射,无损转换。
模型每次调用发送**完整**列表;新列表替换旧列表(回放时 last-write-wins)。这是 claude-code V1、opencode 和 codex `update_plan` 共同用的形,也是模型训练最多的形——没有逐项 id,没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`:与 codex `update_plan` 相同的三元组,且关键的是**与 ACP `PlanEntryStatus` 完全一致**bridge 因此可以 1:1 映射,无需有损转换。
### 状态在会话日志上,而非服务
列表 `todo/write` 事件追加,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明所有这些
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。
### 不是 surface 事件
`todo/write`意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;一次 todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入 surface 链表,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,伴随对话传播但不属于对话的一部分。(开发模式的不变式仍要求它位于一个打开的轮次内,事实也确实如此:它在工具调用的 mid-step 阶段追加。)
`todo/write`意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入 surface 链表,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。)
### priority 仅在 ACP 边界合成
### Priority 仅在 ACP 边界合成
ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段,不如让 bridge 在构建 `plan` 时为每条目合成一个常量 `priority: 'medium'`priority 是 ACP 协议格式(wire format)的要求,不是 harness 概念,因此它恰好存在于需要它的边界
ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段,bridge 在构建 `plan` 时为每条目合成常量 `priority: 'medium'`Priority 是 ACP 协议格式(wire format)的要求,不是 harness 概念,因此它恰好存在于需要它的边界
### 相比 claude-code V1 去掉的字段:`activeForm`、id、priority
### 相比 claude-code V1 舍弃的字段:`activeForm`、id、priority
claude-code V1 的 item `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但那只是为了支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将 item 保持在最小集:`{ content, status }`没有 `activeForm`(现在进行时标签)——UI 直接展示 `content`没有 id——全量替换不需要稳定标识;没有 priority——见上文。每去掉一个字段,模型每次调用就少产出一项。
claude-code V1 的条目`{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但仅为支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`不要 `activeForm`(现在进行时标签)——UI 直接展示 `content`不要 id——整列表替换不需要稳定标识;不要 priority——见上文。每舍弃一个字段,模型每次调用就少产出一项。
### 单一所有者——无集群机制(YAGNI)
每个列表属于调用 agent 会话,非 agent 调用被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。
每个列表属于调用它的 agent 会话,非 agent 调用被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。
### 校验:低成本的中间路线
schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content` 以及多于一个 `in_progress` 任务。claude-code 将 single-in-progress 给 promptoh-my-pi 在代码中强制。我们取中间路线:强制那些使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述给模型。被拒绝的写入返回 `isError` 结果,模型自行修正。
schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content`以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 给 prompt 约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。
## 为什么没有 cordis-catalog 条目 / 没有 `@mode`
## 为没有 cordis-catalog 条目 / 没有 `@mode`
`todo/write``SessionEventMap` 的成员,不是一等的 cordis `interface Events` 事件。catalog 生成器(`scripts/gen-cordis-catalog.ts`)扫描 `interface Events` 声明;`SessionEventMap` 变体搭载有的 `session/event` emit,不产生新的 catalog 行。因此它不携带 `@mode` 标签(生成器仅对 `interface Events` 成员要求标签)——加上它也没有意义。
`todo/write``SessionEventMap` 的成员,不是一等的 cordis `interface Events` 事件。catalog 生成器(`scripts/gen-cordis-catalog.ts`)扫描 `interface Events` 声明;`SessionEventMap` 变体搭载有的 `session/event` emit,不产生新的 catalog 行。因此它不携带 `@mode` 标签(生成器仅对 `interface Events` 成员要求标签)——添加一个毫无意义。
## 测试
,预先设计:
- **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR 安全性);ACP `todosToPlan` 映射;stdio 渲染分支。
- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。
- **全链路集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write``todo/write` 事件落地,第二次调用替换它。
- **`session/load` 回放**——一条持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。
- **带 key 的 e2e + 快照**——一个真实 prompt 诱导 `todo_write`;快照 golden 新增 `plan` 通知和日志事件。
个层级,预先设计:
- **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR(热模块替换)安全性);ACP `todosToPlan` 映射;stdio 渲染分支。
- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它**有** `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。
- **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write``todo/write` 事件落地,第二次调用替换它。
- **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。
- **带密钥 e2e + 快照**——真实 prompt 诱导一次 `todo_write`;快照 golden 获得 `plan` 通知和日志事件。
## 曾考虑的替代方案
- **内存中的 `ctx.todos` 服务**——需要重新发明日志免费提供的持久性、回放和 `session/load` 重建。
- **逐项 delta 协议**——仅在共享多所有者列表时需要,不在本次范围内;全量替换更简单且与参考实现一致。
- **工具放在 `core/`**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它其他工具族一样放在自己的 `packages/todo/` 分组中。
- **逐项 delta 协议**——仅在共享多所有者列表时需要,超出当前范围;整列表替换更简单且与参考实现一致。
- **工具放在 `core/`**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它其他工具族一样位于自己的 `packages/todo/` 分组中。
## 后果
todo 列表是持久、可回放的会话状态:一条持久化的 `todo/write``session/load` 时重新编辑器发出 `plan` 更新,日志(而非插件内存)是唯一真源。全量替换意味着每次更新一次工具调用last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。
todo 列表是持久、可回放的会话状态:持久化的 `todo/write``session/load` 时重新发出编辑器 `plan` 更新,日志(而非插件内存)是唯一真源。整列表替换意味着每次更新一次工具调用last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-hook-bridges.md: 17ff57307c34121c845592efa93c723e66c98886
2026-06-30-hook-bridges.zh.md: 2a94d5cca2f490e4aac493fe357a825ad3b4d271
2026-06-30-hook-bridges.zh.md: a4b8c12593cdac35deb882ba15a58876650c1653
@@ -1,4 +1,4 @@
# RFCdsh-hooks-claude + dsh-hooks-codex——Claude Code / Codex 钩子桥接插件
# RFCdsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件
Status: implemented
@@ -6,65 +6,65 @@ Status: implemented
## 问题
harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start``agent/prompt-submit``tools/pre-execute``tools/post-execute``agent/turn-continuation``subagent/start``subagent/end`。但用户带着**有的** Claude CodeCC)和 Codex 钩子配置到来——一个 `hooks.json`(或设置文件中的 `hooks` 键)里满是 shell 命令钩子——并且希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,于共享的协议格式(wire format)库(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md)构建
harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start``agent/prompt-submit``tools/pre-execute``tools/post-execute``agent/turn-continuation``subagent/start``subagent/end`。但用户带着**有的** Claude CodeCC)和 Codex 钩子配置到来一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md))。
贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。**桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能更强力地完成——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射 seam Decision。各 package 的 README 记录了当前相对官方协议的不支持事件部分字段清单
贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射 seam Decision。各 package 的 README 维护着当前不支持事件部分字段的完整清单,以官方协议为参照
## 决策
`packages/hooks/` 组下两个独立插件,各自为函数/命名空间插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前钩子点中的七个:`SessionStart``UserPromptSubmit``PreToolUse``PostToolUse``Stop``SubagentStart``SubagentStop`。拥有 CC 形的每事件 stdin payload(基础字段 `session_id`/`cwd`/`hook_event_name`,加上每事件特有字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则匹配模式。CC 钩子的 stdin 带有**尾换行**。
- **`dsh-hooks-codex`**——Codex 当前钩子点中的五个:`PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`。使用始终为正则的匹配模式、Codex 形的 snake_case payload `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简 `tool_input: { command }`中携带真实的 `tool_name`
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart``UserPromptSubmit``PreToolUse``PostToolUse``Stop``SubagentStart``SubagentStop`。拥有 CC 形的每事件 stdin payload(基础字段 `session_id`/`cwd`/`hook_event_name`每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则匹配模式。CC 钩子的 stdin 带有**尾换行**。
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`。使用始终为正则的匹配模式、Codex 形的 snake_case payload `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简后的 `tool_input: { command }`中携带真实的 `tool_name`
### 结果 → Decision 映射
### Outcome → Decision 映射
每个桥接将共享库返回的中性 `MergedHookOutcome` 映射到 seam 的类型化 Decision
| Seam | CC | Codex |
|---|---|---|
| `agent/session-start`emit | additionalContext → `agent.inject()` | plain-stdout 输出 → additionalContext → `agent.inject()` |
| `agent/session-start`emit | additionalContext → `agent.inject()` | plain-stdout output → additionalContext → `agent.inject()` |
| `agent/prompt-submit` | `deny``block`;仅上下文→delegate+fold | `block``block`;仅上下文→delegate+fold |
| `tools/pre-execute` | `deny``deny``ask``ask` | `block``deny`(无 allow/ask |
| `tools/post-execute` | `deny``block`+feedback;仅上下文→delegate+fold | 同上 |
| `agent/turn-continuation` | 阻塞 Stop → `continue`reason = 下一步 steering(中途引导)) | 同上 |
| `subagent/start`emit | additionalContext → 注入进程内活跃子 agent;远程 agent 没有本地注入目标 | 本桥接不支持 |
| `agent/turn-continuation` | 阻塞 Stop → `continue`reason = next-step steering(中途引导)) | 同上 |
| `subagent/start`emit | additionalContext → 注入到存活的进程内 subagent;远程 subagent 本地注入目标 | 本桥接不支持 |
| `subagent/end`(emit) | 仅观察 | 本桥接不支持 |
CC 桥接的 `ask` 结果是一条真正的权限路径,而非桥接的终态决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 解析它。组合式 ACP 应答器向拥有编辑器会话发起提示,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 关闭。
CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 解析它。组合式 ACP 应答器向拥有该会话的编辑器会话发起提示,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。
### 上下文来源始终是插件(错标防护)
### 上下文来源始终是插件(误标签防护)
`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()``HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定了最终 `context/message.source` 为插件而非用户。
`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()``HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。
### 添加上下文不是否决——先 delegate,再 fold
仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext`下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。
仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext`叠进下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。
### CLAUDE_PROJECT_DIR 默认为会话工作区
Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `$CLAUDE_PROJECT_DIR` 来构造项目相对路径。显式的 `config.projectDir` 优先;当它被省略时(默认 ACP 接线只配置 `configPath`),桥接将该环境变量按每次运行默认为 agent 的会话工作区——即钩子已经运行其中`session.header.cwd`——而不是留空。因此一个标准的项目相对钩子在默认配置下即可工作。
Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `$CLAUDE_PROJECT_DIR` 来构造项目相对路径。显式的 `config.projectDir` 优先;当它被省略时(默认 ACP 接线只配置 `configPath`),桥接将该环境变量按每次运行默认为 agent(智能体)的会话工作区——即钩子已经在其中运行的 `session.header.cwd`——而留空。这样,一个标准的项目相对路径钩子在默认配置下即可正常工作。
### 隔离
配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非崩溃启动(一个拼错的路径不拖垮 agent)。CC 只运行 shell 形式的 `type: 'command'` 钩子;`http``mcp_tool``prompt``agent` 处理器被解析后跳过。Codex 只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start``subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。
配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非崩溃启动(一个拼错的路径不拖垮 agent)。CC 桥接只运行 shell 形式的 `type: 'command'` 钩子;`http``mcp_tool``prompt``agent` 处理器被解析后跳过。Codex 桥接只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start``subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。
### 钩子的运行位置与配置来源
### 钩子在哪里运行,配置从哪里来
钩子在 agent 的会话工作区中运行,因此相对路径指向用户的项目。`configPath` 相对于进程启动 cwd 解析一次,适用于所有会话。按会话的项目本地发现仍推迟在 `TODO(per-session-hook-config)` 下。
钩子在 agent 的会话工作区中运行,因此相对路径指向用户的项目。`configPath` 相对于进程启动时的 cwd 解析一次,适用于所有会话。按会话的项目本地发现仍推迟在 `TODO(per-session-hook-config)` 下。
## 推迟的兼容性缺口
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不生效——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为预执行参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。
- **Stop 循环防护**`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active`文档中没有等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——钩子作者必须自行限制,直到状态追踪落地
- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享 merge 将其折 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(decision/上下文)。
- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未重新实现(`TODO(per-session-hook-config)`)。
- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动之外,因此其上下文在就绪时注入,但可能错过第一个请求或短命agent。保证首请求送达需要一个 awaited 的启动 seam。
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。
- **Stop 循环防护**`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active`未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。
- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(决策/上下文)。
- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未重新实现(`TODO(per-session-hook-config)`)。
- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动过程之外,因此其上下文在就绪时注入,但可能错过个请求或短命的 subagent。保证首请求送达需要一个 awaited 的启动 seam。
## 曾考虑的替代方案
**同一点的钩子并发执行。** 参考引擎对一点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行它们(匹配循环内钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N1*且逐钩子超时不重叠——对真实配置使用的钩子数量而言可接受;如果某配置扇出到足以影响挂钟时间,再重新审视
**每点钩子并发执行。** 参考引擎对一点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N1*以及每钩子超时不重叠——对真实配置的钩子数量可接受;如果某配置扇出大到影响总耗时,再重新评估
## 后果
匹配语义、退出码处理合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支加上通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形。原生插件绕过协议格式,直接返回类型化决策。
匹配语义、退出码处理合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支以及通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形。原生插件绕过协议格式,直接返回类型化决策。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-hook-protocol-lib.md: 924c320f7ef9fdb55b20ff06f492addbf42d1720
2026-06-30-hook-protocol-lib.zh.md: 2f1cf0f1e4c99eaf1172642475e3b9bc8c8aed39
2026-06-30-hook-protocol-lib.zh.md: 81315cbe8767e9a3cc07cdef92359734e4e20f31
@@ -1,4 +1,4 @@
# RFCdsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库
# RFCdsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库
[English](2026-06-30-hook-protocol-lib.md) | 中文
@@ -6,27 +6,27 @@ Status: implemented
## 问题
钩子子系统提供两个桥接插件:一个运行用户有的 Claude CodeCC)钩子,一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code``~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。**它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型——Codex 的源码甚至以 Claude 的引擎命名自己的引擎,并在注释中标注了有意偏离之处。因此两个桥接插件如果各自实现,将重复协议的大部分内容
hooks 子系统提供两个桥接插件:一个运行用户有的 Claude CodeCC)钩子,一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code``~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了"有意偏离"之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑
本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言各自持有的部分之间的切分,是本设计的重心所在
本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。
## 决策
`packages/hooks/` 下新建一个组,`hook-protocol` 作为纯库存在。它拥有四个原语族以及 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude``dsh-hooks-codex`)拥有真正不同的部分。
`packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude``dsh-hooks-codex`)拥有真正不同的部分。
**共享(本库):**
- **Matcher**——`matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的交替),其视为正则;`codex` 始终无锚定正则。缺/`''`/`'*'` 匹配全部;无效正则匹配空集(绝不向循环抛出异常)。
- **Execution**——`runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已提供了经过清理但可覆盖的 env、进程组 kill 和超时——正是协议所需的能力,而 `dsh-bash``stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),尊重钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器的 rejection 变为 non-blocking-error 的 `HookOutput`)。
- **Decode**——`parseHookOutput(exit, stdout, stderr)`exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdoutexit `2` → blocking error`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只尊重对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此从不进入 transcript,因此没有什么可抑制;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
- **Merge**——`mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**halt 在首个 `continue:false` 时粘滞,block 原因`\n\n` 拼接,context/system-messages 按序累积。
- **`hook/*` 会话事件**——`hook/invoked` / `hook/result`通过 declaration-merge 加入 `SessionEventMap`(仅记录日志,类似 `compact/*`——不是 `SurfaceEventType`),附带 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对和轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义——决策字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从此处`HookOutput` 导出,而非各桥接插件中分别实现。
- **Matcher**`matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其视为正则;`codex` 始终无锚定正则。缺/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。
- **Execution**`runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时正是协议所需的能力`dsh-bash``stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
- **Decode**`parseHookOutput(exit, stdout, stderr)`exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdoutexit `2` → blocking error`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此从不进入 transcript(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
- **Merge**`mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**halt 在首个 `continue:false` 时粘滞,block reason `\n\n` 拼接,context/system-messages 按序累积。
- **`hook/*` 会话事件**`hook/invoked` / `hook/result`declaration-merge `SessionEventMap`(仅日志, `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与 turn 包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库`HookOutput` 派生,而非各桥接插件各自实现。
**方言各自持有(桥接插件):**构建每个事件的 stdin payloadCC 的 base + per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射 harness seam 特定类型化 Decision`PreToolDecision``PromptDecision``ContinuationDecision``PostToolDecision`)。
**方言专属(桥接插件):** 构建每个事件的 stdin payloadCC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射 harness seam 专属的类型化 Decision`PreToolDecision``PromptDecision``ContinuationDecision``PostToolDecision`)。
## 曾考虑的替代方案
**一参数化引擎。** 否决,因为 payload 构建和决策映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。
**一参数化引擎。** 否决,因为 payload 构建与 decision 映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;每个桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。
## 后果
每个桥接插件解析配置、构建方言 payload、调用共享的 runner merge 逻辑、映射决策、追加 `hook/*` 事件。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput`解析,但在 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地之前仅记录日志并发出警告
每个桥接插件解析配置、构建方言 payload、调用共享的 runner merge 逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-interception-seams.md: fb8efe1e1c2057db13b440881f110ca7f579a81e
2026-06-30-interception-seams.zh.md: b22b3d61bd14b6708e5a063f02537e981fead0fc
2026-06-30-interception-seams.zh.md: 668b96dba282ecdcbe85cc0b1dc56c2de293b3b3
@@ -6,55 +6,55 @@ Status: implemented
## 问题
harness 需要一套钩子子系统:用户在生命周期节点扩展或拦截 agent(智能体),方式类似 Claude CodeCC)和 Codex。驱动本设计的关键重构是:**"原生钩子"不是一个 package**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件都能直接做——而且更强大(没有序列化边界、完整 `ctx`、类型化返回值)。
harness 需要一套钩子子系统:用户像 Claude CodeCC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**"原生钩子"不是一个**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(序列化边界、完整 `ctx`、类型化返回值)。
这个表面需要为以下各阶段提供不同的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及带面向模型原因的继续。如果把这些阶段混为一谈,插件就会获得不需要的修改通道,终态也会依赖监听器顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则类型化 Decision 惯用法;本 RFC 将它们应用生命周期 seam
表面需要为以下场景提供各自独立的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及带面向模型原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则类型化 Decision 惯用法;本 RFC 将应用生命周期 seam。
## 决策
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回归一化结果;通知接收不可变快照,不能影响结果。覆盖范围包括本次纳入的钩子点`session-start``prompt-submit``pre-tool``post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留独立组合。
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start``prompt-submit``pre-tool``post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留独立组合。
**Agent 事件**`dsh-agent`):
- `agent/session-start(agent, source)`——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知——它**不能**阻塞启动(这是有意的缺口:桥接用于记录/注入,不用于拦截启动)。监听器通过 `agent.inject()` 注入上下文。
- `agent/prompt-submit(agent, content, source, next) → PromptDecision`——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。
- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知**不能**阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。
- `agent/prompt-submit(agent, content, source, next) → PromptDecision` ——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。
**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision``{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。
**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision``{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。
### 工具流水线为每个阶段赋予一种权限
每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute``tools/result`。注册表快照调用方输入、化并冻结参数、分配不透明 token。嵌套调用携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「行了什么」达成一致。
每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute``tools/result`。注册表快照调用方输入、实体化并冻结参数、分配一个不透明 token。嵌套调用携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「行了什么」达成一致。
- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均归一化为拒绝。每种结果仍会到达后策略最终观测者。
- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的作用域感知策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。
- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已归一化的抛出或未知工具结果;返回自己的有效结果短路调度。
- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的就地修改不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。
- **`tools/result`** 是每次变换、无损 JSON 化和外层错误边界之后的同步受限通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者失败按监听器隔离,不能改变或拒绝 `ToolRegistry.execute()` 返回的结果。
- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每种结果仍会到达后策略最终观测者。
- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的作用域感知策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。
- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已规范化的抛出或未知工具结果;返回自己的有效结果短路调度。
- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的原地 mutation 不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。
- **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。
核心调度工具体位于归一化边界内,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状失败都解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查抛出异常的工具,最终观测者看到的恰好是调用方收到的、会话日志可持久化的内容。
核心调度工具体位于规范化边界内,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具,最终观测者看到的是调用方收到的、会话日志可持久化的内容。
**`TurnEndReason.rejected`**`dsh-session`):整 prompt 批次`prompt-submit` 阻止的轮次。
**`TurnEndReason.rejected`**`dsh-session`):整 prompt `prompt-submit` 阻止的轮次。
### 三个承重的循环决策
1. **在 prompt 策略之前开启轮次。** 被完全阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP 提供持久的终事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。
1. **在 prompt 策略之前开启轮次。**部被阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACPAgent Client Protocol提供持久的终事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。
2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()``additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤缓冲每次调用上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。
2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()``additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤每次调用缓冲上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。
3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使下一步骤循环顶部 drain 将其记录为继续轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与有的 `hasSteering` force-continue 覆盖一致)。
3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使下一步骤循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与有的 `hasSteering` 强制继续覆盖一致)。
### Pre-tool 输入重写是一个独立的一致性决策
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
### 边界
seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终的单调停止由 `agent/turn-stop` 独立负责。
seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(compaction)(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终结性的单调停止由 `agent/turn-stop` 独立负责。
## 曾考虑的替代方案
- **将 pre-tool 输入重写作为本 seam 集的一部分交付**——推迟,视为过度扩展信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。
- **将持久的 `hook/*` SessionEvent 与 seam 一起声明**——否决原生插件使用类型化 Decision 而完全不需要钩子日志(工作示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。
- **将 pre-tool 输入重写作为本 seam 集的一部分发布**推迟,视为越界信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。
- **将持久的 `hook/*` SessionEvents 与 seam 一起声明**否决原生插件使用类型化 Decision 而完全不需要钩子日志(实际示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。
## 后果
规范拦截表面实现了统一类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation`dsh-tools` 负责身份封存五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) [工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。
规范拦截表面具有统一类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation`dsh-tools` 负责身份封存五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) [工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-session-store-fork-api.md: 4bf5c3fe43821570fd947034358e54d0a0a602f9
2026-06-30-session-store-fork-api.zh.md: 3dd15f5beb095fecb7fdaa7d80abcf4b99c07920
2026-06-30-session-store-fork-api.zh.md: a3ffb881a446647861fa5fbaf57dde291838a090
@@ -1,20 +1,20 @@
# RFCSessionStore fork API
Status: implemented
[English](2026-06-30-session-store-fork-api.md) | 中文
Status: implemented
## 问题
事件溯源的会话日志已经具备 fork 所需的原语:创建一个新会话并带上种子事件前缀,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但普通的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以复制、子会话打上什么元数据、错误如何分类。
事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以复制、子会话打上哪些元数据、以及错误如何分类。
语义风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且被轮次封闭。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`可能还有未关闭的 `step/start`,以及悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子会话历史——看起来像是参与了父会话中一个未完成的轮次。现有的 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次尚未关闭时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝。
语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个未完成的轮次。现有的 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求
## 决策
`dsh-session` 直接在 `ctx.sessions` 上拥有普通活跃会话 fork 能力。没有独立的 `dsh-session-fork` 包(package),也没有 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久工作都委托给现有的会话存储与持久化后端。
`dsh-session` 直接在 `ctx.sessions` 上拥有常规活跃会话 fork 能力。不设独立的 `dsh-session-fork` 包(package),也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久工作都委托给现有的 session store 和持久化后端。
存储暴露一个操作:
store 暴露一个操作:
```ts ignore-check
type SessionForkSource = Session | SessionId
@@ -24,20 +24,20 @@ class SessionStore extends Service {
}
```
`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 创建一个空的子会话。fork 有的校验检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 标记为源会话 id,并将 `seedLength` 设为复制前缀长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。
`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 创建一个空的子会话。fork 有的校验检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 为源会话 id,并将 `seedLength` 设为复制前缀长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。
空前缀可以 fork;任何非空边界必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因是什么。类型化的错误区分源不存在、对象陈旧、子会话 id 重复和边界无效。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。
空前缀可以 fork;任何非空边界必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因为何。类型化的错误区分源缺失、对象陈旧、子 id 重复和边界无效等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。
## 曾考虑的替代方案
**独立的 `ctx.sessionFork` 服务。** 这是第一版实现,但评审表明它过度套用了能力 seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方发现并安装第二个服务,仅仅为了在会话存储原语之上执行策略
**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在 session store 原语之上执行一层策略而去发现并安装第二个服务。
**两个函数:`snapshot()` 加 `fork()`。** 这保留了可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还接口感觉比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 保持了 API 直接,同时仍支持对先前时间点的 fork。
**两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。
**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的,因为委托通常在父轮次尚未关闭时开始,子会话应只继承已完成的前缀。但对普通的用户/会话分支来说是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并静默丢弃了父轮次的尾部。
**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的——委托通常在父轮次仍然打开时开始,子会话应只继承已完成的前缀。但对常规的用户/会话分支而言是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并静默丢弃了父轮次的尾部。
## 后果
公开接口保持小巧且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化一次该种子,并在头部保留 `parentSession` / `seedLength`。
公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession``seedLength`。
v1 范围仍排除 ACP `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 获得专的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
v1 范围仍排除 ACPAgent Client Protocol `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 获得专的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-subagent-observe-enrich.md: b48a1fff32130345e669a3b3b905c4fda987e41e
2026-06-30-subagent-observe-enrich.zh.md: 59dc555ce4acff30f4ba0b5929b605dc5252fe38
2026-06-30-subagent-observe-enrich.zh.md: 8ce070001e219572658fd4e94c660de1094ddee5
@@ -1,4 +1,4 @@
# RFCSubagent 生命周期充实——lastAssistantMessage(仅观
# RFCSubagent 生命周期丰富化——lastAssistantMessage(仅观
Status: implemented
@@ -6,26 +6,26 @@ Status: implemented
## 问题
钩子子系统([拦截 seam RFC](2026-06-30-interception-seams.md))允许插件在生命周期节点观测和门控 agent(智能体)。Claude Code 和 Codex 都暴露了 **SubagentStart / SubagentStop** 钩子,且 CC 的钩子携带 subagent 的最终消息。harness 已经发出 `subagent/start``subagent/end` 生命周期事件([subagent 能力 seam](2026-06-21-subagent-capability-seam.md)),但其载荷极为精简(`provider``id`,以及 end 时的 `stopReason`——不足以让钩子桥接层在不另行访问活跃运行的情况下报告 subagent 产出了什么。
钩子子系统([拦截 seam RFC](2026-06-30-interception-seams.md))允许插件在生命周期节点观察和拦截 agent(智能体)。Claude Code 和 Codex 都暴露了 **SubagentStart / SubagentStop** 钩子,且 CC 的钩子携带 subagent 的最终消息。harness 已经发出 `subagent/start``subagent/end` 生命周期事件([subagent 能力 seam](2026-06-21-subagent-capability-seam.md)),但其载荷极为精简(`provider``id`,以及 end 时的 `stopReason`不足以让钩子桥接层在不单独访问活跃 run 的情况下报告 subagent 产出了什么。
本 RFC 充实 end 载荷。它刻意限定为**仅观**:不改变控制流,不引入 waterfall(瀑布式事件)。影响运行的 subagent-stop 决策(续行、注入改变运行的内容)属于另一更大的重设计,不在本 RFC 范围内。
本 RFC 丰富 end 载荷。它刻意限定为**仅观**:不改变控制流,不引入 waterfall(瀑布式事件)。影响 run 的 subagent-stop 决策(续行、改变 run 的注入)属于另一更大的重设计,不在本 RFC 范围内。
## 决策
**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结路径上,它是只读的类型化 `SubagentResult.output`,观者无需持有运行即可看到子 agent 产出。在基础设施拒绝不存在 `SubagentResult` 的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听是受信任的同进程协作者,遵守借用不可变载荷的契约。
**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结路径上,它是只读的类型化 `SubagentResult.output`,观者无需持有 run 即可看到子 agent 产出了什么。在基础设施拒绝不存在 `SubagentResult`的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听是受信任的同进程协作者,遵守借用不可变载荷的契约。
两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观附加到就绪的提供方运行上,发出 `subagent/start`,然后返回该运行;因此进程内监听可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程提供方无需在本地注册表中有条目。提供方启动被拒绝时不发出任何事件。回调保持仅观测,逐监听隔离确保一个订阅者不会阻塞活跃运行或饿死后续监听
两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观附加到就绪的 provider run 上,发出 `subagent/start`,然后返回该 run进程内监听方因此可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程 provider 无需在本地注册表中有对应条目。provider 启动被拒绝时不发出任何事件。回调保持仅观察,且逐监听隔离确保一个异常订阅者不会阻塞活跃 run 或饿死后续监听
## 曾考虑的替代方案
**`agentType` subagent 类别标签**CC 的 `subagent_type` 在 harness 中的对应物)放在请求两个生命周期载荷上——早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(这里没有任何代码解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为向 Claude Code 自身的 SubagentStart/Stop `agent_type` 匹配器喂入其默认值 `"general-purpose"`,因此本 RFC 只交付一项充实`lastAssistantMessage`
**`agentType` subagent 类别标签**CC 的 `subagent_type` 在 harness 中的对应物)放在请求两个生命周期载荷上早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(此处没有任何逻辑解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为直接为其 SubagentStart/Stop `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 RFC 只交付一项丰富化`lastAssistantMessage`
**控制流式 `subagent/end`**——推迟;见下文。
**控制流式 `subagent/end`**推迟;见下文。
## 为何仅观,以及推迟了什么
## 为何仅观,以及推迟了什么
控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听、在进程内提供方中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重设计(同一项重新设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观测充实`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在该重新设计发生时将落地的位置
控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听、在进程内 provider 中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重设计(同一个重设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观察丰富化`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在设计发生时的落点
## 后果
钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器——无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件触发方式与之前完全相同end 载荷多了一个可选的)字段——因此不需要快照或 e2e 测试变更
钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件触发方式与之前完全一致end 载荷多了一个可选字段——因此无需更新快照或 e2e 测试。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-dynamic-workflows.md: 67ceebf7017f197bd800fd339b575390b3b936c1
2026-07-05-dynamic-workflows.zh.md: 0ea8e88bfa750a9bb253c7dd3061766fe15d3630
2026-07-05-dynamic-workflows.zh.md: 54ba0d903de228e14a53e6f64ead0f5156e61289
@@ -1,80 +1,80 @@
# RFC:动态工作流——脚本驱动的多 agent 编排 seam
Status: implemented
[English](2026-07-05-dynamic-workflows.md) | 中文
Status: implemented
## 问题
harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立片段的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划没有持久存放处,每一步的协调都要消耗一次模型往返。Claude Code 以[动态工作流](https://code.claude.com/docs/en/workflows)的形式提供这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。
harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划无处持久存,每一步的协调都要消耗一次模型往返。Claude Code 以 [dynamic workflows](https://code.claude.com/docs/en/workflows) 的形式提供这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。
## 决策
`packages/workflow/` 下以 bash seam 的形态(接口/实现/消费方)提供一组工作流能力,加上 subagent seam 上所需的结构化输出基础。
`packages/workflow/` 下以 bash seam 的形态(接口/实现/消费方)提供一组工作流能力,以及它在 subagent seam 上所需的结构化输出基础。
### 脚本契约(兼容 Claude Code
一次工作流调用包含 JSON `meta``name``description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被求值。正文接收 `agent(prompt, options)``parallel(thunks)``pipeline(items, ...stages)``phase(title)``log(message)``args`。pipeline 各阶段接收 `(prev, item, index)`,阶段间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过 journaling 延后处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。
一次工作流调用包含 JSON `meta``name``description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)``parallel(thunks)``pipeline(items, ...stages)``phase(title)``log(message)``args`。pipeline 各阶段接收 `(prev, item, index)`,阶段间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过日志化延迟处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。
与 Claude Code 的一处刻意**偏离**:钩子误用——未知或延的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——抛出 `fatal: true``WorkflowError`,组合器 fatal 错误**重新抛出**而非将 item 置为 null。如果不这样做,一个拼错的选项会溶解为与子 agent 失败无法区分的 `null`——正是本仓库禁止的「接受后静默忽略」失败模式。一处**新增**:工具的 `args` 参数是 JSON 对象(裸列表被包装为一个字段),以保持协议格式(wire format诚实。
与 CC 有一处刻意的严格性**差异**:钩子误用——未知或延的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——抛出 `fatal: true``WorkflowError`,组合器会**重新抛出** fatal 错误而非将 item 置为 null。如果不这样做,一个拼错的选项会悄然变成一个与子 agent 失败无法区分的 `null`——正是本仓库禁止的「接受后忽略」失败模式。另有一处新增:工具的 `args` 参数是一个 JSON **对象**(裸列表被包装为一个字段),使协议格式(wire format保持诚实。
### seamdsh-workflow
`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出异常;返回的 `WorkflowRun``result` 永不 reject(失败解析为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅观察的 emit,携带数据快照(id + meta`workflow/end` 不含 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇细节见 [core-data-structures/workflow.md](../../../core-data-structures/workflow.md)。
`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`——每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出;返回的 `WorkflowRun``result` **永不** reject(失败解析为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅观察的 emit,携带**数据快照**id + meta`workflow/end` 省略 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇详情见 [core-data-structures/workflow.md](../../../core-data-structures/workflow.md)。
### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程
**信任前提**:工作流脚本与模型的 bash 访问有相同信任级别。引擎约束有 bug 的脚本,保证 result 必定 settle、值 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 之后放置一个独立进程或 isolated-vm 引擎。
**信任前提**:工作流脚本与模型的 bash 访问有相同信任级别。引擎容纳有缺陷的脚本,保证结果已 settled、值 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。
**为何选择 `node:worker_threads`**:每次运行获得一个非池化 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。
**为何选择 `node:worker_threads`**:每次运行获得一个非池化 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。
宿主在发布前校验元数据并解析正文。私有枚举键 payload map 定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、result 优先级 dispose 静默在协议两侧维持 subagent run 契约。[agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 拥有这些竞态算法
宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级 dispose 静默,在此协议上保持 subagent run 契约。这些竞态算法归 [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 所有
引擎暴露一条进程内 `MessageChannel` 测试路径,因为主进程 V8 覆盖率无法观测 worker 执行。
**Meta 是数据**:经 schema 校验的 `meta` 字段以 JSON 形式到达 seam,仅做形状校验。宿主从不元数据字面量求值——否则脚本控制的访问器在 worker 隔离之外运行。
**Meta 是数据**:经 schema 校验的 `meta` 字段以 JSON 形式到达 seam,仅做形状校验。宿主从不执行元数据字面量否则脚本控制的访问器可以在 worker 隔离之外运行。
**值边界**`materializeFromRealm` 复制出站值,拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数。数据属性复制使 `"__proto__"` 安全;getter 正常读取,抛出异常的 getter 会大声失败。`args` 通过 `workerData` 传入,暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用全量渲染器以确保 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`因此脚本按 `name``code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和 grace 限制均为经校验的配置。
**值边界**`materializeFromRealm` 复制出站值,拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数。数据属性复制使 `"__proto__"` 安全;getter 正常读取,抛出异常的 getter 会大声失败。`args` 通过 `workerData` 传入,暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用全量渲染器,因此 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`脚本应基于 `name``code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和宽限限制均为经校验的配置。
### 消费方(dsh-tool-workflow
一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、等待`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed``isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述面向模型的编写规范。使用策略作为工具自身的 `tool:<toolName>` prompt 段随工具一起交付(显式请求才使用的导——工具导存在于工具插件中,从不在部署 persona );harness 没有 ultracode 风格的 effort 门控。
一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed``isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述**即**面向模型的编写规范。使用策略工具自身的 `tool:<toolName>` prompt 段随工具发布(显式请求才使用的导——工具导存在于工具插件中,从不在部署 persona );harness 没有 ultracode 风格的 effort 门控。
### 基础:subagent seam 上的结构化输出
`SubagentStartRequest.outputSchema``dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同 schema 而不共享可变策略,dispose 子 agent 时整个附件被移除
`SubagentStartRequest.outputSchema``dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。
输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用外层 `run_code` 结果),在捕获进入 pending 状态后拒绝后续副作用,并在提交后不再请求模型步骤即停止子 agent。校验失败仍可重试的工具错误;干净完成但没有已提交捕获的情况 settle 为错误
输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用外层 `run_code` 结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍可重试的工具错误;没有已提交捕获的正常完成以错误结算
`StructuredOutputSchema``dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type``properties`/`required`/`additionalProperties``items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。[agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 拥有组装、提交、守卫和终止停止的正确性算法
`StructuredOutputSchema``dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type``properties`/`required`/`additionalProperties``items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归 [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 所有
## 测试
worker 侧逻辑通过进程内 `MessageChannel` 运行,以便 V8 覆盖率能度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带 key 的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。
worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带密钥的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。
## 延(本轮明确的非目标)
## 延(本轮明确的非目标)
- **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。
- **Journaling + 恢复**`resumeFromRunId`、缓存的 agent() 前缀):实现它会将 Claude Code 的确定性禁令作为脚本契约收紧重新引入(脚本今天可以读取时钟)。
- **保存/打包的工作流**`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到 run 目录**(tool-call 事件已经持久记录了脚本)。
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延后项的消息大声拒绝)。
- **整体运行的挂钟超时**:取消总能释放调用方(result 在 grace 内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。
- **超越 worker 线程的引擎加固**:在同一 seam 之后放置 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
- **ACP 进度 UI**基于 `workflow/*` 事件`/workflows` 风格视图);事件已为此存在。
- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍能力门控 `false`)。
- **日志化 + 恢复**`resumeFromRunId`、缓存的 agent() 前缀):实现它会脚本契约收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。
- **保存/打包的工作流**`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(tool-call 事件已经持久记录了脚本)。
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延的消息大声拒绝)。
- **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。
- **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
- **ACP 进度 UI**基于 `workflow/*` 事件`/workflows` 风格视图);事件已为此存在。
- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍能力标志 `false` 门控)。
## 曾考虑的替代方案
- **宿主侧的恶意值防**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆并带结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上使跨 realm 值全量化。
- **进程内 `node:vm` 执行**:机最简——无 RPC、无线程——但 `start()` 会在脚本首段同步切片期间阻塞调用方,个 await 之后的同步自旋无法在进程内被杀死vm `timeout` 仅覆盖首段切片),`dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。
- **后台执行作为默认**Claude Code 的形态):延。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具各做一套
- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`
- **Meta 嵌入脚本作为 `export const meta = {...}`**Claude Code 的精确格式):保持脚本自包含且 Claude Code 脚本可直接使用,但获取 meta 需要在宿主上模型编写的文本求值。即使空的限时 vm 上下文,在宿主读取结果对象时也无法约束脚本控制的 getter。JSON 参数消除了扫描器、求值和宿主自旋漏洞;代价是 Claude Code 脚本的 meta 头必须移入参数(正文保持可直接使用)。
- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,无法在不丢失转换精度的情况下对其校验。
- **schema 对象库(zod 或仓库的 schemastery)用于结构化输出子集**:schema 是协议数据——纯 JSON,跨越 `agent({schema})` 中的 vm realm 边界逐字落入强制工具的 parameters——正是活 schema 对象无法存在的位置;在运行时消费原始 JSON Schema 需要在其上加第三方转换器(zod core 只输出 JSON Schema,不反向),且会在 schemastery 的配置角色之外引入第二种 schema 语言。
- **ajv 值校验**:它校验完整 JSON Schema,因此子集门控——模块的真正要点,因为每个被接受的关键字都必须是 harness 强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的个运行时依赖,所有这些只为替换约 70 行的值遍历器,而路径限定的、报告每一处违规的错误输出无论如何都是自定义的。
- **提供方 JSON 模式代替捕获工具**:它保证有效 JSON,不保证 schema 一致性,且它与工具调用的交互不明确。捕获工具保留了轮次内的校验重试。提供方侧的严格工具 schema 可以在不改变本设计的前提下进一步收窄接受的子集。
- **宿主侧的恶意值防**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上使跨 realm 值全量化。
- **进程内 `node:vm` 执行**:机械上最简——无 RPC、无线程——但 `start()` 会在脚本的初始同步切片期间阻塞调用方,第一个 await 之后的同步自旋无法在进程内终止vm `timeout` 仅覆盖第一个切片),`dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。
- **后台执行作为默认**CC 的形态):延。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具设计
- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`
- **Meta 嵌入脚本作为 `export const meta = {...}`**CC 的确切格式):保持脚本自包含且 CC 脚本可直接使用,但获取 meta 需要在宿主上执行模型编写的文本。即使一个空的限时 vm 上下文也无法约束脚本控制的 getter(当宿主读取结果对象时)。JSON 参数消除了扫描器、执行和宿主自旋漏洞;代价是 CC 脚本的 meta 头必须移入参数(正文保持可直接使用)。
- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,无法在不丢失转换精度的情况下对其进行校验。
- **schema 对象库(zod 或仓库的 schemastery)用于结构化输出子集**:schema 是协议数据——纯 JSON,跨越 `agent({schema})` 中的 vm realm 边界逐字落入强制工具的 parameters——正是活 schema 对象无法存在的位置;在运行时消费原始 JSON Schema 需要在其上加一个第三方转换器(zod core 只输出 JSON Schema,不反向),且会在 schemastery 的配置角色旁边放置第二种 schema 语言。
- **ajv 用于值校验**:它校验完整 JSON Schema,因此子集门控——模块的真正要点,因为每个被接受的关键字都必须是 harness 强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的第一个运行时依赖,为替换约 70 行的值遍历器,而路径限定的、报告每一处违规的错误报告无论如何都是自定义的。
- **提供方 JSON 模式代替捕获工具**:它保证有效 JSON,不保证 schema 一致性,且它与工具调用的交互不明确。捕获工具保留了轮次内的校验重试。提供方侧的严格工具 schema 后续可以在不改变本设计的情况下收窄接受的子集。
## 后果
扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和 message-port RPC 的开销,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。Worker 线程不是安全边界。无效选项失败而非退化为 Claude Code 的 `null`;消费方通过 run 句柄保持控制,观察者仅接收快照。
扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和 message-port RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制,观察者仅接收快照。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-skill-system.md: 6cfd1f977ae5a1e1ad646a707a4201e57d46bc38
2026-07-05-skill-system.zh.md: d491899e03854140c93f67d11e4079a5b6525185
2026-07-05-skill-system.zh.md: f59fd5d850a38d7324b391a116c72c4e71ef7401
@@ -1,55 +1,55 @@
# RFCSkill 系统——面向 agent 的渐进式指令披露
Status: implemented
[English](2026-07-05-skill-system.md) | 中文
Status: implemented
## 问题
各 agent 产品已趋同于一种 skill 模式:保持请求提示词精简,仅列出可用的指令包,模型判定任务匹配时再加载完整正文。Codex、Claude Code、OpenCode Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用行为而无需在每个轮次支付全量提示词成本
Agent(智能体)产品已趋同于一种 skill(技能)模式:保持请求提示词精简,仅列出可用的指令包,模型判定任务匹配时再加载完整正文。Codex、Claude Code、OpenCode Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用行为而无需在每个轮次支付全量提示词开销
DeepSeek Harness 使用同一原语,项目的评审指导、插件编写指导和工具使用指存放在工作区或用户的 agent 配置旁,而非硬编码 agent loop(智能体循环)。
DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和工具使用指存放在工作区或用户的 agent 配置旁,而非硬编码 agent loop(智能体循环)
## 决策
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACP 应用获得相同行为,同时嵌入式或远程提供方可在不改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry``local``tool` 分支分别转发给对应的负责方
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACPAgent Client Protocol应用获得相同行为,同时嵌入式或远程提供方可在不改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry``local``tool` 分支分别转发给对应的所有者
提供方插件在 `apply()` 期间同步注册。提供方成员关系是直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从 awaited `list()` 调用返回排序后的候选项,远程提供方在此期间执行初始化、认证和发现,同时遵守查找的 abort signal。注册表校验每个候选项,对同名 skill 按 rank、提供方注册顺序和提供方内部顺序执行 first-wins 解析,然后按 skill 名称排序摘要以保证消费方获得确定性结果。注册表仅缓存已完成的目录快照,提供方/运行时修订版本在发现过程中发生变化时重试,因此 unload 不会将一个陈旧不可解析的 skill 冻结会话前缀。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project-over-user 优先级;`runtime` 为注册表有的提供方名称被保留
提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧不可解析的 skill 冻结会话前缀。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 优先级;`runtime` 保留为注册表有的提供方名称。
本地提供方按 first-wins 的 rank 顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents``customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`使系统有的目录被当作普通用户内容。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。
本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents``customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`以免系统有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md``name``description` 为必填;`whenToUse``disableModelInvocation``metadata` 为可选。名称使用 kebab-case。YAML frontmatter 使用 `yaml` 包解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求声明的现代解析器,手写窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md``name``description` 为必填;`whenToUse``disableModelInvocation``metadata` 为可选。名称用 kebab-case。YAML frontmatter 使用 `yaml`package解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve``stat` 探测 `.git`,根目录发现使用 `listDir`skill 读取使用 `readText`对于未挂载 fs seam 的最小上下文,Node 文件系统仍作为回退。缺失的根目录、不可读或格式错误的 skill 文件以及提供方 `list()` 的瞬态失败均降级为 warn-and-skip,使个坏源不会导致每个 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve``stat` 探测 `.git`,根目录发现使用 `listDir`skill 读取使用 `readText`Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
`dsh-tool-skill` 通过 [`agent/session-prefix`](2026-07-07-session-prefix.md) 贡献一 user-role `<system-reminder>` 目录。目录仅包含排序后的 skill 名称描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 限制,其默认值为 `500`,最小值为 `3`会话前缀 seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整 skill 正文从不包含在目录中。
`dsh-tool-skill` 通过 [`agent/session-prefix`](2026-07-07-session-prefix.md) 贡献一 user-role `<system-reminder>` 目录。目录仅包含排序后的 skill 名称描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`session-prefix seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整 skill 正文从不包含在目录中。
`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">``<skill_resources>``<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的披露路径。
`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">``<skill_resources>``<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。
数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md),服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。
数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md),服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。
## 曾考虑的替代方案
**将完整 skill 正文注入每条系统提示词。** 否决,因为这破坏了渐进式披露,使每个请求都为可能不适用的指令付出代价。
**仅将 skill 暴露为斜杠命令** 否决,因为模型主动加载是核心能力;斜杠/ACP 命令广播不改变发现机制。
**仅以斜杠命令暴露 skill** 否决,因为模型主动加载是核心能力;斜杠/ACP 命令广播不改变发现机制。
**将本地文件系统扫描直接放 `ctx.skills`** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 同构:注册表负责冲突解和消费方,实现负责加载。
**将本地文件系统扫描直接放 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解和消费方,实现拥有加载。
**使用系统提示词段落。** 否决,因为渲染后的系统提示词是单一字符串,而目录是一条具有仅请求生命周期要求的 user-role `<system-reminder>` 消息。[`agent/session-prefix`](2026-07-07-session-prefix.md) 是选定的机制:它将目录置于派生历史之前,并将组合后的消息记录在请求头中。
**将内置 DSH 编写 skill 物化到 `~/.dsh/skills/.system`。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
** `~/.dsh/skills/.system` 下物化内置 DSH 编写 skill** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包覆盖配置的根目录,同时保持重复处理和目录顺序易于推理。
**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包覆盖配置的根目录,同时使重复处理和目录顺序易于推理。
**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
## 后果
agent-core 主干包含一个会话前缀贡献者、一个本地提供方和一个面向模型的工具。skill 发现 cwd 敏感,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
agent-core 主干包含一个 session-prefix 贡献者、一个本地提供方和一个面向模型的工具。Skill 发现 cwd 敏感,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
目录固定的根目录集和运行时注册修订版本是确定性的,但不监磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。
目录对于固定的根目录集和运行时注册修订版本是确定性的,但不监磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。
## 延后
fork skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、不强制执行这些字段。
Fork skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、不执行这些字段。

Some files were not shown because too many files have changed in this diff Show More