docs(i18n): refresh Agent Note translations after merge

This commit is contained in:
Tianyi Cui
2026-07-23 00:10:53 +08:00
parent cd17f8d23a
commit 744d65d63c
325 changed files with 1535 additions and 1321 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-content-block-vocabulary.md: 6a90813ec90c6522a88a09d3536ce0cef8250238 2026-06-11-content-block-vocabulary.md: 9aad01cee6083b1f380be66869af3137a07d9f1f
2026-06-11-content-block-vocabulary.zh.md: c9c65ae7e556cdf4eced0d45051862ff87fcd0fe 2026-06-11-content-block-vocabulary.zh.md: 123ca87f55be5129855a330efbfe0818d7cdbc0c

View File

@@ -1,4 +1,4 @@
# RFC: 由 dsh-llm 拥有的提供方无关内容块词汇 # Agent Note: 由 dsh-llm 拥有的提供方无关内容块词汇
Status: implemented Status: implemented
@@ -12,7 +12,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。 自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
会话内上下文注入(`context/message`、`steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 会话内上下文注入(`context/message`)和轮次中途 steering(`steering/message`)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
## 后果 ## 后果
- 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。
- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md)。 - 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。
- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。
- 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。
- 跨包(package)边界的 ID 使用品牌类型(`CallId`、`SessionId`、`AgentId`)——零运行时开销的名义类型。 - 跨包(package)边界的 ID 使用品牌类型(`CallId`、agent 与 session 共享的 `SessionId`)——零运行时开销的名义类型。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-custom-schema-dsl.md: 34c018b779d45c5eadb7337cb060c43b6f8d09b1 2026-06-11-custom-schema-dsl.md: 9cef4175e4c4a2e6d2ea034d3522d8a8ddd1f5cb
2026-06-11-custom-schema-dsl.zh.md: 674a6b1a0b67617ffb4ab919899aa108c646a489 2026-06-11-custom-schema-dsl.zh.md: 8b1241edea4c591e428fbaa15916437a3de42061

View File

@@ -1,4 +1,4 @@
# RFC: 使用自定义类型化 tool-schema DSL 替代 schemastery # Agent Note: 使用自定义类型化 tool-schema DSL 替代 schemastery
Status: implemented Status: implemented

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-dev-invariants-over-deep-readonly.md: 01a9e45fac77d2513924e78566a44a6059dd9d28 2026-06-11-dev-invariants-over-deep-readonly.md: 8f0e79f15af82ce3125b1f6f767d4ea727aa6d29
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 8f8db9f5bb095b8fc9c600b741c16d630a6c8167 2026-06-11-dev-invariants-over-deep-readonly.zh.md: 9998ddd784f956e3497a0bb9d8f5096047f14a70

View File

@@ -1,4 +1,4 @@
# RFC: 源端拥有的会话不可变性与开发模式不变式 # Agent Note: 源端拥有的会话不可变性与开发模式不变式
Status: implemented Status: implemented
@@ -30,11 +30,11 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
`deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。 `deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
### 不变式插件检查关系 ### 包拥有的不变式配套插件检查关系
`dsh-invariants` 是一个纯监听器的开发插件。它不冻结记录,没有配置;dispose(资源释放)仅移除其断言。它检查需要跟踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。 `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))。
当插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。这使得在轮次中途热重载是安全的,同时不赋予插件对会话存储的所有权。 当 session 配套插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -55,6 +55,6 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
- `session.events` 暴露稳定的不可变快照,而非私有的增长数组。 - `session.events` 暴露稳定的不可变快照,而非私有的增长数组。
- 请求侧的修改无法通过派生消息触及已存储的历史。 - 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为;dispose 或省略该插件不会削弱日志不可变性。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。
- `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。 - `dsh-invariants` 配置全局启用状态以及包 allow/block regex 列表;每项检查仍由其产品包拥有并测试。
- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。 - 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-event-sourced-sessions.md: c1460b84c5928a53537a5f6a51f41c4d5b101a12 2026-06-11-event-sourced-sessions.md: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0
2026-06-11-event-sourced-sessions.zh.md: 938404524cd23a5a5b3d6d0b7fd5020df3d35a51 2026-06-11-event-sourced-sessions.zh.md: fc900b0dc43b18ba2016b4dd57584cf15707b016

View File

@@ -1,4 +1,4 @@
# RFC: 事件溯源的会话与派生消息历史 # Agent Note: 事件溯源的会话与派生消息历史
Status: implemented Status: implemented

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-microkernel-event-taxonomy.md: 47e363f949b506756d9b20ee7dc53ca81e3f5e4d 2026-06-11-microkernel-event-taxonomy.md: 8bf05b7deba5f054d4ec8ecf104c3b8798e42d4e
2026-06-11-microkernel-event-taxonomy.zh.md: 3b0aa54108bf8737e6fe341c7b0797a554d328ec 2026-06-11-microkernel-event-taxonomy.zh.md: 4ff2ab632ca02e98137a15f19a7996a740a519b0

View File

@@ -1,4 +1,4 @@
# RFC: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环 # Agent Note: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环
Status: implemented Status: implemented
@@ -12,8 +12,8 @@ Status: implemented
纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式: 纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式:
- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决或包装:`agent/prompt-submit`、`agent/request`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 - **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**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 - **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 和 `agent/post-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。
- **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。 - **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。
- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。 - **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。
@@ -25,7 +25,7 @@ Status: implemented
## 后果 ## 后果
- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 - 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。
- HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。 - HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。 - waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。
- 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering(中途引导)永远不会被搁置(有回归测试保障)。 - 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering(中途引导)永远不会被搁置(有回归测试保障)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-runtime-arg-validation.md: 82d01263225ce719e80631d1890f0a6cc328119d 2026-06-11-runtime-arg-validation.md: 6ac2a2ab475c9813dbfbb09e45da71821489d6cd
2026-06-11-runtime-arg-validation.zh.md: 4bd6720f7ec87fcef028ba94f445a5210f1b0d7a 2026-06-11-runtime-arg-validation.zh.md: 119f738dbb3aaee4786aa58d5495ab5e012e206e

View File

@@ -1,4 +1,4 @@
# RFC: 模型边界处的运行时参数校验 # Agent Note: 模型边界处的运行时参数校验
Status: implemented Status: implemented
@@ -21,4 +21,4 @@ Status: implemented
- `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。 - `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。
- 校验开销相对于一次模型调用可忽略不计。 - 校验开销相对于一次模型调用可忽略不计。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-structured-error-taxonomy.md: 5a5f75038b6124457d2d4d08cbe0bec80415a387 2026-06-11-structured-error-taxonomy.md: 9122193b3d01cf5a4c315e6f7a7218153fd4a60a
2026-06-11-structured-error-taxonomy.zh.md: d5a45a447ca00d48cb8366911a10f34c584d915c 2026-06-11-structured-error-taxonomy.zh.md: cb29503314edf248c90f2eecd54e01f5b203b99c

View File

@@ -1,4 +1,4 @@
# RFC: 结构化错误分类体系 # Agent Note: 结构化错误分类体系
Status: implemented Status: implemented
@@ -12,7 +12,7 @@ Status: implemented
在 `dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。 在 `dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。
- `LlmError`、`ToolArgsError`(dsh-tools)和 `InvariantError`(dsh-invariants)现在继承该基类,保留各自既有的 code。 - `LlmError` 和 `ToolArgsError`(dsh-tools)继承该基类,保留各自既有的 code。
- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。 - `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。
- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。 - agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。
@@ -21,6 +21,6 @@ Status: implemented
- 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。 - 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。
- 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。 - 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。
- `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。 - `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。
- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。 - 参数校验保留其既有的 code 和行为;包自有的诊断不变式独立携带稳定 code,使不变式注册表无需导入产品包。共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-11-tool-schemas-in-prompt-assembly.md: 260d56ffab054d874e13eb34eac029bf59b381fc 2026-06-11-tool-schemas-in-prompt-assembly.md: 3643ac3d61be08f629ef0cd0424fef5cb9696c3a
2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 5ae98dfac676574d00f34580ec079bb6a8f2331f 2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 645b829627674e5ccd1507002309b7e8364ac59f

View File

@@ -1,4 +1,4 @@
# RFC: 工具 schema 是系统提示词组装的一部分 # Agent Note: 工具 schema 是系统提示词组装的一部分
Status: implemented Status: implemented

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-13-capability-seams.md: f10182737347fc54ff3fd06edb299e569397773d 2026-06-13-capability-seams.md: 7c755dced7825d2831acc0901f6412b8e5afe95a
2026-06-13-capability-seams.zh.md: e65d2f31eb80ce80e92b3e4bbb4d5b8948185c44 2026-06-13-capability-seams.zh.md: f3aa1ebdee3a16bf095f317b86d01e00b6bbf0c7

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-13-twin-llm-adapters.md: edd7080e2a16e9f1af2ebd56a4265040dc970c17 2026-06-13-twin-llm-adapters.md: 5c3308b281ce71407002e95dd6e794da2a421fa8
2026-06-13-twin-llm-adapters.zh.md: 2087d498f65326fa0a2cb74be409a926a5e343f5 2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608

View File

@@ -1,4 +1,4 @@
# RFC: 以两个 LLM 适配器作为设计验证孪生体 # Agent Note: 以两个 LLM 适配器作为设计验证孪生体
Status: implemented Status: implemented
@@ -24,4 +24,4 @@ Status: implemented
## 后果 ## 后果
孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 RFC 论证退役其中一个适配器。 孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-14-session-persistence.md: a50c076746981892239304266194d1e8e309574f 2026-06-14-session-persistence.md: 1122a52471c6279eff7454cfd31692f05a7bba76
2026-06-14-session-persistence.zh.md: d609e663bd9e36f74bf34404c021a30f54d3bd76 2026-06-14-session-persistence.zh.md: 0f2bcd4948a3c95cd0497cfa63ef4454630635c7

View File

@@ -1,4 +1,4 @@
# RFC: 会话持久化作为基于现有 `SessionEvent` 的抽象服务 # Agent Note: 会话持久化作为基于现有 `SessionEvent` 的抽象服务
Status: implemented Status: implemented
@@ -6,24 +6,24 @@ Status: implemented
## 问题 ## 问题
会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复("继续昨天的任务")、持久 fork 以及 ACP(Agent Client Protocol)的 `session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。 会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复(「继续昨天的任务」)、持久 fork 以及 ACP(Agent Client Protocol)的 `session/load` 方法([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。
[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行"持久化消息"类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。 [事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。
## 决策 ## 决策
持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: 持久化是一个抽象的**能力 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 }`),原样复用,无转换类型。 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`**)。 2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字节保留,**包括 `assistant/chunk`**),默认编码为[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md),也可通过配置使用原始行。
以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的: 以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的:
- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,而过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 - **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,而过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写到 `turn/end` 的事件永不被重写,且循环仅在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。)
- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。 - **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -33,4 +33,4 @@ Status: implemented
## 后果 ## 后果
新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。 新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-15-turn-enclosure-invariant.md: 26ae1890e481288b3be17f97db14c332f69be4b8 2026-06-15-turn-enclosure-invariant.md: 6e2abd1716f8efc08c06d5ff8faec38282f2a17f
2026-06-15-turn-enclosure-invariant.zh.md: 1946f829100e39afed826b8cf3ba82d22058c27d 2026-06-15-turn-enclosure-invariant.zh.md: 0921c2574dc171e887664d8a2ea840a4e81f1531

View File

@@ -1,4 +1,4 @@
# RFC: 每个会话事件都封闭在一个轮次内 # Agent Note: 每个会话事件都封闭在一个轮次内
Status: implemented Status: implemented
@@ -20,10 +20,10 @@ Status: implemented
**每个会话事件都位于一个轮次内部**:在 `turn/start` 与其匹配的 `turn/end` 之间。具体而言: **每个会话事件都位于一个轮次内部**:在 `turn/start` 与其匹配的 `turn/end` 之间。具体而言:
- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非之前。因此,一旦这些消息被记录,就欠下一个 `turn/end`,既有的 finalizer 保证它被写入。 - agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非之前。因此,一旦这些消息被记录,就欠下一个 `turn/end`,既有的 finalizer 保证它被写入。
- agent **运行中**调用 `agent.inject()` 时,`context/message` 追加到已打开的轮次中(行为不变)。 - agent **运行中**调用 `agent.inject()` 时,它会加入已打开的轮次。当前步骤执行 assistant 工具调用期间,已接受的上下文按到达顺序等待该批次结算,随后在每个已记录结果之后追加;即使执行中断,也会在轮次关闭前写入。
- agent **空闲时**调用 `agent.inject()`,则将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`。 - agent **空闲时**调用 `agent.inject()`,则将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`。
- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。 - agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。
- `dsh-invariants` 插件在开发环境中**强制执行**该不变式:在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出 `InvariantError`。 - `dsh-session/invariant` companion 将该检查注册到 `ctx.invariants`:选中后,在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出归因于 `@deepseek-ai/dsh-session` 的 `InvariantError`。
可序列化性不变式在同一源码边界处强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。 可序列化性不变式在同一源码边界处强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-17-filesystem-capability-seam.md: c87c8a44d2e956a9613039378d26f72e8ee97ee7 2026-06-17-filesystem-capability-seam.md: cd8f8572730b1833ed1d7dbe1861d516c2d32925
2026-06-17-filesystem-capability-seam.zh.md: 4864e77ced5c078fc8c1e970a2ff88c78a37af2a 2026-06-17-filesystem-capability-seam.zh.md: a7a8d4cbc48c473b1f0669a95c5fbe25eecf94f4

