Merge origin/master into codex/provider-retry-policy

This commit is contained in:
Turtle
2026-07-25 10:35:22 +08:00
766 changed files with 19344 additions and 1424 deletions

View File

@@ -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
README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668
README.zh.md: 60ec5421e7f271460daebc966aa6548f6ef8a511

View File

@@ -1,5 +1,7 @@
# Agent Notes
English | [中文](README.zh.md)
One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the front door and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format).
## Layout and naming

117
.agents/notes/README.zh.md Normal file
View File

@@ -0,0 +1,117 @@
# Agent Notes
[English](README.md) | 中文
这里存放一类设计文档。**Agent Noteagent 决策记录)** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和契约Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。
## 布局与命名
每份 Agent Note 有两个维度,都编码在其**路径**中:`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`
- **生命周期**(顶层文件夹)是 Agent Note 的状态Agent Note 随状态变化在文件夹之间移动:
- **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。
- **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**当代码后续移动文件、重命名包package或更改键名/默认值时Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。
- **`rejected/`**:提案经过讨论后被否决。保留以备查阅,避免同一问题被反复争论。
- **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。
文件名中的日期是该主题**首次提出**的时间(以 git 历史为准。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。
目录树就是清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。
<a id="classification"></a>
## 分类
每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。
| 类别 | 覆盖范围 |
|---|---|
| `feature` | 面向用户或模型的新功能。 |
| `bug-fix` | 修正缺陷或弥补事故复盘postmortem发现的缺口。 |
| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 |
| `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 |
| `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 |
| `testing` | 测试基础设施与策略。 |
`architecture``process` 的界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。(`refactor` 被有意排除:它与 `simplification` 重叠,而后者的判别标准「可观察行为是否改变」已经覆盖了它。)
## 何时需要写一份
每个非平凡变更都必须在同一 PRPull Request中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式或者其他维护者可能合理重新审视的决策就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
更新已经拥有该决策的 Agent Note 即可满足规则不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
<a id="the-file-format"></a>
## 文件格式
每份 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`[scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts)`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。
### 头部块
每份 Agent Note 的前三行严格为:
```markdown
# Agent Note: <title>
Status: <status>
```
后跟一个空行。`Status:` 的值有三种形式,且必须与文件所在的生命周期文件夹一致——门禁会交叉检查:
- `Status: proposed`
- `Status: implemented`
- `Status: rejected — <why, in one line>`
状态行不带日期、不带括号补充说明文件名记录首次提出日期git 记录其余一切;「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。拒绝原因是唯一带内容的状态,因为读者查阅被否决的 Agent Note 时,结论正是他们要找的。
### 正文骨架
每份 Agent Note 的正文以 `## Problem` 开头动机写法上不依赖解决方案即可独立成文。后续内容取决于生命周期固定章节使用以下规范名称且仅限这些名称而真正独特的技术章节包拓扑、协议契约、schema 等)在必需章节之间可自由组织。
#### `proposed/`
```markdown
## Problem
## Proposal
…bespoke sections…
## Alternatives considered
## Acceptance criteria
## Risks
```
`## Proposal` 描述拟议的变更,可以合理地使用将来时态——计划、迁移步骤和待解决问题在工作尚未完成时属于此处。`## Acceptance criteria` 说明什么可观察状态意味着完成。`## Risks` 涵盖可能出错的事项以及该变更有意放弃的东西。
#### `implemented/`
```markdown
## Problem
## Decision
…bespoke sections…
## Alternatives considered
## Consequences
```
`## Decision` 以现在时态描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在此属于规格用语,门禁会拒绝它们:`## Proposal``## Plan``## Migration plan``## Acceptance criteria` 不得出现在 implemented Agent Note 中(原因见 [slop 检查清单](../../docs/AGENTS.md))。`## Testing``## Deferred``## Related` 章节在陈述现在时态的事实时是允许的。
#### `rejected/`
被否决的 Agent Note 是冻结的提案:保留提案时的所有章节(包括 `## Acceptance criteria``## Plan`),结论写在 `Status:` 行上。仅头部块、`## Problem` 开头、`## Proposal` 章节以及下方的「曾考虑的替代方案」强制要求适用。
### 曾考虑的替代方案——必需
每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的。
替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note在该章节位置放置以下精确注释门禁仅对格式规范之前的文件接受此注释
```markdown
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
```
### 在生命周期之间移动
将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/``implemented/``## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria``## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/``rejected/` 仅在 `Status:` 行添加原因并冻结文件。
### 中文对侧文件
`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ``Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。

View File

@@ -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: 9aad01cee6083b1f380be66869af3137a07d9f1f
2026-06-11-content-block-vocabulary.zh.md: 5720f0742a0729a3f98e4b05ab37acf97ae78db5

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-content-block-vocabulary.zh.md)
## Problem
The harness needs one internal language for messages that the loop, session log, and all plugins speak.

View File

@@ -0,0 +1,28 @@
# Agent Note: 由 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`steering/message`)最初渲染为带标签的 user-role 信封system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
## 曾考虑的替代方案
- **镜像 DeepSeek/OpenAI chat-completions 结构**:对第一个提供方零映射成本,但对富内容(推理、结构化块形式的工具结果)处理不便。
- **原样采用 Anthropic Messages 块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。
## 后果
- 推理reasoning在核心层有了归属无需依赖提供方特有的结构。
- 多模态块只有在适配器、UI 和上下文压缩context compaction三方协同支持后才会回归见 [drop-image Agent Note](../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) Agent Note。
- 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。
- 跨包package边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。

View File

@@ -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: 947d53555df078bfa9f3dac48eab4b8c0074007c
2026-06-11-custom-schema-dsl.zh.md: 26ebfe2fb15a6c034e809b3f51187342fa500193

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-custom-schema-dsl.zh.md)
## Problem
Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array.

View File

@@ -0,0 +1,23 @@
# Agent Note: 使用自定义类型化工具 schema DSL 替代 schemastery
Status: implemented
[English](2026-06-11-custom-schema-dsl.md) | 中文
## 问题
工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。
## 决策
该决策已由[统一 JSON 值 schema DSL](2026-07-20-unified-json-value-schema-dsl.md)取代;新设计保留小型编写接口,同时让参数与类型化值共享一套词汇。`ParameterSchemaSpec` 保留逐属性的 `required: true``InferArgs<S>` 将必需键映射为非可选属性;`parameterSchemaSpecToJsonSchema()` 编译隐式开放的对象根;`defineTool()` 则将类型推导、编译与校验串联起来。原始 JSON Schema 的 `ToolDefinition` 仍是 `ToolRegistry.register()` 接受的输入,供 MCP 和其他外部工具使用。
## 曾考虑的替代方案
**Schemastery**(已作为 vendor 引入,用于插件 Config经评估后被否决它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*因此会增加间接层却无法干净地产出协议格式wire format
## 后果
- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。
- 当前节点、字面量约束、联合类型、JSON 值边界与对象开放性规则均由上述统一说明定义。
- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。

View File

@@ -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: 8f0e79f15af82ce3125b1f6f767d4ea727aa6d29
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 2f787bbd55b5a9a91bc5342351756e45cb0515d3

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-dev-invariants-over-deep-readonly.zh.md)
## Problem
The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service seams. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules.

View File

@@ -0,0 +1,60 @@
# Agent Note: 源端拥有的会话不可变性与开发模式不变式
Status: implemented
[English](2026-06-11-dev-invariants-over-deep-readonly.md) | 中文
## 问题
会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实之间关系的检查。如果将二者混为一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。
会话日志是回放、请求重建、持久化与用户可见历史的持久真源。会话包package外部的代码必须能检视历史但不能保留一个可在之后改写历史的引用从调用方接受的输入也不能继续连接到调用方拥有的可变对象。
单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。
TypeScript readonly 类型不是充分的运行时边界。它们在程序运行时消失,类型转换可以绕过它们,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。
## 决策
职责在始终启用的存储边界与可选的开发断言之间分离。
### Session 拥有不可变历史
`Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切分离记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。
此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
### 派生请求保持分离
`deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
### 包拥有的不变式配套插件检查关系
`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session``dsh-agent``dsh-scope``dsh-agent-loop` 目前添加需要 trace 状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent智能体状态转换、主体正确的作用域分发以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。
当会话配套插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose资源释放的子 fiber因此轮次中途热重载是安全的同时不赋予诊断逻辑对会话存储的所有权。
## 曾考虑的替代方案
### 全面的 deep-readonly 类型
[被否决的不可变公共表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公共日志和消息表面上应用递归 readonly 类型。这能提供编辑器反馈但无法提供运行时保证TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。
### 仅在开发模式冻结
仅当不变式插件安装时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用,而开销更大的关系检查则保持为可选的开发支持。
### 仅在派生消息时克隆
分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
## 后果
- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
- `session.events` 暴露稳定的不可变快照,而非私有的增长数组。
- 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为dispose 或过滤一个配套插件不会削弱日志不可变性。
- `dsh-invariants` 配置全局启用状态以及包允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。
- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。

View File

@@ -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: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0
2026-06-11-event-sourced-sessions.zh.md: da3be5965a6900076f253cad065b847c6f5ce17e

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-event-sourced-sessions.zh.md)
## Problem
The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统session完全可回放).

View File

@@ -0,0 +1,28 @@
# Agent Note: 事件溯源的会话与派生消息历史
Status: implemented
[English](2026-06-11-event-sourced-sessions.md) | 中文
## 问题
MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统,会话完全可回放)。
## 决策
`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志是唯一的真源。LLM大语言模型消息历史从日志*派生*`deriveMessages()`);原始流分片被记录以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威依据。回放/fork = 用已有日志初始化一个新会话。
追加操作是同步的(热路径从不阻塞于 I/O`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。
顺序契约agent loop智能体循环*先*追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall瀑布式事件`assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。
## 曾考虑的替代方案
**可变消息数组 + 事件仅作通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身即是状态,分歧在结构上不可能发生。
## 后果
- 回放、追踪与遥测在结构上得到保证,而非事后附加。
- 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。
- 事件词汇可通过合并扩展插件可添加如压缩compaction事件[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。
- 派生成本随日志长度增长,压缩(未来插件)是预期的缓解手段,而非日志变更。

View File

@@ -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: 8bf05b7deba5f054d4ec8ecf104c3b8798e42d4e
2026-06-11-microkernel-event-taxonomy.zh.md: 4ff2ab632ca02e98137a15f19a7996a740a519b0

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-microkernel-event-taxonomy.zh.md)
## Problem
The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core.

View File

@@ -0,0 +1,31 @@
# Agent Note: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环
Status: implemented
[English](2026-06-11-microkernel-event-taxonomy.md) | 中文
## 问题
产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩context compaction、沙箱、权限、UI、持久化、MCP、skill技能都必须能以插件形式编写无需修改核心。
## 决策
纯 Cordis 事件分类体系。agent loop智能体循环的扩展 seam 是带类型的事件,具有明确的分发模式:
- **waterfall瀑布式事件**around-middleware插件可变换、否决、恢复或包装`agent/prompt-submit``agent/request``agent/request-error``agent/step-result``agent/turn-continuation``tools/pre-execute``tools/execute``tools/post-execute``llm/stream``system-prompt/assemble`
- **serial**(按监听器顺序依次 awaitbail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step``agent/post-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 风格)** 与**显式阶段状态机(插件向其中插入阶段)**:两者都需要重新实现 Cordis 原生事件系统已提供的分发、dispose资源释放与重载语义作为 Cordis effect监听器天然获得 HMR热模块替换与 dispose 能力。
## 后果
- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。
- HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。
- 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering中途引导永远不会被搁置有回归测试保障

View File

@@ -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: e0bca0ff24c5adc7ca58007932dff6580694b01d
2026-06-11-runtime-arg-validation.zh.md: 09958147766b4015d6bebf786c4947b9d2941f74

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-runtime-arg-validation.zh.md)
## Problem
`defineTool` ([the unified schema DSL](2026-07-20-unified-json-value-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, or a literal outside the declared set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape or silently misbehaved.

View File

@@ -0,0 +1,24 @@
# Agent Note: 模型边界处的运行时参数校验
Status: implemented
[English](2026-06-11-runtime-arg-validation.md) | 中文
## 问题
`defineTool`[统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.md))为工具作者的 `execute(args)` 提供了经 `InferArgs<S>` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON没有任何机制强制模型遵守 schema因此畸形调用缺少必需键、声明为数字的位置传入字符串或字面量超出声明的集合会以「仅名义类型化」的状态到达 `execute`。工具函数体随后要么在错误形状上崩溃,要么静默地行为异常。
## 决策
`validateArgs(spec, args): string[]` 编译 `ParameterSchemaSpec`,并委托共享的 `validateJsonSchemaValue()` 遍历器,对格式正确的声明返回可读的违规列表。`defineTool` 在定义时对编译后的参数 schema 创建快照,并在调用类型化函数体之前执行校验;存在违规时会抛出 `ToolArgsError``INVALID_ARGS`),注册表将其作为模型可据以修正的错误结果返回。
校验器与编译器因此共享完全一致的语义:隐式参数根是开放对象;必需键仅来自 `required: true`;默认值仍是注解;显式嵌套对象遵循其声明的开放性;数组通过 `items` 递归;标量字面量约束保证类型正确;`oneOf` 仅在恰好一个分支匹配时才接受。原始注册的工具自行负责输入校验。
## 后果
- 模型在自身畸形调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。
- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。
- `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。
- 校验开销相对于一次模型调用可忽略不计。
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -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: 9122193b3d01cf5a4c315e6f7a7218153fd4a60a
2026-06-11-structured-error-taxonomy.zh.md: 56a196ccd10a81b51953887f18e522412cd9463b

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-structured-error-taxonomy.zh.md)
## Problem
Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically.

View File

@@ -0,0 +1,26 @@
# Agent Note: 结构化错误分类体系
Status: implemented
[English](2026-06-11-structured-error-taxonomy.md) | 中文
## 问题
故障跨越 seam 时只是裸字符串。工具错误被扁平化为一个文本块name、code 和 stack 全部丢失),导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES模型得到的反馈也不如本可以那样具有可操作性。非 Error 的 throw 退化更严重agent loop智能体循环将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对其进行通用的 `instanceof` 判断。
## 决策
`dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。
- `LlmError``ToolArgsError`dsh-tools继承该基类保留各自既有的 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` 分支,而无需对消息做子串匹配。
- 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。
- `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。
- 参数校验保留其既有的 code 和行为;包自有的诊断不变式独立携带稳定 code使不变式注册表无需导入产品包。共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -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: 3643ac3d61be08f629ef0cd0424fef5cb9696c3a
2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 10389fd7c63755e5b00b3c508fd303a541287f2c

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md)
## Problem
On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment.

