docs(i18n): RFC tree batch — 146 bilingual pairs via the committed pipeline
implemented(除 4 篇超长文档随后补)、proposed、rejected 全树配对; 同一流水线 + 二遍校验(paraphrase-back + 仓库上下文一致性)产出。 docs/rfc/implemented/AGENTS.md 与其 CLAUDE.md 符号链接列入排除 (agent 指令文件,与根 AGENTS.md 同策略)。
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-content-block-vocabulary.md: 9414bda624fa6e5fc7e9b11b7a738d32b269af6b
|
||||
2026-06-11-content-block-vocabulary.zh.md: 32308a5fe58f3a2e3c402982b7067811b9698218
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Provider-neutral content-block vocabulary owned by dsh-llm
|
||||
|
||||
English | [中文](2026-06-11-content-block-vocabulary.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# RFC:由 dsh-llm 持有的提供方无关内容块词汇
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-content-block-vocabulary.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 需要一套统一的内部消息语言,供 agent loop(智能体循环)、会话日志和所有插件共同使用。
|
||||
|
||||
## 决策
|
||||
|
||||
自行持有词汇:消息是类型化内容块(`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。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **镜像 DeepSeek/OpenAI chat-completions 的结构**:对第一个提供方零映射成本,但对富内容(推理(reasoning)、作为结构化块的工具结果)处理起来别扭。
|
||||
- **原样采用 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`):零运行时成本的名义类型。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-custom-schema-dsl.md: 4c4d572b15d5e474e00e99fc5e7dd89240251c63
|
||||
2026-06-11-custom-schema-dsl.zh.md: 31af594846b6b1bc8ce983b7b9d4ddd956a560ff
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Custom typed tool-schema DSL instead of schemastery
|
||||
|
||||
English | [中文](2026-06-11-custom-schema-dsl.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# RFC:使用自定义类型化工具 schema DSL 替代 schemastery
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-custom-schema-dsl.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工具参数必须以标准 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 来源的工具就是这样注册的。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**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 之后已有类型层面的回归测试。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-dev-invariants-over-deep-readonly.md: dc89b5b66d02bde2f4fe2794e04b76ecdeac62ae
|
||||
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 02beb323df84cd442611f0c52b3c56734d6d0554
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Source-owned session immutability and dev-mode invariants
|
||||
|
||||
English | [中文](2026-06-11-dev-invariants-over-deep-readonly.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# RFC:源头拥有的会话不可变性与开发模式不变式
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-dev-invariants-over-deep-readonly.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实间关系的检查。如果将二者混为一体放进一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。
|
||||
|
||||
会话日志是回放、请求重建、持久化和用户可见历史的持久真源。会话包以外的代码必须能够检视该历史,但不能保留一个可以事后改写它的引用;从调用方接收的输入也不能继续连接到调用方拥有的可变对象上。
|
||||
|
||||
单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。
|
||||
|
||||
TypeScript readonly 类型不构成充分的运行时边界。它们在程序运行时消失,一次类型转换即可绕过,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。
|
||||
|
||||
## 决策
|
||||
|
||||
职责在一个始终开启的存储边界与可选的开发断言之间分离。
|
||||
|
||||
### Session 拥有不可变历史
|
||||
|
||||
`Session` 仅在一次递归遍历完成无损 JSON 快照后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切脱离记录,因此校验和存储不可能从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
|
||||
|
||||
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者收到同一条记录,`session.events` 返回一份冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的校验、快照与冻结边界。
|
||||
|
||||
这一保证属于 `Session` 而非可选的监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
|
||||
|
||||
### 派生请求保持脱离
|
||||
|
||||
`deriveMessages()` 将已记录的表面事件投影为脱离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。请求组装因此可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
|
||||
|
||||
### 不变式插件检查关系
|
||||
|
||||
`dsh-invariants` 是一个纯监听的开发插件。它不冻结记录,没有配置;dispose 仅移除其断言。它检查需要追踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent 状态转换、主体正确的作用域分发,以及 agent loop 构建的请求与从其会话日志前缀重建的请求之间的等价性。
|
||||
|
||||
当插件附加到已有或已播种的会话时,它回放不可变日志以重建追踪状态。这使得在轮次中间进行热重载是安全的,同时不赋予插件对会话存储的所有权。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 全面的 deep-readonly 类型
|
||||
|
||||
[被否决的不可变公开表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公开的日志和消息表面全面应用递归 readonly 类型。这能提供编辑器反馈,但不能提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。
|
||||
|
||||
### 仅在开发模式冻结
|
||||
|
||||
仅在安装了不变式插件时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终开启,而更昂贵的关系检查保持为可选的开发支持。
|
||||
|
||||
### 仅在派生消息时克隆
|
||||
|
||||
脱离 `deriveMessages()` 会保护最常见的请求路径,但 `session.events` 的其他读者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,不是替代品。
|
||||
|
||||
## 后果
|
||||
|
||||
- 每条被接受的实时或种子会话事件在任何观察者收到之前,都已从调用方拥有的输入中脱离并深度不可变。
|
||||
- `session.events` 暴露稳定的不可变快照,而非私有的增长数组。
|
||||
- 请求侧的修改无法通过派生消息触及已存储的历史。
|
||||
- 开发构建可以启用关系断言而不改变存储行为;dispose 或省略该插件不会削弱日志不可变性。
|
||||
- `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。
|
||||
- 运行时边界在每条被接受的事件上承担一次递归快照与冻结的开销;后续读者和缓存投影复用已拥有的不可变记录。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-event-sourced-sessions.md: 04ff974826ffbc9052c7eb9f5794bc16557241f9
|
||||
2026-06-11-event-sourced-sessions.zh.md: a3fec18445673bc2368333413f9802f9822b9c89
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Event-sourced sessions with derived message history
|
||||
|
||||
English | [中文](2026-06-11-event-sourced-sessions.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# RFC:事件溯源的会话与派生消息历史
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-event-sourced-sessions.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
MVP 要求严格的基于事件的 trace、logging 系统,session 完全可回放。
|
||||
|
||||
## 决策
|
||||
|
||||
`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志,是唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`);原始流分片被记入日志以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威来源。回放/fork = 用已有日志初始化一个新会话。
|
||||
|
||||
追加操作是同步的(热路径从不阻塞在 I/O 上);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。
|
||||
|
||||
顺序契约:agent loop(智能体循环)先追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**可变消息数组 + 事件作为通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身就是状态,分歧在结构上不可能发生。
|
||||
|
||||
## 后果
|
||||
|
||||
- 回放、trace 与遥测在结构上得到保证,而非事后附加。
|
||||
- 持久化仍是插件关注点;内存存储随 dsh-session 一起发布。
|
||||
- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。
|
||||
- 派生成本随日志长度增长——压缩(未来插件)是预期的缓解手段,而非日志变更。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-microkernel-event-taxonomy.md: c66968257a5a6304f187ccb5b9a162aa143e608d
|
||||
2026-06-11-microkernel-event-taxonomy.zh.md: 86750f6ece488e735f2c14f759db7c9eef360828
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Microkernel — extension via Cordis event taxonomy, one concrete loop
|
||||
|
||||
English | [中文](2026-06-11-microkernel-event-taxonomy.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# RFC:微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-microkernel-event-taxonomy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩(context compaction)、沙箱、权限、UI、持久化、MCP、skill(技能)都必须能以插件形式编写,而无需修改核心。
|
||||
|
||||
## 决策
|
||||
|
||||
纯 Cordis 事件分类体系(taxonomy)。循环的扩展 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` 观测值的事件。
|
||||
|
||||
事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且本身可替换——它之外的任何代码都不得依赖它。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**专用中间件栈(koa-compose 风格)** 与 **插件插入其中的显式阶段状态机**:两者都需要重新实现分发、dispose(资源释放)和重载语义,而 Cordis 原生事件系统已经提供了这些;作为 Cordis effect,监听器天然获得 HMR(热模块替换)和 dispose 能力。
|
||||
|
||||
## 后果
|
||||
|
||||
- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持最新)。
|
||||
- HMR 和 dispose 免费获得:监听器和注册都是 Cordis effect。
|
||||
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——已在 AGENTS.md 中记录,并由组合测试覆盖。
|
||||
- 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何 seam 的 steering(中途引导)绝不会被搁置(有回归测试保障)。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-runtime-arg-validation.md: 6da117643166d304bee1d368a314cc1602cac828
|
||||
2026-06-11-runtime-arg-validation.zh.md: fb67568da3bfc7f1f8fcc0429b6c68193e04693a
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Runtime arg validation at the model boundary
|
||||
|
||||
English | [中文](2026-06-11-runtime-arg-validation.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# RFC:模型边界处的运行时参数校验
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-runtime-arg-validation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`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` 结果。
|
||||
|
||||
校验器严格镜像 `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` 的调用方。
|
||||
- 校验开销相对于一次模型调用可忽略不计。
|
||||
|
||||
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-structured-error-taxonomy.md: 2baf88a1f942215e79561e565f455276c80178c4
|
||||
2026-06-11-structured-error-taxonomy.zh.md: f4762c4e92fb94c5bdbccc5f9a61e1496dc4c66d
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Structured error taxonomy
|
||||
|
||||
English | [中文](2026-06-11-structured-error-taxonomy.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# RFC:结构化错误分类体系
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-structured-error-taxonomy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
错误跨越服务边界时只是裸字符串。工具错误被扁平化为一个文本块——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` 在服务边界处做类型收窄。
|
||||
|
||||
- `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`)。
|
||||
|
||||
## 后果
|
||||
|
||||
- 错误在端到端链路上可被机器路由:插件可以按 `error.code` 分支,而非对 message 做子串匹配。
|
||||
- 一个基类被广泛导入,但它位于所有包本已依赖的包中,代价只是一条 import 语句,而非一条新的依赖边。
|
||||
- `deriveMessages` 不会将 `error` 字段呈现到模型历史中——模型仍然看到文本块;结构化字段服务于代码逻辑和回放。
|
||||
- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类添加了跨服务边界的路由元数据,不改变面向模型的文本。
|
||||
|
||||
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-tool-schemas-in-prompt-assembly.md: 443e6f20115e5a76001b4466c2d756675adbd886
|
||||
2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 2d23d6fd0ead17fbeed3feaf3cec864813ef47e4
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Tool schemas are part of the system-prompt assembly
|
||||
|
||||
English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# RFC:工具 schema 属于系统提示词组装的一部分
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-tool-schemas-in-prompt-assembly.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
在协议格式(wire format)层面,工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。但从架构角度看,「模型被告知它能做什么」是一个内聚的关注点:提示词段落和工具列表由同一批插件贡献组装而成,并在同一时刻被消费。
|
||||
|
||||
## 决策
|
||||
|
||||
`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema(工具注册表自动贡献一个提供方)。agent loop(智能体循环)每步消费一个 assembly;适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall(瀑布式事件)是模型前置信息的唯一拦截点——工具过滤(ToolSearch / 渐进式披露)是一次 assembly 改写,与提示词编辑无异。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**循环分别向工具注册表和提示词服务查询**——将一个内聚的关注点拆到两个 seam 上;任何想塑造「模型被告知什么」的拦截(工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 改写。
|
||||
|
||||
## 后果
|
||||
|
||||
- 一条 waterfall 统管模型的常驻上下文;plan 模式等插件可以在一个监听器中同时替换提示词文本和可见工具。
|
||||
- assembly 接口通过声明合并实现可扩展(无需无类型的 `extras` 包——扩展即声明合并)。
|
||||
- 「schema 出现在提示词服务中」有轻微的概念意外感,本文与 package README 对此做了说明。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-13-capability-seams.md: e9d417dbd2bafcaece39601b12dbb310feb1e19b
|
||||
2026-06-13-capability-seams.zh.md: e5d9be803f71ef3b85f79ed483dab64612a27fc7
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Capability seams — interface / implementation / consumer split
|
||||
|
||||
English | [中文](2026-06-13-capability-seams.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# RFC:能力 seam——接口/实现/消费方拆分
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-13-capability-seams.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 具有可替换的能力:目前是 bash 执行,未来会有沙箱/远程执行器和替代模型提供方。一项能力有三个关注点,它们以不同的速率、出于不同的原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面对什么来编程)。将三者打包在一个 package 中会耦合这些变化速率:把本地执行器换成沙箱执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。
|
||||
|
||||
这与「运行时谁提供、谁需要一项能力」是不同的问题,后者 Cordis 已经用 service + `inject` 回答了(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到该服务存在)。那套机制是必要的,但它不决定 package 边界;本 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,从不导入实现类型。
|
||||
|
||||
实现与消费方随后独立演进:沙箱执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。
|
||||
|
||||
当各部分确实属于同一关注点时,拆分并非强制:LLM seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现 package。不要预防性拆分:只有一种可设想的实现和一个消费方的能力保持为一个 package,直到第二个出现。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **合并为一个 package**:否决,因为它重新耦合了拆分所要分离的三种变化速率(这正是拆分的全部意义)。
|
||||
- **`@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 记录的是*为什么*默认选择拆分。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-13-twin-llm-adapters.md: 4efefcf4e2f6b1d60567ba3bfed7ae1ea53a7a4e
|
||||
2026-06-13-twin-llm-adapters.zh.md: 806eea84ff3e6c4de988348964429ccaea27f4ba
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Two LLM adapters as a design-verification twin
|
||||
|
||||
English | [中文](2026-06-13-twin-llm-adapters.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# RFC:以两个 LLM 适配器作为设计验证孪生
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-13-twin-llm-adapters.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` 库(有自己的事件词汇)访问同一端点。
|
||||
|
||||
它们强制执行的规则是:**凡是 StreamChunk 词汇无法同时为两个实现表达的东西,都是核心词汇的 bug**——立即暴露,而非等到下一个提供方才发现。这对孪生确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程为原始 JSON 字符串,以及消费方必须在两侧都处理的两条合法错误路径(从 `stream()` 抛异常,*或*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由库封装的适配器暴露出来的,单一手写适配器会将其掩盖。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **单一适配器**:代码更少、e2e 成本减半,但「提供方无关」的声明无法验证;词汇会默默编码 DeepSeek-via-fetch 的假设。
|
||||
- **mock 第二适配器**:更便宜,但不会触及真实提供方的协议格式(wire format)怪癖,因此证明力有限。孪生是真实对真实。
|
||||
|
||||
## 后果
|
||||
|
||||
孪生使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理模式下的表现——换来的是对 seam 中立性的持续验证和第二份实现示例。两者都使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来的一致性测试套件可以通过一份取代性 RFC 来论证退役其中一个适配器。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-14-session-persistence.md: 4078487b71862791dd25cf2afcfc03255cfeb0be
|
||||
2026-06-14-session-persistence.zh.md: 801632b56d2056b36dbf7869f5114d74599bd5d5
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Session persistence as an abstract service over the existing `SessionEvent`
|
||||
|
||||
English | [中文](2026-06-14-session-persistence.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# RFC:会话持久化——基于既有 `SessionEvent` 的抽象服务
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-14-session-persistence.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
会话此前只存在于内存中。示例插件 `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))都不可能实现。
|
||||
|
||||
[事件溯源模型](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`**)。
|
||||
|
||||
以下关键选择记录于此,因为它们是持久的、有争议的、且出人意料的:
|
||||
|
||||
- **规范持久日志逐字保留每个 `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` 硬注入循环**——会让非持久化的演示永远挂起。
|
||||
|
||||
格式版本控制: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` 保持逐字保留。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-15-turn-enclosure-invariant.md: bf0789f21ba7bd928e023bb5fd844d9ec78c4bc1
|
||||
2026-06-15-turn-enclosure-invariant.zh.md: 9f6f69b923ed7feff51122c4e4040bff3c9db8ea
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Every session event is enclosed in a turn
|
||||
|
||||
English | [中文](2026-06-15-turn-enclosure-invariant.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# RFC:每个会话事件必须包含在一个轮次内
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-15-turn-enclosure-invariant.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
持久化的会话持久化后端(在一个配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`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 **空闲**(两个轮次之间)时。
|
||||
|
||||
对于情况 2,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并**在恢复时丢弃**——注入的上下文虽然已持久化到磁盘,但在重新加载时被静默丢失。情况 1 单独来看是无害的(`user/message` 之后总是紧跟它触发的轮次),但使得「什么可以出现在轮次之外」这条规则变得模糊。
|
||||
|
||||
## 决策
|
||||
|
||||
**每个会话事件都位于一个轮次内部**——在一个 `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`。
|
||||
|
||||
可序列化性不变式在同一个源码边界强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一处统一管控,而非由下游恰好在监听的某个后端去发现。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条可检查的生产端规则优于一条更宽松的边界扫描逻辑,后者需要同时推理部分轮次*和*轮次间的散落事件。
|
||||
|
||||
## 后果
|
||||
|
||||
轮次现在是*唯一的*持久化/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:一个被中断的最终轮次会被关闭(用合成的 `turn/end {interrupted}`),其真实事件被保留,且完全不存在将轮次间上下文混入其中的风险,因为不再有轮次间上下文。`scanLog` 保持简单(至多一个可能未关闭的最终轮次,永远没有散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后得以存活。
|
||||
|
||||
代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行,且推导出的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 本就纯粹按事件类型推导,因此渲染结果不变。`injection` 触发器是一个新的磁盘词汇值;与每一个 `SessionEventMap`/`TurnTriggerMap` 的新增项一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`),这对任何断言旧顺序的代码是可观测的——agent loop 自身的测试是唯一的此类消费方。
|
||||
|
||||
该规则有意采用生产端强制执行 + 开发模式检查的方式,而非读取端容忍:未来的后端(SQLite/WAL)可以免费继承同样干净的边界,而在轮次外记录事件的插件会在开发模式下大声失败,而非在下次重新加载时静默丢失数据。
|
||||
|
||||
轮次内检测到的失败在 `turn/end` 之前记录。之后的 flush 失败没有合法的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-17-filesystem-capability-seam.md: c502ae712de22e97661192057d4410c7c55ea044
|
||||
2026-06-17-filesystem-capability-seam.zh.md: 01a4318237ebbd29fe3effa3f8528b6e4a5132d6
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools
|
||||
|
||||
English | [中文](2026-06-17-filesystem-capability-seam.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# RFC:文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-17-filesystem-capability-seam.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包将同时拥有文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。
|
||||
|
||||
这把三个独立变化的关注点耦合在了一起:
|
||||
|
||||
1. 文件系统契约:插件可以请求哪些操作。
|
||||
2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目范围的文件系统。
|
||||
3. 消费方接口:面向模型的 `read` / `write` / `edit` schema 与结果格式化。
|
||||
|
||||
没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,也会搅动工具 schema、演示和提示词引导。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。
|
||||
|
||||
我们需要让文件系统工具在成为公开包接口之前,以与 bash 相同的能力 seam 形态落地。
|
||||
|
||||
## 决策
|
||||
|
||||
文件系统访问是一个一等能力 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/*` 事件。
|
||||
|
||||
消费方包仅依赖接口包,从不依赖 `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)。本文已更新为描述最终落地的四包形态。
|
||||
|
||||
第一个后端刻意仅限本地:`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) 添加。
|
||||
|
||||
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基础目录解析相对路径,但隔离策略是独立决策:要么由更严格的 `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。
|
||||
|
||||
## 包拓扑
|
||||
|
||||
文件系统 seam 使用与 bash 三件套相同的依赖方向:
|
||||
|
||||
```text
|
||||
@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-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-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`。
|
||||
|
||||
根 `tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read`、`write` 和 `edit`)。它注入 `fs`,从不导入实现包。
|
||||
|
||||
## `ctx.fs` 契约
|
||||
|
||||
`@deepseek-ai/dsh-fs` 拥有一个语义文件系统服务。它比 `readFile` / `writeFile` 更高层,这样 `tool-fs` 就不必重新实现路径解析、版本管理、文本解码、二进制拒绝、分页、原子替换、符号链接行为或字面编辑语义。
|
||||
|
||||
该接口覆盖以下语义操作:
|
||||
|
||||
- 将模型/插件提供的路径解析为后端定义的目标。
|
||||
- 在不读取文件内容的情况下获取目标元数据。
|
||||
- 从目标读取有界的 UTF-8 文本页。
|
||||
- 创建或替换一个 UTF-8 文本文件。
|
||||
- 通过字面替换编辑一个已存在的 UTF-8 文本文件。
|
||||
|
||||
提供方 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`)。
|
||||
|
||||
授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都记录目标的版本,后续的写入/编辑只要文件仍处于该版本即被授权——因此对第 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 才能将用户提供的路径解析为稳定的目标标识。
|
||||
|
||||
解析后的目标必须至少暴露三个概念:
|
||||
|
||||
- 原始输入路径,用于诊断。
|
||||
- 不透明的 `targetKey`,用于过期守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
|
||||
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
|
||||
|
||||
读取和变更结果必须包含一个不透明的文件 `version`。本地后端可以使用 mtime/size 或类 hash 令牌;远程后端可以使用修订 id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。
|
||||
|
||||
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 以流式传输相同的文本语义用于大文件。二者都负责常规文件检查;有界行/输出处理不是它们的职责——行窗口、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。
|
||||
|
||||
已观察状态记录不在 `ctx.fs` 上:成功读取后执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。
|
||||
|
||||
全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已存在的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并在已存在时以 `FS_NOT_OBSERVED` 拒绝(这是策略为未观察 owner 使用的路径);`replaceIfVersion` 仅在目标处于已观察版本时替换,否则 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的已观察状态选择提供哪个期望。
|
||||
|
||||
字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此针对旧读取的编辑会报告 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地式组合。
|
||||
|
||||
策略插件(而非 `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) 添加。)
|
||||
|
||||
## 工具消费方行为
|
||||
|
||||
`@deepseek-ai/dsh-tool-fs` 是面向模型的消费方。它拥有工具名称、JSON Schema、模型边界的参数校验、提示词段落和结果格式化。它不拥有文件系统执行。
|
||||
|
||||
第一个工具套件包含:
|
||||
|
||||
- `read`:检查一个 UTF-8 文本文件并返回带行号的内容与分页引导。
|
||||
- `write`:创建或完全替换一个 UTF-8 文本文件。
|
||||
- `edit`:通过替换字面文本更新一个已存在的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。
|
||||
|
||||
每个工具遵循相同的执行形态:
|
||||
|
||||
1. 校验并规范化模型参数。
|
||||
2. 调用相应的 `ctx.fs` 操作。
|
||||
3. 将结果格式化为面向模型的 `ContentBlock[]`。
|
||||
4. 让抛出的后端/工具错误流经 `ToolRegistry.execute()`,由其转换为 `isError` 工具结果。
|
||||
|
||||
该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()` 和 `ToolRegistry.schemas()` 流入正常的提示词组装路径;无需修改 agent loop。
|
||||
|
||||
工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` 的 schema 不会仅因后端变化而改变。
|
||||
|
||||
默认部署要求在用 `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` 中测试,不在此处。
|
||||
|
||||
本仓库曾踩过的防御性模式类别被直接固定:
|
||||
|
||||
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `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` 提供方;后续提供方启动时没有继承的状态。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **面向模型的工具直接使用 `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` 上的权限/沙箱插件强制执行。
|
||||
|
||||
**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露展示元数据,而不要求消费方理解宿主路径。
|
||||
|
||||
**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义。这会重新制造本 RFC 试图避免的耦合。
|
||||
|
||||
**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地结算:一个赢,另一个得到 `FS_STALE_VERSION`。
|
||||
|
||||
**已观察状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内;split-fs-seam RFC 随后确立:沙箱/远程后端不应继承面向模型的观察策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 仅保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、已观察状态和先读后编辑门控,通过 `fs/*` 事件实现。
|
||||
|
||||
**`resolve` 后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再作为单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端而言这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每一步变为独立请求,使单次 `read` 变成两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观察契约不变。
|
||||
|
||||
**已观察状态持久化被推迟。** 已观察状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观察可回放。
|
||||
|
||||
**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观察失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs` 从 `dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且仅限于错误词汇。
|
||||
|
||||
**包拆分的代价前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改变包接口代价更高。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-agent-lifecycle-and-ownership-seams.md: a70e7db8d809efd68ae770995795fc7b3d1b83d2
|
||||
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: ac42a09c70e9570d3def0f0bd056bd571b923315
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Agent lifecycle and ownership seams
|
||||
|
||||
English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# RFC:Agent 生命周期与所有权 seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的不同症状:插件可以通过 `ctx.agents` 创建或恢复 agent,但无法独立拥有并 dispose(资源释放)单个 agent;长时间运行的 bash 任务在执行器内部也没有稳定的所有者。ACP 在断开连接时中止并等待 agent,却无法只注销该会话的 agent;`session/cancel` 无法取消已排队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。
|
||||
|
||||
## 决策
|
||||
|
||||
三个 seam:队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。
|
||||
|
||||
### 1. 队列感知的 `Agent.cancel(reason?)`
|
||||
|
||||
`cancel()` 是唯一的公开停止原语。它清除已排队的输入和 steering(中途引导)输入,中止正在执行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已排队的提示词在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲 agent 的 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。
|
||||
|
||||
拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放先停止循环并等待 `agent.done`,再分离会话。如果使用兄弟 effect,它们会并发释放,可能在关闭 flush 之前移除 append 钩子。释放通知被隔离,不会中断拆除链。
|
||||
|
||||
### 3. Bash 所有者令牌置于 seam 中
|
||||
|
||||
后台任务的所有权归执行器持有。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` 与 `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保留在任务上,使得这道围栏在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。
|
||||
|
||||
## 验证
|
||||
|
||||
- ACP 断开连接或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。
|
||||
- 在已排队的提示词启动前取消,能阻止该提示词运行或吸收下一条提示词。
|
||||
- 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。
|
||||
- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 的演示无需显式管理 handle。
|
||||
|
||||
## 会话所有者令牌在存活 agent 中唯一
|
||||
|
||||
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`。
|
||||
- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。
|
||||
|
||||
## 后果
|
||||
|
||||
本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。简洁的同步 `Agent.send()` 人体工学得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-session-surface.md: 31297166b735468147850a81d7fd43a8fa30a1e8
|
||||
2026-06-18-session-surface.zh.md: 9e2933a1e564b3dbb7719d55b5264f670c3b833f
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Session surface — a linked list over the event log for LLM message derivation
|
||||
|
||||
English | [中文](2026-06-18-session-surface.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# RFC:Session surface——基于事件日志的链表,用于 LLM 消息推导
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-18-session-surface.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
事件日志是权威数据源,但历史操作此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源记录,且每次新增操作都要修改 `deriveMessages()`。
|
||||
|
||||
## 决策
|
||||
|
||||
新增一个 **surface**:一条从事件日志派生、带缓存的链表,由「surface 节点」(即产出 LLM 消息的那部分事件)组成,通过事件日志中的 `surfaceOp` 标记维护。
|
||||
|
||||
### `SessionEvent` 上的两个新顶层字段
|
||||
|
||||
每个 `SessionEvent` 新增两个可选字段(与 `seq`/`time` 同属结构元数据):
|
||||
|
||||
- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如:构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。
|
||||
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
|
||||
|
||||
### SurfaceOp:两种操作
|
||||
|
||||
```ts
|
||||
export type SurfaceOp =
|
||||
| 'append' // normal tail append
|
||||
| { 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` 来源)。
|
||||
|
||||
2. **Replace**:移除从 `start` 到 `end`(两端含)的节点,并在其位置插入一个新节点。`start` 和 `end` 都必须是当前 surface 上有效的 surface 节点 seq;`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍保留在日志中,但不再出现在 surface 上。
|
||||
|
||||
### SurfaceManager:基于增量,而非全量重建
|
||||
|
||||
`SurfaceManager` 类(`Session` 的私有实现)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前事件不会改变;种子日志只是在首次访问时折叠的初始增量。
|
||||
|
||||
无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。
|
||||
|
||||
`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 字段被吸收而不递增版本号。
|
||||
|
||||
### 崩溃恢复
|
||||
|
||||
`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result` 关闭事件。这些关闭事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建后的 surface 有效。
|
||||
|
||||
### 不变式
|
||||
|
||||
开发模式不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`(replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。
|
||||
|
||||
每个 surface 可达事件都必须携带 `surfaceOp`,否则它会从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此要求;`append` 和种子构造函数中的运行时检查覆盖了宽化联合类型和加载的日志。无效种子在预发布格式策略下被拒绝而非升级。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **逐插件的 `agent/request` 包装**(surface 之前的历史操作模式):监听器排序脆弱,不留持久化的变更记录,且每次新增操作都要修改核心 `deriveMessages()`。
|
||||
- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。surface 是双向链表,端点自然以节点 seq 命名,单节点替换(`start === end`)在闭区间语义下读起来更自然。
|
||||
- **脏标记触发全量重建**而非增量处理:在会话生命周期内为 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/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/session-persistence/session-persistence`**:抽象接口不变。
|
||||
|
||||
Surface 是未来历史操作的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点取代该范围在 surface 上的位置,而插件自身的跟踪事件(如 `compaction/start`、`compaction/end`)则不进入 surface。回放确定性地保留这一决策。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-shared-persistence-write-coordinator.md: 3fc30dc2e1382fd983d050433123f46a2cd0ed19
|
||||
2026-06-18-shared-persistence-write-coordinator.zh.md: 6c8ccef8dd5603d837dd0a9884adf9c1cd8a17d4
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Shared persistence write coordinator
|
||||
|
||||
English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# RFC:共享持久化写入协调器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`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`)委托给协调器。
|
||||
|
||||
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险点——「协调器不得迫使非常规后端与继承层级搏斗」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
|
||||
|
||||
### 钩子接口(`PersistenceBackend<TornMarker>`)
|
||||
|
||||
六个方法(五个必需 + 一个可选生命周期钩子)——协调器与存储之间唯一的 seam:
|
||||
|
||||
- `name`:后端标签,用于 dispose 失败时的 `AggregateError`。
|
||||
- `loadStored(id)`:按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucket;SQLite 的 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 失败不会掩盖排空错误。
|
||||
|
||||
### 不透明的撕裂标记
|
||||
|
||||
保持 seam 干净的唯一设计选择:崩溃修复中的「撕裂尾部在哪里」token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的标记类型:JSONL 使用要截断到的字节偏移量,SQLite 使用要从其开始删除的 seq(两者碰巧都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较**折叠在钩子内部**,因此返回的标记已经是 `number | undefined`;如果不做这个折叠,协调器就必须了解字节长度。
|
||||
|
||||
## 测试
|
||||
|
||||
共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、冲突、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个 through-coordinator 的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture 钩子),确保协调器的撕裂标记修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生撕裂标记,因此无法触达该分支。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **后端继承的基类**:否决,改用组合。后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
|
||||
- **更宽的钩子面**:每个候选钩子都被折叠掉了:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与第一个事件批次原子提交);没有单独的创建冲突探测(它就是 `loadStored(id) !== undefined`);`list()` 也不经过协调器透传(列举不需要任何编排)。
|
||||
|
||||
## 后果
|
||||
|
||||
协调器增加了一层间接和一个不透明的撕裂标记,但将此前每个后端重复的正确性密集编排集中到一处。其钩子面保持窄小:冲突检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,无需复制事件-缓冲区-flush 生命周期。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-20-branded-ids.md: f6d066857d8904ae5343f12310266663806a0ae2
|
||||
2026-06-20-branded-ids.zh.md: 14f82c395cdf43e2f5df5b2317dda3d45c595a64
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Branded IDs everywhere they belong
|
||||
|
||||
English | [中文](2026-06-20-branded-ids.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# RFC:在所有应当使用品牌类型的位置推行 Branded ID
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-20-branded-ids.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
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 今天仍能通过类型检查。
|
||||
|
||||
**缺口 1:bash seam 中未打品牌的 ID。** `BashTask.id` 以及所有 executor/tool 边界使用裸 `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 别名漏洞」。
|
||||
|
||||
**缺口 2:既有品牌类型的侵蚀。** `CallId`、`SessionId` 和 `AgentId` 在注册表 map、公开查找参数、ACP 会话追踪和持久化协调器中退化为裸 string。在查找边界丢弃品牌类型,等于废掉了它的核心保护。
|
||||
|
||||
## 决策
|
||||
|
||||
纯类型变更。品牌类型是零成本 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 边界处打品牌)。
|
||||
|
||||
- **铸造独立的 `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`。(理由见下一节。)
|
||||
|
||||
- **阻止品牌侵蚀。** 将既有品牌传播到缺口 2 列出的 `Map` 键类型和公开方法参数:`Map<SessionId, Session>`、`get(id: SessionId)`、`Map<AgentId, Agent>`、`Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械性最大的部分,也是让*既有*品牌在查找处真正发挥作用(而非仅在结构体字段上标注)的关键。
|
||||
|
||||
示意形状(工厂模式与现有三个品牌完全一致):
|
||||
|
||||
```ts ignore-check
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** A background bash task handle (generated `bash-N` by the local executor). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
export function BashTaskId(id: string): BashTaskId {
|
||||
return id as BashTaskId
|
||||
}
|
||||
|
||||
/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
|
||||
export type OwnerToken = Branded<'OwnerToken'>
|
||||
export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
```
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 为什么不把 `owner` 类型标注为 `SessionId`?
|
||||
|
||||
executor 将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保持了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。
|
||||
|
||||
## 不在范围内 / 可能的扩展
|
||||
|
||||
遵循「并非每个 string 都需要品牌类型」策略,刻意保持窄范围。以下每项都是合理的未来品牌候选,附有推迟理由而非承诺:
|
||||
|
||||
- **`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 中处理,不应捆绑进这次纯类型改动。
|
||||
|
||||
## 验证
|
||||
|
||||
`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿 executor、本地实现和面向模型的 tool,且未引入 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用对应的品牌类型而非裸 `string`;来自提供方、ACP 和模型的原始输入通过品牌工厂进入,而非散落的 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。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-20-extract-example-app-packages.md: 3a0a0f5d4b329afed72bd3c00bf880989e24fe54
|
||||
2026-06-20-extract-example-app-packages.zh.md: 9de9f79369ebad387778a0418b75dfde96b285a7
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Extract example apps into packages
|
||||
|
||||
English | [中文](2026-06-20-extract-example-app-packages.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# RFC:将示例应用提取为 package
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-20-extract-example-app-packages.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
示例目录本应是*薄*的:只包含演示的可变接线,而非演示的机制本身。在本次变更之前它是厚的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示还需要的 `logger` + `hmr`)、三个共享 YAML 片段的嵌套引入(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),以及每个示例各自的 `agent-loop`/持久化/系统提示词配置。真正的应用——每个 agent 都需要的服务主干——分散在叶子配置和那些 include 中。
|
||||
|
||||
叶子配置还拥有一个耦合的前门。ACP 要求 stdout 纯净,通过 `session/new` 创建 agent;stdio 需要控制台 logger 和一个预创建的 `main`。防止错误组合的唯一手段是行文中的警告,而三个 `start.ts` 文件重复了 Loader 引导和生命周期代码。
|
||||
|
||||
## 决策
|
||||
|
||||
每个示例现在**基本上是对一个 app package 的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**app 包拥有组合**,叶子 `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 路由到预创建的 agent,acp 路由到 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 的情况下替换进来。
|
||||
|
||||
### 实现修正:`hmr` 保留为叶子条目
|
||||
|
||||
提案将 `hmr` 列入 stdio app 内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包在两方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付:
|
||||
|
||||
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 省略它。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 为什么不继续用共享 YAML include 来接线?
|
||||
|
||||
旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水只能在三个 `start.ts` 文件中复制。包将「ACP app 绝不向 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 保持不变,因为插件集和加载顺序未改变。
|
||||
|
||||
## 后果
|
||||
|
||||
- **裸插件树教学法。** echo-agent 的内联 `cordis.yml` 曾一次展示所有插件;主干现在藏在 bundle 后面,因此查看完整树意味着打开 `dsh-agent-spine-demo`。app 包的 README 承担了这部分教学职责。
|
||||
- **多了一层间接。** 「这个演示加载了什么?」变成了读一个 package,而非扫一份 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 特有的前门)。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-20-package-hierarchy.md: faf5815222b20699a32f1af489e625ba3e891230
|
||||
2026-06-20-package-hierarchy.zh.md: 4b71cd41826e727eea19d305635c6485e18393c2
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Reorganize packages into a modular hierarchy
|
||||
|
||||
English | [中文](2026-06-20-package-hierarchy.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# RFC:将包(package)重组为模块化层级结构
|
||||
|
||||
[English](2026-06-20-package-hierarchy.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`packages/` 原先是扁平的:18 个包全部位于 `packages/<name>/`,一个包的位置无法体现它是核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。package README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑,看起来都同等基础。
|
||||
|
||||
这不仅是外观问题。因为每个顶层包看起来都属于同一个公开接口面,未来移除更难;发布/lint/文档脚本不得不通过注释或手工维护的静态列表来编码意图,而非从布局直接读取。
|
||||
|
||||
## 决策
|
||||
|
||||
按模块角色分组,统一为 `packages/<group>/<pkg>/` 两层深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略,不是包重命名。
|
||||
|
||||
```text
|
||||
packages/
|
||||
core/ (product API spine)
|
||||
session/
|
||||
system-prompt/
|
||||
tools/
|
||||
agent/
|
||||
agent-loop/
|
||||
llm/ (product — capability family)
|
||||
llm/
|
||||
llm-deepseek/
|
||||
llm-pi-ai/
|
||||
bash/ (product — capability family)
|
||||
bash/
|
||||
bash-local/
|
||||
tool-bash/
|
||||
session-persistence/ (product — capability family)
|
||||
session-persistence/
|
||||
session-persistence-jsonl/
|
||||
session-persistence-sqlite/
|
||||
ui/ (product integration)
|
||||
acp/
|
||||
support/ (dev/test/example infrastructure)
|
||||
invariants/
|
||||
ui-stdio/
|
||||
llm-replay/
|
||||
```
|
||||
|
||||
### 放置决策
|
||||
|
||||
- **能力族使用同名嵌套。** 一个族的接口包位于 `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 布局使大部分可以被推导出来:
|
||||
|
||||
- `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 门禁保证结构及其引用的正确性,使本次重组所需的人工检查不必再次手动重复:
|
||||
|
||||
- `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 通配符即可解析所有包。
|
||||
- **将持久化嵌套在 `core/session/` 下**:否决。存储后端构成一个平行的能力族,与 `llm/` 和 `bash/` 对称,而会话日志本身属于核心产品 API。
|
||||
- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口面;`acp` 是 `ui/` 的唯一成员,因为编辑器确实在驱动它。
|
||||
|
||||
## 后果
|
||||
|
||||
本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种搅动在发布前是可接受的(遵循 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约;而且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需再做额外的结构编辑。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-21-mandatory-app-attribution-headers.md: 4fa773e089b3a4b682e42269a66d85aeaf5c18f6
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: 3660b8e7de977c01a19f9ed9ac9e73409f69706a
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Mandatory `User-Agent` attribution for provider requests
|
||||
|
||||
English | [中文](2026-06-21-mandatory-app-attribution-headers.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# RFC:对提供方请求强制携带 `User-Agent` 归属标识
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
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 的机制是提供方特定的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title` 和 `X-OpenRouter-Categories` 追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。
|
||||
- **在 agent 工具领域,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACP(Agent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。
|
||||
- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特定附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。
|
||||
- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件的身份标识,说明它用于互操作性报告和分析,并说用户代理应当(SHOULD)在每个请求中发送它,除非被配置为不发送。这是唯一直接匹配「哪个产品在发出这个 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 服务器测试套件对两个适配器都断言头部到达了线路。
|
||||
|
||||
## 决策
|
||||
|
||||
在 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` 归属。
|
||||
|
||||
提供方无关的身份由 `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` 阻塞发布,直到该仓库实际存在
|
||||
|
||||
默认值是强制的且非空。白标部署向 `attributionHeaders(identity)` 传入自己的 `AppIdentity`——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 让模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
|
||||
|
||||
线路映射(`attributionHeaders`;代码中头部名称为小写——HTTP 字段名在线路上不区分大小写):
|
||||
|
||||
| 目标 | 映射 |
|
||||
|---|---|
|
||||
| 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 |
|
||||
| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档记录了等效契约,否则不发送 OpenRouter 专用头部。 |
|
||||
| OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 |
|
||||
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特定 RFC 接受额外头部。不以类推方式复用 `HTTP-Referer`。 |
|
||||
|
||||
端点检测不属于本 RFC,因为此处不接受任何端点特定映射。如果后续落地 OpenRouter 支持,检测必须是显式的:要么是专用的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名。
|
||||
|
||||
## 验证
|
||||
|
||||
已落地的契约:
|
||||
|
||||
- `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 应用归属记录为已实现行为。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特定的产品功能,不是本 RFC 试图标准化的提供方无关模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在第一个共享归属辅助函数中。
|
||||
|
||||
**所有地方都发 OpenRouter 头部。** 否决。这会把一份自定义 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的字段。还有风险把 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。
|
||||
|
||||
**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/分类,不帮助 OpenRouter 等网关构建应用排名。
|
||||
|
||||
**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态产品身份,且可安全地在每个请求上发送。
|
||||
|
||||
**仅配置 opt-in 的归属。** 否决。默认关闭的设置正是适配器持续漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。
|
||||
|
||||
**以产品命名的令牌(`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` 令牌,因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 以连续性胜出:它是提供方已经从本代码库看到的身份,与组织/仓库身份和包作用域一致,且在展示文案承载产品名的同时保持线路归属稳定。
|
||||
|
||||
## 后果
|
||||
|
||||
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混入通用 SDK 流量的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。
|
||||
|
||||
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在创建之前该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,使其不会在未解决的情况下发版(见 `docs/development.md` 标记语义)。
|
||||
|
||||
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;pi-ai 封装的适配器依赖 pi-ai 继续遵守 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件变红。这对抽象层是有益的压力:一个无法设置强制头部的提供方适配器无法完整实现 harness 的 LLM 契约。
|
||||
|
||||
**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 才能实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-26-file-context-as-event-gate.md: 6e78e2df5f7969b5ed9b74c0b597e2fcacbe8e82
|
||||
2026-06-26-file-context-as-event-gate.zh.md: b0806fd3e1d61a9bdaf20a728dbfcfc945013b78
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface
|
||||
|
||||
English | [中文](2026-06-26-file-context-as-event-gate.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# RFC:将 `dsh-fs-policy` 改为事件门禁插件,而非方法接口
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-26-file-context-as-event-gate.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[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`。
|
||||
|
||||
这把三件本应可分离的事情耦合在了一起:
|
||||
|
||||
1. **工具做什么**——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 `ctx.fs`。
|
||||
2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。
|
||||
3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。
|
||||
|
||||
因为工具调用 `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;
|
||||
emits fs policy events; renders results
|
||||
policy dsh-fs-policy plugin: listens to fs/write-intent +
|
||||
fs/edit-intent (single-slot waterfall) and fs/observed
|
||||
(emit) events; adds observed-state + freshness.
|
||||
provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version
|
||||
guard is OPTIONAL; owns the fs policy event vocabulary
|
||||
provider dsh-fs-local local implementation of ctx.fs
|
||||
```
|
||||
|
||||
该模型是叠加式的:裸 `ctx.fs` 执行原子的、无约束的文本 I/O,而 `dsh-fs-policy` 在其上叠加观测状态、读后才能编辑、以及版本守卫。因此移除策略后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了在服务边界保持策略可选,而非作为正常部署姿态。
|
||||
|
||||
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 以及 `tools`/`systemPrompt`。
|
||||
|
||||
## 策略由提供方 CAS 强制执行,而非由 `dsh-fs-policy` stat
|
||||
|
||||
`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的变更临界区检测陈旧:
|
||||
|
||||
- "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。
|
||||
- "你读到的版本还是最新的吗?"由 **`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`)并对先前观测进行门控。
|
||||
|
||||
## 提供方契约变更:版本守卫变为可选
|
||||
|
||||
为使裸提供方不受约束,其两个变更操作上的版本守卫变为**可选**——有则守卫,无则无条件:
|
||||
|
||||
```ts ignore-check
|
||||
// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
|
||||
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
// undefined → unconditionally create-or-overwrite (bare default)
|
||||
// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged]
|
||||
// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged]
|
||||
|
||||
// editText: expected becomes optional (was the required { version: FsVersion }).
|
||||
editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
// undefined → unconditionally replace literal text in the current content (bare default);
|
||||
// a missing target still reports FS_STALE_VERSION
|
||||
// { 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`,为"此刻无法编辑该目标"保留一个统一的编辑失败码。
|
||||
|
||||
## 事件词汇(归属 `dsh-fs`)
|
||||
|
||||
事件定义在 `@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——而非面向模型的概念(行窗口、行号、渲染页脚均不会泄漏到此层)。
|
||||
|
||||
**两个 `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 结构。
|
||||
|
||||
```ts
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Single-slot decision: produce the write expectation for the next
|
||||
* ctx.fs.writeText. The default returns undefined (unconditional create-or-
|
||||
* overwrite — the bare provider). The policy listener returns createIfAbsent
|
||||
* (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed).
|
||||
* The listener does NOT call next(): one decision, not a composable chain. @mode waterfall
|
||||
*/
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
/**
|
||||
* Single-slot decision: produce the optional version guard for the next
|
||||
* ctx.fs.editText. The default returns undefined (unconditional edit of the
|
||||
* current content — the bare provider; no stat). The policy listener returns
|
||||
* { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or
|
||||
* has not observed the target. Does NOT call next(): one decision. @mode waterfall
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
/**
|
||||
* Record that an actor observed a target at a version, after a successful
|
||||
* read/write/edit. Fire-and-forget (plain emit). Listeners MUST be
|
||||
* synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap
|
||||
* write); the tool does not guard the emit, so a throwing listener surfaces as
|
||||
* the tool's isError result. No listener ⇒ nothing recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
}
|
||||
```
|
||||
|
||||
`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由 loop 分发且无 `this`),而非服务绑定的 waterfall(如 `llm/stream`)。分发方是 `dsh-tool-fs` 插件,它不是一个服务。
|
||||
|
||||
## 工具契约(`dsh-tool-fs`)
|
||||
|
||||
工具保留其面向模型的 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` 是一个注册全部三个工具(`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:
|
||||
|
||||
- **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`。
|
||||
|
||||
工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,这样 `dsh-fs-policy` 就能推导其观测状态的 owner。工具不知道策略插件是否存在:它总是在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
|
||||
|
||||
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步的、不抛异常的记录器;工具不对 plain emit 做守卫,因此抛异常的监听器会在变更已成功后报告失败。异步或可失败的观测需要另一个事件契约。
|
||||
|
||||
## 策略插件契约(`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`。
|
||||
|
||||
- `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 安全)。
|
||||
|
||||
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外部世界。这正是从 `dsh-tool-fs` 移除方法耦合的关键。
|
||||
|
||||
## 裸提供方行为(无 `dsh-fs-policy`)
|
||||
|
||||
这不是预期的部署姿态——加载 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`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。
|
||||
|
||||
两个变更操作仍然是原子的(后端的 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` 的描述已在同一变更中更新。
|
||||
|
||||
## 验证
|
||||
|
||||
测试固定了两条路径:无 `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 处理,而同级的工具包都不需要这些;每工具的注册辅助函数保留为根插件组合的内部模块。
|
||||
|
||||
## 后果
|
||||
|
||||
- **事件间接层取代方法调用。** 一次 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 工具的配置的预期姿态。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 72aae03361cbc088cf64f3548a43ac6253eb21eb
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 5c708cb6bfed28b2164cbd1d0b1c7368bf3e1d07
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: stdin + extra env on the bash seam
|
||||
|
||||
English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# RFC:bash seam 上的 stdin 与额外 env
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
钩子子系统运行外部钩子命令的方式与 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 添加这两项输入。
|
||||
|
||||
`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。
|
||||
|
||||
三个刻意的选择:
|
||||
|
||||
1. **面向模型的工具不暴露 `stdin` 和 `env`。** Shell 语法已经覆盖这些需求,重复的参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。
|
||||
|
||||
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使名称看起来像凭证。这是正确的,因为擦除的职责很窄:阻止 harness 自身 *ambient* `process.env` 中的凭证泄漏到子命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非 ambient 密钥),因此擦除不是对它的约束。`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` 一致。
|
||||
|
||||
`dsh-bash-local` 仅在提供了字节时才创建 stdin 管道;否则 fd 0 保持 `/dev/null`,维持原有行为。它写入字节后关闭管道。如果子进程未读取就退出导致 `EPIPE`,则忽略该错误,因为命令退出状态和输出决定结果。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**可配置的 ambient 密钥擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的 ambient 保护。
|
||||
|
||||
## 后果
|
||||
|
||||
钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子专属变量,保留其进程组管理、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一入口。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-event-domain-semantics.md: e05c238c52052454d3e01e82767cddd9af316a9d
|
||||
2026-06-30-event-domain-semantics.zh.md: a5453824183aa3f71486b5dbd24ed8c056d9c854
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Event-domain semantics — session is the fact log, agent is the live surface
|
||||
|
||||
English | [中文](2026-06-30-event-domain-semantics.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# RFC:事件域语义——session 是事实日志,agent 是实时表面
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-30-event-domain-semantics.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着分类体系的增长,三个事件域之间的界限变得模糊:
|
||||
|
||||
- `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。
|
||||
- `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 事件,以及为什么。
|
||||
|
||||
这套词汇是拦截决策、持久的 `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`)同理。
|
||||
- **`tools/*`——工具注册表与执行 seam。**
|
||||
|
||||
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中、从 `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)。
|
||||
|
||||
## 后果
|
||||
|
||||
- 循环不再 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`。
|
||||
- Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。
|
||||
|
||||
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-fs-per-session-cwd.md: 00643955d918dff87241f4b240bb7e6774d21a0b
|
||||
2026-07-02-fs-per-session-cwd.zh.md: ca37e1f41af53c43151ac82e1be1b76eaafdb97e
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Resolve filesystem paths against the caller's session cwd
|
||||
|
||||
English | [中文](2026-07-02-fs-per-session-cwd.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# RFC:将文件系统路径解析基于调用方的会话 cwd
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-02-fs-per-session-cwd.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
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 个工作区。
|
||||
|
||||
文件系统路径解析使用的是插件加载时的单一 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
|
||||
|
||||
## 决策
|
||||
|
||||
将调用方的会话 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`,后端则应用其默认值。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 为什么由调用方提供 cwd(而非提供方)
|
||||
|
||||
提供方 seam 不应依赖 `dsh-agent`/`dsh-session`:它是一个文本存储后端,沙箱或远程实现同样满足该接口,而它们没有「agent 会话」的概念。工具已经接收到 `ToolExecution`(`exec`),其中携带了 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包边界处显式优于隐式」的约定:基目录作为显式参数到达提供方并由其执行,而非让提供方越界去读取它不应知道的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。
|
||||
|
||||
默认值只存在于**一个**地方:提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会制造一个提供方本来会自行选择的基目录。
|
||||
|
||||
## 后果
|
||||
|
||||
- 在 ACP 演示中,fs 工具和 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目文件夹,两类工具都在该目录下工作。
|
||||
- `FsTarget` 的标识不变:`targetKey` 仍然是解析后绝对路径的 realpath,因此 observed-state 键控和符号链接标识不受影响——正确的 per-session cwd 产生的 key 与 bash 目标一致。
|
||||
- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。
|
||||
- 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-result-time-applied-hunk-diffs.md: 81ab8b9827ddaec39d63e2a3f8fbb864085a9ac9
|
||||
2026-07-02-result-time-applied-hunk-diffs.zh.md: 3914bb872025d7116a047dfaf3455f527e35879a
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Result-time applied-hunk diffs for file mutations
|
||||
|
||||
English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# RFC:结果时刻的 applied-hunk diff 用于文件变更
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[带标签的渲染意图联合类型](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` 如果触及五个分散位置,仍然只渲染为一对片段。
|
||||
|
||||
驱动 `claude-agent-acp` 自身的 ACP 桥接层可以看到完整编辑器 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。
|
||||
|
||||
## 决策
|
||||
|
||||
新增一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来承载 applied-hunk diff。
|
||||
|
||||
### 1. 工具结果上的 `meta` 通道(core)
|
||||
|
||||
`ToolDefinition.execute` 现在可以返回其面向模型的 `ContentBlock[]`(不变,常见情况)或 `{ content: ContentBlock[]; meta?: unknown }`:
|
||||
|
||||
```ts ignore-check
|
||||
type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
```
|
||||
|
||||
`meta` 是工具自有的 `unknown`,core 持久化它但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷传回 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。
|
||||
|
||||
这是通用形态("工具附加持久化的结果展示"),而非 fs 专用——任何工具都可以使用。
|
||||
|
||||
### 2. 工具计算 hunk;后端返回变更前/后文本(fs)
|
||||
|
||||
按照[能力-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 元数据,正常渲染错误信息。
|
||||
|
||||
### 3. 桥接层渲染 `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` 完全一致。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**手写或 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` 引入了一个小型、知名的运行时依赖。
|
||||
|
||||
## 非目标
|
||||
|
||||
- **实时增量 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。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-tool-render-intent-union.md: 8256f09f9c297658627d0c3d9e99ee1c5424b254
|
||||
2026-07-02-tool-render-intent-union.zh.md: ed46bf0a8bea1cbfbce4287e5dc48be21c8d8fb9
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Tagged render-intent union for tool-call presentation
|
||||
|
||||
English | [中文](2026-07-02-tool-render-intent-union.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# RFC:用于工具调用展示的标签化 render-intent 联合类型
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-02-tool-render-intent-union.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工具通过 `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。
|
||||
|
||||
`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 按标签分发。
|
||||
|
||||
```ts ignore-check
|
||||
type FileLocation = { path: string; line?: number }
|
||||
type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file
|
||||
|
||||
// presentCall → ToolCallView
|
||||
type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
|
||||
interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] }
|
||||
interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string }
|
||||
interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] }
|
||||
|
||||
// presentResult → ToolResultView
|
||||
type ToolResultView = GenericResultView | TerminalResultView
|
||||
interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] }
|
||||
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 处中断编译——这正是我们想要的信号。
|
||||
|
||||
### 为什么标签联合类型优于字段大杂烩
|
||||
|
||||
- **无效状态变得不可表示。** 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-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 门控行为。
|
||||
|
||||
### 纯函数性保持不变
|
||||
|
||||
`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` 子串的精确结构化替换实现——对文件卡片类型通用,从不特判工具名。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其结论明确推迟到两个真实工具和两个真实消费方存在后再做这个联合类型,而该门槛现已达到。
|
||||
- **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何都需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟。
|
||||
- **保留可选字段大杂烩**:即「问题」一节所剖析的现状:无效状态可表示、字段交互未文档化、且完全无法请求 diff 卡片。
|
||||
|
||||
## 后果
|
||||
|
||||
新的渲染意图是 bridge switch 处的编译中断变更——这是有意为之:渲染代码必须在卡片种类存在之前就位。无效的卡片/字段组合现已不可表示,bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。
|
||||
|
||||
## 非目标
|
||||
|
||||
- **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 RFC 自身推迟的后续工作,本 RFC 不涉及。
|
||||
|
||||
## 相关
|
||||
|
||||
- 取代 [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 侧的展示类型改变)。
|
||||
- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-03-filesystem-directory-listing-seam.md: bb8d9c4deda18b320b85d548bbd5bcb32f1c1d72
|
||||
2026-07-03-filesystem-directory-listing-seam.zh.md: a0332fe6cec576ad1c5b4722e2decb87344aeee1
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Add direct directory listing to the filesystem seam
|
||||
|
||||
English | [中文](2026-07-03-filesystem-directory-listing-seam.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# RFC:为文件系统 seam 添加直接目录列举能力
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-03-filesystem-directory-listing-seam.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`@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 依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。
|
||||
|
||||
本决策只添加提供方能力,不引入面向模型的 `ls`/`list` 工具,也不改变 skill 发现逻辑。那些消费方需要独立的 UX、提示词和策略决策。
|
||||
|
||||
## 决策
|
||||
|
||||
在 `@deepseek-ai/dsh-fs` 中添加 `FileSystem.listDir(target, signal?)`。
|
||||
|
||||
`listDir` 仅列举一级目录。它以稳定的名称顺序返回直接子项,包含:
|
||||
|
||||
- `name`:子项的 basename。
|
||||
- `type`:`file`、`directory` 或 `other`。
|
||||
- `target`:已解析的子项 `FsTarget`。
|
||||
- `version`:可用时提供的轻量元数据。
|
||||
- `size`:可用时提供的常规文件大小。
|
||||
|
||||
它从不读取文件内容。递归遍历、glob 匹配、分页、搜索、文件监听和面向模型的渲染均有意不在范围内。
|
||||
|
||||
本地后端通过 `readdir({ withFileTypes: true })`、`resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的提示词/列表输出稳定,并提升前缀缓存复用率。
|
||||
|
||||
损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。列举目录或解析/探测子项元数据时遇到的权限或后端 I/O 故障会以结构化的 `FsError` 代码使整个列举失败:
|
||||
|
||||
- `FS_NOT_FOUND`:目标不存在。
|
||||
- `FS_NOT_DIRECTORY`:目标存在但不是目录。
|
||||
- `FS_PERMISSION_DENIED`:权限不足。
|
||||
- `FS_IO_ERROR`:其他后端 I/O 故障。
|
||||
- `FS_ABORTED`:调用被中止。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其提示词、schema 和渲染契约与提供方原语无关。
|
||||
|
||||
**让每个消费方自行枚举目录。** 否决。这会把 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。
|
||||
|
||||
**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根目录发现只需要直接子项,简单的单级列举是未来消费方可以安全组合的最小后端契约。
|
||||
|
||||
**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target,因此解析子项时遇到的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。
|
||||
|
||||
## 后果
|
||||
|
||||
每个文件系统后端现在必须多实现一个提供方原语。这是 harness 尚未发布时有意为之的基础工作,但也意味着未来的沙箱/远程后端需要定义等价的直接子项列举行为。
|
||||
|
||||
该能力仍然面向提供方。在消费方落地之前,ACP/模型会话仍需使用 `bash` 等既有工具来列举目录。没有面向模型的 `listdir` 工具是预期行为,而非接线遗漏。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: fce9d555c8843b99fdbfa7b652b46d0b88053935
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 5af4fca19649d4ee458eaa6a23ae7374abdd89e4
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
# RFC: Prompt variables and tool-guidance ownership
|
||||
|
||||
English | [中文](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# RFC:提示词变量与工具指导归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
组装后的系统提示词有四个缺陷,同属一类:harness 已经掌握的事实在别处被手工重述,然后漂移。
|
||||
|
||||
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每次请求,但没有任何提示词文本携带它——也不可能携带:`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 的欢迎横幅也手动枚举了工具集。
|
||||
|
||||
**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「使用 read 工具……」再读到「你是 coding-agent」——与身份优先的惯例(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)",但重复项被静默接受。
|
||||
|
||||
## 决策
|
||||
|
||||
**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包的 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 过滤或扩展。
|
||||
|
||||
### 提示词变量
|
||||
|
||||
插件通过 `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 的运行时事实,替换循环自行提供自己的变量。
|
||||
|
||||
### 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`、工具指导 `100–199`。
|
||||
|
||||
### 工具指导归属
|
||||
|
||||
每个工具的语义和选择指导存放在工具描述中。Prompt section 仅承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的描述已包含完整契约。部署 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)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **循环自行组合一行身份文本**——在必须保持精简的那个包里硬编码面向模型的行文("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) 中被否决。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 更多变量(`date`、平台、git 状态)——注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。
|
||||
- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化)——推迟到 session-cwd 方案重新讨论时。
|
||||
|
||||
## 交付的不变式
|
||||
|
||||
- coding-agent 提示词通过一条组装路径渲染:identity、带插值模型名的 persona,然后是 fs/bash/web 指导。
|
||||
- fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。
|
||||
- 未知、无值、格式错误或不平衡的变量引用会指名 section 并抛出异常;重复的 section、变量和工具注册也会抛出异常。
|
||||
- 快照回放与提示词无关:它按轮次和步骤索引已录制的 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 确实需要,届时再添加。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-05-reconstructable-requests.md: 0978cd8760c6a0420be1bf0a3baf6b50c1a04a13
|
||||
2026-07-05-reconstructable-requests.zh.md: 82f9cf085db3d6cd408e54a8e7cf99082d848176
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Every LLM request is reconstructable from the session log
|
||||
|
||||
English | [中文](2026-07-05-reconstructable-requests.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# RFC:每个 LLM 请求都可从会话日志重建
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-05-reconstructable-requests.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
请求流水线此前不保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。
|
||||
|
||||
快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只追加、从不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型必须看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。
|
||||
|
||||
## 决策
|
||||
|
||||
### 原则
|
||||
|
||||
**模型可见 ⟺ 已记录。** 凡到达模型请求的内容,都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何持有日志的人都能逐字节重建它。精确的范围说明:保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由它推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对已记录区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。
|
||||
|
||||
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。逐字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。
|
||||
|
||||
### 机制
|
||||
|
||||
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 改写(压缩的 `replace`——`SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,其中的消息是共享的、深度冻结的:通过投影修改已记录的历史是不可表达的(会抛异常),取代了旧的每次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
|
||||
|
||||
`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` 活跃。每实例状态仅有缓存的前缀和其锚定快照是否已写入。
|
||||
|
||||
**`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 的下降。
|
||||
|
||||
### MiniCode 形态:采纳,但溯源箭头反转
|
||||
|
||||
与 MiniCode 一样,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同的是,事件日志仍是真源,因为它还拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志派生的消息和 header 折叠结果,使每个请求都可独立检查。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **客户端作为真源**(照搬 MiniCode):在日志之外出现第二个生效的真相——两者漂移而无人察觉;见上节。
|
||||
- **镜像日志的有状态传输客户端**:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑面,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。
|
||||
- **逐次调用的请求标量**(每次 `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 排除由服务端管理。
|
||||
- `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 参数)应归属何处。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-05-subagent-provider-lifecycle-events.md: 6d711f2a6d8496a8a229ec63d86dd89816efb6f8
|
||||
2026-07-05-subagent-provider-lifecycle-events.zh.md: 45eedcfdfa834722000c9f2c15e3955ced791c2f
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`
|
||||
|
||||
English | [中文](2026-07-05-subagent-provider-lifecycle-events.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# RFC:Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed`
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[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))。
|
||||
|
||||
## 决策
|
||||
|
||||
注册表将提供方的成员变动作为类型化事件广播,消费方镜像这些事件而非假设顺序:
|
||||
|
||||
- **`subagent/provider-added(provider)`**:一个提供方在 `ctx.subagents` 注册表中变为可解析。在注册时发出。
|
||||
- **`subagent/provider-removed(name)`**:一个提供方离开了注册表(其插件 fiber 被 dispose——卸载或 HMR 重载)。从注册的 disposer 中发出。
|
||||
|
||||
`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞;当提供方离开时注销工具;在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不可能对模型撒谎。这里**刻意不留**任何需要文档化的加载顺序要求:事件使顺序问题消失,而非将其钉死。
|
||||
|
||||
这些事件还补全了该 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn`、`fork`、`acp`)在其上共存;一个内容会被其他插件用来派生状态的注册表,应当以类型化事件广播成员变动,而非要求轮询或依赖加载顺序。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在 `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)` 记录了这一爆炸半径;工具注册表的重名拒绝机制仍是最终兜底。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-06-timeout-deadline-library.md: 9906aa7cce40ffd7b5f7082199d05edb8d0a54b2
|
||||
2026-07-06-timeout-deadline-library.zh.md: ef99a1a051f3cedbe5a2770e5bbeeebe716745c4
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: A shared timeout/deadline primitive, with hard-kill left to each capability
|
||||
|
||||
English | [中文](2026-07-06-timeout-deadline-library.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# RFC:共享的超时/截止时间原语,hard-kill 留给各能力自行实现
|
||||
|
||||
[English](2026-07-06-timeout-deadline-library.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
超时处理在各个承载工具的能力之间逐渐分化,而这种分化并非表面的——同一套逻辑被三种方式各自重新实现,每种都带着自己微妙的正确性负担。
|
||||
|
||||
- **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 中保持无超时——见「后果」。)
|
||||
|
||||
每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「被取消」——而融合和原因恢复恰恰是最容易出微妙错误的部分(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 家族所示范的。
|
||||
|
||||
### 库的对外接口
|
||||
|
||||
三个函数加一个 reason 类型:
|
||||
|
||||
```ts ignore-check
|
||||
/** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */
|
||||
export class TimeoutReason extends Error {
|
||||
override name = 'TimeoutReason'
|
||||
|
||||
constructor(readonly code: string, readonly timeoutMs: number) {
|
||||
super(`${code} after ${timeoutMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate/fill a caller's optional positive hint from the backend's default, then cap at its max. */
|
||||
export function clampTimeout(
|
||||
requested: number | undefined,
|
||||
def: number,
|
||||
max: number,
|
||||
name = 'timeoutMs',
|
||||
): number
|
||||
|
||||
/**
|
||||
* Build a deadline signal that aborts on upstream cancellation OR on timeout,
|
||||
* with the timeout carrying a `TimeoutReason`. `timeoutMs <= 0` means "no
|
||||
* timeout" (background tasks): forward only the upstream signal, arm no timer.
|
||||
* The returned object's `[Symbol.dispose]` clears the timer — `using` for a
|
||||
* scope-lifetime consumer, a manual call for an event-lifetime one.
|
||||
*/
|
||||
export function deadline(
|
||||
upstream: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
code: string,
|
||||
): { signal: AbortSignal; [Symbol.dispose](): void }
|
||||
|
||||
/** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */
|
||||
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined
|
||||
```
|
||||
|
||||
`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的无超时哨兵,用于后端自有的后台任务;外部提示经 `clampTimeout` 后必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,但具有相同的 disposal 形状。提供方将超时原因翻译为 seam 特定的结果。`timeoutOf(signal, code)` 通过 code 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力的超时。
|
||||
|
||||
### 分工
|
||||
|
||||
| 关注点 | 负责方 |
|
||||
|---|---|
|
||||
| 校验请求提示并钳位 default/max | `dsh-timeout`(`clampTimeout`)——纯算术加共享的正有限请求契约 |
|
||||
| 启动定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) |
|
||||
| 清除定时器 | `dsh-timeout`(`[Symbol.dispose]`) |
|
||||
| 中止后分类首个 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 设超时。
|
||||
|
||||
### 各能力如何消费
|
||||
|
||||
- **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 基线,已满足)。
|
||||
|
||||
不在本 RFC 范围内,列出以标明边界:`web_search` 可以在其 tool-schema/快照覆盖率规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,hard kill 仍是各能力自己的事。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**统一的超时*插件* / `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`),使一个小型共享原语严格更干净,因此成本/收益不同。
|
||||
|
||||
**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在 deadline 时 resolve *工具调用*的 promise,而不停止底层工作——子进程或 fetch socket 会泄漏。发出信号并要求能力去监听,才能强制一条真正的终止路径存在。这与「dispose 必须达到静止态,而非仅仅请求它」的防御性规则一致。
|
||||
|
||||
**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了自建定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径不变。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-07-tool-call-timeout-policy.md: 0e69c8504dd34dfc4427bf1f3865a80be93eff9d
|
||||
2026-07-07-tool-call-timeout-policy.zh.md: d842d0a3c811e7502f4d45baba49010a06c5f1a5
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: Tool-call timeout policy as a plugin
|
||||
|
||||
English | [中文](2026-07-07-tool-call-timeout-policy.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# RFC:工具调用超时策略作为插件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-07-tool-call-timeout-policy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[超时/截止时间 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` 模型工具通过同一后端复用了前台执行、后台启动、后台轮询和钩子调用。一步到位地把所有超时都移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。
|
||||
|
||||
## 决策
|
||||
|
||||
工具调用超时是一项仅适用于面向模型的工具执行的策略,由三部分组成:
|
||||
|
||||
- `@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)
|
||||
-> tools/pre-execute
|
||||
-> tools/execute
|
||||
-> registry dispatch (the base next())
|
||||
-> tool.execute(args, exec)
|
||||
-> thrown tool errors normalize to ToolExecutionResult
|
||||
-> tools/post-execute
|
||||
```
|
||||
|
||||
默认行为是保守的:未声明 `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` 结果,永远不会导致轮次失败。
|
||||
|
||||
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` 的定义上:
|
||||
|
||||
```yaml
|
||||
- id: timeout-policy
|
||||
name: '@deepseek-ai/dsh-timeout-policy'
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetchTimeoutMs: 30000
|
||||
searchTimeoutMs: 30000
|
||||
```
|
||||
|
||||
超时声明在工具定义上而非自由文本的名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 会校验预算为正有限数。分发期间,执行器派生截止时间信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。
|
||||
|
||||
信号替换采用**就地修改 `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()` 的内部截止时间代码(作用域化,使嵌套的外层截止时间读取为普通取消),以及结构化工具结果的错误代码。其替换结果为:
|
||||
|
||||
```ts ignore-check
|
||||
function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
|
||||
isError: true,
|
||||
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这是一个协作式截止时间。它不会通过与工具 promise 竞速来杀死任意工作;工具或其调用的能力必须遵守 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 的含义是「此工具对 `exec.signal` 是协作式的」,插件 README 将此作为契约声明。
|
||||
|
||||
可重建性不需要新的会话事件:`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`。
|
||||
|
||||
`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` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们要么只能尽力而为,要么没有必要。
|
||||
|
||||
未来面向模型的 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`。
|
||||
|
||||
**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的原有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。对 web 类工具而言它不够好,因为每个新的支持超时的工具都必须自行选择校验、上限语义、文档、快照和分类。插件集中了策略和分类,同时让每个工具的 schema 专注于业务输入。
|
||||
|
||||
**立即将所有超时策略移出 bash-local。** 长期更干净:bash-local 将变为纯子进程执行器,所有调用方拥有自己的截止时间。作为第一步它不合适,因为钩子直接调用 `ctx.bash`,而 bash 模型工具有前台/后台语义,这与工具调用的生命周期不同。保留 `BASH_TIMEOUT` 维持了这些路径的稳定,同时工具调用超时在更简单的工具上验证自身。
|
||||
|
||||
**为所有工具使用全局默认预算。** 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明的预算使采纳成为有意识的行为。
|
||||
|
||||
**暴露面向模型的 `timeout_ms` 覆盖参数。** Claude Code 的 `WebFetch`/`WebSearch` 和 Codex 的 web 工具将超时排除在模型调用形态之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 `timeout-policy` 引入 schema/参数剥离规则。Web 超时仅作为部署策略。
|
||||
|
||||
**让 `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` 给超时一个词法作用域:启动、委托、分类、释放。
|
||||
|
||||
**使用 `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`,由拥有该工具的插件从其配置中设置)而非在此插件的配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在「## 决策」中描述。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-08-agent-scope-contexts.md: f238d58d90413d36e81b34c1a2c94e1291e889de
|
||||
2026-07-08-agent-scope-contexts.zh.md: dfade19709ccbf206054f414882ecff114ac1e44
|
||||
@@ -1,5 +1,7 @@
|
||||
# RFC: The agent is a registration scope
|
||||
|
||||
English | [中文](2026-07-08-agent-scope-contexts.zh.md)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# RFC:agent 即注册作用域
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-08-agent-scope-contexts.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一个应用需要在多个 agent(智能体)之间共享基础设施,同时让每个 agent 拥有自己的工具、prompt 贡献、策略和监听器。共享的适配器、持久化和用户界面属于部署层面;而一个人设、工具变体或监听器往往只属于某一个 agent。
|
||||
|
||||
为每个 agent 建立独立的服务图会重复共享基础设施。一个全局注册图则有相反的问题:某个 agent 的专属贡献可能泄漏到无关的 agent 中。贡献者需要一种普通的注册机制,既能决定谁能看到一项贡献,又能决定何时清理它。
|
||||
|
||||
该机制还需要一个发布边界。agent 在其本地世界完整之前不得变为可见,而拆除过程必须保留该世界直到最终工作停止。
|
||||
|
||||
## 决策
|
||||
|
||||
每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有该贡献的上下文进行注册;感知作用域的服务将部署全局注册与恰好一个匹配的 agent 层组合;操作从其真实 agent 选择该层;该层在 agent 完整的已发布生命周期内存在。
|
||||
|
||||
Cordis 是 SDK 底层的插件框架。Cordis **上下文(context)** 是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../cordis-primer.md)对框架有更详细的说明。
|
||||
|
||||
对大多数贡献者而言,完整的契约是四条规则:
|
||||
|
||||
| 问题 | 规则 |
|
||||
|---|---|
|
||||
| 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通的注册 API |
|
||||
| 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,使用所属服务的合并规则 |
|
||||
| 哪些作用域监听器会运行? | 无作用域监听器加上为该操作的 agent 注册的监听器 |
|
||||
| 该层存在多久? | setup 在发布前完成;dispose 保留该层直到工作达到静止 |
|
||||
|
||||
作用域是扁平的。解析永远不会遍历父级或兄弟作用域,生命周期所有权也不意味着注册继承。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
plain["Plain plugin context<br/>cleanup follows the plugin"] -->|"registers into"| globalLayer["Deployment-global layer"]
|
||||
agentAContext["agentA.ctx<br/>cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"]
|
||||
agentBContext["agentB.ctx<br/>cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"]
|
||||
|
||||
operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view<br/>globals plus A local"]
|
||||
globalLayer --> agentAView
|
||||
agentALayer --> agentAView
|
||||
operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view<br/>globals plus B local"]
|
||||
globalLayer --> agentBView
|
||||
agentBLayer --> agentBView
|
||||
```
|
||||
|
||||
缺失的交叉边就是隔离规则: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` 功能。
|
||||
|
||||
### 注册来源决定可见性与清理
|
||||
|
||||
通过普通插件上下文进行的注册是部署全局的,随该插件 dispose。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域 dispose。
|
||||
|
||||
| 注册来源 | 默认可见性 | 随谁 dispose |
|
||||
|---|---|---|
|
||||
| 普通插件上下文 | 每个符合条件的 agent 视图 | 注册插件 |
|
||||
| `agent.ctx` | 仅该 agent 的视图 | agent 作用域 |
|
||||
|
||||
工具、prompt 段落与变量、工具限制、守卫和作用域事件监听器都采用此契约。同名的本地值通常对该 agent 遮蔽同名的全局值;每个所属服务自行记录例外与合并行为。
|
||||
|
||||
普通贡献者的模式是在 agent setup 期间注册完整的本地世界:
|
||||
|
||||
```js
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('reviewer'),
|
||||
sessionId: SessionId('reviewer-session'),
|
||||
agentOptions: { model: 'model-name' },
|
||||
setup(agentCtx) {
|
||||
agentCtx.systemPrompt.section({
|
||||
name: 'deployment:persona',
|
||||
order: 0,
|
||||
text: 'Review code, but do not modify files.',
|
||||
})
|
||||
agentCtx.tools.register({
|
||||
name: 'review_summary',
|
||||
description: 'Return the review summary.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'review complete' }]
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
ctx.tools.get('review_summary') // undefined: not global
|
||||
ctx.tools.get('review_summary', handle.agent) // the reviewer-local tool
|
||||
|
||||
await handle.dispose()
|
||||
ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone
|
||||
```
|
||||
|
||||
setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通的插件和服务。其契约仅限组合:通过强制转换或内部注册表调用来驱动或发布正在构建的 agent 是不受支持的。
|
||||
|
||||
### 操作选择视图
|
||||
|
||||
注册来源与操作主体是两个独立的事实。通过 `agent.ctx` 调用服务决定的是新注册归属何处,并不将后续读取绑定到该 agent。
|
||||
|
||||
工具查找与执行接收其服务的 agent。prompt 组装接收正在构建请求的 agent 的组装上下文。事件分发接收其领域主体。这使共享服务实例可在多个 agent 间复用,同时让每个操作的视图保持显式。
|
||||
|
||||
只有采纳了作用域契约的服务才会解析 agent 层。`agent.ctx` 不会自动改变任意 Cordis 服务调用的行为。
|
||||
|
||||
### 作用域事件将路由与事件数据分离
|
||||
|
||||
关于 Agent A 的事件通常到达无作用域监听器和 A 作用域监听器,而不到达 B 作用域监听器。没有 agent 主体的事件只到达无作用域监听器。
|
||||
|
||||
在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。
|
||||
|
||||
以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。
|
||||
|
||||
### 创建最后发布,dispose 最后撤销
|
||||
|
||||
`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。
|
||||
|
||||
可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式的 dispose 权。
|
||||
|
||||
如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;所有失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。
|
||||
|
||||
`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷新,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。
|
||||
|
||||
调用方的 Cordis 上下文和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"]
|
||||
privateWorld --> setup["Await composition through agent.ctx"]
|
||||
setup --> admission["Admit final session and agent entries"]
|
||||
admission --> publish["Announce lifecycle and start the driver"]
|
||||
publish --> live["Return AgentHandle"]
|
||||
|
||||
privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"]
|
||||
setup -->|"failure, cancellation, or owner loss"| rollback
|
||||
admission -->|"duplicate or owner loss"| rollback
|
||||
publish -->|"listener failure or owner loss"| rollback
|
||||
live -->|"handle or owner disposal"| quiesce["Stop and drain work"]
|
||||
rollback --> quiesce
|
||||
quiesce --> detach["Detach agent, then session"]
|
||||
detach --> revoke["Dispose the agent scope"]
|
||||
```
|
||||
|
||||
## 安全与权限是非目标
|
||||
|
||||
agent 作用域组合的是受信的同进程注册。它不沙箱化插件,不定义父到子的权限格,不在创建时冻结授权,也不保证子级不能做超出父级的事。
|
||||
|
||||
父级可以拥有一个可见工具比自身更宽的子级,因为生命周期所有权不捐赠也不封顶注册。持有 Cordis 上下文的插件同样运行在同一进程中,可以直接调用可用服务。
|
||||
|
||||
需要非升级保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式的未来授权 API、以及通用的能力/输出/终止标签均不在本决策范围内。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
被否决的设计要么将可见性与清理分离,要么只覆盖一个注册族,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。
|
||||
|
||||
### 向每次注册传递 agent 选项
|
||||
|
||||
类似 `tools.register(definition, { agent })` 的 API 在每个注册表中重复作用域管道,并允许可见性所有权与清理所有权漂移。通过 `agent.ctx` 注册使两个事实跟随同一个 Cordis 效果所有者。
|
||||
|
||||
### 过滤事件但保持注册表全局
|
||||
|
||||
监听器过滤能阻止错误的钩子运行,但无法限定工具 schema、可执行查找、prompt 段落、变量或其他已注册数据的作用域。agent 本地组合仍需临时的全局变更。
|
||||
|
||||
### 为每个 agent 创建一个服务图
|
||||
|
||||
所需的视图是共享部署服务加上一个本地注册层。每 agent 一个图会重复适配器,并使共享持久化、提供方注册表和应用启动复杂化。
|
||||
|
||||
### 继承父级注册作用域
|
||||
|
||||
父子关系描述的是生命周期和对话谱系,而非通用合并策略。层级查找会让无关服务意外继承,且在没有独立权限模型的情况下无法定义安全性。
|
||||
|
||||
## 后果
|
||||
|
||||
贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作上选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,teardown 保留本地行为直到工作停止。
|
||||
|
||||
代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等于权限,subagent 组合控制作为独立功能存在,而非隐藏在作用域语义中。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-14-acp-agent-client-protocol.md: 0bb2a2f2e307b8f23a3a9ca98edb2a2d3b5df0a8
|
||||
2026-06-14-acp-agent-client-protocol.zh.md: 19744cb9f4d4b675586d4327a1da423fdea21b77
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user