View File

@@ -1,4 +1,4 @@
# RFC: 文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 # Agent Note: 文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具
Status: implemented Status: implemented
@@ -20,7 +20,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
## 决策 ## 决策
文件系统访问是一个一等的能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): 文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。
2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。
@@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。
读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 RFC 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 Agent Note 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。
第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。
@@ -36,7 +36,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。
读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。 读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) Agent Note。
## 包拓扑 ## 包拓扑
@@ -73,7 +73,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
- `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。 - `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。
- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 - `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。
授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。) 授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 Agent Note 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate Agent Note 将其替换为此处描述的基于新鲜度的策略插件。)
路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。
@@ -83,7 +83,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
- 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类似 hash 的令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 读取和变更结果必须包含不透明的文件 `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 拒绝;它不知道行窗口或视图。 提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。
@@ -137,7 +137,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
- **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 - **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。
- **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。 - **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。
- **观测状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 - **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。
## 后果 ## 后果
@@ -145,11 +145,11 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露显示元数据,而不要求消费方理解宿主路径。 **接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露显示元数据,而不要求消费方理解宿主路径。
**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 RFC 试图避免的耦合。 **接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 Agent Note 试图避免的耦合。
**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`。 **编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`。
**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内部;split-fs-seam RFC 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 **观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 Agent Note 最初将其放在文件系统 seam 内部;split-fs-seam Agent Note 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。
**`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。 **`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-18-agent-lifecycle-and-ownership-seams.md: c37578020366dd40250c061e34a86d2de6907e19 2026-06-18-agent-lifecycle-and-ownership-seams.md: 70ebf1c6de97cb14e27377ec1f9bac18fc0766a6
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 1f3cb609da5508d22046f445e08a75dc5a7901cd 2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 2091182b663dfbd1ac306550de60c1f00d12656f

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md)
## Problem ## 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. 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

@@ -1,4 +1,4 @@
# RFC: Agent 生命周期与所有权 seam # Agent Note: Agent 生命周期与所有权 seam
Status: implemented Status: implemented
@@ -12,36 +12,38 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se
三个 seam:队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。 三个 seam:队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。
### 1. 队列感知的 `Agent.cancel(reason?)` ### 1. 队列感知的 `Agent.cancel(cause?)`
`cancel()` 是唯一的公开停止原语。它清除已入队和 steering(中途引导)输入、中止正在进行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已入队的 prompt 在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲状态的 cancel 不设置标记。 `Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的 prompt 永不运行,而后来的 prompt 仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条 prompt 搁浅。`whenIdle()` 会到达取消后的静默状态,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。
### 2. `AgentHandle` 异步释放器 ### 2. `AgentHandle` 异步释放器
`ctx.agents.create`/`resume` 和 `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法将其拆除。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享一个 memoize 的拆除过程:停止循环、等待静默与刷写完成、分离 agent 和会话,然后解除其 scope。ID 在注册表条目分离后变为可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。 `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-store 条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。
拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放会先停止循环并等待 `agent.done`,然后再分离会话。若使用兄弟 effect,则会并发释放,可能在关闭刷写之前就移除 append 钩子。释放通知被隔离,不会中断拆除链。 **拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让 session store 的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed` 和 `session/disposed` 通知无法拒绝该链或跳过后续拆除。
### 3. Bash seam 中的所有者令牌 ### 3. Bash seam 中的所有者令牌
后台任务的所有权属于执行器。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` 和 `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保存在任务上,使得这道隔离在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。 后台任务所有权从 `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-store 条目,包括 `session/load` 与拆除竞争的情况。 以下不变式已经成立,并由测试固定:
- 在已入队的 prompt 启动前取消,能阻止该 prompt 运行或吸收下一条 prompt。
- 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。 - ACP 断连/会话关闭后,不留下该会话的任何已注册 agent 或 session-store 条目,即使 `session/load` 与拆除竞争。
- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 演示无需显式管理 handle。 - 已入队的 prompt 启动前执行 `session/cancel`,能阻止该 prompt 运行;后来接受的 prompt 仍是独立的已入队轮次。
- `tool-bash` HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。
- 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 `AgentLoop` 插件 fiber 所有。
## 会话所有者令牌在存活 agent 中唯一 ## 会话所有者令牌在存活 agent 中唯一
bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。 bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布会依次进入 session 和 agent;`SessionStore.enter()` 拒绝重复的存活 session id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*(token 比较)留在消费方 `tool-bash`;bash seam 只存储不透明的 `owner` 字符串且从不解释它——这是正确的接口/实现/消费方拆分。
## 曾考虑的替代方案 ## 曾考虑的替代方案
- **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam:否决。一条读取路径即可,无需冗余 API。 - **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam:否决。一条读取路径即可,无需冗余 API。
- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 - **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。
- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。 - **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md))。
## 后果 ## 后果

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-18-session-surface.md: bcad236eaa2bead3c140e7a12772729710d43f83 2026-06-18-session-surface.md: 80034881d0112076759a68737b5931c8ff659d15
2026-06-18-session-surface.zh.md: 2ec60501c183d1bdab22ceb67b7f6aa0040ae82b 2026-06-18-session-surface.zh.md: 49303c87aa87569bcc61f215642c9b7f0c408dbf

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-06-18-session-surface.zh.md)
## Problem ## 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()`. 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

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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-18-shared-persistence-write-coordinator.md: 7648c6f8e43fb8c78f881838aa4d476aba209dca 2026-06-18-shared-persistence-write-coordinator.md: 12800b118bd2fd1189319d0edf37cace3d916a9c
2026-06-18-shared-persistence-write-coordinator.zh.md: 19f583c2f3d78fcdac1378cfe00e49c0be943eb1 2026-06-18-shared-persistence-write-coordinator.zh.md: 23d6c66989880369c4e26c38145559c198ea1033

View File

@@ -1,4 +1,4 @@
# RFC: 共享持久化写入协调器 # Agent Note: 共享持久化写入协调器
Status: implemented Status: implemented
@@ -12,7 +12,9 @@ Status: implemented
将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。 将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
协调器通过每个存活会话的 `session/disposed` 通知将其退役:等待该 Session 对象自身的初始化,串行执行最后一次排空,随后移除其拥有的状态、缓冲区和初始化条目。排空失败时保留缓冲区,供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会先注销写入路径监听器,再等待所有已准入的退役、剩余缓冲区和链,最后关闭后端。
### 钩子接口(`PersistenceBackend<TornMarker>`) ### 钩子接口(`PersistenceBackend<TornMarker>`)
@@ -28,11 +30,11 @@ Status: implemented
### 不透明的 torn marker ### 不透明的 torn marker
保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者恰好都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠在钩子内部,因此返回的 marker 已经是 `number | undefined`;如果不做这层折叠,协调器就必须了解字节长度。 保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 携带要截断到的字节偏移,以及从不完整最终帧中解码出的任何完整事件;SQLite 则携带要从其开始删除的 seq。协调器因此既不了解字节长度,也不了解帧恢复状态。
## 测试 ## 测试
共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、碰撞、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试规格缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture(测试前置数据)钩子),确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker,因此无法触达该分支。 共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。`runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、碰撞、会话与后端 dispose 排空,以及崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。协调器专属测试固定退役 map 清理、同 id 链尾竞态、排空失败后的重试,以及关闭顺序。各后端自身的测试规格只保留存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture(测试前置数据)钩子),确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker,因此无法触达该分支。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -41,4 +43,4 @@ Status: implemented
## 后果 ## 后果
协调器增加了一层间接和一个不透明的 torn marker,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。 协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、保留未提交的缓冲区,并以后端 teardown 为静止状态边界。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-20-branded-ids.md: aab47a1413451edb707ae797e5b99cda6e34efad 2026-06-20-branded-ids.md: 93bab1d47c793cc4dd1f1d19fa3af22d1721be29
2026-06-20-branded-ids.zh.md: 1ccfda3282069510bcdbfbddf4546305fd0923a5 2026-06-20-branded-ids.zh.md: f45f39cc3a49f7abf116e77f1ace2db74fc3de67

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-06-20-branded-ids.zh.md)
## Problem ## 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. 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

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-20-extract-example-app-packages.md: b0dd1fe32b2389578d3f3bd3672435923eb1a5d1 2026-06-20-extract-example-app-packages.md: b757a0099382a648a4efda4639e275ae6e6a02d1
2026-06-20-extract-example-app-packages.zh.md: 9f47c98c9c54357a304490e3d4075fad6cea855a 2026-06-20-extract-example-app-packages.zh.md: 5814704f0426707ef255a67a7aed9b79cae4aa24

View File

@@ -1,4 +1,4 @@
# RFC: 将示例应用提取为独立包 # Agent Note: 将示例应用提取为独立包
Status: implemented Status: implemented
@@ -8,29 +8,28 @@ Status: implemented
示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。 示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。
叶子配置还拥有一个耦合的前门。ACP(Agent Client Protocol)要求 stdout 纯净,并通过 `session/new` 创建 agent;stdio 则需要一个控制台 logger 和一个预创建的 `main`。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。 叶子配置还拥有耦合的前门。ACP(Agent 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 执行器、模型、提示词、持久化根目录)。 每个示例现在**主要是对一个应用包(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))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。 - **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。
- **`@deepseek-ai/dsh-stdio-demo`**([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**([packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`;ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以添加插件,但安全的组合现在是默认产物。 - **`@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`(`dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(例如 `dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个精简的自执行组合,基于这些辅助函数加上其应用特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍被排除在覆盖率之外(自执行 CLI(命令行界面)入口,与旧的 `start.ts` 性质相同),由 keyless 的 Loader 路径测试驱动。 - **`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` 精简为**后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个承载应用配置的 app 条目(模型、系统提示词、持久化根目录——以应用包自身的 `Config` 形式暴露,由它将各值路由到应用接线的目标位置:stdio 路由到预创建的 agent,acp 路由到 bridge 插件)。 - **每个叶子 `cordis.yml` 精简为**后端、可选产品工具,以及一个承载应用配置的 app 条目。TUI 和 Headless 把模型/会话选择路由到预创建的 agent;ACP 把初始提供方/模型路由到 bridge。
- **echo-agent 折叠到 `dsh-stdio-demo` 上**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。
- **`base.yml`、`base-core.yml` 和 `acp-agent/acp-tail.yml` 已退役**——它们共享的主干现在位于 `dsh-agent-spine-demo` 中。 - **`base.yml`、`base-core.yml` 和 `acp-agent/acp-tail.yml` 已退役**——它们共享的主干现在位于 `dsh-agent-spine-demo` 中。
`bash-local` 和 LLM 适配器仍然是**叶子选择**:bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器无需触碰应用即可替换。 `bash-local` 和 LLM 适配器仍然是**叶子选择**:bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器无需触碰应用即可替换。
### 实现修正:`hmr` 保留为叶子条目 ### 实现修正:`hmr` 保留为叶子条目
提案最初将 `hmr` 列入 stdio 应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付: 提案最初将 `hmr` 列入交互式应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到应用包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付:
1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃的 `loader` 服务时会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。 1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃的 `loader` 服务时会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。
2. 进程内测试层(vitest)甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。 2. 进程内测试层(vitest)甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。
关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患:ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧,因此将它留在叶子层不会损失耦合论证所关注的安全性。**logger**(真正的耦合点)保持内置:stdio 应用包含它,ACP 应用省略它。 关键在于,`hmr` 不是 stdout 纯净隐患:ACP 配置中误加该条目不会破坏 JSON-RPC 帧。所有已交付应用都省略 stdout 控制台 logger;stdout 只归应用或协议 driver 所有。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -41,13 +40,13 @@ Status: implemented
## 验证 ## 验证
- 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。 - 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。
- `demo:echo`、`demo:repl` 和 `demo:acp` 调用应用包的 bin。 - `demo:tui`、`demo:headless` 和 `demo:acp` 调用应用包的 bin。
- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。 - 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。
- ACP 回放 transcript(文本记录)保持不变,因为插件集合和加载顺序未改变。 - ACP 回放 transcript(文本记录)保持不变,因为插件集合和加载顺序未改变。
## 后果 ## 后果
- **裸插件树的教学性。** echo-agent 内联的 `cordis.yml` 曾一次展示所有插件;主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。 - **裸插件树的教学性。** 主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。
- **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。 - **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。
## 相关 ## 相关
@@ -55,3 +54,4 @@ Status: implemented
- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。 - 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。
- 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。 - 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。
- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放应用特有的前门)。 - 与 [Reorganize packages into a modular hierarchy](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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-20-generic-long-running-tool-runtime.md: 9b83a4443d6d654cda75c72fba3369a16be078e2 2026-06-20-generic-long-running-tool-runtime.md: c28236d194ae839f88d900f0fc52707b2c9e53e2
2026-06-20-generic-long-running-tool-runtime.zh.md: c1684be4fd5c1c3c9d913e063f4ba66bb6064459 2026-06-20-generic-long-running-tool-runtime.zh.md: 911c4288fff6e4235b9c88efeaaa882910e559b0

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-06-20-generic-long-running-tool-runtime.zh.md)
## Problem ## 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. 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