View File

@@ -0,0 +1,23 @@
# Agent Note: 工具 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 放在「提示词」服务中略有概念上的意外感,已在本文及包 README 中加以说明。

View File

@@ -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: 7c755dced7825d2831acc0901f6412b8e5afe95a
2026-06-13-capability-seams.zh.md: 4148c79cb5e1930dca77eaf3afd2024f508275b5

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-13-capability-seams.zh.md)
## Problem
The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed.

View File

@@ -0,0 +1,32 @@
# Agent Note: 能力 seam——接口实现消费方三分
Status: implemented
[English](2026-06-13-capability-seams.md) | 中文
## 问题
harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*模型和其他插件面向什么编程。将三者捆绑在一个包package中会耦合这些变化速率——把本地执行器换成沙箱化执行器时模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。
这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过服务 + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。
## 决策
一项可替换的能力由**三个包**构成:
1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.<key>`,仅依赖其词汇依赖(例如 `dsh-bash``BashExecutor``BashRunResult``BashProcess`)。
2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、溢出文件截断。沙箱化远程后端是实现同一接口的兄弟包。
3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash``bash` schema后台句柄注册到通用任务运行时。消费方 `inject` 接口键,从不导入实现类型。
实现与消费方由此独立演进:沙箱化执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。
当各部分确实属于同一个关注点时三分并非强制LLM大语言模型 seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop智能体循环本身而非可替换的 schema 表面),适配器作为实现包。不要预防性地拆分——如果一项能力只有一种可设想的实现和一个消费方,就保持为一个包,直到第二种出现。
## 曾考虑的替代方案
- **单一合并包**:否决。因为它重新耦合了三分设计本要分离的三种变化速率(这正是拆分的意义所在)。
- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam的候选方案不是替换实现的机制。混淆这两个「能力」概念正是本 Agent Note 所指出的陷阱。
## 后果
每项能力需要更多包和更多样板代码(一组 `package.json`/`tsconfig`/README加上 inject 接线)。换来的是:实现与消费方独立发布和版本管理,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions「Capability seams are three packages」和 [architecture.md](../../../../docs/architecture.md) §「Capability seams」中bash 三件套是参考模板。何时合并、何时拆分是一个判断问题,架构文档对此有详细说明——本 Agent Note 记录的是*为什么*默认选择拆分。

View File

@@ -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: 5c3308b281ce71407002e95dd6e794da2a421fa8
2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-13-twin-llm-adapters.zh.md)
## Problem
`dsh-llm` owns a provider-neutral streaming vocabulary — the `StreamChunk` protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`) and the content-block types ([the content-block vocabulary](2026-06-11-content-block-vocabulary.md)). A vocabulary defined against a single adapter risks baking that adapter's quirks into the "neutral" contract: anything the one implementation happens to do becomes the de-facto spec, and the abstraction is unverified until a second provider arrives — by which point the leak is expensive to fix.

View File

@@ -0,0 +1,27 @@
# Agent Note: 以两个 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` + SSEServer-Sent Events解析直接对接 DeepSeek API。
- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。
二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts``StreamChunk` 上的约定usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。
## 曾考虑的替代方案
- **单一适配器**代码更少、e2e 成本减半,但「提供方无关」的声明无从验证;词汇会默默编码 DeepSeek-via-fetch 的假设。
- **mock 第二适配器**更便宜但不会触及真实提供方的协议格式wire format怪癖因此证明力有限。孪生体是真实对真实的验证。
## 后果
孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理reasoning模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey``baseURL``models`;手写适配器暴露 `thinking`/`reasoningEffort`pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。

View File

@@ -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: 2683bcd68e1f52fbd2bc78660fbe17e556a9a60d
2026-06-14-session-persistence.zh.md: 28e32925a5866fe520c0d9c3eb7b0647845d36f5

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-14-session-persistence.zh.md)
## Problem
Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible.
@@ -19,16 +21,16 @@ Key choices recorded here because they are durable, contested, and surprising:
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)``append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)``append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation.
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
## Alternatives considered
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
## Consequences
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim.
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.

View File

@@ -0,0 +1,36 @@
# Agent Note: 会话持久化作为基于现有 `SessionEvent` 的抽象服务
Status: implemented
[English](2026-06-14-session-persistence.md) | 中文
## 问题
会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent智能体因此持久恢复「继续昨天的任务」、持久 fork 以及 ACPAgent Client Protocol`session/load` 方法([ACP 支持](../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`**),默认编码为[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md),也可通过配置使用原始行。
以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的:
- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过分片而过滤分片的方案Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end`。合成的结果保证恢复后的提供方 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一次表达在文件字节上一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()``createdAt` 是以 Unix epoch 毫秒表示的非负安全整数运行时创建和持久化注册会拒绝小数值JSONL 会验证解码后的 headerSQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `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()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop智能体循环不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
## 曾考虑的替代方案
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `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 支持](../feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度`assistant/chunk` 保持逐字节不变。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。

View File

@@ -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: 6e2abd1716f8efc08c06d5ff8faec38282f2a17f
2026-06-15-turn-enclosure-invariant.zh.md: 0921c2574dc171e887664d8a2ea840a4e81f1531

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-15-turn-enclosure-invariant.zh.md)
## Problem
A durable session-persistence backend (added in a companion change) uses the **turn** as its crash-recovery boundary: a crash can leave an unclosed final turn, which `load` closes with a synthetic `turn/end {kind:'interrupted'}` while preserving the turn's real events (see [session persistence](2026-06-14-session-persistence.md)). This recovery is only well-defined if nothing *legitimately* durable sits OUTSIDE a turn — between the last `turn/end` and the next `turn/start` — since such an event would be swept into the next turn's interrupted close.

View File

@@ -0,0 +1,42 @@
# Agent Note: 每个会话事件都封闭在一个轮次内
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()` 时,它会加入已打开的轮次。当前步骤执行 assistant 工具调用期间,已接受的上下文按到达顺序等待该批次结算,随后在每个已记录结果之后追加;即使执行中断,也会在轮次关闭前写入。
- agent **空闲时**调用 `agent.inject()`,则将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}``context/message``turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`
- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。
- `dsh-session/invariant` companion 将该检查注册到 `ctx.invariants`:选中后,在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出归因于 `@deepseek-ai/dsh-session``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` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。

View File

@@ -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: 08e8d52b314eb10e2c7ec444dd61a96d8621e032
2026-06-17-filesystem-capability-seam.zh.md: 6f4889234516ee134c9873781a874b5f1a3644ac

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-17-filesystem-capability-seam.zh.md)
## Problem
The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once.

View File

@@ -0,0 +1,160 @@
# Agent Note: 文件系统能力 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` 策略强制隔离,否则它只是一个基础路径。
我们需要文件系统工具在成为公开包package接口之前以与 bash 相同的能力 seam 形态落地。
## 决策
文件系统访问是一个一等的能力 seam遵循[能力 seam Agent Note](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` 以获得读后写/编辑能力。本 Agent Note 确立了由三个包构成的 seam策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](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` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由[为文件系统 seam 添加直接目录列举能力](2026-07-03-filesystem-directory-listing-seam.md)添加。
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。
读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.md) Agent Note。
## 包拓扑
文件系统 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 视为不透明。(本 Agent Note 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;拆分文件系统 seam 与事件门控两份 Agent Note 将其替换为此处描述的基于新鲜度的策略插件。)
路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。
解析后的目标必须至少暴露三个概念:
- 原始输入路径,用于诊断。
- 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev``ino``size``mtimeNs``ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`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 区分,因此已删除。目录列表相关的错误码后来由[为文件系统 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` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,既不信任规范值,也不信任渲染内容。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。
本仓库曾踩过的防御性模式类别被直接固定:
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only`'wx'``0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。
- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的陈旧写入可通过另一个路径检测到。
- **并发/陈旧竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。
- **HMR热模块替换安全与 dispose资源释放。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续的提供方以无继承状态启动。
## 曾考虑的替代方案
- **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。
- **单一合并包 `dsh-fs-tools`**seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。
- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。
## 后果
**`cwd` 可能被误认为沙箱。** 本地后端的基目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。
**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露显示元数据,而不要求消费方理解宿主路径。
**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 Agent Note 试图避免的耦合。
**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`
**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 Agent Note 最初将其放在文件系统 seam 内部;拆分文件系统 seam Agent Note 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。
**`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。
**观测状态持久化被推迟。** 观测状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观测可回放。
**错误码成为 seam 的一部分。** `FsError` 错误码使陈旧版本和观测失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs``dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。
**包拆分的成本前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改包接口代价更高。

View File

@@ -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: 70ebf1c6de97cb14e27377ec1f9bac18fc0766a6
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 54d174aa325384f7c7492f072f10954f0b1d08c9

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md)
## Problem
Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through `ctx.agents`, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; `session/cancel` could not cancel queued-but-not-yet-started work; and `tool-bash` kept task ownership in a plugin-local `Map`, so an HMR reload could make an old task look unowned.

View File

@@ -0,0 +1,50 @@
# Agent Note: Agent 生命周期与所有权 seam
Status: implemented
[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文
## 问题
ACPAgent Client Protocol与 tool-bash 的若干限制是同一个缺失 seam 的症状:插件可以通过 `ctx.agents` 创建或恢复 agent智能体但无法独立拥有和 dispose资源释放单个 agent而长时间运行的 bash 任务在执行器中也没有稳定的所有者。ACP 在断连时中止并等待 agent却无法仅注销该会话的 agent`session/cancel` 无法取消已入队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR热模块替换重载就可能让旧任务看起来无主。
## 决策
三个 seam队列感知的取消、`AgentHandle` 释放器,以及 bash 所有者令牌。
### 1. 队列感知的 `Agent.cancel(cause?)`
`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO在存在活跃轮次时中止它并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause空闲取消不发出任何事件也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。
### 2. `AgentHandle` 异步释放器
`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有handle 被丢弃。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。
**拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分。fiber 卸载会并发释放兄弟 effect`Promise.all`),这会让会话存储的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed``session/disposed` 通知无法拒绝该链或跳过后续拆除。
### 3. Bash seam 中的所有者令牌
后台任务所有权从 `tool-bash` 插件本地的 `Map<string, Agent>` 移入执行器。`BashExecRequest` 新增可选的 `owner?: string`;解析后的 `BashExecSpec` 将其作为必需但可空的 `owner: string | undefined` 携带(被遗忘的 owner 是可见的 `undefined`,而非静默缺失的属性)。执行器把 token 存在任务上,并通过新的 `BashExecutor.ownerOf(id): string | undefined` seam 暴露它(不放在公开的 `BashTask` 上——只有一条读取路径,没有冗余 API`tool-bash` 完全删除其 `Map`:它在 `start` 时将 `exec.agent?.id`(共享的注册表/会话 id盖章为 owner`bash_output`/`bash_kill` 则以 `!== undefined` 语义把 `ctx.bash.ownerOf(id)` 与调用方 token 比较(空字符串 token 仍是真实 owner。完成通知通过扫描 `ctx.get('agents')?.list()` 查找 `agent.id === ownerToken` 的存活 agent`ctx.get` 读取——`onTaskDone` 运行在 bash fiber 这一外部 fiber 上,直接使用 `ctx.agents` proxy 会抛异常)。由于所有权现在存活在执行器的任务上(随 `dsh-bash` fiber dispose它能跨越 `tool-bash` HMR 重载,关闭旧的 `XXX(tool-bash-owner-hmr)` 缺口。(`onTaskDone` 监听器仍受 `tool-bash``apply` effect 约束,因此落在重载间隙的完成仍会丢失一条通知——既有的重载间隙丢失——但所有权隔离本身已经不受 HMR 影响。)
## 验证
以下不变式已经成立,并由测试固定:
- ACP 断连/会话关闭后,不留下该会话的任何已注册 agent 或会话存储条目,即使 `session/load` 与拆除竞争。
- 已入队的提示词启动前执行 `session/cancel`,能阻止该提示词运行;后来接受的提示词仍是独立的已入队轮次。
- `tool-bash` HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。
- 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 `AgentLoop` 插件 fiber 所有。
## 会话所有者令牌在存活 agent 中唯一
bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布会依次进入会话和 agent`SessionStore.enter()` 拒绝重复的存活会话 id每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*token 比较)留在消费方 `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()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md))。
## 后果
本变更有意触及公开接口(`Agent``AgentFactory`、bash seam而非作为 ACP 的局部补丁。同步 `Agent.send()` 的简洁易用性得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。

View File

@@ -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: 80034881d0112076759a68737b5931c8ff659d15
2026-06-18-session-surface.zh.md: 26a3119faf0b6988049a7599ea9551a8ae65d63d

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-18-session-surface.zh.md)
## Problem
The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`.

View File

@@ -0,0 +1,73 @@
# Agent Note: 会话 surface事件日志上的有序投影
Status: implemented
[English](2026-06-18-session-surface.md) | 中文
## 问题
事件日志是权威数据源但历史操纵此前没有持久化的共享机制。如果没有这样的机制上下文压缩context compaction等插件只能通过顺序敏感的监听器改写派生请求不留溯源信息且每次新增操纵都要反复修改 `deriveMessages()`
## 决策
新增一个 **surface**:事件 seq 的派生、缓存有序投影(即产出 LLM大语言模型消息的事件子集通过事件日志中的 `surfaceOp` 标记维护。
### `SessionEvent` 新增两个顶层字段
每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级):
- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq或被压缩标记遮蔽的 surface 节点)。出现的 `[]` 只在 `assistant/message` 上有效,表示已知为空的提供方流;在该事件上省略字段表示旧数据或未记录的溯源。其他 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**:在尾部追加新事件的 seq。`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`(两端包含)的条目,并在其位置插入新事件的 seq。`start``end` 都必须存在于当前 surface`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中但不再出现在 surface 上。
### SurfaceManager基于增量而非全量重建
一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `number[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 契约暴露同一个管理器因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置找到两端都包含的端点,并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。
无新事件时增量处理为 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 有效。
### 不变式
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs``surfaceOp`:只有 `assistant/message` 可以使用空的溯源列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;溯源必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是可选的不变式服务贡献。
每个 surface 可达事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。
## 曾考虑的替代方案
- **逐插件的 `agent/request` 包装**surface 之前的历史操纵模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改。
- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。端点由 surface 事件 seq 命名,单条目替换(`start === end`)在闭区间语义下读起来更自然。
- **链接节点对象加 seq map**:否决。生产代码不读取前驱链接,唯一的后继用途就是数组中的下一个位置,而替换本来就需要线性 `indexOf` 查找。单个 seq 数组在保留相同渐进复杂度的同时,只留下一个需要校验的表示。
- **脏标记后全量重建**替代增量处理:在会话生命周期内为 O(N²),每次单事件追加都要重新扫描所有先前事件。
## 后果
- **`packages/core/session`**`surface.ts``SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent``deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。
- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集分片 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/session-persistence/session-persistence-jsonl`**:无需改动。
- **`packages/session-persistence/session-persistence`**:抽象接口不变。
Surface 是未来历史操纵的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start``compaction/end`)不进入 surface。回放以确定性方式保留该决策。
一次 `tool/result` 替换只能改写当前的一个 `tool/result`,并且必须保留除 `content` 以外的每个数据字段。Session 接纳会与位置范围和溯源校验一起强制这条规则,不依赖可选的诊断插件。

View File

@@ -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: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65
2026-06-18-shared-persistence-write-coordinator.zh.md: 3b4dd7b762c2f39a908eabe23e5d734981b5767b

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md)
## Problem
`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind control, per-id operation serialization, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. Only the storage primitives (write bytes vs. INSERT rows) differed.

View File

@@ -0,0 +1,47 @@
# Agent Note: 共享持久化写入协调器
Status: implemented
[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
## 问题
`dsh-session-persistence-jsonl``dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们重复实现了写入路径编排:每会话状态、`session/created` 接管、后端特定的前缀读取、write-behind 控制、按 id 串行执行操作、HMR热模块替换种子注入与 dispose资源释放排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。唯一的差异在于存储原语(写字节 vs. INSERT 行)。
## 决策
将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`load`/`inspect`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括供读模型使用、不修改状态的 `inspect` 契约。
协调器为每个确切的存活 `Session` 持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。
协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush串行执行最后一次排空且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown拆除重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。
### 钩子接口(`PersistenceBackend<TornMarker>`
五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界:
- `name`——后端标签,用于 dispose 失败时的 `AggregateError`
- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀JSONL 的所有 cwd bucketSQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。
- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync先截断再追加SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers和 live-adoption仅截断`closers = []`)。
- `list()`——列出所有已存储的元数据。
- `close?()`——可选的生命周期清理SQLite 关闭 db 句柄JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await因此 close 失败不会掩盖排空错误。
### 不透明的 torn marker
保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers它拥有来自 `dsh-session``interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型JSONL 携带要截断到的字节偏移以及从不完整最终帧中解码出的任何完整事件SQLite 则携带要从其开始删除的 seq。协调器因此既不了解字节长度也不了解帧恢复状态。
## 测试
共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明在 `load` 执行恢复之前,`inspect` 会保持被中断的日志与修订版本不变。`runCoordinatorContract``tests/coordinator-contract.ts`通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空,以及崩溃尾部修复。协调器专属测试覆盖立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers却不会产生 torn marker。
## 曾考虑的替代方案
- **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。
- **更宽的钩子面**——每个候选钩子都被折叠掉:没有限定存储范围的实时查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径,而 SQLite 已按 id 绑定;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined``list()` 也不经由协调器透传,因为列举不需要任何编排。
## 后果
协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查与不修改状态的检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时,不会因提交中断 closers 而与新的存活所有者产生竞态。新后端只需实现存储原语,而无需复制立即写入生命周期。

View File

@@ -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: 93bab1d47c793cc4dd1f1d19fa3af22d1721be29
2026-06-20-branded-ids.zh.md: 6cc8a45717b547614a873d3f13c54c3950484a22

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-20-branded-ids.zh.md)
## Problem
The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today.

View File