@@ -1,43 +1,130 @@
# RFC: 提取通用的长时间运行工具运行时 # Agent Note: 后台任务运行时(`ctx.tasks`)与通用任务控制工具
Status: proposed Status: implemented
[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文 [English](2026-06-20-generic-long-running-tool-runtime.md) | 中文
## 问题 ## 问题
bash 能力 seam 同时支持前台命令和长时间运行的后台任务。后台支持体量不小:抽象执行器暴露 `start`、`get`、`ownerOf`、`list`、`readOutput`、`kill` 和 `onTaskDone`;本地执行器负责跟踪任务、增量读取、owner token、进程清理和完成监听;模型侧看到三个工具(`bash`、`bash_output`、`bash_kill`);工具插件将完成通知注入回所属 agent(智能体)的会话。本地执行器用 owner token 隔离任务访问,因为可预测的全局 task id 会带来跨会话的读取/终止风险。 后台 bash 原本兼有两项职责:bash 执行器既运行进程,又管理 task id、所有权、增量读取、取消、完成监听器和面向模型的控制工具。新增后台 subagent 需要相同的生命周期与交互契约。如果每种长时间运行能力都独立实现该契约,就会重复隔离、清理、通知和提示词行为,还会让模型为每种生产方学习不同的收集与停止协议。
[工具实操手册](../../../cookbook/adding-a-tool.md)已经指出了真正的设计异味:后台 bash 本质上是寄居在单个工具内部的通用长时间运行工具基础设施。如果未来的工具也需要后台执行、轮询、终止、所有权和完成通知,这些语义不应藏在 `dsh-bash` 里。 任务注册表、控制工具与完成通知共同构成一项 harness 功能。bash 和 subagent 只提供执行专属的钩子,不拥有通用任务行为。
## 提案 ## 决策
将长时间运行任务的语义从 bash 上移到一个与工具无关的运行时中。bash 仍然能运行后台命令,但不再拥有 task id、ownership token、轮询、取消、完成通知以及模型侧「读取/终止此任务」命令等通用概念。 `tasks/` 包组拥有后台任务语义:
该运行时应拥有: - `@deepseek-ai/dsh-tasks` 将运行中的工作注册为 `ctx.tasks`,并拥有 task id、授权、快照、读取、取消、等待、完成监听器与清理。
- `@deepseek-ai/dsh-tool-tasks` 暴露 `task_output`、`task_list` 和 `task_kill`,注入完成通知,并提供后台任务的系统提示词指导。
- 稳定的 task id 和 owner token,按调用方的会话/agent 做键。 长时间运行工具是生产方。`dsh-tool-bash` 将 `BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。
- 注册一个长时间运行任务,附带增量输出的生产者和一个完成 promise。
- 通用的 read/cancel/list 操作,对所有工具使用相同的跨会话授权规则。
- 向所属会话注入完成通知。
- 针对 pending/running/completed 任务状态的展示钩子,bash 只提供命令特有的标签和输出格式化。
`dsh-bash` 保留 bash 特有的执行契约:将请求解析为命令规格、运行前台命令,或启动进程并将其流/进程句柄交给通用运行时。`dsh-tool-bash` 保留模型侧的命令工具,但后续操作变为通用的长时间运行工具操作,或者 bash 向其注册的共享工具,而不是专属的 `bash_output`/`bash_kill` 管道。 `TaskService` 是一个具体的进程内服务。TODO(task-service-backend):当第二个后端明确所需生命周期后,将其公共契约与实现分离;systemd 驱动的运行时是一种可能方案,但本 PR(Pull Request)不臆测其持久性、重连、所有权或观察语义。
## 当前 seam 消费情况 ## 运行时契约
当前消费方划分清晰:`dsh-tool-bash` 使用完整的前台/后台 seam,而钩子桥接层只使用前台的 `resolve` 和 `run`(带受信的 `stdin` 和 `env`)。`get` 和 `list` 仅在测试中使用;`BashTask.done` 仅在实现内部用于 dispose(资源释放),生产环境的完成通知使用 `onTaskDone`。提取出的运行时应暴露一个公开的完成机制,保留钩子所需的简单前台路径,并决定后台的 `timeoutMs` 是否属于 `start`。如果它拥有进程 spawn 的职责,还应集中处理目前重复的凭证清洗逻辑。 字面类型见[任务数据结构目录](../../../../docs/core-data-structures/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。
## 验收标准 生产方钩子定义三项职责:
- bash 特有的包(package)不再定义通用的任务注册表、owner-token 授权、轮询、取消或完成通知机制。 - `cancel(reason?)` 同步请求终止,具备幂等性,并且必须使 `done` 完成。
- 一个共享的长时间运行任务服务或工具层拥有这些语义,并被文档化为未来任何具备后台能力的工具的接入路径。 - `done` 从不拒绝,并且仅在生产方释放任务资源后完成。
- bash 后台行为仍可通过共享层使用,测试证明跨会话隔离依然成立。 - 可选的 `readOutput()` 返回下一个消费式输出增量。省略该钩子即声明这是最终输出任务,其终止结果来自 `TaskOutcome.output`。
- ACP 和快照 fixture(测试前置数据)通过共享任务词汇渲染后台 bash,而非通过 bash 专属的生命周期语义。
- [工具实操手册](../../../cookbook/adding-a-tool.md)将长时间运行工具指向共享运行时,而不是让每个工具自行发明任务协议。
## 风险 状态包括 `running`、`stopping`、`completed`、`killed` 和 `failed`。退出码或停止原因等生产方专属信息放在 `detail` 中,注册表不解释这些信息。任务 kind 构成可合并扩展的字符串联合;task id 带品牌,并按 `<kind>-N` 生成,每个 kind 各有一个计数器。
bash 包失去了对一个已经可用的后台任务实现的本地所有权,实现 PR(Pull Request)可能暂时搅动模型侧的工具名称或 transcript(文本记录)展示。如果最终结果是留下一份后台任务契约,而不是让每个未来的长时间运行工具克隆 bash 的私有协议,这种搅动是值得的。 运行时为 `done` 附加一个 continuation,记录第一个终止结果、解决等待方,并逐个调用完成监听器,同时隔离每个监听器的错误。首次结果优先的结算在资源销毁期间至关重要:如果 `cancel` 抛出,运行时会强制将记录标为失败,并警告工作可能遗留,而不是永远等待一个可能永不完成的 promise。后续生产方结果不能覆盖该诊断,也不能重复通知。`cancel` 返回后如果最终未使 `done` 完成,仍会阻塞资源销毁,因为运行时无法区分这种情况与缓慢但有效的停止。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 任务注册不是生产方工具 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 无关的工具,并使用通用 ACP 卡片:
- `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`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。
## 生产方显式启用
每个生产方通过带默认值的配置,自行决定其 schema 是否暴露 `run_in_background`。`dsh-tool-bash` 和每个 `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、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。
## 后果
bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本契约。
有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-20-package-hierarchy.md: a06e963d118d895cc55a0f5e378bbf12678f3491 2026-06-20-package-hierarchy.md: aba5fb56176ecab9dd92c61a72cb91d501a442d8
2026-06-20-package-hierarchy.zh.md: a136f15a4197645b06fa0a68564a9e0464af1cba 2026-06-20-package-hierarchy.zh.md: 304bf1da3631187d09b229fb14beb54d4e9a46cc

View File

@@ -2,6 +2,8 @@
Status: implemented 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. 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 ## Problem

View File

@@ -1,9 +1,11 @@
# RFC: 将包重组为模块化层级结构 # Agent Note: 将包重组为模块化层级结构
Status: implemented Status: implemented
[English](2026-06-20-package-hierarchy.md) | 中文 [English](2026-06-20-package-hierarchy.md) | 中文
后续的 [fold-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 支撑和仅用于快照的回放支撑看起来同样基础。 `packages/` 原先是扁平的:18 个包(package)全部位于 `packages/<name>/`,从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-21-mandatory-app-attribution-headers.md: 132c827b48693af85fde67cf9d2d71cc84e853c6 2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de
2026-06-21-mandatory-app-attribution-headers.zh.md: 9bba6d912b0b9bb33174c1ebb51a84dfdb47af0f 2026-06-21-mandatory-app-attribution-headers.zh.md: 724a828ce6a9907bec5615d1b2c3a53f2603e388

View File

@@ -1,4 +1,4 @@
# RFC: 对提供方请求强制携带 `User-Agent` 归属标识 # Agent Note: 对提供方请求强制携带 `User-Agent` 归属标识
Status: implemented Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题 ## 问题
LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 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 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 直接触发因素来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。
@@ -26,11 +26,11 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。
本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本 RFC 定义的共享 `User-Agent` 归属。 本 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` 确定了提案中留待决定的值: 提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值:
- `User-Agent` 的产品 token:`deepseek-harness`(与 RFC 之前的线路值及仓库/组织身份保持连续性) - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性)
- 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量 - 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 - 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布
@@ -42,10 +42,10 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
|---|---| |---|---|
| 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 |
| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效契约,否则不发送 OpenRouter 特有头部。 | | 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效契约,否则不发送 OpenRouter 特有头部。 |
| OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | | OpenRouter 端点 | 目前仅 `User-Agent`。本 Agent Note 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 |
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 RFC 接受额外头部。不要类比复用 `HTTP-Referer`。 | | 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 |
端点检测不在本 RFC 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 端点检测不在本 Agent Note 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。
## 验证 ## 验证
@@ -55,19 +55,19 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
- 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。 - 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。
- `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。 - `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。
- `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。 - `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。
- 本 RFC 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 - 本 Agent Note 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。
- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。 - 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。
- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。 - 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。
## 曾考虑的替代方案 ## 曾考虑的替代方案
**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 RFC 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。 **现在就实现 OpenRouter 应用归属。** 本 Agent Note 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 Agent Note 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。
**向所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义的 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。 **向所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义的 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。
**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。 **仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。
**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。 **终端用户 `user`/`metadata` 字段。** 本 Agent Note 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。
**仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。 **仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-24-web-capability-seam.md: fdb611be9efbf29717e41ffa2db86c55258ebe2e 2026-06-24-web-capability-seam.md: 4f4e821fec9494fe9ea96894267707d9dd202e4d
2026-06-24-web-capability-seam.zh.md: f5db538f0565c4fc5667dcbec0791579246d238d 2026-06-24-web-capability-seam.zh.md: 1d81d06d7ff0202174f4348146117e22ea1de038

View File

@@ -77,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: `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 ```ts
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
interface WebSearchProvider { interface WebSearchProvider {
readonly id: string readonly id: string
available(): boolean available(): boolean
@@ -208,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. 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 ```ts
interface WebFetchRequest { export interface WebFetchRequest {
readonly url: string readonly url: string
} }
interface WebFetchResult { export interface WebFetchResult {
readonly url: string readonly url: string
readonly statusCode: number readonly statusCode: number
readonly body: WebFetchBody readonly body: WebFetchBody
readonly truncated: boolean readonly truncated: boolean
} }
type WebFetchBody = export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string } | { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string } | { readonly kind: 'text'; readonly content: string }
``` ```

View File

@@ -1,4 +1,4 @@
# RFC: Web 能力 seam——稳定的工具覆盖多个提供方 # Agent Note: Web 能力 seam——稳定的工具覆盖多个提供方
Status: implemented Status: implemented
@@ -16,7 +16,7 @@ harness 需要面向模型的 web 工具,但不能将模型契约绑定到某
## 决策 ## 决策
Web 访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 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`。 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`。
@@ -34,7 +34,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的契约。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的契约。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
## 包拓扑 ## 包拓扑
@@ -77,6 +77,8 @@ flowchart LR
`ctx.web` 是一个提供方注册表加上一个带提供方选择的执行面。注册表部分与 `LlmService` 保持接近:每种能力类别一个 `Map<id, provider>`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状: `ctx.web` 是一个提供方注册表加上一个带提供方选择的执行面。注册表部分与 `LlmService` 保持接近:每种能力类别一个 `Map<id, provider>`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状:
```ts ```ts
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
interface WebSearchProvider { interface WebSearchProvider {
readonly id: string readonly id: string
available(): boolean available(): boolean
@@ -208,18 +210,18 @@ seam 请求刻意不包含逐调用超时、`format`、`prompt` 或提供方特
HTTP 状态码是已获取资源状态的一部分,不自动构成工具失败。成功的网络获取一个 `404` 或 `500` 响应会返回带有状态码和有界解码正文(当内容类型受支持时)的 `WebFetchResult`。`WebError` 用于无法安全获取或表示资源的失败:无效或被阻断的 URL、重定向策略违规、超时、abort、响应过大、不支持的内容类型、提供方失败或网络失败。 HTTP 状态码是已获取资源状态的一部分,不自动构成工具失败。成功的网络获取一个 `404` 或 `500` 响应会返回带有状态码和有界解码正文(当内容类型受支持时)的 `WebFetchResult`。`WebError` 用于无法安全获取或表示资源的失败:无效或被阻断的 URL、重定向策略违规、超时、abort、响应过大、不支持的内容类型、提供方失败或网络失败。
```ts ```ts
interface WebFetchRequest { export interface WebFetchRequest {
readonly url: string readonly url: string
} }
interface WebFetchResult { export interface WebFetchResult {
readonly url: string readonly url: string
readonly statusCode: number readonly statusCode: number
readonly body: WebFetchBody readonly body: WebFetchBody
readonly truncated: boolean readonly truncated: boolean
} }
type WebFetchBody = export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string } | { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string } | { readonly kind: 'text'; readonly content: string }
``` ```
@@ -278,7 +280,7 @@ prompt 引导解释了语义分工——`web_search` 用于发现和获取当前
## 测试 ## 测试
每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 每一层在自己的 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`。
## 曾考虑的替代方案 ## 曾考虑的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-26-file-context-as-event-gate.md: ac9a8bcbc5bfda2b35cae4993497273608abf35b 2026-06-26-file-context-as-event-gate.md: 4700222aa2e0f91d9f355495c228e2eb92825f55
2026-06-26-file-context-as-event-gate.zh.md: eda637f9adf8346779c70d59575863bf7e1162c9 2026-06-26-file-context-as-event-gate.zh.md: cf490c9f59f1914f1d5d6bdd9fdf4c08711db91e

View File

@@ -1,4 +1,4 @@
# RFC: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口 # Agent Note: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口
Status: implemented Status: implemented
@@ -6,12 +6,12 @@ Status: implemented
## 问题 ## 问题
[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。
这把三件本应可分离的事情耦合在了一起: 这把三件本应可分离的事情耦合在了一起:
1. **工具做什么**——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 `ctx.fs`。 1. **工具做什么**——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 `ctx.fs`。
2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。 2. **新鲜度/观测策略**——「编辑前必须先读」、「写入/编辑必须基于你读到的版本」。这是 `dsh-fs-policy` 插件的职责。
3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。 3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。
由于工具调用的是 `fileContext` 方法,移除策略层就是一个破坏性变更,而非优雅地失去一个*附加*能力。策略层对工具的运行是承重性的,而非可选的收紧。 由于工具调用的是 `fileContext` 方法,移除策略层就是一个破坏性变更,而非优雅地失去一个*附加*能力。策略层对工具的运行是承重性的,而非可选的收紧。
@@ -37,12 +37,12 @@ provider dsh-fs-local local implementation of ctx.fs
## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat ## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat
`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性: `dsh-fs-policy` 强制执行「你必须基于你读到的版本来写入/编辑」,**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性:
- "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。 - 「你读过这个文件吗?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。
- "你读到的版本是否仍为最新?"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 - 「你读到的版本是否仍为最新?」由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。
这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最近一次读取"的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;「必须基于最近一次读取」的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。
## 提供方契约变更:版本守卫变为可选 ## 提供方契约变更:版本守卫变为可选
@@ -62,7 +62,7 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion
// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) // { 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`,保持一个统一的编辑失败码表示"此刻无法编辑该目标"。 `FsWriteIntent` 联合类型本身不变——第三种「无条件」状态通过*省略* `expected` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的「无守卫」情况是新增的,且它是裸提供方的默认行为。无论哪种情况,mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);「无条件」去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`,保持一个统一的编辑失败码表示「此刻无法编辑该目标」。
## 事件词汇(由 `dsh-fs` 拥有) ## 事件词汇(由 `dsh-fs` 拥有)
@@ -110,7 +110,7 @@ interface Events {
## 工具契约(`dsh-tool-fs`) ## 工具契约(`dsh-tool-fs`)
工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称"后端"要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。 工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称「后端」要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。
`dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 `dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。
@@ -134,7 +134,7 @@ interface Events {
- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。 - `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。
- `fs/observed` 监听器:`record(owner, key, version)`。 - `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 安全)。 一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着「此 owner 在此版本观测过此目标」,而非狭义的「已读取过」。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。 `dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。
@@ -144,13 +144,13 @@ interface Events {
- **read** 行为不变(它从不需要策略;只是 emit 了一个现在无人监听的 `fs/observed`)。 - **read** 行为不变(它从不需要策略;只是 emit 了一个现在无人监听的 `fs/observed`)。
- **write** 无条件 create-or-overwrite:`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无先读要求,无版本检查。 - **write** 无条件 create-or-overwrite:`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无先读要求,无版本检查。
- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 无版本守卫、无先读要求地匹配并重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。 - **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`,从而叠加这些约束;裸提供方本身无需任何变更。 两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、先读后编辑和版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身无需任何变更。
## 取代关系 ## 取代关系
本 RFC 修正——而非推翻——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 本 Agent Note 修正——而非推翻——[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
## 验证 ## 验证
@@ -158,14 +158,14 @@ interface Events {
## 曾考虑的替代方案 ## 曾考虑的替代方案
- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 - **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。
- **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 - **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 - **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。
## 后果 ## 后果
- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。 - **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。
- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。 - **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它「只是存储」。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。
- **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 - **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 Agent Note(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。
- **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。 - **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。
- **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。 - **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 712c17532bc1e6b95479aec5b544bc7e296952ec 2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: db50f08dae30aa0c43e741a4f27f948f712954e0 2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a

View File

@@ -1,4 +1,4 @@
# RFC: 在 bash seam 上支持 stdin 与额外 env # Agent Note: 在 bash seam 上支持 stdin 与额外 env
Status: implemented Status: implemented
@@ -6,9 +6,9 @@ Status: implemented
## 问题 ## 问题
钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 RFC 添加这两个输入。 钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 Agent Note 添加这两个输入。
`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。 `stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。
## 决策 ## 决策
@@ -16,9 +16,9 @@ Status: implemented
三个有意为之的选择: 三个有意为之的选择:
1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使键名与凭证同形。这是正确的,因为擦除的职责很窄:阻止 harness 的*环境* `process.env` 凭证泄漏到被 spawn 的命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非环境中的秘密),因此擦除不构成对它的约束。`childEnv(extra?)` 按 `scrub(process.env)` → `ENV_OVERRIDES`(对模型友好的 `TERM=dumb` 等)→ `extra` 的顺序分层,后者优先。 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` 一致。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。
@@ -30,4 +30,4 @@ Status: implemented
## 后果 ## 后果
钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。 钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/core-data-structures/bash.md)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-30-event-domain-semantics.md: a9d33a7bfb549e1ec2102c23af78f91d9ae4b904 2026-06-30-event-domain-semantics.md: 7310840088d4ff77ddf83c5c16753b4d46256692
2026-06-30-event-domain-semantics.zh.md: dc7390e3d2e1cda3469fa77389d0d6ad39b7a713 2026-06-30-event-domain-semantics.zh.md: b082fdfda36968e1946ba6325f2315a970cd445b

View File

@@ -1,4 +1,4 @@
# RFC: 事件域语义——session 是事实日志,agent 是运行时表面 # Agent Note: 事件域语义——session 是事实日志,agent 是运行时表面
Status: implemented Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题 ## 问题
harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊:
- `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 - `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。
- `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。
@@ -26,14 +26,14 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 **将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)从 `session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 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 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。
- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的 turn 边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的 turn 边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。
- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。
- 完整实现见[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 - 完整实现见[简化 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`)重新生成以移除镜像事件。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-02-fs-per-session-cwd.md: 2513294d6bc991ee39155bbf20869829cca1a188 2026-07-02-fs-per-session-cwd.md: aeb17e0a235720bee277f6533468dbdb6cae82dc
2026-07-02-fs-per-session-cwd.zh.md: 2c3957113642fe2bf7b3a4f2b6186246b32aff3f 2026-07-02-fs-per-session-cwd.zh.md: 34b868761112b3dab31699cd7c945a64e22b7d59

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-07-02-fs-per-session-cwd.zh.md)
## Problem ## 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. 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

@@ -1,4 +1,4 @@
# RFC: 相对文件系统路径按调用方的会话 cwd 解析 # Agent Note: 相对文件系统路径按调用方的会话 cwd 解析
Status: implemented Status: implemented
@@ -6,17 +6,21 @@ Status: implemented
## 问题 ## 问题
ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd Agent Note 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。
文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。 文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
一个有效的绝对 cwd 本身可能看起来有两个父目录:当它包含 `symlink/..` 时,文件系统查找会先跟随符号链接再应用 `..`,而 `path.resolve()` 会从词法上抹掉这两个组件。如果用词法解析沙箱策略却从原始 cwd 启动 bash,就会把权限授予无关的词法父目录、拒绝真实工作区内的写入,并让文件系统工具把相对路径解析进错误目录。
普通的符号链接 cwd 在请求的相对路径包含 `..` 时也暴露同一区别:进程从符号链接的物理目标开始遍历,`path.resolve(cwd, path)` 却从其词法拼写开始遍历。因此,对于同一个模型提供的路径,read 会选择与 bash 或沙箱化 mutation 不同的文件。
## 决策 ## 决策
将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。 将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。当 cwd 或请求路径任一包含父目录段时,在任何词法 join 之前把 cwd 解析为原生文件系统标识;没有遍历会使标识可观察时,则保留普通 cwd 拼写以供展示。mutation 和沙箱化 bash 调用复用解析后的沙箱策略根目录,使一次调用只有一个工作区标识。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。
- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`。`opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。采用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。 - `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-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`。`config.cwd` 仍作为调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。
- `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。 - `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec, requestedPath)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。只要任一值中的父目录段可能跨越符号链接,该辅助函数就使用原生 realpath 语义,否则保留普通拼写;沙箱化 mutation 复用完整策略的 `workspaceRoot`;非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -29,6 +33,7 @@ ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区
## 后果 ## 后果
- 在 ACP 演示中,fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。 - 在 ACP 演示中,fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。
- 对于包含 `symlink/..` 的会话 cwd,或普通符号链接 cwd 搭配含父目录遍历的相对路径,bash、文件系统工具和沙箱授权都会从同一个物理工作区解析;词法父目录不会获得授权。
- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key。 - `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key。
- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。 - 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。
- 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。 - 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-02-result-time-applied-hunk-diffs.md: fd02951f21319be2933bf3ffaddeb96bc2a6819c 2026-07-02-result-time-applied-hunk-diffs.md: b1e1884f2264f17f6fff17569d79d8352e3fc709
2026-07-02-result-time-applied-hunk-diffs.zh.md: 4ed9a29bb023343cab28b8114453d17408bc429a 2026-07-02-result-time-applied-hunk-diffs.zh.md: 091bbb6b9752238348c594a4abe39944b005755d

View File

@@ -1,4 +1,4 @@
# RFC: 结果时刻的 applied-hunk diff 用于文件变更 # Agent Note: 结果时刻的 applied-hunk diff 用于文件变更
Status: implemented Status: implemented
@@ -8,7 +8,7 @@ Status: implemented
[tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。 [tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。
在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本「updated successfully」,没有 diff。
障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。 障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。
@@ -26,7 +26,7 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn
`meta` 是工具自有的 `unknown`,core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。 `meta` 是工具自有的 `unknown`,core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。
这是通用形态("工具附加持久化的结果展示"),而非 fs 特有的——任何工具都可以使用。 这是通用形态(「工具附加持久化的结果展示」),而非 fs 特有的——任何工具都可以使用。
### 2. 工具计算 hunk;后端返回 before/after(fs) ### 2. 工具计算 hunk;后端返回 before/after(fs)
@@ -56,6 +56,6 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn
## 相关 ## 相关
- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 - 补全了 [Tagged render-intent union](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` 事件上,因此回放可复现卡片)。 - 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)(before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)(`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。
- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。 - `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-02-tool-render-intent-union.md: 6b828c32fb79f43c0f974f2c9f032e11430de5b4 2026-07-02-tool-render-intent-union.md: c7adf8f405000ec7940f82e1e1e2406d86253461
2026-07-02-tool-render-intent-union.zh.md: 8c44178ca3e8986579e7ed696aec2f900705e9ad 2026-07-02-tool-render-intent-union.zh.md: c77455b2d8c47add16e36f1b8a8ac3bb12f02707

View File

@@ -1,4 +1,4 @@
# RFC: 用于工具调用展示的带标签 render-intent 联合类型 # Agent Note: 用于工具调用展示的带标签 render-intent 联合类型
Status: implemented Status: implemented
@@ -12,7 +12,7 @@ Status: implemented
- 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。 `packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot 回放路径)。
## 决策 ## 决策
@@ -45,7 +45,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
### 生产者映射 ### 生产者映射
- `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` 中 Read/Write/Edit 各分支逐字段对应。 - `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` 中 Read/Write/Edit 各分支逐字段对应。
- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` 和 `bash_output`/`bash_kill` → `generic`。 - `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` → `generic`。通用 `task_*` 控制工具拥有各自的 generic 卡片。
- `dsh-tool-todo` → `generic`。 - `dsh-tool-todo` → `generic`。
### 终端回退的归属 ### 终端回退的归属
@@ -72,11 +72,11 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
## 非目标 ## 非目标
- **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 RFC 自身推迟的后续工作,本 RFC 不涉及。 - **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 Agent Note 自身推迟的后续工作,本 Agent Note 不涉及。
## 相关 ## 相关
- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 RFC 即为那个联合类型。 - 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。
- 被 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 - 被 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。
- 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。 - 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。
- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。 - ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-03-filesystem-directory-listing-seam.md: 8920e54993eb1402bd310d6ce78fb9866de628c9 2026-07-03-filesystem-directory-listing-seam.md: c7db576ff3c7a56622f90a4400bd9297c9591bef
2026-07-03-filesystem-directory-listing-seam.zh.md: 5ca9b195d4bf5c1450c60451dd469a19dd54166f 2026-07-03-filesystem-directory-listing-seam.zh.md: 44996d4c680bb2a0c3e880a120e0f9bd420493cd

View File

@@ -1,4 +1,4 @@
# RFC: 为文件系统 seam 添加直接目录列举能力 # Agent Note: 为文件系统 seam 添加直接目录列举能力
Status: implemented Status: implemented

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 1819a730b214270304a8feb103a7f26e9c8d6e6e 2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 4211241b1b24fd461f5341bf8d777007a7a36482 2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 2342b89ab666a7987f78cfeabe6fa90ce9dd0cad

View File

@@ -1,4 +1,4 @@
# RFC: Prompt 变量与工具指导归属 # Agent Note: Prompt 变量与工具指导归属
Status: implemented Status: implemented
@@ -10,11 +10,11 @@ Status: implemented
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 **模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml` 和 `examples/acp-agent/cordis.yml` 的 `systemPrompt` 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。 **工具指导是 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 coding-agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 **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)",但重复项被静默接受。 **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)」,但重复项被静默接受。
## 决策 ## 决策
@@ -32,7 +32,7 @@ Status: implemented
### Persona 作为 order-0 section ### Persona 作为 order-0 section
`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此测量的正是用于压缩(compaction)的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 `dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的 prompt,稍后由 `ctx.tokenMeter` 为压缩压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。
### 工具指导归属 ### 工具指导归属
@@ -40,25 +40,25 @@ Status: implemented
### Subagent 对话历史描述符 ### Subagent 对话历史描述符
`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。 `SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。
## 曾考虑的替代方案 ## 曾考虑的替代方案
- **循环自行组合一行 identity 文本**:在必须保持精简的那个包("用插件,不改循环")中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) - **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
- **通过 `agent/request` waterfall 注入模型名称**:prompt 文本在两处组合,且 `agent/pre-step` 的 `fullSystemPrompt` 会遗漏它,导致 compaction 测量的 prompt 与模型实际看到的不一致。 - **通过 `agent/request` waterfall 注入模型名称**:prompt 文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在 prompt 中更早出现的声明。
- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 RFC 要治愈的病症。 - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。
- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。
- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**:`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**:`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。
- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。 - **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。
## 不在范围内 ## 不在范围内
- 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。 - 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 Agent Note 不认领任何一个。
- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到 session-cwd 方案重新讨论时。 - 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到 session-cwd 方案重新讨论时。
## 交付的不变式 ## 交付的不变式
- coding-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 - tui-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。
- fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。 - fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。
- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 - 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 快照回放与 prompt 无关:它按轮次和步骤索引已记录的 chunk 流,不比较发出的请求。 - 快照回放与 prompt 无关:它按轮次和步骤索引已记录的 chunk 流,不比较发出的请求。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-05-reconstructable-requests.md: b07dbceaf6f3ecad64a464df6b97ee2a32284768 2026-07-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0
2026-07-05-reconstructable-requests.zh.md: cb57d0915547b8a38b6afde7e36bec36c86e19c3 2026-07-05-reconstructable-requests.zh.md: c14738f8a2bc8fd25b66a3b3f82b650dd52231c1

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-05-subagent-provider-lifecycle-events.md: b5300706ce4c53a6d348a86803d221a4dcca9632 2026-07-05-subagent-provider-lifecycle-events.md: afd45027e8b56cbf1d17e6dec749d8602c81124d
2026-07-05-subagent-provider-lifecycle-events.zh.md: 8694612e5e3d4d65117896e337c170f47b31abb0 2026-07-05-subagent-provider-lifecycle-events.zh.md: be94de29d8fadafb08acc95f1eb15e2fb931b3bb

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-06-timeout-deadline-library.md: e8a688fbd5285c5e177eca5339bb204e2e4b01bc 2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4
2026-07-06-timeout-deadline-library.zh.md: 427aa4e10fe90a69b8773ed739d92e37754a4998 2026-07-06-timeout-deadline-library.zh.md: c6e5706e9a62877dc71d56951e82bc892b02a75e

View File

@@ -1,4 +1,4 @@
# RFC: 共享的超时/截止时间原语,硬终止留给各能力自行实现 # Agent Note: 共享的超时/截止时间原语,硬终止留给各能力自行实现
Status: implemented Status: implemented
@@ -20,7 +20,7 @@ Status: implemented
### 库的对外接口 ### 库的对外接口
三个函数加一个 reason 类型: 四个函数、一个 watchdog 接口加一个 reason 类型:
```ts ignore-check ```ts ignore-check
/** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */ /** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */
@@ -53,19 +53,34 @@ export function deadline(
code: string, code: string,
): { signal: AbortSignal; [Symbol.dispose](): void } ): { signal: AbortSignal; [Symbol.dispose](): void }
/** A stable signal plus one-at-a-time, timer-guarded async-iterator demand. */
export interface IdleWatchdog {
readonly signal: AbortSignal
next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
[Symbol.dispose](): void
}
/** Arm only while one iterator `next()` is outstanding, then rearm on later demand. */
export function idleWatchdog(
upstream: AbortSignal | undefined,
timeoutMs: number,
code: string,
): IdleWatchdog
/** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */ /** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined
``` ```
`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。 `deadline` 通过 `AbortSignal.any` 将上游信号与一次性定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。`idleWatchdog` 则要求正有限的间隔,在整个流期间保持一个稳定的融合信号,并且只在一个迭代器 `next()` 尚未结算时启动定时器;结算会解除定时器,后续 demand 会重新启动,并发 demand 会失败,dispose 会清除当前 arm。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。
### 职责划分 ### 职责划分
| 关注点 | 负责方 | | 关注点 | 负责方 |
|---|---| |---|---|
| 校验请求提示并钳位默认值/最大值 | `dsh-timeout`(`clampTimeout`):纯算术加共享的正有限请求契约 | | 校验请求提示并钳位默认值/最大值 | `dsh-timeout`(`clampTimeout`):纯算术加共享的正有限请求契约 |
| 启动定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) | | 启动一次性定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) |
| 清除定时器 | `dsh-timeout`(`[Symbol.dispose]`) | | 仅围绕未结算的迭代器 demand 启动和重启 | `dsh-timeout`(`idleWatchdog`) |
| 清除定时器 | `dsh-timeout`(任一原语的 `[Symbol.dispose]`) |
| 中止后对首个 abort reason 进行分类 | `dsh-timeout`(`timeoutOf`) | | 中止后对首个 abort reason 进行分类 | `dsh-timeout`(`timeoutOf`) |
| **实际终止工作** | 各能力的实现 | | **实际终止工作** | 各能力的实现 |
| 默认值/最大值*数值* | 各能力的配置 | | 默认值/最大值*数值* | 各能力的配置 |
@@ -77,6 +92,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
- **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。
- **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 - **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。
- **LLM 适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在 chunk 之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。
## 后果 ## 后果
@@ -84,6 +100,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
- `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传入 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传入 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。
- web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。
- `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。
- 模型流现在共享一个可重启的定时器契约,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。
以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其 tool-schema/snapshot 覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其 tool-schema/snapshot 覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-07-tool-call-timeout-policy.md: 20e0b5d70166404e034c0d0ece75cec61cab0df9 2026-07-07-tool-call-timeout-policy.md: 538225b197f7dd62b4798c037b72bf3cfc40648a
2026-07-07-tool-call-timeout-policy.zh.md: 00b85ce9e2e7ae4ba31a9770beefce9c2eb8c319 2026-07-07-tool-call-timeout-policy.zh.md: e37c8099a7780a8aa44f3154ceaf7ffa772f1e3b