@@ -0,0 +1,69 @@
# Agent Note: 在所有应有之处使用 branded ID
Status: implemented
[English](2026-06-20-branded-ids.md) | 中文
## 问题
harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `CallId``packages/llm/llm/src/brand.ts`)和 agent/会话共享的 `SessionId``packages/core/session/src/types.ts`)做 brand 处理该机制由纯类型包package `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。
**缺口 1bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string``BashTask.id: string``packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId``assertTaskAccess``packages/bash/tool-bash/src/index.ts``task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``。bash task id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。
bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined``packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent智能体共享的 `Agent.id`/`SessionId``callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。
**缺口 2*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、`ToolPresenter` 的 call-id map、ACP 的会话 id 记录和 loading set以及持久化协调器。在集合键处丢弃 brand会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。
## 决策
纯类型变更。Brand 是零开销 cast运行时行为、序列化、比较和协议格式wire format均不变。工作分三部分全部遵循既有的「不是每个 string 都需要」策略。
- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId``task_id` 在模型 string 到达的工具边界处被 brand
- **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id``SessionId`cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 从不导入 `dsh-session`。(理由见下一节。)
- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`Map<SessionId, Agent>`、`get(id: SessionId)`、`Map<CallId, …>`、ACP 的 `SessionId` surface、协调器的 `Map<SessionId, …>`。这是 diff 中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。
示意形状(工厂模式与已有的三个 brand 完全一致):
```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`
显而易见的捷径是直接把 `owner` 类型标注为 `SessionId`——它确实*总是*一个会话 id。我们否决这个方案。bash 执行器 seam 是能力 seam接口 `dsh-bash`、实现 `dsh-bash-local`、消费方 `dsh-tool-bash`),其 owner token 被*明确记录为刻意不透明*执行器「从不解释它seam 中没有访问策略——那是消费方的职责)」(`packages/bash/bash/src/types.ts`)。把 seam 字段类型标注为 `SessionId`,会把 `dsh-session` 的词汇引入一个不应知道 owner token *含义*的包——这会让通用执行后端耦合会话模型,并违背不透明 token 的设计。取代 `dsh-bash-local` 的沙箱或远程执行器不应继承会话依赖。独立的 `OwnerToken` brand 使 seam 保持解耦:`dsh-bash` 只知道「owner 是某种带 brand 的不透明 token」而已经决定访问策略的 `dsh-tool-bash` 消费方,是把其 `SessionId` cast 为 `OwnerToken` 的唯一边界。该 brand 仍带来安全收益(不能把 `BashTaskId` 或裸 string 传到 owner 位置),且不引入耦合。
## 不在范围内 / 可能的扩展
遵循「不是每个 string 都需要 brand」的策略刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺:
- **`ModelId`**`GenerateOptions.model``LlmService` 适配器注册表的键一个真正的跨包查找键config → agent → llm → 适配器);合理的下一个 brand仅为控制本 Agent Note 的影响范围而暂不纳入。
- **`ToolName`**`ToolRegistry` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。
- **`ErrorCode`**`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id如果要做string 字面量联合类型比 brand 更合适。
- **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string``Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。
- **带校验的构造**brand 工厂是纯 cast无运行时检查且每个边界ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时怎么办?),应在独立 Agent Note 中处理,不应捆绑进这次纯类型变更。
## 验证
已落地的不变式:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,并端到端贯穿执行器 seam、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的 surface且 `dsh-bash` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `task_id`)都通过 cast 工厂构造 brand而不是散落的 `as` cast。
## 后果
- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam接口 + 实现 + 消费方)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* 会话 id 只要仍是合法的 string就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。
- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少`brand.ts` 中的策略是裁决依据,本 Agent Note 倾向于面向模型或用于访问控制的 id。

View File

@@ -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: 8e87a7164d9cc4789705b3d8fe83dc4978b4def0
2026-06-20-extract-example-app-packages.zh.md: bb5bf6a78d749bf2fc0637a48bff4496399a787d

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-20-extract-example-app-packages.zh.md)
## Problem
An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes.

View File

@@ -0,0 +1,57 @@
# Agent Note: 将示例应用提取为独立包
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 中。
叶子配置还拥有耦合的前门。ACPAgent Client Protocol要求 stdout 纯净,并通过 `session/new` 创建 agent终端应用和 Headless 应用则预创建 `main`,但进程 I/O 契约不同。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。
## 决策
每个示例现在**主要是对一个应用包package的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**应用包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM大语言模型适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。
- **`@deepseek-ai/dsh-agent-spine-demo`**[packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含提供方、不含执行器、不含 UI 的主干,并转发 agent loop智能体循环的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。
- **`@deepseek-ai/dsh-tui-demo`**、**`@deepseek-ai/dsh-cli-demo`** 和 **`@deepseek-ai/dsh-acp-demo`** 各自内置其进程角色。TUI 包含全屏 UI 和预创建的 `main`Headless 包含 one-shot driver 和预创建的 `main`ACP 包含 bridge 且不预创建 agent。三者都包含 JSONL 持久化,并省略 stdout logger。
- **`start.ts` 已移除。** 每个应用包都暴露一个 bin`demo:*` 脚本调用它。Loader 引导、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));精简的自执行入口由 keyless 的 Loader 路径测试驱动。
- **每个叶子 `cordis.yml` 精简为**后端、可选产品工具,以及一个承载应用配置的 app 条目。TUI 和 Headless 把模型/会话选择路由到预创建的 agentACP 把初始提供方/模型路由到 bridge。
- **`base.yml``base-core.yml``acp-agent/acp-tail.yml` 已退役**——它们共享的主干现在位于 `dsh-agent-spine-demo` 中。
`bash-local` 和 LLM 适配器仍然是**叶子选择**bundle 提供 `tool-bash`(消费方 schema叶子选择执行器实现因此沙箱执行器或回放适配器无需触碰应用即可替换。
### 实现修正:`hmr` 保留为叶子条目
提案最初将 `hmr` 列入交互式应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到应用包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付:
1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——它需要活跃的 `loader` 服务及其内部模块访问权限,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。
2. 进程内测试层vitest甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。
关键在于,`hmr` 不是 stdout 纯净隐患ACP 配置中误加该条目不会破坏 JSON-RPC 帧。所有已交付应用都省略 stdout 控制台 loggerstdout 只归应用或协议 driver 所有。
## 曾考虑的替代方案
### 为什么不继续用共享 YAML include 来管理接线?
旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水一直在三个 `start.ts` 文件中重复。包将「ACP 应用绝不向 stdout 输出日志」从文字警告变成了产物的属性:叶子中不存在可以写错的 logger 条目。
## 验证
- 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。
- `demo:tui``demo:headless``demo:acp` 调用应用包的 bin。
- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。
- ACP 回放 transcript文本记录保持不变因为插件集合和加载顺序未改变。
## 后果
- **裸插件树的教学性。** 主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。
- **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。
## 相关
- 取代[使共享示例基础配置与提供方无关](../../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。
- 与[将包重组为模块化层级结构](2026-06-20-package-hierarchy.md)互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle`ui` 放应用特有的前门)。
- 后续的[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)拥有最终的 TUI/Headless 拆分,并移除行式与仅 mock 的叶子。

View File

@@ -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-generic-long-running-tool-runtime.md: 25668a6a699576e435670b9580385073f2f036fe
2026-06-20-generic-long-running-tool-runtime.zh.md: 3c214bb4309ea331ae2d4ba73c01df1ded8f40ef

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-20-generic-long-running-tool-runtime.zh.md)
## Problem
Background bash originally combined two responsibilities: the bash executor ran processes and also managed task ids, ownership, incremental reads, cancellation, completion listeners, and model-facing control tools. Adding background subagents required the same lifecycle and interaction contract. Implementing that contract independently for every long-running capability would duplicate isolation, cleanup, notification, and prompt behavior while teaching the model a different collect-and-stop protocol for each producer.

View File

@@ -0,0 +1,134 @@
# Agent Note: 后台任务运行时(`ctx.tasks`)与通用任务控制工具
Status: implemented
[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文
## 问题
后台 bash 原本兼有两项职责bash 执行器既运行进程,又管理 task id、所有权、增量读取、取消、完成监听器和面向模型的控制工具。新增后台 subagent 需要相同的生命周期与交互契约。如果每种长时间运行能力都独立实现该契约,就会重复隔离、清理、通知和提示词行为,还会让模型为每种生产方学习不同的收集与停止协议。
任务注册表、控制工具与完成通知共同构成一项 harness 功能。bash 和 subagent 只提供执行专属的钩子,不拥有通用任务行为。
## 决策
`tasks/` 包组拥有后台任务语义:
- `@deepseek-ai/dsh-tasks` 将运行中的工作注册为 `ctx.tasks`,并拥有 task id、授权、快照、读取、取消、等待、完成监听器与清理。
- `@deepseek-ai/dsh-tool-tasks` 暴露 `task_output``task_list``task_kill`,注入完成通知,并提供后台任务的系统提示词指导。
长时间运行工具是生产方。`dsh-tool-bash``BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。
`TaskService` 是一个具体的进程内服务。TODO(task-service-backend)当第二个后端明确所需生命周期后将其公共契约与实现分离systemd 驱动的运行时是一种可能方案,但本 PRPull Request不臆测其持久性、重连、所有权或观察语义。
## 运行时契约
字面类型见[任务数据结构目录](../../../../docs/core-data-structures/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用控制接口添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有接口行为,因此运行时不会向无关的生产方类别施加隐式默认值。
面向模型的生产方会在规范成功值中暴露已提交的 id通常为 `{ kind: 'background', taskId }`Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id取消就归任务自身的控制器与任务运行时所有随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。
生产方钩子定义三项职责:
- `cancel(reason?)` 同步请求终止,具备幂等性,并且必须使 `done` 完成。
- `done` 从不拒绝,并且仅在生产方释放任务资源后完成。
- 可选的 `readOutput()` 返回下一个消费式输出增量。省略该钩子即声明这是最终输出任务,其终止结果来自 `TaskOutcome.output`
状态包括 `running``stopping``completed``killed``failed`。退出码或停止原因等生产方专属信息放在 `detail` 中,注册表不解释这些信息。任务 kind 构成可合并扩展的字符串联合task id 带品牌,并按 `<kind>-N` 生成,每个 kind 各有一个计数器。
运行时为 `done` 附加一个 continuation记录第一个终止结果、解决等待方并逐个调用完成监听器同时隔离每个监听器的错误。首次结果优先的结算在资源销毁期间至关重要如果 `cancel` 抛出,运行时会强制将记录标为失败,并警告工作可能遗留,而不是永远等待一个可能永不完成的 promise。后续生产方结果不能覆盖该诊断也不能重复通知。`cancel` 返回后如果最终未使 `done` 完成,仍会阻塞资源销毁,因为运行时无法区分这种情况与缓慢但有效的停止。
任务注册不是生产方工具 fiber 的 effect。因此重新加载工具或控制接口插件不会终止由 agent智能体和后端拥有的工作。任务服务自身释放时会取消所有实时任务并等待遵守契约的生产方。
## 授权与所有者生命周期
task id 在运行时全局可见且可预测,因此注册表会授权每次访问。`get``read``wait``kill` 接受调用方 `Agent``list` 仅返回该调用方可见的任务。有所有者的任务仅允许对应的确切会话访问。无所有者任务向非 agent 调用方开放,并随任务服务一起终止。
快照存储所有者的品牌化 `SessionId` 以供授权,生命周期操作则保留确切的实时 `Agent` 实例。这两种身份用途不同:会话相等性授予访问权,精确对象身份决定清理和完成通知的接收方。复用 agent 或会话 id不能将旧作用域的清理或通知重定向到替代实例。
某个所有者的第一个任务会向 `owner.ctx` 附加一个异步 effect。agent 作用域释放时会取消该所有者的实时任务、等待其终止记录,并移除其快照。该 effect 可跨生产方重载存续,并加入 agent 现有的完全停稳边界。任务服务保留 effect disposer使服务重载可以在全局资源销毁后从仍然存活的 agent 作用域中分离回调。
对于遵守契约的生产方,`AgentHandle.dispose()` 只在所属后台工作停止后解决。需要比 agent 存活更久的工作必须以无所有者方式启动;要跨运行时重启存续,则需另行设计持久任务。
## 服务接口
`TaskService` 提供:
- `start(spec)`:经过预检的原子注册。
- `get(id, caller?)``list(caller?)`:非消费式快照。
- `read(id, caller?)`:消费式流增量或幂等的最终结果。
- `kill(id, caller?, reason?)`:取消。
- `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。
- `onTaskDone(listener)`effect 作用域内的观察,具有精确所有者投递和监听器隔离。
- `attachSurface(name)`:控制接口可用性防线。
`wait` 在任务完成时返回终止快照,在等待超时时返回实时快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内的结算无法代表一个什么也未收到的读取方压制完成通知。
如果生产方加载时没有任何控制接口,调用方就能启动无法收集或停止的工作。因此,`dsh-tool-tasks` 在其整个生命周期内调用 `attachSurface()`;没有附加接口时,`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时,因为兄弟插件可能并发激活。自定义的非模型接口可以自行附加,无需让注册表了解工具名称。
## 面向模型的控制接口
`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 ACPAgent Client Protocol卡片
- `task_output(task_id, wait?, timeout_ms?)` 读取输出,并始终追加 `[status: ...]`。流式任务只返回上次读取以来的输出;最终输出任务在结算后返回结果。除非指定 `wait: true`,否则读取不会阻塞;等待超时由插件配置提供默认值并限定上限。等待超时会报告仍在运行的状态,不会停止任务。
- `task_list()` 将调用方可见的任务返回为 `<id> [<kind>] <status> — <label>`,没有任务时返回 `(no background tasks)`
- `task_kill(task_id, reason?)` 立即请求取消。可选的已记录原因会转发给生产方。终止任务报告现有状态;生产方的取消操作若抛出,调用便会失败,任务保持运行。
流式读取共享一个任务作用域内的消费游标因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API共享该游标会让读取方彼此消费对方的输出。
系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务并终止不再重要的工作。完成时系统会向确切所有者的会话注入一条已记录的 `context/message`;它会成为下一个请求的持久上下文,但不会唤醒空闲的 agent。
当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task <id>` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail乃至截断标记本身因此 PTY 的最小上限仍能标识需要收集的任务。任务接口在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
## 生产方显式启用
每个生产方通过带默认值的配置,自行决定其 schema 是否暴露 `run_in_background``dsh-tool-bash``dsh-tool-pty` 和每个 `dsh-tool-subagent` 实例都使用 `enableRunInBackground`,默认值为 true。禁用的实例会省略该参数由于通用参数校验器允许未声明的键它还会在执行时拒绝强制传入的后台参数。省略 schema 用于声明能力不可用;执行检查负责强制该约束。
`ctx.tasks` 不改写生产方 schema。bundle 只转发其所拥有生产方的配置。如果后台调用在没有附加接口的情况下到达 `start()`,运行时防线会在执行前使其失败。
## 生产方集成
bash seam 暴露 `resolve``run``start``start(spec)` 返回一个 `BashProcess`,提供增量读取、取消、退出事实以及不拒绝的完全停稳 promise。本地执行器只为自身释放时能终止并等待进程而保留实时句柄。前台调用方继续直接使用 `resolve``run`
对于后台 bash`dsh-tool-bash` 将调用方 agent 注册为所有者。其钩子将 `kill()` 映射为取消,将 `done` 映射为 completed 或 killed 的 `TaskOutcome`,并将 `readOutput()` 映射为进程的有界增量输出,以及溢出文件与沙箱通知。通用任务工具拥有 id、状态行、列表、等待和完成通知。
对于后台 subagent`dsh-tool-subagent` 创建由任务拥有的 `AbortController`,并在任务 starter 内启动提供方。无论提供方就绪前后,取消都会中止同一个 signal。`done` 同时等待子运行结果和子运行释放,将已完成输出映射为最终结果,将中止映射为 `killed`,并将其他停止原因或基础设施失败映射为 `failed`。中间子历史保留在子会话中,不通过 `readOutput()` 暴露。
## 备选方案
### 按功能划分控制工具
为 bash 与 subagent 分别提供输出/停止工具,会重复 id、隔离、清理、通知和指导并增加模型的 schema 与协议负担。统一运行时将执行专属行为保留在生产方中,而无需复制任务生命周期。
### 立即抽象任务运行时后端
当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在第二种实现出现前抽取接口,会固化错误的边界。
### 由消费方负责授权或清理事件
由消费方负责检查,会使每个新接口的隔离实现不一致或遗漏。广播清理事件会迫使每个监听器过滤所有 agent且不提供注册 disposer。集中授权加一个所有者作用域内的 effect为每个消费方提供相同防线以及可等待、可移除的生命周期钩子。
### 阻塞输出或单独的等待工具
默认阻塞会在后台工作运行时串行化父任务。只等待而不读取会增加一次不返回有用信息的模型调用和 schema。`task_output(wait: true)` 显式表达阻塞,并将其与结果交付合并。
等待使用共享的 deadline 原语,而不使用通用工具超时策略。等待超时是一次成功的观察,会返回 `[status: running]`;通用策略会将它替换为超时错误。任务返回 task id 后,没有任何工具调用超时会控制任务生命周期。
### 由运行时拥有输出接收端
推送式接收端可以集中缓冲,但 bash 已经在执行器 seam 后拥有有界缓冲、截断与溢出文件。拉取格式化增量能够保留这一所有权。拥有存储的持久化后端可能足以支持重新审视生产方接口。
### 随机 id、提升或生命周期会话事件
授权而非不可猜测性才是访问边界,并且 id 不用于派生文件系统路径;顺序生成的品牌化 id 可保持 transcript文本记录易读。将前台任务提升为后台任务需要 SDK 并未规定的用户交互契约。启动、读取和通知已作为工具与上下文事件记录,因此专用任务会话事件会重复面向模型的事实。
## 测试
单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。
## 后果
bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本契约。
有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。

View File

@@ -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: ab0732172934414f2361b9e4976b54e5daee298c
2026-06-20-package-hierarchy.zh.md: 57058860de5b4cba3e8a45ecad9d611d068fb82c

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-20-package-hierarchy.zh.md)
The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here.
## Problem

View File

@@ -0,0 +1,77 @@
# Agent Note: 将包重组为模块化层级结构
Status: implemented
[English](2026-06-20-package-hierarchy.md) | 中文
后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。这里拥有的决策仍是统一的二层目录深度。
## 问题
`packages/` 原先是扁平的18 个包package全部位于 `packages/<name>/`,从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)``scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。
这不仅仅是外观问题。由于每个顶层包看起来都属于同一个公开接口,未来移除更加困难,而 publish/lint/doc 脚本不得不通过注释或手工维护的静态列表来编码意图,而不是从布局中直接读取。
## 决策
按模块角色将包分组,统一放在 `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.host.json``tsconfig.client.json`)复用该源映射,并携带显式 project references 以保持包/vendor 类型检查边界完整。(这里引入了一个细节:路径候选中包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手动剥离注释。)
- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`
- 聚合配置的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest元数据清单生成这些引用留作后续工作见[通过发现机制获取包清单](../../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 列表和形状门禁意味着新增一个包无需额外的结构性编辑。

View File

@@ -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: a8ffe91c431cdc7907626bbc3eaf8096035777de
2026-06-21-mandatory-app-attribution-headers.zh.md: 5529a42dddf4615ee1054b4d1dee36b077800d7d

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-21-mandatory-app-attribution-headers.zh.md)
## Problem
LLM provider requests should identify the product making them. That is useful for provider-side support, abuse investigation, compatibility debugging, and traffic analytics. Before this Agent Note the harness only partially did this: the hand-rolled DeepSeek adapter sent a hand-copied `User-Agent` constant (`packages/llm/llm-deepseek/src/adapter.ts`), while the pi-ai-backed twin sent no harness-owned headers at all (`packages/llm/llm-pi-ai/src/adapter.ts`). New adapters could therefore omit attribution silently, and a library-backed adapter could drift from the hand-rolled adapter even though [the twin-adapter Agent Note](2026-06-13-twin-llm-adapters.md) exists to keep the provider seam honest across both implementations.

View File

@@ -0,0 +1,84 @@
# Agent Note: 对提供方请求强制携带 `User-Agent` 归属标识
Status: implemented
[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文
## 问题
LLM大语言模型提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。
直接触发因素来自 OpenRouter 的[应用归属](https://openrouter.ai/docs/app-attribution)文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。
## 调研
- **OpenRouter 的机制是提供方特有的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title``X-OpenRouter-Categories` 来追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。
- **在 agent 工具生态中,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACPAgent Client Protocol等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界中被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。
- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特有的附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。
- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份,说明它用于互操作性报告和分析,并说用户代理应当在每个请求中发送它(除非被配置为不发送)。这是唯一直接对应「哪个产品在发出此 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` 定义为负责用户代理的人的电子邮件地址。机器人代理应当发送它以便服务器联系运营者但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空捏造或全局强制要求。
- **请求体中的 `user``metadata` 字段不是应用归属。** 部分模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特有的 body schema要么不保证能通过 OpenAI 兼容网关透传。它们不能替代静态的应用身份头部。
- **SDK 遥测头部标识的是 SDK而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试其客户端,但除非应用显式提供产品归属层,否则它们不能标识 harness 作为应用。
- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai``StreamOptions.headers` 将调用方头部最后合并覆盖提供方默认值因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。
## 决策
在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。
本 Agent Note **不**实现 OpenRouter 应用归属。`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter也只发送本 Agent Note 定义的共享 `User-Agent` 归属。
提供方无关的身份由 `dsh-llm``packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值:
- `User-Agent` 的产品 token`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性)
- 版本:通过 `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`。本 Agent Note 下不发送 `HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`。 |
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 |
端点检测不在本 Agent Note 范围内,因为此处不接受任何端点特有的映射。如果后续支持 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 服务器套件断言精确值。
- 本 Agent Note 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`)。
- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。
- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。
## 曾考虑的替代方案
**现在就实现 OpenRouter 应用归属。** 本 Agent Note 否决。发送 `HTTP-Referer``X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 Agent Note 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。
**向所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义的 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。
**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。
**终端用户 `user`/`metadata` 字段。** 本 Agent Note 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。
**仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。
**以产品命名的 token`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` token因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 因连续性胜出:它是提供方从本代码库已经看到的身份,与组织/仓库身份和包 scope 一致,且在展示文案承载产品名的同时保持线路归属稳定。
## 后果
**提供方看到流量来自 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` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。

View File

@@ -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-24-web-capability-seam.md: 4f4e821fec9494fe9ea96894267707d9dd202e4d
2026-06-24-web-capability-seam.zh.md: d7c07a8ae0365c321120102a0af401d85d7e2eae

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-24-web-capability-seam.zh.md)
## Problem
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
@@ -75,6 +77,8 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
```ts
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
interface WebSearchProvider {
readonly id: string
available(): boolean
@@ -206,18 +210,18 @@ The seam request deliberately does not include a per-call timeout, `format`, `pr
HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
```ts
interface WebFetchRequest {
export interface WebFetchRequest {
readonly url: string
}
interface WebFetchResult {
export interface WebFetchResult {
readonly url: string
readonly statusCode: number
readonly body: WebFetchBody
readonly truncated: boolean
}
type WebFetchBody =
export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string }
```

View File