View File

@@ -1,4 +1,4 @@
# RFC: 工具调用超时策略作为插件 # Agent Note: 工具调用超时策略作为插件
Status: implemented Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题 ## 问题
[超时/截止时间 RFC](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 [超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。
与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子调用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子调用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。
@@ -52,9 +52,9 @@ catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重
searchTimeoutMs: 30000 searchTimeoutMs: 30000
``` ```
超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消契约](2026-07-19-cooperative-tool-cancellation.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。
信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的惯用方式——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的(可能已中止的)截止信号。 信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的截止信号。
`timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止代码(有作用域,使嵌套的外层截止读为普通取消)和结构化工具结果错误代码。其替换结果为: `timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止代码(有作用域,使嵌套的外层截止读为普通取消)和结构化工具结果错误代码。其替换结果为:
@@ -80,13 +80,13 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
`bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background`;`dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。 `bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background`;`dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。
`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们而言要么只能尽力而为,要么没有必要。 `read`、`write`、`edit`、`todo_write`、`task_list` 和 `task_kill` 不加入工具调用超时。`task_output` 自己拥有有界等待,因为等待超时是成功的实时状态结果,而非工具失败。
未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现而无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;这不在本次范围内。 未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现而无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;这不在本次范围内。
## 曾考虑的替代方案 ## 曾考虑的替代方案
**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包(package)为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 **将插件命名为 `tool-timeout`。** 字面的 Agent Note 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包(package)为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。
**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的既有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。它对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。 **仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的既有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。它对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。
@@ -100,12 +100,12 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 **使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。
**使用 `Promise.race` 对非协作工具强制超时。** 与超时库 RFC 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 **使用 `Promise.race` 对非协作工具强制超时。** 与超时库 Agent Note 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。
## 后果 ## 后果
- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。 - `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。
- 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 - 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。
- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 - 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这一未达静止状态的工具体,而不是竞速它;同时插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。
- 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 - 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。
- 与字面提案的偏差,按 implemented-RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 - 与字面提案的偏差,按 implemented-Agent Note 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-08-agent-scope-contexts.md: b67d51f06f74f0460f76e9fc4b45699d00b7db46 2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08
2026-07-08-agent-scope-contexts.zh.md: e37ea60418095cb34d354150aed79a937b1b3552 2026-07-08-agent-scope-contexts.zh.md: ff8243ebdd541e4a7821d9c4ff99217a8060c2b4

View File

@@ -1,4 +1,4 @@
# RFC: agent 即注册作用域 # Agent Note: agent 即注册作用域
Status: implemented Status: implemented
@@ -16,7 +16,7 @@ Status: implemented
每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的 context 进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的 context 进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。
Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../cordis-primer.md)对该框架有更详细的说明。 Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。
对大多数贡献者而言,完整契约是四条规则: 对大多数贡献者而言,完整契约是四条规则:
@@ -45,7 +45,7 @@ flowchart LR
缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。
配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
### 注册来源决定可见性与清理 ### 注册来源决定可见性与清理
@@ -62,8 +62,7 @@ flowchart LR
```js ```js
const handle = await ctx.agents.create({ const handle = await ctx.agents.create({
agentId: AgentId('reviewer'), sessionId: SessionId('reviewer'),
sessionId: SessionId('reviewer-session'),
agentOptions: { model: 'model-name' }, agentOptions: { model: 'model-name' },
setup(agentCtx) { setup(agentCtx) {
agentCtx.systemPrompt.section({ agentCtx.systemPrompt.section({
@@ -105,7 +104,7 @@ setup 接收一个完整的受信 Cordis context,因此可以组合普通插
在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。
以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。 以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../../docs/cordis-catalog/events.md)是详尽的事件参考。
### 创建最后发布,dispose 最后撤销 ### 创建最后发布,dispose 最后撤销

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-12-agent-scope-runtime-design.md: 1940423db9364f56ab8a13e4d636f492f97f2d54 2026-07-12-agent-scope-runtime-design.md: ff7fba1e6f8d496080acbceddb06691c8cddc5f5
2026-07-12-agent-scope-runtime-design.zh.md: bbfb06db7eb886f7bc34cdb8607729fb892cf5ba 2026-07-12-agent-scope-runtime-design.zh.md: 0a101e796658dd48bc76425fb39362f35e7e345e

View File

@@ -1,4 +1,4 @@
# RFC: Agent 作用域运行时设计与正确性 # Agent Note: Agent 作用域运行时设计与正确性
Status: implemented Status: implemented
@@ -14,13 +14,13 @@ Status: implemented
## 决策 ## 决策
运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体;每个活跃的注册表对象有一条入口记录;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和静默态。 运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体与共享 layer store;每个活跃的注册表对象有一条入口记录;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和静默态。
该设计可概括为七项选择: 该设计可概括为七项选择:
| 问题 | 权威机制 | | 问题 | 权威机制 |
|---|---| |---|---|
| 选择全局加某个 agent 的注册 | 不透明作用域键与路由载体 | | 选择全局加某个 agent 的注册 | 不透明作用域键、路由载体与共享 layer store |
| 拥有一个活跃的 agent 或会话 | 由其 disposer 捕获的单条注册表入口 | | 拥有一个活跃的 agent 或会话 | 由其 disposer 捕获的单条注册表入口 |
| 协调创建/恢复 | 单个 `AgentCreationTransaction` | | 协调创建/恢复 | 单个 `AgentCreationTransaction` |
| 保护持久化、队列、模型或协议格式数据 | 在该边界处一次性物化 | | 保护持久化、队列、模型或协议格式数据 | 在该边界处一次性物化 |
@@ -28,9 +28,9 @@ Status: implemented
| 组合模型可见的 prompt 与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 | | 组合模型可见的 prompt 与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 |
| 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/静默态事实 | | 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/静默态事实 |
本 RFC 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。 本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。
[7 月 8 日 RFC](2026-07-08-agent-scope-contexts.md) 仍然是贡献者契约。独立的 [subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 [7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md)仍然是贡献者契约。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。
## Cordis 模型:context、fiber、effect、receiver 与 waterfall ## Cordis 模型:context、fiber、effect、receiver 与 waterfall
@@ -70,11 +70,11 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个
Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。
### 注册表读取叠加一个精确映射 ### 注册表读取叠加一个精确 layer
作用域感知的注册表将全局贡献与按标识键索引的局部贡献分开存储。读取解析全局层和至多一个局部层;它从不遍历父级链。 作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个 context 派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。
每个服务保留其领域规则。命名 prompt 值和工具使用局部遮蔽,工具限制在添加局部工具之前过滤全局,事件选择监听器受众而非注册数据。Scope 提供标识和所有权,而非通用的合并算法。 每个服务保留其领域规则。命名 command 和 prompt 视图使用共享的、保持插入顺序的 shadow merge;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。Prompt 变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。
### 融合 dispatch 辅助函数防止主体漂移 ### 融合 dispatch 辅助函数防止主体漂移
@@ -208,7 +208,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造
私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。
[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule)拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。
`ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。 `ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。
@@ -324,19 +324,19 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进
### 运行时不变式覆盖跨服务事实 ### 运行时不变式覆盖跨服务事实
不变式插件验证每个声明的作用域事件使用带标记的载体,以及暴露主体的事件族使用匹配的键。Session trace 验证在追加提交前暂存,并在同一事件提交后推进。 `dsh-scope/invariant` 配套插件在被选用时验证每个声明的作用域事件使用带标记的载体,以及暴露主体的事件族使用匹配的键。独立的 `dsh-session/invariant` 贡献在追加提交前暂存 trace 验证,并在同一事件提交后推进;二者都通过 `ctx.invariants` 注册。
该插件不通过扫描注册表来管控可信 setup,也不拒绝通过强制转换构造的 prompt assembly 对象。这些检查会将组合契约变成推测性的运行时机制,却不保护真实的外部边界。 该插件不通过扫描注册表来管控可信 setup,也不拒绝通过强制转换构造的 prompt assembly 对象。这些检查会将组合契约变成推测性的运行时机制,却不保护真实的外部边界。
### 生成的产物使公开契约保持对齐 ### 生成的产物使公开契约保持对齐
事件目录、服务目录、生产者/消费者矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 RFC](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 事件目录、服务目录、生产者/消费者矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md)拥有 Program 构造、语义事件发现和解析器生成规则。
行为测试固定了作用域路由和 dispose、最终入口碰撞清理、发布回滚、有序静默、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 行为测试固定了作用域路由和 dispose、最终入口碰撞清理、发布回滚、有序静默、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。
## 曾考虑的替代方案 ## 曾考虑的替代方案
[7 月 8 日 RFC](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域契约的替代方案。此处的替代方案关注实现形态。 [7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md#alternatives-considered)拥有公开扁平作用域契约的替代方案。此处的替代方案关注实现形态。
### 使用透明代理作为作用域载体 ### 使用透明代理作为作用域载体

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-14-acp-agent-client-protocol.md: 50ad2620de1dada78c4a20101b631cc5b0e9f3d1 2026-06-14-acp-agent-client-protocol.md: c6976ed28a254684fca62e2d309cdde7ea90340d
2026-06-14-acp-agent-client-protocol.zh.md: c4b73c00083d63e5c024953cd8c9d8dbd94d6b0a 2026-06-14-acp-agent-client-protocol.zh.md: 711e8c2a7f7146494fa208942523e2ebfa1808a5

View File

@@ -1,4 +1,4 @@
# RFC: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent # Agent Note: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent
Status: implemented Status: implemented
@@ -50,7 +50,7 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本
编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。
桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。 桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。
空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。 空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-14-acp-multi-session.md: a71f2d3d2daff3c8fae460e414d1f50facd5538f 2026-06-14-acp-multi-session.md: 43aa32a43fef7aa69f5efd45d4c8176da9f459cd
2026-06-14-acp-multi-session.zh.md: 8b5ba46e949480d450f13aadad3ea82e0e048161 2026-06-14-acp-multi-session.zh.md: 012752273b469295fcf8338f809dca252fc377ac

View File

@@ -1,4 +1,4 @@
# RFC: 在单个连接上多路复用并发 ACP 会话 # Agent Note: 在单个连接上多路复用并发 ACP 会话
Status: implemented Status: implemented
@@ -10,16 +10,24 @@ Status: implemented
## 决策 ## 决策
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,用于 agent 作用域的回调。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。 ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。
每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。 每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。
权限归属使用同一个反向索引。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。 权限归属使用对正向 map 的同一精确 agent 检查。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output` 和 `bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。 后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output` 和 `bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。
连接拆除时清空活跃 map,将每个待处理的 prompt 以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。 连接拆除时清空活跃 map,将每个待处理的 prompt 以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。
## 协议与工作区作用域
[ACP v1 明确允许一个连接上存在多个并发会话](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24),每个新会话都携带自己的主 `cwd`。本桥实现该会话级多路复用,其中包括[按会话 cwd 决策](../architecture/2026-07-02-fs-per-session-cwd.md)所记录的不同主工作区;它不会为每个会话创建一个 agent 子进程。
一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。[Zed 仅在 agent 公布该能力时发送其余项目工作目录](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145),否则会[从会话请求中丢弃这些目录](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472)。如其[已知限制](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work)所记录,桥不公布该能力,并拒绝非空值,因此当前 Zed 多根项目到达桥时只携带第一个工作目录。
[标准传输是每个 stdio 连接一个由编辑器启动的 agent 子进程](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42);多个编辑器连接因此需要多个子进程或自定义传输,而本决策保证的是一个连接内部存在多个会话。在该连接内,`ctx.sandboxPolicy` 把每个会话的 `cwd` 解析为其自己的 `workspace-write` 根目录,因此共享的 bash 和文件系统服务可以服务并发项目而不授予跨项目写入。这不会添加 ACP `additionalDirectories`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。
## 曾考虑的替代方案 ## 曾考虑的替代方案
**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。 **每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-15-code-mode.md: fb95f3010121fa432b3ac24e6aa477b1205a0ba7 2026-06-15-code-mode.md: f403e87601aad0fda5d53ec5b94ba44452e79b49
2026-06-15-code-mode.zh.md: 2da6628c1842bfd507c62d4b66a91f323e6fd0e7 2026-06-15-code-mode.zh.md: b4b432a6ce2206ea4920f89275d431ed2d3d0c44

View File

@@ -1,4 +1,4 @@
# RFC: Code Mode——模型针对工具注册表编写 TypeScript # Agent Note: Code Mode——模型针对工具注册表编写 TypeScript
Status: implemented Status: implemented
@@ -6,20 +6,20 @@ Status: implemented
## 问题 ## 问题
在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../../docs/architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。
工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。 工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。
## 决策 ## 决策
三项决策,各自在下方独立小节中展开: 三项决策,各自在下方独立小节中展开:
1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。
2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。
### 注册表拥有模式 ### 注册表拥有模式
@@ -40,15 +40,15 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: 在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`:
1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。
2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。
**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的相邻性,而一个程序可以产生多个 context。支持它需要一个复数通道或循环级别的子分发缓冲区。 **子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute block 会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 **并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。
**呈现。** `run_code` 的 render intent 按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 **呈现。** `run_code` 的 render intent 按 [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。
### 可观测性:`tool/code-dispatch` ### 可观测性:`tool/code-dispatch`
@@ -62,7 +62,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。 - `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。 - `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` - `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时。 - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时。
- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 - 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。
@@ -76,7 +76,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。 3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。
4. **通过 message port 桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。 4. **通过 message port 桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。 5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。
6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。 6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.md)。
### 信任姿态 ### 信任姿态
@@ -88,18 +88,18 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw
## 后果 ## 后果
切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,桥不传播每次调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。 切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。
## 测试 ## 测试
- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 - **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。 - **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层 block 抑制以及 HMR(热模块替换)清理。
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、结果文件和策展后的回答。 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。
- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段、请求头工具列表、分发事件和结果卡片。 - **快照:** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。
## 曾考虑的替代方案 ## 曾考虑的替代方案
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。 **一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 **`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
@@ -121,7 +121,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw
**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。 **`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。
**SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 RFC 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 **SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。
**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 **注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-17-filesystem-tool-schemas.md: dba96157a0df548e4f153a0e233238af7400c087 2026-06-17-filesystem-tool-schemas.md: fa23dccc7ae98a9c25b9c474a75dc1e2f8e5ff21
2026-06-17-filesystem-tool-schemas.zh.md: 3c135de9fa81fb333abc5fa6001a7ce7d7525a9d 2026-06-17-filesystem-tool-schemas.zh.md: 30acf26a9bb2858a9f786591b6e01626593713d0

View File

@@ -1,4 +1,4 @@
# RFC: 文件系统工具 schema——面向模型的读/写/编辑接口形状 # Agent Note: 文件系统工具 schema——面向模型的读/写/编辑接口形状
Status: implemented Status: implemented
@@ -6,9 +6,9 @@ Status: implemented
## 问题 ## 问题
[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包(package)拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFC 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 [文件系统能力 seam Agent Note](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包(package)拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) Agent Note 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。
该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有接口。 该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 Agent Note 为原型选择最小的共有接口。
## 决策 ## 决策
@@ -105,8 +105,8 @@ schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒
## 后果 ## 后果
**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非对初始 schema 的重载。 **首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 Agent Note 或聚焦的后续工作形式到来,而非对初始 schema 的重载。
**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。 **v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。
**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选择 snake_case,并将其视为稳定的面向模型的契约。 **命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 Agent Note 预先选择 snake_case,并将其视为稳定的面向模型的契约。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-18-acp-terminal-and-tool-rendering.md: 9bcb65a1e0b316d0b596a80816647d46ccfca782 2026-06-18-acp-terminal-and-tool-rendering.md: 166e52f8e659a78b1347279c61956ca1001e4939
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 3899e3ed21b386b1652736bc3de41d338efaa30e 2026-06-18-acp-terminal-and-tool-rendering.zh.md: 924ea9fb50a77314748d4826b5c408d5243b32f9

View File

@@ -1,4 +1,4 @@
# RFC: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 # Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
Status: implemented Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题 ## 问题
ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。 参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。
@@ -45,4 +45,4 @@ Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall`
## 超出范围 / 非目标 ## 超出范围 / 非目标
文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 RFC:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。 文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-18-compaction-capability-seam.md: e88b3fd8267f65bba136398df9c439e371237912 2026-06-18-compaction-capability-seam.md: 07da796fdabffb8a43950ad501f5623d4474923a
2026-06-18-compaction-capability-seam.zh.md: 75049079a2d85e27301e53b891228c1cbc87fac3 2026-06-18-compaction-capability-seam.zh.md: 18ef9a8d833bdaa924324867d522399c0ad48243

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-06-18-compaction-capability-seam.zh.md)
## Problem ## Problem
A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact.

View File

@@ -1,4 +1,4 @@
# RFC: 压缩作为能力 seam(抽象契约 + 基础后端) # Agent Note: 压缩作为能力 seam(抽象契约 + 基础后端)
Status: implemented Status: implemented
@@ -8,57 +8,58 @@ Status: implemented
长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即截断响应(`max-tokens`)或性能退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。 长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即截断响应(`max-tokens`)或性能退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。
[session surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一条建立在事件日志之上的链表,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 [session surface](../architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 记录溯源信息以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。
两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——它们独立于*何时*以及*压缩哪段范围*而变化。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上附加 `surfaceOp`,invariants 插件在运行时也会拒绝。 两股力量塑造了设计。第一,压缩策略与可复用的 token 测量独立变化:测量归 LLM 系列的 [`ctx.tokenMeter` 服务](../architecture/2026-07-15-replay-token-meter-service.md)所有,摘要生成则可以使用模型调用、模板或远程服务。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上,编译器与 Session 始终启用的 append/seed 边界都会拒绝在其上附加 `surfaceOp`。
## 决策 ## 决策
### 压缩是一个能力 seam,接口与实现分离 ### 压缩是一个能力 seam,接口与实现分离
遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进: 遵循[能力 seam Agent Note(agent 决策记录)](../architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进:
1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。 1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。
2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,拥有完整算法——token 估算(每 token 字符数,即 `charsPerToken` 配置,默认 4,加上每块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 进行摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是同级包(或覆盖两个 protected 估算/摘要钩子的子类)。 2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,消费 `ctx.tokenMeter`,并拥有尾→头保留遍历、通过 `ctx.llm.stream()` 生成摘要、surface 替换、锁、步骤后压力处理和规范的上下文溢出恢复。`summarize()` 是其唯一的子类钩子;计价与回放仍归 meter 所有。
3. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 RFC 范围之外,以便 seam 先稳定下来。 3. **无模型配套服务** — `@deepseek-ai/dsh-compact-tool-result-prune`:一个具体的可选服务,在后端选择摘要范围之前,重写当前过大的 `tool/result` 节点。它不是第二种压缩实现,也不实现 `CompactService`。
4. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 Agent Note 范围之外,以便 seam 先稳定下来。
### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离 ### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离
能力 seam RFC 规定接口包"仅依赖 cordis"(对 `dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义*在* `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约就无法表达。 能力 seam Agent Note 规定接口包「仅依赖 cordis」(对 `dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词作用于 agent 所有的 `Session`(`compactRegion(start, end, agent)`),其输出使用内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约就无法表达。
这不是耦合异味,而是契约的领域所在。"仅 cordis"的指导原则一直是"接口仅依赖契约真正需要命名的东西,绝不依赖实现"的简写。`dsh-session` 和 `dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 这不是耦合异味,而是契约的领域所在。「仅 cordis」的指导原则一直是「接口仅依赖契约真正需要命名的东西,绝不依赖实现」的简写。`dsh-session` 和 `dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。
### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端 ### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端
早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,仅 `estimateContentTokens()` 和 `summarize()` 为抽象。这会将契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——并让接口保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆盖而无需重新实现遍历——但那是后端的私有关注点,不是契约的。 早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。
`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接收**必需**参数(而非最初的全可选形式)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)和轮次的 abort signal,因此可选性只会在 seam 处引入隐藏的默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件在那里已经选择了实际模型。 `compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为手动调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。
### 自动压缩在 `agent/pre-step` 运行——一个专用的 surface 变更 seam ### 成功的持久步骤工作完成后运行自动压力检查
压缩会变更 session surface,因此在步骤开启之前、消息派生之前运行。`agent/request` 保持为调用配置变换,无需在 surface 变更后重建历史。 成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。
解决方案是一个专用的循环 seam:**`agent/pre-step`**(`@mode serial`),由循环在系统组装*之后*、步骤开启(`step/start`)*之前*触发: 规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误与连续重试次数,compact-basic 在强制执行一次有效且平衡的缩减前先修剪。仅当 `session.surface.replaceGeneration` 增加时,它才返回 retry;这包括没有摘要范围时仅修剪取得的进展。随后循环开启新的编号步骤,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。
``` ```
assembly = ctx.systemPrompt.assemble() assistant/message → tool/result/context/steering
await ctx.serial('agent/pre-step', agent, turn, step, system, signal) ⟵ compaction mutates the surface here await serial agent/post-step ⟵ pressure compaction inside the successful step
session('step/start') ⟵ the step opens AFTER the seam step/end
messages = session.deriveMessages() ⟵ single derive, reflects the compaction
request = waterfall agent/request ⟵ pure request transform (hooks, model switch)
```
循环在 `agent/pre-step` 之后派生一次消息。在 `step/start` 之前运行,使压缩记录位于任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且串行的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。 provider overflow → step/end
await waterfall agent/request-error ⟵ forced compaction between attempts
retry → next numbered step/start ⟵ derives from the replacement surface
```
### 保留是轮次无关的;工具配对平衡是唯一的结构守卫 ### 保留是轮次无关的;工具配对平衡是唯一的结构守卫
自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 在*一轮之内*就会增长。单独一轮就可能超出窗口("失控轮次"),而在下一次模型调用溢出之前唯一能挽救的时机是下一步的 `pre-step` 检查点。如果将压缩限制在轮次的第一步(或者更糟,逐字保留整个进行中的轮次),恰好重新打开了压缩存在的意义所要堵住的缺口:harness 会在最需要压缩时崩溃。 自动压缩在**每个成功的**步骤之后检查,而非每轮一次。这对失控轮次存活至关重要:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 会在一轮之内增长。步骤后检查可以在后续步骤开启前压缩早期已关闭的工具对;如果请求率先越过限制,由提供方确认的溢出仍是兜底机制。
`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。 `compactIfNeeded` 保留估算大小达到解析后保留 token 预算的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`dsh-compact` 导出前后边缘辅助函数;只要 `replaceGeneration` 不变,其逐会话缓存就只折叠新增的 surface 尾部节点,面对仅日志增长时不读取事件,并在替换后重建当前成员关系与平衡。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。
因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`)并在该步骤关闭后重试。 因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`)并在该步骤关闭后重试。
**单单元溢出不在范围内,这是有意为之。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点(如粘贴的 `user/message`)——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地指出这一点,而非掩盖。 **部分单单元溢出仍不在范围内。** 摘要范围选择无法拆分不可分割的单元。当可移除的文本型工具结果内容占据大部分空间,且修剪后的余量能够容纳时,可选修剪器可以修复一个已关闭的工具对。仅信封压力、粘贴的 `user/message` 等不可分割的超大非工具节点,以及不可修剪余量仍然过大的工具单元,依旧不属于压缩范围;限制这些单元是另一个关注点。
### 头部锚定:一个自动检查点,始终在头部 ### 头部锚定:一个自动检查点,始终在头部
@@ -66,11 +67,11 @@ request = waterfall agent/request ⟵ pure request transform (hooks, model s
### 近似收敛不变式 ### 近似收敛不变式
`resolveConfig` 校验数值参数,但不基于虚构的摘要长度不变式来拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显式的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 仅是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于其遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能将保留尾部推过预算),这恰好是上述范围外的关注点,而非抖动 bug。 `resolveConfig` 提供可用默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、空的摘要提供方/模型覆盖、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 以及 `auto: true`。可选的精确提供方/模型策略会部分覆盖顶层默认值;压力根据拥有该路由的 LLM 适配器所报告容量缩放比例,而 `retainTokens` 可以替代按比例保留。保留量必须低于最终阈值。收敛仍然是动态的,因为提供方输出上限可能被隐藏或显式的推理 token 消耗,摘要大小也不可预测。如果压力仍高于阈值,`compactIfNeeded()` 会按配置的重试次数再次压缩头部检查点,但每次提交的摘要必须小于其遮蔽的内容。溢出不需要容量元数据,并会绕过阈值和保留尾部策略,执行一次最大且平衡的头部缩减,留下最新的不可分割单元。所有权划分由[已路由模型上下文与压缩策略 Agent Note](../architecture/2026-07-20-routed-model-context-and-compaction-policy.md)规定。
### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要 ### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要
由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的节点*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件: 由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的条目*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 溯源信息)。surface 变更位于锁**内部**,`compact/end` 是最后追加的事件:
``` ```
compact/start → log-only. Acquires the lock. compact/start → log-only. Acquires the lock.
@@ -81,7 +82,7 @@ user/message → surfaceOp { op:'replace', start, end }. THE surface mutatio
compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). compact/end → log-only. Releases the lock (carries `error` on a recoverable failure).
``` ```
`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。 `deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_entries]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。
### 检查点框架 + 增量合并(后端私有) ### 检查点框架 + 增量合并(后端私有)
@@ -92,12 +93,14 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
`compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序: `compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序:
1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。 1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。
2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是重入绊线——抛出"already in progress"表示真正的 bug。) 2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在任一 awaited 自动 seam 上都是单线程的,因此这也是重入绊线——抛出「already in progress」表示真正的 bug。)
该锁只排除另一项压缩,不排除无关的仅日志事实。基础后端会在 `compact/start` 之后对 token meter 的 surface 节点取快照,并在异步摘要后再次比较;任何 surface 变更都会使替换前的检查失败,而标题或其他仅日志追加不会使已选范围失效。
两种失败路径,均有文档记录: 两种失败路径,均有文档记录:
- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,因此完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。 - **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**,不会落地摘要替换。派生 surface 保持为 `compact/start` 时已经持久化的 surface:如果修剪未产生替换,就是完整历史;如果已经修剪,就是已修剪历史。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。
- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,surface 保持不变,模型调用以完整历史继续。 - **可恢复**(摘要生成抛出异常但循环存活):后端追加设置了 **`error`** 字段的 `compact/end`,但不落地摘要替换。步骤后压力处理发出警告,并从最新的持久 surface 继续:如果尝试前没有替换,就是完整历史;如果修剪已经落地,就是已修剪 surface。溢出恢复只会在没有任何替换前委托;先前修剪带来的 generation 进展允许从该持久 surface 重试,除非取消或资源释放胜出。
`compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。 `compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。
@@ -105,23 +108,24 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
## 曾考虑的替代方案 ## 曾考虑的替代方案
- **完整算法作为接口的具体方法**(仅估算/摘要为抽象)——早期草案;否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。 - **完整算法作为接口的具体方法**——否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;可复用测量属于单独的 LLM 系列服务,`summarize()` 是 basic 唯一的钩子。
- **在 `agent/request` waterfall(瀑布式事件)上执行压缩**——早期方案;否决,因为它强制双重派生,且将监听器上下文交给了结构上无法压缩的对象。专用的 `agent/pre-step` seam 从构造上使分层正确。 - **在 `agent/request` 或临时 `agent/pre-step` 输入上执行压缩**——否决,因为两者都无法证明最终的持久请求,而且都会将通用生命周期耦合到压缩专属的信封数据。步骤后回放与规范溢出恢复同时覆盖成功和被拒绝的调用。
- **`compact` 布尔值或无类型的请求元数据 map**——否决,因为多个辅助调用种类会变成互斥标志,而开放 map 会丢弃由编译器检查的词汇。一个类型化的 `purpose` 判别字段可以扩展其他调用种类,而无需再为 `GenerateOptions` 添加字段。
- **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。 - **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。
- **教导核心轮次修复识别 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。 - **教导核心轮次修复识别 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。
## 后果 ## 后果
- **新包**:`packages/compact/compact`(接口)和同级的 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。 - **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写。`packages/llm/token-meter` 独立拥有回放感知的测量。消费方层推迟。
- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是对循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。 - **自动 seam**:`agent/post-step`(`@mode serial`)处理成功调用的压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。通用 `agent/pre-step` 保持为四参数检查点,不携带压缩专属的提示词/前缀 payload。
- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。
- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆分步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在并被复用。 - **`dsh-compact`** 拥有 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`;这些带缓存的 surface 边缘检查由 `compactRegion` 和 `compactIfNeeded` 用来避免拆分工具调用/结果对。缓存按 seq 校验当前成员关系,并从每个切割点的一条平衡序列回答两侧边缘;陈旧或缺失的 seq 以及孤立结果都会被拒绝。
- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩将高序号替换节点放在较旧范围的*位置*上,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次封闭不变式原样复用。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用;已校验的替换仍是位于轮次内的重写。
- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中生效(此前它未被任何地方加载)。 - **接线**:`examples/tui-agent/cordis.yml` 依次加载零配置的 `dsh-token-meter`、`dsh-compact-tool-result-prune` 和 `dsh-compact-basic`;服务级默认值使组合无需重复数值策略即可使用。
## 测试 ## 测试
- **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。 - **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、修剪配置与回放、富块顺序、元数据保留、收敛、`compact/end` 的两种结果、开放尾部拒绝、仅修剪与带摘要的溢出恢复、generation 证明、上限和原始错误保留。
- **循环测试:** 测试固定每步在 `turn/start` 与 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在该处的 surface 变更落在步骤之外,并出现在单次派生的请求中。 - **循环测试:** 测试固定步骤后处理发生在持久工具结果之后、`step/end` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。
- **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。
- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 - **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-21-subagent-capability-seam.md: ff8ca7d6292055715d94b3878860bffafb8a8057 2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47
2026-06-21-subagent-capability-seam.zh.md: c11623531c753cd454621f450f33cff5edbbf815 2026-06-21-subagent-capability-seam.zh.md: 9cab992e86120dd32cedea05b63c777b8678d409

View File

@@ -2,6 +2,8 @@
Status: implemented Status: implemented
English | [中文](2026-06-21-subagent-capability-seam.zh.md)
> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its Agent Note](2026-06-22-acp-subagent-backend.md)). > The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its Agent Note](2026-06-22-acp-subagent-backend.md)).
## Problem ## Problem

View File

@@ -1,18 +1,18 @@
# RFC: Subagent 能力 seam # Agent Note: Subagent 能力 seam
Status: implemented Status: implemented
[English](2026-06-21-subagent-capability-seam.md) | 中文 [English](2026-06-21-subagent-capability-seam.md) | 中文
> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 RFC](2026-06-22-acp-subagent-backend.md))。 > 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 Agent Note](2026-06-22-acp-subagent-backend.md))。
## 问题 ## 问题
harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智能体)将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 RFC 实现了这个 seam;上方横幅列出了已交付的内容。 harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智能体)将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 Agent Note 实现了这个 seam;上方横幅列出了已交付的内容。
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP(Agent Client Protocol))。我们预见的传输方式: 决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP(Agent Client Protocol))。我们预见的传输方式:
- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。 - 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。
@@ -20,7 +20,7 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智
### 为何不采用 bash seam 的形状 ### 为何不采用 bash seam 的形状
bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。 bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。
## 决策 ## 决策
@@ -34,7 +34,6 @@ bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-s
| `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent | | `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent |
| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent | | `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent |
| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 | | `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 |
| `@deepseek-ai/dsh-subagent-mock` | 辅助:用于通过真实加载路径测试 seam 的脚本化提供方 |
| `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 | | `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 |
### 原语:异步 `start → SubagentRun` ### 原语:异步 `start → SubagentRun`
@@ -64,11 +63,11 @@ bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-s
## 测试 ## 测试
seam 通过真实的 Cordis Loader/export 路径测试,这能捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状错误。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。提供方与消费方的 export 形状仍保留 Loader 回归覆盖,以防止[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。
## 后果 ## 后果
- **递归。** 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭状态,并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界。 - **递归。** 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭状态,并拒绝此类请求。[subagent 组合控制 Agent Note](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界。
- **阻塞父轮次。** 同步收集在子 agent 的整个持续时间内保持父 agent 的 `runStep` 打开。这对首版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该设计将统一 subagent 和 bash 的长时间运行工具处理**(一个 subagent 和一个长时间运行的 `bash` 后台任务面临相同的问题——「模型启动了一个慢操作,之后如何收集结果」——应共享一套机制,而非各自发明)。 - **阻塞父轮次。** 前台收集在子 agent 的整个持续时间内保持父 agent 的步骤打开。后台委派使用共享的 `ctx.tasks` 运行时与通用 `task_*` 工具,与后台 bash 共用同一套收集机制;subagent seam 本身仍不感知任务。
- **实时进度。** 本版仅暴露生命周期事件与最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理。 - **实时进度。** 本版仅暴露生命周期事件与最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理。
- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;首版不声明这两项能力,子 agent 在自己的进程中自行服务。 - **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;首版不声明这两项能力,子 agent 在自己的进程中自行服务。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-22-acp-subagent-backend.md: c02b027177ae96d75ff0d3dcac227145fe71b340 2026-06-22-acp-subagent-backend.md: a45ce5e34873249969bbe4dabb87a89d10246b3d
2026-06-22-acp-subagent-backend.zh.md: 2b4db4e20fd872d21ffb72b1c2a4432c6f0103a9 2026-06-22-acp-subagent-backend.zh.md: f313c11b22dc6aae6d1d1e72fce2381a572ded8d

View File

@@ -1,4 +1,4 @@
# RFC: ACP subagent 后端(进程外委派) # Agent Note: ACP subagent 后端(进程外委派)
Status: implemented Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题 ## 问题
subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在同一个 Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的进程外子 agent,以证明该抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。 subagent seam([seam Agent Note](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在同一个 Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的进程外子 agent,以证明该抽象能跨越进程边界泛化。本 Agent Note 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。
## 决策 ## 决策
@@ -18,11 +18,15 @@ subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计
### 最小化客户端桩 ### 最小化客户端桩
客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam RFC 所述。 客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam Agent Note 所述。
### 无启动时能力 ### 无启动时能力
提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无权访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),本阶段也未实现 `outputSchema`。如果请求需要其中任何一项,服务在 `start` 运行前即拒绝。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`。 提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无权访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),本阶段也未实现 `outputSchema`。如果请求需要其中任何一项,服务在 `start` 运行前即拒绝。后端仅注入 `subagents`(而非 `ctx.agents`);它从 `request.parent` 读取的唯一内容是会话 header 的 cwd(见下方工作区解析)——对话上下文、深度和工具状态都不会跨越进程边界。
### 工作区 cwd 解析
子进程工作目录来自显式解析,绝不使用 harness 进程的 cwd:若已配置部署 `cwd` 覆盖,则相对于启动目录将其转为绝对路径并在加载时验证;否则使用父会话 header 的 cwd 并在启动时验证;如果两者都不存在,则在生成任何进程前大声拒绝。一个 ACP 服务端进程会服务来自多个工作区的会话,因此 `process.cwd()` 不能代替会话工作区——旧的隐式回退会让子进程在服务端启动目录中运行。候选路径必须是 harness 可以进入的绝对目录(要求 `X_OK`;仅 `statSync().isDirectory()` 会接受 mode-600 的目录,而 spawn 会因 EACCES 失败);解析出的同一路径同时用作子进程 cwd 与 ACP `session/new` 工作区。
### StopReason 映射 ### StopReason 映射
@@ -35,6 +39,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`
## 测试 ## 测试
- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。 - **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
- **无需密钥的 Loader 组合测试:** 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的 `cwd`;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。
- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。 - **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。
- **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。 - **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。
@@ -54,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`
## 后续提供方 ## 后续提供方
同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam RFC 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-25-ask-user-question.md: e28d34e7b13eb8920db1eb77f4cc66aec58c3525 2026-06-25-ask-user-question.md: 97bb6c29c7c0677a9c0c2c9fe3007dd28e31adee
2026-06-25-ask-user-question.zh.md: 86194b4eeca2761b0af412f1510a688ef48b0422 2026-06-25-ask-user-question.zh.md: 1ed916e39b3460d32e7e308c8ac89b8e09b763ac

View File

@@ -1,4 +1,4 @@
# RFC: ask-user 提问能力 # Agent Note: ask-user 提问能力
Status: implemented Status: implemented
@@ -22,9 +22,9 @@ Provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终
## UI 映射 ## UI 映射
`dsh-stdio-demo` 的包内 readline 模块渲染每个问题,在下一行显示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序询问,作为一个答案对象整体解析。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个提示占用 stdin。 `dsh-tui` 将每个问题渲染为键盘叠层,展示选项描述,支持单选、多选和自由格式自定义答案,并在中止、provider dispose(资源释放)或终端关闭时拒绝待处理的问题。批量请求和并发请求都会排队,确保同一时刻只有一个叠层占用键盘焦点。
`dsh-acp` 为 ACP 会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由出去,并为每个问题调用 ACP `unstable_createElicitation`(附带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项的问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation,都会转为结构化的 `UserInteractionError`。 `dsh-acp` 为 ACP 会话提供同一 seam。它通过 `ownedRecord` 解析调用方 `Agent`,要求位于 `agent.session.id` 的正向会话 map 记录拥有该精确 agent 对象,并为每个问题调用 ACP `unstable_createElicitation`(附带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项的问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation,都会转为结构化的 `UserInteractionError`。
ACP 映射有意使用 elicitation 而非 `session/request_permission`。`request_permission` 仍保留给独立的权限门禁:它是围绕工具执行的 yes/no 或策略式授权协议。`ask_user_question` 是一个通用的信息收集工具,支持可选的自由格式答案,因此 ACP 表单 elicitation 是更贴合的协议。bridge 的会话路由与未来的权限门禁共享,但用户意图不同。 ACP 映射有意使用 elicitation 而非 `session/request_permission`。`request_permission` 仍保留给独立的权限门禁:它是围绕工具执行的 yes/no 或策略式授权协议。`ask_user_question` 是一个通用的信息收集工具,支持可选的自由格式答案,因此 ACP 表单 elicitation 是更贴合的协议。bridge 的会话路由与未来的权限门禁共享,但用户意图不同。
@@ -44,8 +44,8 @@ ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化
该功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。 该功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和面向模型的工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。 `dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`dsh-tui-demo` 选择性加载 seam、TUI provider 和面向模型的工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。
## 测试 ## 测试
单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc`)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。 单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc`)。TUI 测试覆盖选项描述、排队请求、关闭/中止清理、无选项自由格式输入、无效选择、重复多选和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-29-todo-write-tool.md: 7147440ec0cdb0b53093c371828d1987c17aabb0 2026-06-29-todo-write-tool.md: bf5fceaafd475224914212beb460fdbc42e3dd68
2026-06-29-todo-write-tool.zh.md: 7602ac8434963c84f089fe99a6f1e973c05e5bef 2026-06-29-todo-write-tool.zh.md: 565db37b1bf36c2aa33109870df4b36a82a57bdc

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