@@ -0,0 +1,336 @@
# Agent Note: Web 能力 seam——稳定的工具覆盖多个提供方
Status: implemented
[English](2026-06-24-web-capability-seam.md) | 中文
## 问题
harness 需要面向模型的 web 工具,但不能将模型契约绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 seam 并非只是镜像某一家厂商。Fetch 是另一项独立能力:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。
面向模型的接口必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。
如果把搜索和 fetch 直接放进 `dsh-tool-web`,面向模型的工具就要同时承担提供方选择、后端请求映射、传输策略、结果归一化、提示词引导、展示和 schema 注册。让每个提供方注册自己的工具则有相反的问题:工具的可用性、名称、描述和参数将取决于恰好加载了哪些提供方包,提供方特有字段会泄漏到模型契约中。
还有一个提供方选择的问题。现有的 `tool-bash``tool-fs` 可以依赖 Cordis 的 `inject`因为只有一个后端服务键。Web 有两项独立能力(`search``fetch`),每项能力可能有多个提供方。`inject: ['web']` 能证明 seam 存在,但不能证明存在可用的搜索或 fetch 提供方,也无法定义多个提供方注册时谁胜出。
## 决策
Web 访问是一个一等能力 seam遵循[能力 seam Agent Note](2026-06-13-capability-seams.md)
1. `@deepseek-ai/dsh-web``packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa``@deepseek-ai/dsh-web-search-perplexity``@deepseek-ai/dsh-web-search-deepseek``@deepseek-ai/dsh-web-fetch-local`
3. `@deepseek-ai/dsh-tool-web``packages/web/tool-web`)拥有面向模型的 `web_search``web_fetch` 工具 schema、提示词段落、参数校验、结果格式化以及通过 `ctx.web` 实现的工具展示。
提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。
搜索和 fetch 是两个独立工具,但属于同一个 web 访问 seam。`ctx.web` 为两个并行注册表统一拥有提供方选择、abort/错误词汇和部署配置。它们的请求 schema 和提供方逻辑保持独立;共享的服务是触达 web 的产品边界。
`dsh-tool-web` 在产品启用了相应工具且 `ctx.web` seam 存在时注册面向模型的 web 工具。后端可用性是执行时关注点,而非 schema 注册时关注点:
- `web_search` 在产品/应用启用了 web 搜索时注册,`web_fetch` 在启用了 web fetch 时注册。
- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。
- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR热模块替换时序纳入面向模型的契约。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE``WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
## 包拓扑
由三个包构成的接口/实现/消费方拆分沿用 bash 和 filesystem 的模式,但*接口*包更接近 LLM大语言模型 seam。`LlmService``packages/llm/llm/src/index.ts`)是一个按名称键控的提供方注册表:`registerAdapter(models, adapter)` 将适配器存入 `Map`、返回 disposer、对重复键抛出 `DUPLICATE_ADAPTER`、在解析时抛出 `NO_ADAPTER``ctx.web` 沿用该注册表形状,但有两种能力类别和更丰富的选择策略(配置的提供方 id或在恰好只有一个可用提供方注册时自动选择因此执行时抛出的 `WebError` 能解释搜索或 fetch 能力为何无法运行。
依赖方向与 bash 和 filesystem 一致:
```text
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
consumer interface implementation
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
implementation
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
implementation
<--depends on-- @deepseek-ai/dsh-web-fetch-local
implementation
```
运行时,提供方包向 `ctx.web` 注册能力;`tool-web``ctx.tools` 注册稳定的工具并通过 seam 执行:
```mermaid
flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
```
`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性契约和错误码。它不导入工具、agent、会话、LLM 或提供方包。
提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。
`@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web``@deepseek-ai/dsh-tools``@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。
## `ctx.web` 契约
`ctx.web` 是一个提供方注册表加上一个带提供方选择的执行面。注册表部分与 `LlmService` 保持接近:每种能力类别一个 `Map<id, provider>``registerSearchProvider`/`registerFetchProvider` 方法返回 disposer重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`seam 的形状:
```ts
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
interface WebSearchProvider {
readonly id: string
available(): boolean
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
interface WebFetchProvider {
readonly id: string
available(): boolean
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
interface WebService {
registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
```
可选的 signal 是执行控制,而非业务输入:`tool-web` 直接传递 `exec.signal`,使轮次取消、工具超时和 agent dispose资源释放能到达提供方的网络请求、流读取器和高开销解码。seam 不传递 `ToolExecution`——否则 `dsh-web` 就要依赖 `dsh-tools`
提供方 id 是稳定字符串,在各自的能力类别内唯一。注册重复的搜索提供方 id 或重复的 fetch 提供方 id 会失败,而非静默替换旧提供方。提供方注册返回 disposer沿用现有的 `ctx.tools.register()`/`ctx.systemPrompt.section()` 模式:变更包裹在 `ctx.effect()` 中,注册随贡献它的 fiber 一起拆除。
## 提供方可用性与选择
提供方可用性与能力选择是两个独立概念,但都保持最小化。提供方仅报告该具体实现是否可用,通过廉价的本地检查(如凭证是否存在、端点配置是否可解析)。提供方的 `available()` 禁止发起网络调用。
`LlmService` 完全没有状态类型:可用性通过注册表成员资格加解析时抛出来表达。`ctx.web` 遵循同样的纪律。seam 不暴露聚合的能力状态查询——`search()`/`fetch()` 在每次调用时根据配置的提供方 id、已注册的提供方和每个提供方廉价的本地 `available()` 布尔值派生选择结果,选择失败就是执行时抛出的结构化 `WebError`。需要知道某项能力能否运行的调用方通过执行并路由该错误来获知;没有任何东西作为可变服务状态存储。
该布尔值是选择的输入,而非健康系统。`tool-web` 从不直接调用提供方的 `available()`——它进入 seam 的唯一路径是 `search()`/`fetch()`——因此选择策略只有一个所有者。
选择不得依赖注册顺序。Cordis 加载顺序、配置排列和 HMR 时序不是产品语义。
| 情况 | 执行行为 |
|---|---|
| 配置的提供方 id 已注册且 `available() === true` | 运行该提供方 |
| 配置的提供方 id 未注册 | 以 `WEB_PROVIDER_CONFIGURED_MISSING` 失败 |
| 配置的提供方 id 已注册但不可用 | 以 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 失败 |
| 未配置提供方 id且该类别恰好有一个已注册且可用的提供方 | 运行该唯一提供方 |
| 未配置提供方 id且该类别无已注册提供方 | 以 `WEB_PROVIDER_UNAVAILABLE` 失败 |
| 未配置提供方 id且该类别有多个可用提供方已注册 | 以 `WEB_PROVIDER_AMBIGUOUS` 失败,而非按注册顺序选择 |
| 未配置提供方 id且有提供方存在但均不可用 | 以 `WEB_PROVIDER_UNAVAILABLE` 失败 |
「唯一提供方自动选择」规则面向测试、演示和简单部署。产品配置设置显式提供方 id
```yaml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
fetchProvider: local-http
- id: web-search-exa
name: '@deepseek-ai/dsh-web-search-exa'
- id: web-search-perplexity
name: '@deepseek-ai/dsh-web-search-perplexity'
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
- id: web-fetch-local
name: '@deepseek-ai/dsh-web-fetch-local'
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
```
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。
`ctx.web.search()``ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE``WEB_PROVIDER_CONFIGURED_MISSING``WEB_PROVIDER_CONFIGURED_UNAVAILABLE``WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。
## 搜索请求与结果 schema
面向模型的 `web_search` 工具很小。唯一的面向模型参数是:
- `query`:必填字符串。
`max_results` 不暴露给模型。它是 `dsh-tool-web` 层的决策:工具设定结果上限——`searchMaxResults` 插件配置,默认 `8`(与 OpenCode 的 Exa 默认值对齐),类似 `dsh-tool-fs``readLimit`——并作为 `WebSearchRequest` 上的 `maxResults` 传给 seam。将其排除在模型 schema 之外意味着模型只需提问,产品控制返回多少上下文;该字段日后可以提升为面向模型的参数而不破坏 seam。
`maxResults` 沿工具 → seam → 提供方流动,上限在返回路径上强制执行:
- `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。
- `ctx.web` 将请求原样传递给选定的提供方。
- 当提供方的 API 支持结果数量控制时Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。
- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制Perplexity或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。
seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。
```ts
interface WebSearchRequest {
readonly query: string
/** Upper bound on returned sources; the seam truncates to it. Omitted = no bound. `dsh-tool-web` always sets it. */
readonly maxResults?: number
}
interface WebSearchResult {
readonly content?: string
readonly sources: readonly WebSearchSource[]
readonly truncated: boolean
}
interface WebSearchSource {
readonly url: string
readonly title?: string
readonly snippet?: string
readonly publishedAt?: string
}
```
`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用面。source 必有 URLtitle、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填Perplexity 风格的引用可能只提供 URL强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它Perplexity 在搜索结果上返回 `date`因此它是真实的提供方数据而非派生值seam 以字符串形式传递,日期解析留给消费方。
Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource``url``url``title``title``snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet被丢弃`publishedAt``publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url``url``title``title``snippet``snippet`(常为空)、`publishedAt``date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。
完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。
## Fetch 请求与结果 schema
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `local-http`。它从具体 URL 获取字节,应用下述基本传输卫生措施(仅 http/https、拒绝 URL 中的凭证、字节/时间上限、跨源重定向阻断),解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。(完整的 SSRF/私有网络阻断推迟——见[推迟工作](#deferred-work)。)
seam 请求比 OpenCode 的面向模型工具更小:
- `url`:必填 HTTP(S) URL。
seam 请求刻意不包含逐调用超时、`format``prompt` 或提供方特有的提取控制。取消通过直接的可选执行信号实现fetch 提供方拥有一个部署配置的超时兜底。`format` 是对已获取资源的展示决策;`prompt` 是更高层的 LLM 摘要指令Firecrawl、Exa、Tavily 或 Parallel 等提取 API 可能不暴露具体的 HTTP 响应。如果产品日后需要提供方支撑的页面提取,那是一个独立的 `web_extract` 能力或对本 seam 的刻意扩展——提取语义绝不通过将每个 HTTP 字段设为可选来偷渡进 `web_fetch`
HTTP 状态码是已获取资源状态的一部分,不自动构成工具失败。成功的网络获取一个 `404``500` 响应会返回带有状态码和有界解码正文(当内容类型受支持时)的 `WebFetchResult``WebError` 用于无法安全获取或表示资源的失败:无效或被阻断的 URL、重定向策略违规、超时、abort、响应过大、不支持的内容类型、提供方失败或网络失败。
```ts
export interface WebFetchRequest {
readonly url: string
}
export interface WebFetchResult {
readonly url: string
readonly statusCode: number
readonly body: WebFetchBody
readonly truncated: boolean
}
export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string }
```
`WebFetchResult.url` 是允许的重定向之后的最终 URL。请求 URL 已在 `WebFetchRequest` 中,因此没有单独的 `requestedUrl`/`finalUrl` 对。
`WebFetchBody` 是封闭的可辨识联合类型,因为正文类别需要 seam、提供方和工具三方协调变更而非独立的插件扩展。穷举 switch 使新类别在每个渲染器处编译失败,直到被处理。独立的对象分支为类别特有字段留出空间。
提供方负责安全的资源获取URL 校验、HTTP 传输、重定向策略、超时、abort 传播、字节上限、字符集解码、内容类型分类和二进制拒绝。`dsh-tool-web` 负责展示HTML 转 Markdown、HTML 转纯文本、面向模型的截断格式化,以及未来的摘要。
fetch 提供方的资源控制:
- 仅接受 `http:``https:` URL拒绝 URL 中的凭证。
- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。
- Abort 信号传播到网络获取和高开销解码。
- 仅自动跟随同源重定向;跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用,从而触发新的提供方/权限决策。Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 请求携带显式的产品 User-Agent而非静默伪装浏览器。
SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他非公开目的地,通过先 DNS 解析再验证 IP 来防御 rebinding并在重定向的每一跳重新验证**推迟**——见[推迟工作](#deferred-work)。在其落地之前,`web_fetch` 是一个 SSRF 原语,不得在能触达敏感内部网络目标的部署中启用。
## 工具消费方行为
`dsh-tool-web` 拥有两个 `ToolDefinition``web_search``web_fetch`。它拥有面向模型的 JSON Schema、snake_case 参数名、提示词段落、结果渲染为 `ContentBlock[]``presentCall``presentResult`
`dsh-tool-web` 禁止枚举提供方或直接调用提供方的 `available()`。它进入 seam 的唯一路径是 `ctx.web.search()`/`ctx.web.fetch()`。这将提供方选择保持在单一层;否则工具包可能判定某个提供方可用,而执行时解析出不同的状态。
工具注册是最小化的稳定同步:插件启动时,`dsh-tool-web``Config``search?: boolean``fetch?: boolean`,均默认 `true`)启用或禁用每个 web 工具;已启用的工具通过基于 effect 的注册表以 fiber 作用域的 disposer 注册;任何工具都不会仅因其选定的提供方缺失、不可用或存在歧义而被 disposedispose `tool-web` fiber 时自动拆除其注册。
提供方可用性变化影响执行结果和诊断信息,而非面向模型的 schema 是否存在。如果产品完全不需要 web 工具,在配置中禁用 `dsh-tool-web` 或单个 web 工具即可;如果需要 web 工具但后端配置有误,模型在执行时看到结构化的工具错误。
提示词引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——提示词和工具结果告诉模型用 Markdown 链接引用相关 URL。
面向模型的输出以文本为先,因为工具结果是 `ContentBlock[]`,但 seam 的产出保持结构化,以便 UI 展示和未来的适配器无需解析渲染后的文本。
## 错误
`dsh-web` 定义 `WebError extends HarnessError`,带有稳定的错误码,仅覆盖调用方可能合理分支的状态:
- `WEB_PROVIDER_UNAVAILABLE`
- `WEB_PROVIDER_CONFIGURED_MISSING`
- `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`
- `WEB_PROVIDER_AMBIGUOUS`
- `WEB_DUPLICATE_PROVIDER`
- `WEB_INVALID_URL`
- `WEB_BLOCKED_URL`
- `WEB_REDIRECT_BLOCKED`
- `WEB_FETCH_TOO_LARGE`
- `WEB_FETCH_TIMEOUT`
- `WEB_ABORTED`
- `WEB_UNSUPPORTED_CONTENT_TYPE`
- `WEB_PROVIDER_ERROR`
`WEB_DUPLICATE_PROVIDER``registerSearchProvider`/`registerFetchProvider` 发现该能力类别中已有相同 id 时同步抛出(类似 `LlmService``DUPLICATE_ADAPTER`);它是注册时的编程错误而非执行结果,但共享 `WebError` 码空间,使调用方看到统一的分类体系。`WEB_PROVIDER_ERROR` 是提供方自身失败通过 seam 浮出的兜底码,包括 `web-fetch-local` 中的网络/传输失败DNS、连接拒绝、TLS刻意不设单独的 `WEB_NETWORK` 码——提供方设置描述性消息,使模型和日志能区分网络失败与提供方 API 失败。
工具执行让这些错误流经 `ToolRegistry.execute()`,后者已将 `HarnessError` 转换为带结构化元数据的错误工具结果。模型得到可读的错误消息;钩子、测试和 UI 代码可以根据稳定的错误码路由。
## 测试
每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture测试前置数据的请求/响应映射Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`
## 曾考虑的替代方案
### 让每个提供方注册自己的面向模型工具
这与最灵活的提供方插件系统一致:每个提供方可以暴露其完整的原生 schema。在 harness 中被否决,因为它将面向模型的名称、描述、提示词引导和结果格式化的所有权交给了提供方包。多个搜索提供方会产生重复的工具名或提供方特有的工具名,模型将学到后端细节而非稳定的产品能力。
### 将提供方调度直接放在 `dsh-tool-web` 中
这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。
### 将搜索和 fetch 拆为两个 seam`dsh-search`、`dsh-fetch`
很有吸引力,因为两半不共享请求 schema 和业务逻辑,各自能干净地映射到 bash/fs 的三包模板上,且 `WebService` 上的 `Search`/`Fetch` 方法对重复也会消失。否决,因为共享的机制——提供方 id 注册表、不依赖注册顺序的选择策略、abort 传播、`WebError` 分类体系,以及面向产品的「这个 harness 如何触达 web」配置面——是真实存在的否则会在两个几乎相同的 seam 之间重复。一个 `ctx.web` 中间层给产品一个统一的注入和配置对象,给提供方选择一个唯一的所有者。代价是并行的 `searchX`/`fetchX` 方法对,这是有意接受的。
### 选择第一个注册的提供方
否决。注册顺序不是产品策略。它可能随配置顺序、插件加载、HMR 或重构而变化。提供方选择必须是显式的,或仅在恰好只有一个可用提供方时自动选择。
### 将 Firecrawl/Exa/Tavily/Parallel 提取视为 fetch
在第一版中否决。这些提供方通常返回提取或摘要后的内容,而非具体的 HTTP 响应。如果产品需要提取,日后设计 `web_extract` 或刻意扩展 fetch seam。
### 镜像 Claude Code 的 `url + prompt` WebFetch 形状
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`
## 后果
**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title``snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。
**稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当醒目地浮出结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,使用户不会在模型调用工具后才发现配置问题。
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 能触达敏感网络目标或通过 URL 外泄数据。仅交付基本传输卫生措施(仅 http/https、拒绝凭证、字节/时间上限、跨源重定向阻断SSRF/私有网络阻断推迟(见[推迟工作](#deferred-work)),因此在其落地之前,`web_fetch` 不得在能触达内部目标的环境中启用。
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated``tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
## 推迟工作
- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP防御 DNS rebinding/TOCTOU、跨重定向的每跳重新验证以及 IPv6 边缘处理私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断OpenCode 做前缀检查后直接 fetchClaude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前`web_fetch` 只能在无法触达敏感内部目标的部署中启用。
- `pdf` `WebFetchBody` 类别:`local-http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`
- 推迟的权限系统落地后的权限策略集成。
- `query``maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
## 开放问题
- 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING``WEB_PROVIDER_CONFIGURED_UNAVAILABLE``WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出?
- 推迟的权限系统落地后,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有?

View File

@@ -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: 4700222aa2e0f91d9f355495c228e2eb92825f55
2026-06-26-file-context-as-event-gate.zh.md: 21c8706bcc14790a5092fa59e03bce329049760a

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-26-file-context-as-event-gate.zh.md)
## Problem
[The split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`.

View File

@@ -0,0 +1,171 @@
# Agent Note: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口
Status: implemented
[English](2026-06-26-file-context-as-event-gate.md) | 中文
## 问题
[拆分文件系统 seam Agent Noteagent 决策记录)](../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 基准提供,让提供方的 mutation 临界区检测陈旧性:
- 「你读过这个文件吗?」是 `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`)并对先前观测进行门控。
## 提供方契约变更:版本守卫变为可选
为使裸提供方不受约束,其两个 mutation 上的版本守卫变为**可选**——传入则守卫,省略则无条件执行:
```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` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容只有之前不可能出现的「无守卫」情况是新增的且它是裸提供方的默认行为。无论哪种情况mutation 仍在后端的 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`,由循环分发且无 `this`),而非服务绑定的 waterfall如 `llm/stream`)。分发者是 `dsh-tool-fs` 插件,它不是一个服务。
## 工具契约(`dsh-tool-fs`
工具保留其面向模型的 schema`read`/`write`/`edit`,逐字节不变)和提示词段落。提示词引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称「后端」要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变提示词立场。
`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`)仍作为根插件组合的内部模块保留。)
通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat
- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(为安全起见拒绝写入:模型会重新读取;由于 `editText` 会在其锁内复查,模型绝不会基于错误版本写入)。
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`**工具内零 stat**。
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。
工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,以便 `dsh-fs-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器会在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件契约。
## 策略插件契约(`dsh-fs-policy`
`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
- `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。
- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。
- `fs/observed` 监听器:`record(owner, key, version)`。
一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着「此 owner 在此版本观测过此目标」,而非狭义的「已读取过」。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`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`,与有守卫编辑路径的「此刻无法编辑该目标」错误码一致。
两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、先读后编辑和版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身无需任何变更。
## 取代关系
本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**强制性方法服务变为插件拥有的事件门控fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
## 验证
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`write 或 edit 均为零次。面向模型的 schema 逐字节不变,因此快照不变。
## 曾考虑的替代方案
- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。
- **策略侧版本检查**`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。
## 后果
- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。
- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它「只是存储」。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor不携带面向模型的概念因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/会话所有者结构。
- **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 Agent Note可组合的值传递 seam而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。
- **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔为安全起见拒绝写入(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。
- **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。

View File

@@ -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: 284cd45a66294dbc9e8207a1e00e9642d32d4e58
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md)
## Problem
The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This Agent Note adds those two inputs.

View File

@@ -0,0 +1,33 @@
# Agent Note: 在 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)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 Agent Note 添加这两个输入。
`stdin``env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。
## 决策
`BashExecRequest`(模型/插件侧请求)和 `BashExecSpec``run`/`start` 所作用的已解析 spec上**同时**添加 `stdin?: string``env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿它们:`resolve()` 原样传递,`run()`/`start()` 将其传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。
三个有意为之的选择:
1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)``ENV_OVERRIDES` → 普通 `env``dshEnv`
3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK普通 optional而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」这是安全的常规情况所有模型驱动的调用都如此。因此它们保持普通 optional`signal` 一致。
`dsh-bash-local` 仅在有字节需要写入时才创建 stdin 管道;否则 fd 0 仍为 `/dev/null`,保持先前行为。它写入字节后关闭管道。子进程未读取即退出时产生的 `EPIPE` 被忽略,因为命令退出码和输出决定结果。
## 曾考虑的替代方案
**可配置的环境秘密擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的环境保护。
## 后果
钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量保留其进程组终止、截断和溢出行为。模型接口面不变bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/core-data-structures/bash.md)。

View File

@@ -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: 7310840088d4ff77ddf83c5c16753b4d46256692
2026-06-30-event-domain-semantics.zh.md: 61d99878ab5e04b74d55d02789c434e0919bd785

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-event-domain-semantics.zh.md)
## Problem
The harness extends the agent loop through a Cordis event taxonomy (see [the microkernel event-taxonomy Agent Note](2026-06-11-microkernel-event-taxonomy.md)). As that taxonomy grew, the line between the three event domains blurred:

View File

@@ -0,0 +1,39 @@
# Agent Note: 事件域语义——会话是事实日志agent 是运行时表面
Status: implemented
[English](2026-06-30-event-domain-semantics.md) | 中文
## 问题
harness 通过 Cordis 事件分类体系扩展 agent loop智能体循环见[微内核事件分类体系 Agent Note](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`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的钩子子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 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中途引导`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` 结算;唯一的轮次镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL`session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。步骤镜像先被移除(它们完全没有消费方);轮次镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 Agent Note](../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 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。
- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。
- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`。内部分发校验在日志推入之前运行可能在不打开步骤的情况下拒绝post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。
- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`
- Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -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: aeb17e0a235720bee277f6533468dbdb6cae82dc
2026-07-02-fs-per-session-cwd.zh.md: b9ad4b868c472c8feb9e6f65e42b1658ffdcd2e0

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-02-fs-per-session-cwd.zh.md)
## Problem
The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd Agent Note work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.

View File

@@ -0,0 +1,39 @@
# Agent Note: 相对文件系统路径按调用方的会话 cwd 解析
Status: implemented
[English](2026-07-02-fs-per-session-cwd.md) | 中文
## 问题
ACPAgent Client Protocol桥接层为每个会话提供独立的工作区`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd``dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent智能体`session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的每会话 cwd Agent Note 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程N 个工作区。
文件系统解析使用的是插件加载时的 cwd而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
一个有效的绝对 cwd 本身可能看起来有两个父目录:当它包含 `symlink/..` 时,文件系统查找会先跟随符号链接再应用 `..`,而 `path.resolve()` 会从词法上抹掉这两个组件。如果用词法解析沙箱策略却从原始 cwd 启动 bash就会把权限授予无关的词法父目录、拒绝真实工作区内的写入并让文件系统工具把相对路径解析进错误目录。
普通的符号链接 cwd 在请求的相对路径包含 `..` 时也暴露同一区别:进程从符号链接的物理目标开始遍历,`path.resolve(cwd, path)` 却从其词法拼写开始遍历。因此对于同一个模型提供的路径read 会选择与 bash 或沙箱化 mutation 不同的文件。
## 决策
将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash``workdir` 的处理方式完全一致。当 cwd 或请求路径任一包含父目录段时,在任何词法 join 之前把 cwd 解析为原生文件系统标识;没有遍历会使标识可观察时,则保留普通 cwd 拼写以供展示。mutation 和沙箱化 bash 调用复用解析后的沙箱策略根目录,使一次调用只有一个工作区标识。**调用方**(即工具)提供 cwd提供方不读取会话或 agent。
- `FileSystem.resolve` 接受 `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>``opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。后端执行 I/O 时,`opts.signal` 可以取消解析。options 对象把调用方拥有的两个解析控制项放在一起,避免位置参数继续增长。
- `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, requestedPath)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd并传给 `resolve`。只要任一值中的父目录段可能跨越符号链接,该辅助函数就使用原生 realpath 语义,否则保留普通拼写;沙箱化 mutation 复用完整策略的 `workspaceRoot`;非 agent无 header 的调用方得到 `undefined`,后端因此应用其默认值。
## 曾考虑的替代方案
### 为何由调用方(而非提供方)提供 cwd
提供方 seam 不得依赖 `dsh-agent``dsh-session`——它是一个文本存储后端沙箱或远程实现同样满足该接口而这些实现没有「agent 会话」的概念。工具已经接收了 `ToolExecution``exec`),其中携带 agent因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包package边界处显式优于隐式」的约定基准目录作为显式参数传入提供方据此行动而非让提供方越界去读取它不应知晓的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。
默认值只存在于一个地方——提供方的 `config.cwd``sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本应自行选择的基准目录。
## 后果
- 在 ACP 演示中fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。
- 对于包含 `symlink/..` 的会话 cwd或普通符号链接 cwd 搭配含父目录遍历的相对路径bash、文件系统工具和沙箱授权都会从同一个物理工作区解析词法父目录不会获得授权。
- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath因此 observed-state 键控与符号链接标识不受影响——正确的每会话 cwd 产生与 bash 目标相同的 key。
- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。
- 单会话 stdio 演示不受影响:它不提供会话 cwd其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。

View File

@@ -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: fe163484ad56858fabd55dacace1d56814bd93e4
2026-07-02-result-time-applied-hunk-diffs.zh.md: e8283a40d96c031a84e2627117801b3043eb8904

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md)
## Problem
The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gave `dsh-tool-fs` write/edit a `card:'diff'` at CALL time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). An editor renders that as an inline diff, but it is a **context-free** diff — the bare `old_string``new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair.

View File

@@ -0,0 +1,57 @@
# Agent Note: 结果时刻的 applied-hunk diff 用于文件变更
Status: implemented
[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文
## 问题
[带标签的 render-intent 联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`纯粹从工具参数推导write ⇒ `{oldText:null, newText:content}`整个新文件edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff但这是一个**无上下文**的 diff裸的 `old_string``new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。
在对接 `claude-agent-acp` 自身的 ACPAgent Client Protocol bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**`replace_all` 的每个变更位置各一个 hunk由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更而非浮动片段的关键。我们的工具止步于调用时刻的片段完成后的结果只携带纯文本「updated successfully」没有 diff。
障碍在于一个 seam 边界:`presentResult(args, result)`**`args` + 面向模型的 `result``{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容`FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。
## 决策
添加一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来携带 applied-hunk diff。
### 1. 规范工具输出上的可回放展示投影core
原始实现允许 `execute` 返回 `{ content, meta }`。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)取代了这种编写形态:每个工具如今返回一个由 schema 声明的 JSON 值,`output.render(args, value)` 从中派生面向模型的内容块,可选的 `output.presentationMeta(args, value)` 则派生可回放的 UI 数据。
`presentationMeta` 是工具自有的 `JsonValue`core 会持久化它,但不解释其中的字段。`Session.append` 将它与事件的其余部分一并校验,回放再把存储的载荷传回 `presentResult`;因此展示无需 I/O 或重新计算即可复现。规范值本身只存在于执行期间,不会加入会话格式。
这仍是通用形态(「工具投影持久化的结果展示」),而非 fs 特有;任何工具都可以使用。
### 2. 工具计算 hunk后端返回 before/afterfs
按照 [capability-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**
- `dsh-fs``FsEditOutcome` 扩展为包含 `{ before: string; after: string }`,将 `FsWriteOutcome` 扩展为包含 `{ before: string | null; after: string }``before: null` 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。
- `dsh-tool-fs` 返回规范的变更前/后事实,并将上下文 hunk 投影为 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:创建或无变化的覆写回退到由参数推导的整文件 diff而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。
### 3. Bridge 渲染 `diff` 结果卡片
`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}``ToolCallContent`与调用侧分支对称。ACP 的 `tool_call_update.content` 在编辑器中替换调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,再结果 diff`claude-agent-acp` 完全一致。
## 曾考虑的替代方案
**手写或 vendor diff 算法。** 上下文 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。仓库的 vendor 策略适用于框架源码,而非每个叶子工具库。
## 后果
`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)` 跟踪。
## 相关
- 补全了[带标签的 render-intent 联合类型](2026-07-02-tool-render-intent-union.md)中作为非目标列出的最后一项表示差异——该 Agent Note 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。
- 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。
- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。

View File

@@ -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: c7adf8f405000ec7940f82e1e1e2406d86253461
2026-07-02-tool-render-intent-union.zh.md: 01dff0daaacc45f5c85eb2c4380dc78800ce9a81

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-02-tool-render-intent-union.zh.md)
## Problem
A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy:

View File

@@ -0,0 +1,82 @@
# Agent Note: 用于工具调用展示的带标签 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` 用临时条件逻辑拼接在一起。
- 哪些组合是*合法的*没有文档说明:一个设置了 `content``terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**`{path, oldText, newText}`Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM大语言模型*`ContentBlock[]` 词汇text/image工具根本无法请求 diff 展示。
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash``dsh-tool-fs`和两个消费方ACP bridge 实时路径 + 快照回放路径)。
## 决策
用一个**以 `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` → `generic`。通用 `task_*` 控制工具拥有各自的 generic 卡片。
- `dsh-tool-todo` → `generic`。
### 终端回退的归属
`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。
### 纯函数性保持不变
`presentCall`/`presentResult` 仍然是 `args``presentResult` 还有 result的纯函数——它们在实时流式输出和会话日志回放中都会运行因此必须具备回放确定性。每个 view 仅从 args 推导write 的 diff 是新文件风格(`oldText:null`因为工具在调用时没有旧内容edit 的 diff 是 `old_string``new_string`
## 相对路径显示标题
`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` 流式输出**与**命令分类**:终端渲染 Agent Note 自身推迟的后续工作,本 Agent Note 不涉及。
## 相关
- 取代[折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。
- 被[结果时已应用 hunk 差异](2026-07-02-result-time-applied-hunk-diffs.md)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff叠加在本联合类型的调用时 diff 卡片之上。
-`ToolTerminal` 折入 [ACP terminal 与工具调用渲染](../feature/2026-06-18-acp-terminal-and-tool-rendering.md)所描述的 `terminal` view`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。
- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。

View File

@@ -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: c7db576ff3c7a56622f90a4400bd9297c9591bef
2026-07-03-filesystem-directory-listing-seam.zh.md: 75ee6851127ca6d8c3fc60a66115d521d4627cdc

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-03-filesystem-directory-listing-seam.zh.md)
## Problem
`@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`.

View File

@@ -0,0 +1,53 @@
# Agent Note: 为文件系统 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 尚未发布时有意为之的基础工作,但也意味着未来的沙箱/远程后端需要定义等价的直接子项列举行为。
该能力仍停留在提供方层面。在消费方落地之前ACPAgent Client Protocol/模型会话仍需使用 `bash` 等既有工具来列举目录。缺少面向模型的 `listdir` 工具是预期行为,而非接线错误。

View File

@@ -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: 94f5fa409e7b539b48750d12576c7a342a30c9ba
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: f1379e143a94a3ae3a07b3120c6f0b9fc8561fe9

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)
## Problem
The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted.

View File

@@ -0,0 +1,72 @@
# Agent Note: 提示词变量与工具指导归属
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 输入。
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本ACP 那份已经被删减)——而 `dsh-tool-fs``dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。
**Persona 渲染在工具指导之后。** agent loop智能体循环`agent.options.systemPrompt` 字符串拼接在已组装的 section 之后于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定Claude Code、Codex相反且是 section 流水线之外的第二条组合路径。
**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义编写的描述——「a separate agent that works in its own context … it does not see this conversation」——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次拿到了同样的措辞YAML 行文在带外纠正了这个谎言。小问题:`PromptSection.name` 文档标注为「(diagnostics / dedup)」,但重复项被静默接受。
## 决策
**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包package的提示词 section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。
### 组装上下文
`SystemPrompt.assemble(context)` 接受一个可合并扩展的 `AssembleContext``dsh-system-prompt` 声明可选的 `scope` 选择器用于 scoped 路由,而 `dsh-agent` 通过声明合并将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖循环)。循环在每个步骤调用 `assembleContextFor(agent)`,使两个字段标识同一个 agentsection 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall瀑布式事件也接收它监听器可据此按 agent 过滤或扩展。
### 提示词变量
插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、已注册的提供方返回 `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)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 区间为identity `-100`、persona `0`、工具指导 `100199`
### 工具指导归属
每个工具的语义和选择指导放在工具 description 中。提示词 section 只承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section因为它们的 description 包含完整契约。部署 persona 只包含角色和行为。
### Subagent 对话历史描述符
`SubagentProvider.inheritsParentContext` 描述的是对话种子而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`fork 设为 `true``dsh-tool-subagent` 根据该标志派生工具和提示词参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。
## 曾考虑的替代方案
- **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section`system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
- **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。
- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。
- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript文本记录审查时才会被发现。
- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据提供方名称选择措辞**`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。
- **在 `apply` 时解析提供方(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。
## 不在范围内
- 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 Agent Note 不认领任何一个。
- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到会话 cwd 方案重新讨论时。
## 交付的不变式
- tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona然后是 fs/bash/web 指导。
- fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。
- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。
## 后果
- 组装后的提示词中每个事实现在恰好有一个归属方leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。
- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型提示词对该步骤的声明就会过时如果一个插件在那里提供模型options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。
- 当一个已绑定的提供方不存在时尚未激活、已卸载、HMR热模块替换重载中subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。
- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望大声暴露的撰写错误。
- 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加。

View File

@@ -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: 153d37a2faf2265134d5ff9e88f0bbfa275328e0
2026-07-05-reconstructable-requests.zh.md: caf51c3065e416fd11aebc1c1d4dc2ee248e2e5c

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-05-reconstructable-requests.zh.md)
## Problem
The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded.

View File

@@ -0,0 +1,56 @@
# Agent Note: 每个 LLM大语言模型请求都可从会话日志重建
Status: implemented
[English](2026-07-05-reconstructable-requests.md) | 中文
## 问题
请求流水线未能保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。
快乐路径的参考形态是 MiniCode 的 `LLMClient`一个有状态的对话客户端随对话推进只做追加而不重建仅在系统提示词、工具集或压缩compaction真正改变了模型需要看到的内容时才重置。本 Agent Note 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。
## 决策
### 原则
**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`提供方协议格式wire format字节由此推导而来因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数直接的一次性调用压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,因为只有循环会标记请求归属,所以它们不在不变式内。
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3
### 机制
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change``foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
每个步骤重建提示词组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。
**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step(agent, turn, step, signal)` 仍是当前请求所需内容的通用 seam。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
**强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm``markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或会话 id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token逐步骤用量是生产信号header 变更或压缩表现为下一步骤的 cache-read 下降。
### MiniCode 形态:采纳,但溯源箭头反转
与 MiniCode 相同,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同,事件日志仍是真源,因为它同时拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志推导的消息和 header 折叠结果,使每个请求都可独立检查。
## 曾考虑的替代方案
- **客户端作为真源**(照搬 MiniCode在日志之外多出一个运行时真相——两者漂移而无人察觉见上节。
- **镜像日志的有状态传输客户端**:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑面,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。
- **逐次调用的请求标量**(一个可自由变异的配置传给每次 `agent/request` 分发):监听器可以零记账地逐次切换 model悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态waterfall瀑布式事件提议日志记录。
- **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。
- **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。
- **自定义 header-delta 编解码器**(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。
- **Header 快照上的叙事性变更字段列表**:可以通过比较连续快照推导。`reason` 仍保留,因为实例边界无法从快照值推导。
## 后果
- 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。
- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()` 以及工具/prompt-submit 的 `additionalContexts`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。
- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目、真正的提示词、工具或配置变更reason 为 `change``request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。
- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。
- 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
- 快照预期输出变更一次(每个 transcript文本记录增加其 header 事件);写入文件系统的 fixture测试前置数据以规范化的撰写形式存储工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问采样标量出于谨慎放在那里以及当适配器需要时提供方特定的额外项reasoning 选项、额外 body 参数)应归属何处。

View File

@@ -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: afd45027e8b56cbf1d17e6dec749d8602c81124d
2026-07-05-subagent-provider-lifecycle-events.zh.md: 58d439936a3f2cc51d8190cbebe8e68cdb14c855

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-05-subagent-provider-lifecycle-events.zh.md)
## Problem
[The prompt-variables Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule.

View File

@@ -0,0 +1,36 @@
# Agent Note: Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed`
Status: implemented
[English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文
## 问题
[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`spawn/ACP 为 `false`fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。
如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/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 引导移出了描述,与提示词变量 Agent Note 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。
- **根据提供方名称而非提供方对象确定措辞**`providerName` 本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。
## 后果
- 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。
- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。
- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持提示词组装的时效性。
- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。

View File

@@ -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: 11d4b8cd48dd345d2324b63e01bd726f12d846b4
2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891

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