docs(i18n): re-translate RFC batch with the prompt-v4 pipeline
146 篇 RFC 译文按 v4 基线(#348)重出:v4 模板+术语表、金标 few-shot、三段协议、切换行后处理;全量机械核对零异常(一处 task id 术语违规已修)。三篇超长 RFC(code-mode 已入,web-seam/ agent-scope/sandbox/cds-core 仍在长文档通道产出)随后补。
This commit is contained in:
292 files changed
+2819
-2820
No files matched your search
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-14-acp-agent-client-protocol.md: 0bb2a2f2e307b8f23a3a9ca98edb2a2d3b5df0a8
|
||||
2026-06-14-acp-agent-client-protocol.zh.md: 19744cb9f4d4b675586d4327a1da423fdea21b77
|
||||
2026-06-14-acp-agent-client-protocol.zh.md: 7bb0e066572150c9c8fc0b94de1d5bf2d69527ee
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:ACP(Agent Client Protocol)支持——从外部编辑器驱动编码 agent
|
||||
# RFC:Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent
|
||||
|
||||
[English](2026-06-14-acp-agent-client-protocol.md) | 中文
|
||||
|
||||
@@ -6,54 +6,54 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 最初只通过 readline 循环暴露 agent(智能体)。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成状态、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的情况下取消某个对话。ACP 将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
|
||||
harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
|
||||
|
||||
桥接层必须保持 harness 既有的职责边界。它不能依赖具体的 agent loop(智能体循环)、绕过工具注册表、在编辑器中执行 shell 命令,或发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
|
||||
桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编程接口级服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不改变 agent loop,也不是能力 seam 的实现。
|
||||
`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。
|
||||
|
||||
桥接层实现以下稳定的会话路径:
|
||||
|
||||
- `initialize` 协商协议版本,声明支持 text 与 `resource_link` prompt,并声明 `loadSession`。
|
||||
- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回组合支持的配置选项。
|
||||
- `session/load` 在构造 agent 之前,先用持久化元数据校验请求的 cwd;在异步恢复期间预留 id;将 user/assistant/tool 事件作为 ACP update 回放;并报告恢复后的 config-option fold。
|
||||
- `session/prompt` 接受 text 和 resource link,拒绝不支持或空的内容,每个会话只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误 turn 拒绝 RPC;其他关闭 turn 的原因通过一个全覆盖的 ACP stop-reason codec 映射。
|
||||
- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的 prompt,并声明 `loadSession` 能力。
|
||||
- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回由组合层支持的配置选项。
|
||||
- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间保留 id,将用户/助手/工具事件作为 ACP update 回放,并报告恢复后的 config-option 折叠结果。
|
||||
- `session/prompt` 接受文本和 resource link,拒绝不支持的或空的内容,每个会话同时只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。
|
||||
- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的 prompt。
|
||||
|
||||
工具调用的呈现仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 约定;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境变量清理、任务归属和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中没有硬编码的工具名分支。
|
||||
工具调用的展示仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控约定 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit`;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境清洗、所有权和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中无需硬编码工具名分支。
|
||||
|
||||
权限处理是[用户审批 seam](2026-07-06-approval-seam.md) 上的一个 answerer,而非 ACP 中「每次工具调用都询问」的策略。一个带有 call id 的、针对桥接层所属 agent 的 `approval/request`,会变成该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。非本桥接层的请求或无 call id 的请求走委托路径;answerer 缺失或失败时保持 fail-closed。决定是否询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。
|
||||
权限处理是 [user-approval seam](2026-07-06-approval-seam.md) 上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 保持 fail-closed。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。
|
||||
|
||||
当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。出厂的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一个审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验,并写入两个所属旋钮事件。在 open turn 期间的切换立即追加;idle 状态下的切换在响应中叠加,并在下一次 `agent/prompt-submit` 时锚定,位于请求组装之前。在此之前它仅存于内存,因此崩溃后恢复的是持久化的 fold。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 仍为连接级。
|
||||
当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。已发布的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一条审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在开放轮次中的切换立即追加;空闲时的切换叠加在响应中,并在下一次 `agent/prompt-submit` 时锚定到开放轮次之前的请求组装阶段。在此之前它仅存于内存,因此崩溃后恢复的是持久化的折叠结果。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 保持连接级别。
|
||||
|
||||
桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述和自定义回答覆盖语义均被保留。
|
||||
桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。
|
||||
|
||||
生命周期归属是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt、并行 dispose 每个 handle、等待循环静默和持久化刷盘,然后移除记录。流式通知失败被隔离,已消失的客户端无法破坏 agent turn。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
|
||||
生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt,并行 dispose 所有 handle,等待循环静默与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
|
||||
|
||||
精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);package README 是运维契约。
|
||||
精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);package README 是操作契约。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在 `tools/execute` 前置一个监听器,对每个 ACP 所属调用都询问权限**:否决。这会把权限策略硬编码进 UI 桥接层,即使没有策略要求也会询问,且无法服务执行开始后才产生的审批请求。共享的用户审批 seam 将机制、询问策略和 UI answerer 分离。
|
||||
**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会将权限策略硬编码到 UI 桥接层,即使没有策略要求也会询问,且无法服务于执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。
|
||||
|
||||
**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、idle 观察和 dispose 是 `dsh-agent` 上的接口级归属操作;UI 插件不需要依赖规则的例外。
|
||||
**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放是 `dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。
|
||||
|
||||
**通过 ACP `terminal/*` 执行 bash**:否决。那会把执行移到 harness 之外,绕过其沙箱、凭证清理、任务归属、cwd 解析和会话日志。终端元数据仅用于呈现。
|
||||
**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清洗、任务所有权、cwd 解析与会话日志。终端元数据仅用于展示。
|
||||
|
||||
**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的旧接口。
|
||||
**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。
|
||||
|
||||
**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用归属范围,且与协议传输竞争。应用组合拥有 stdout 纯净性。
|
||||
**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。
|
||||
|
||||
## 后果
|
||||
|
||||
编辑器可以通过一条 ACP 连接创建、加载、prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 和每会话配置的持久真源。工具呈现与人工回答通道仍是可扩展的插件契约,而非 ACP 特有行为。
|
||||
编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。
|
||||
|
||||
桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托,以及 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。
|
||||
桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。
|
||||
|
||||
idle 状态下的 config 选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到 open turn 之前不具有持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件 turn 封闭且回放安全的代价。
|
||||
空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。
|
||||
|
||||
## 验证
|
||||
|
||||
ACP 测试套件覆盖内存协议编解码、创建/加载回放、精确的 prompt 结算、取消竞态、不支持的内容、工具呈现、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/dispose 静默,以及 HMR(热模块替换)清理。快照和 built-bin 测试检验应用组合,真实 API 的 e2e 在无 key 时自动跳过。
|
||||
ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的 prompt 结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放静默,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-14-acp-multi-session.md: b96557d2d94711adb2183aa4f5dd8debf39c1de8
|
||||
2026-06-14-acp-multi-session.zh.md: 263e292e7161b789b8e06772deb0d2bc896f8de6
|
||||
2026-06-14-acp-multi-session.zh.md: 6a9f5e8162d46ed8719164247e22b8b9c5d26c61
|
||||
@@ -1,39 +1,39 @@
|
||||
# RFC:在单连接上多路复用并发 ACP 会话
|
||||
|
||||
Status: implemented
|
||||
# RFC:在单个连接上多路复用并发 ACP 会话
|
||||
|
||||
[English](2026-06-14-acp-multi-session.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上维持多个活跃对话。如果桥接层只允许单活跃会话,就不得不额外启动进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台任务 id 都绝不能跨越会话边界。
|
||||
一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。
|
||||
|
||||
## 决策
|
||||
|
||||
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,供 agent 作用域的回调使用。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待生效的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造重复的 agent;不同 id 可以并发加载。
|
||||
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,用于 agent 作用域的回调。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。
|
||||
|
||||
每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到来时结算;来自已取消的先前轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到单条记录,只调用该 agent 的队列感知取消路径。
|
||||
每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。
|
||||
|
||||
权限归属使用同一个反向索引。ACP `approval/request` 应答器仅向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值仅折叠该会话自身的事件,待生效的空闲切换存储在该记录上,直到下一个轮次将其锚定。
|
||||
权限归属使用同一个反向索引。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。
|
||||
|
||||
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话的 session id。`bash_output` 和 `bash_kill` 在读取或终止之前,会将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不授予访问权限。归属信息存储在执行器任务上,因此工具插件重载不会擦除它。
|
||||
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output` 和 `bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。
|
||||
|
||||
连接拆除时清空活跃 map,将每个待结算的 prompt 以取消状态结算,并并行 dispose 所有 `AgentHandle`。每个句柄停止并等待其循环结束,在仍挂载时刷新会话,注销 agent,然后移除会话。拆除操作被 memoize 并在客户端断开与插件 dispose 之间共享。
|
||||
连接拆除时清空活跃 map,将每个待处理的 prompt 以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**每连接单活跃会话**:否决。它增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。
|
||||
**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。
|
||||
|
||||
**每会话一个 `ctx.extend()`**:否决。子上下文本身并不创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话的归属记录;agent 生命周期由 `AgentHandle` 拥有。
|
||||
**每会话 `ctx.extend()`**:否决。子上下文本身不会创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话拥有的记录;agent 生命周期由 `AgentHandle` 管理。
|
||||
|
||||
**以 agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是应当在插件重载后存活的跨边界标识。
|
||||
**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是跨边界的标识,应当在插件重载后仍然存活。
|
||||
|
||||
## 后果
|
||||
|
||||
N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不为每个会话添加一套监听器,因此在长连接期间避免了监听器扇出。
|
||||
N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。
|
||||
|
||||
桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于推迟状态。
|
||||
桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于延期状态。
|
||||
|
||||
## 验证
|
||||
|
||||
多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离和拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。
|
||||
多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-15-code-mode.md: c64b6d6d8442e60240fa6c849833385ed50d71ff
|
||||
2026-06-15-code-mode.zh.md: f94ef61dae180bad5ec604b5c7f9552eeecff593
|
||||
2026-06-15-code-mode.zh.md: e9ae74f6629f3e34a0e97f0fa532764c70095bba
|
||||
@@ -1,132 +1,132 @@
|
||||
# RFC:Code Mode——模型针对工具注册表编写 TypeScript
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-15-code-mode.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
在注册表的原生呈现方式中,agent loop(智能体循环)将每个可见能力作为 JSON Schema 函数定义广播。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每个**中间 `tool-result` 都在下一次请求时重新进入模型上下文。
|
||||
在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。
|
||||
|
||||
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都把整个中间结果拖回上下文,无论模型是否需要。
|
||||
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。
|
||||
|
||||
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单观察:LLM(大语言模型)写代码比发出工具调用更擅长,因为它们见过数百万行真实代码,而见过的人造工具调用 trace 相对很少。模型不再每步发出一个工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只取回它打印或返回的内容——而非所有中间结果。
|
||||
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。
|
||||
|
||||
工具呈现属于拥有工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位符:Node `worker_threads` 提供独立隔离区、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 现有的信任模型(见§信任姿态)。
|
||||
工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。
|
||||
|
||||
## 决策
|
||||
|
||||
三项决策,各自在下方独立小节中展开:
|
||||
|
||||
1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经过校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 放入系统提示词)、或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其规范贡献;协作式 prompt 组装的结果仍具权威性,请求头日志记录的正是该返回的呈现。
|
||||
1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。
|
||||
2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。
|
||||
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行启动一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——不需要 unsafe-acknowledgement 标志——因为 harness 已经提供了 `dsh-bash-local`,后者以严格**更大**的环境权限执行模型编写的任意 shell 命令。
|
||||
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格**更高**的环境权限执行模型编写的任意 shell 命令。
|
||||
|
||||
### 注册表拥有模式
|
||||
|
||||
`ToolRegistry` 获得一个 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 切换(`tools: { mode: code }`)——无需改代码,遵循 no-hardcoded-tunables 约定。
|
||||
`ToolRegistry` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: code }`),无需改代码,遵循 no-hardcoded-tunables 约定。
|
||||
|
||||
**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接 prompt 提供方和组装 waterfall 仍各自负责自己的贡献。
|
||||
|
||||
**与 `toolOrder` 的交互,预先声明:** 如果配置的 `systemPrompt.toolOrder` 命名了原生能力,则在 `mode: 'code'` 下会拒绝所有组装,因为这些名称不在该模式的协议校验范围内。这是正确行为,不是 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。
|
||||
**与 `toolOrder` 的交互,预先说明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。
|
||||
|
||||
**SDK prompt 段落。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段落为作用域内可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
|
||||
**SDK prompt 段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
|
||||
|
||||
**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。作用域内的 `tools:sdk` 段落可以在分发前遮蔽全局默认值,监听器可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议可行;没有恢复 pass 会覆盖有意的组合。
|
||||
**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
|
||||
|
||||
**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,并将不支持的构造降级为 `unknown`。SDK 以带引号的对象键暴露工具,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
|
||||
**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
|
||||
|
||||
### run_code 工具与分发桥接
|
||||
### run_code 工具与分发桥
|
||||
|
||||
在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以便分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是归一化的外层结果。其 `execute(args, exec)`:
|
||||
在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`:
|
||||
|
||||
1. **构建绑定。** 一个 run 作用域的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对其参数做 JSON 归一化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己的不可变执行身份,并遍历完整的工具流水线。
|
||||
2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 作用域的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
|
||||
3. **静默后结算。** 运行时结算后,桥接 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后没有子调用可以追加。
|
||||
1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。
|
||||
2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
|
||||
3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。
|
||||
|
||||
**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的邻接性,而一个程序可以产生多个上下文。支持它需要一个复数通道或循环级别的子分发缓冲区。
|
||||
**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的相邻性,而一个程序可以产生多个 context。支持它需要一个复数通道或循环级别的子分发缓冲区。
|
||||
|
||||
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要逐工具的并发安全元数据。
|
||||
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。
|
||||
|
||||
**呈现。** `run_code` 的渲染意图按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一段程序文本;`presentResult` → 一个 `generic` 卡片,内容为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。
|
||||
**呈现。** `run_code` 的 render intent 按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。
|
||||
|
||||
### 可观测性:`tool/code-dispatch`
|
||||
|
||||
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具身份、归一化参数和结果摘要。它不进入模型历史,但可供持久化和 UI 使用。追加发生在打开的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
|
||||
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具标识、规范化参数和结果摘要。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
|
||||
|
||||
### code-runtime seam
|
||||
|
||||
`packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`,仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service`(`super(ctx, 'codeRuntime')`)加词汇:
|
||||
`packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`,仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service`(`super(ctx, 'codeRuntime')`)加上词汇:
|
||||
|
||||
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
|
||||
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。
|
||||
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,以 `error` 字段解析。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端 rejection。
|
||||
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。
|
||||
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
|
||||
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——正交的结果按[防御性模式](../../../defensive-patterns.md)独立报告;超时的 run 不是异常,abort 不是超时。
|
||||
- 两个只读的后端描述符,仅供信息参考不用于门控:`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来的为 `'process'`、`'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
|
||||
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时。
|
||||
- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
|
||||
|
||||
请求包含所有运行时输入;实现方拥有经过校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此原生模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 后面替换实现,配对相应的 SDK 生成器。
|
||||
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。
|
||||
|
||||
### worker 线程运行时
|
||||
### worker-thread 运行时
|
||||
|
||||
`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包。每次 `run()`:
|
||||
`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包(package)。每次 `run()`:
|
||||
|
||||
1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。Strip-only 模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理任何其他程序错误一样自我纠正。语法级别的失败永远不会 spawn worker。
|
||||
2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化、不跨 run 共享状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,且状态泄漏不可表达。
|
||||
3. **在 bootstrap 中执行**:剥离类型后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。
|
||||
4. **通过消息端口桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通的自有属性,不会产生原型链冲突。未知名称、重复 id 和结算后的消息被拒绝或忽略——端口协议假设对端是敌对的,因为对端运行的是模型代码。
|
||||
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 限制总经过时间,包括未完成的等待。到期、取消和完成都会终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限都是经过校验的配置。
|
||||
6. **Dispose 至静默**:服务自身的 disposal 终止进行中的 worker 并*等待*它们退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。
|
||||
1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。
|
||||
2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化,不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。
|
||||
3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。
|
||||
4. **通过 message port 桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。
|
||||
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。
|
||||
6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。
|
||||
|
||||
### 信任姿态
|
||||
|
||||
worker 运行时提供的是封闭隔离,而非安全边界:模型代码可以触及 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门控,并额外提供空环境、堆限制、独立隔离区和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
|
||||
worker 运行时提供的是隔离,而非安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
|
||||
|
||||
### 模型看到的内容
|
||||
|
||||
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时 catch 被 reject 的工具调用,并仅返回或打印应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可以与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
|
||||
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
|
||||
|
||||
## 后果
|
||||
|
||||
切换到 `'code'` 的部署必须更新任何仅原生的 `toolOrder`。组装监听器负责维护任何被重写的协议表面的完整性。子分发保持序列化,桥接不会传播逐调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。
|
||||
切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,桥不传播每次调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。
|
||||
|
||||
## 测试
|
||||
|
||||
- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、敌对绑定流量、空环境、structured-clone 回退、输出上限和 disposal 至静默。一个 built-package 测试在纯 Node 下运行 worker 入口。
|
||||
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、作用域可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 归一化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。
|
||||
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、生成的文件和精选的回答。
|
||||
- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段落、头部工具列表、分发事件和结果卡片。
|
||||
- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。
|
||||
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。
|
||||
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、结果文件和策展后的回答。
|
||||
- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段、请求头工具列表、分发事件和结果卡片。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,且依赖监听器顺序。模型被提供哪些工具、以何种表示,是注册表的单一关注点:原生 schema 和 SDK 是同一可见存储的两种投影。
|
||||
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。
|
||||
|
||||
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立隔离区、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
|
||||
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。
|
||||
|
||||
**对原生工具调用做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志表面替换很容易添加,但仍然每次调用付出一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
|
||||
**在原生工具调用上做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
|
||||
|
||||
**循环中的并行原生分发。** 往返开销的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策使两者兼容:当元数据就绪时,原生并行分发和逐工具绑定并行化一起解锁。
|
||||
**循环中的并行原生分发。** 往返成本的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发和每工具绑定并行化一起解锁。
|
||||
|
||||
**始终排他(忠实于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是理想的,强迫每次编辑都通过程序会加重常见场景的负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用而不强加。
|
||||
**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。
|
||||
|
||||
**逐工具可见性层级(此工具原生,彼工具仅 code)。** 推迟:它需要逐工具元数据和 `'native' | 'code' | 'both'` 不具备的呈现拆分,且其设计依赖于模型在 `'both'` 下如何分配使用的证据。
|
||||
**每工具可见性分层(此工具 native,彼工具 code-only)。** 推迟:它需要每工具元数据和 `'native' | 'code' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。
|
||||
|
||||
**SDK 中的消毒标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名冲突逻辑;模型处理 `tools["my-tool"](…)` 没有问题。
|
||||
**SDK 中的清洁化标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`。
|
||||
|
||||
**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。MVP 否决:跨调用状态对会话日志不可见,破坏了每个请求是日志纯函数的可重建性保证;每次 run 全新保持了这一点。内核风格后端在未来仍可通过 seam 表达,配合自己的日志方案。
|
||||
**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。在 MVP 中否决:跨调用状态对会话日志不可见,破坏了「每个请求是日志的纯函数」这一可重建性保证;每次 run 全新保持了这一点。内核风格的后端在未来仍可通过同一 seam 表达,配合自己的日志方案。
|
||||
|
||||
## 风险
|
||||
|
||||
**Worker 不是硬安全边界。** 有意为之且已文档化(见§信任姿态):姿态等同于现有 bash 工具,封闭隔离超过它,门控使用相同的 seam。需要更多的部署需要未来的 `isolation: 'container'` 后端——作为 seam 的设计扩展跟踪,而非本设计的 TODO。
|
||||
**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,隔离程度超过它,门禁使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。
|
||||
|
||||
**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数后面,且 `amaro`/`sucrase` 是 API 变动时的即插即用替代品。可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。
|
||||
**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。
|
||||
|
||||
**SDK 的 prompt 开销,尤其在 `'both'` 下。** `.d.ts` 可以与它补充的原生 schema 相当大;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话开销;mode 是逐部署的;本 RFC 不做无条件节省的声明。何时偏好哪种模式的量化指导明确是上线后的学习。
|
||||
**SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 RFC 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。
|
||||
|
||||
**注册表范围增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥接和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 来约束:所有基底形状的东西都在 `ctx.codeRuntime` 后面。
|
||||
**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。
|
||||
|
||||
**Structured-clone 值可以超出 JSON。** 因此工具绑定在分发前对参数做 JSON 归一化,确保每个执行的调用都可以被记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。
|
||||
**Structured-clone 值可能超出 JSON。** 因此工具绑定在分发前对参数做 JSON 规范化,确保每次执行的调用都可记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。
|
||||
|
||||
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少了往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的相同并发安全元数据绑定。
|
||||
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。
|
||||
|
||||
**预算计量读取事件循环,而非标志。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending dispatch 无法暂停它」)对敌对程序是承重的。两侧都有单元测试(带 pending decoy dispatch 的热循环在 `computeMs` 时死亡;idle-on-slow-binding 存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过。
|
||||
**预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)对恶意程序是承重的。两侧都有单元测试(带 pending 诱饵分发的热循环在 `computeMs` 处死亡;在慢绑定上空闲的程序存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-17-filesystem-tool-schemas.md: c2d3aa679599b1129a19b9082b9254ecf3103f12
|
||||
2026-06-17-filesystem-tool-schemas.zh.md: b13274c41da244d7d2a5fe6ff2064d8d5e0a9b42
|
||||
2026-06-17-filesystem-tool-schemas.zh.md: cd504a37d5ee25f6634b651d30099afe5acd6495
|
||||
@@ -1,24 +1,24 @@
|
||||
# RFC:文件系统工具 schema——面向模型的读/写/编辑形状
|
||||
|
||||
Status: implemented
|
||||
# RFC:文件系统工具 schema——面向模型的读/写/编辑接口形状
|
||||
|
||||
[English](2026-06-17-filesystem-tool-schemas.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及 read-before-write/edit 检查所依赖的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) 两份 RFC 随后将该策略从 `ctx.fs` 移到了 `dsh-fs-policy` 插件的 `fs/*` 事件门上。第一版文件系统工具交付剩余的决策是面向模型的 schema 表面:模型在 `read`、`write` 和 `edit` 中看到哪些参数。
|
||||
[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包(package)拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFC 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。
|
||||
|
||||
schema 应当足够小,能在 `dsh-tool-fs` 的首次实现中完成;同时又足够稳定,使未来的本地/远程/沙箱文件系统后端不会引起面向模型的接口变动。它还应避免从参考系统照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有表面。
|
||||
该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有接口。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-tool-fs` 在第一版文件系统工具套件中暴露以下三个面向模型的工具:
|
||||
`@deepseek-ai/dsh-tool-fs` 在首个文件系统工具套件中暴露以下三个面向模型的工具:
|
||||
|
||||
| Tool | 我们的 schema | Claude Code | OpenCode | 说明 | 纳入原型 |
|
||||
| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype |
|
||||
|---|---|---|---|---|---|
|
||||
| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | 仅文件;`offset` 从 1 开始;首次实现不支持图片/PDF/多模态。 | 是 |
|
||||
| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | 创建或覆写 UTF-8 文本。在默认 fs-policy 下,更新已有文件需要先有一次观测;新建文件则不需要。 | 是 |
|
||||
| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | 字面字符串替换;默认要求唯一匹配;在默认 fs-policy 下需要先有一次观测(任何窗口化的 read 都算)。 | 是 |
|
||||
| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES |
|
||||
| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES |
|
||||
| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES |
|
||||
|
||||
schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string`、`replace_all`),与 Claude Code 及现有 DeepSeek Harness 工具 schema 示例保持一致。消费方包将这些面向模型的名称转换为 `ctx.fs` 调用和 `fs/*` 事件分发。
|
||||
|
||||
@@ -26,47 +26,47 @@ schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string`
|
||||
|
||||
### `read`
|
||||
|
||||
`read` 检查一个 UTF-8 文本文件并返回带行号的内容。
|
||||
`read` 检视一个 UTF-8 文本文件并返回带行号的内容。
|
||||
|
||||
参数:
|
||||
|
||||
- `file_path: string`——必填。要读取的路径,由 `ctx.fs` 解析。
|
||||
- `offset?: number`——可选。返回的第一行,从 1 开始。默认为第一行。
|
||||
- `limit?: number`——可选。返回的最大行数。默认值和上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。
|
||||
- `limit?: number`——可选。返回的最大行数。默认值与上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。
|
||||
|
||||
首次实现的非目标:
|
||||
首次实现不涉及的内容:
|
||||
|
||||
- 不支持 PDF `pages` 参数。
|
||||
- 不支持图片或多模态文件读取。
|
||||
- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的未来工具。
|
||||
- 无 PDF `pages` 参数。
|
||||
- 无图片或多模态文件读取。
|
||||
- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的后续工具。
|
||||
|
||||
### `write`
|
||||
|
||||
`write` 创建或完全替换一个 UTF-8 文本文件。
|
||||
`write` 创建或完整替换一个 UTF-8 文本文件。
|
||||
|
||||
参数:
|
||||
|
||||
- `file_path: string`——必填。要写入的路径,由 `ctx.fs` 解析。
|
||||
- `content: string`——必填。要写入的完整 UTF-8 文本内容。
|
||||
|
||||
在默认 fs-policy 下,用 `write` 更新已有文件需要同一执行上下文对该文件有过一次先前观测(read/write/edit);`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的 stale guard 提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。
|
||||
在默认 fs-policy 下,使用 `write` 更新已有文件需要同一执行上下文先前对该文件有过一次观测(read/write/edit);`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的 stale guard 提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。
|
||||
|
||||
schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为面向模型的参数。stale-version 检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。
|
||||
schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面向模型的参数暴露。过期版本检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。
|
||||
|
||||
### `edit`
|
||||
|
||||
`edit` 通过替换字面文本来更新一个已有的 UTF-8 文本文件。
|
||||
`edit` 通过替换字面文本来更新已有的 UTF-8 文本文件。
|
||||
|
||||
参数:
|
||||
|
||||
- `file_path: string`——必填。要编辑的路径,由 `ctx.fs` 解析。
|
||||
- `old_string: string`——必填。要替换的字面文本。首次实现中空字符串无效。
|
||||
- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配项。
|
||||
- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配内容。
|
||||
- `replace_all?: boolean`——可选。默认为 false。为 false 时,`old_string` 必须恰好匹配一处。
|
||||
|
||||
`edit` 要求同一执行上下文对该文件有过一次先前观测(任何窗口化的 read 都算——授权依据是版本新鲜度,而非全文查看要求),或该上下文对该文件有过先前的 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 强制执行。
|
||||
`edit` 要求同一执行上下文先前对该文件有过一次观测(任何窗口化的 read 都算——授权基于版本新鲜度,而非全文查看要求),或该上下文先前对该文件做过 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 负责执行。
|
||||
|
||||
首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,后端可以自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。
|
||||
首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。
|
||||
|
||||
## 结果形状
|
||||
|
||||
@@ -74,17 +74,17 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为
|
||||
|
||||
默认原生投影:
|
||||
|
||||
| Tool | `tool-fs` 消费的结构化 `ctx.fs` 结果 | 默认模型投影 |
|
||||
| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection |
|
||||
|---|---|---|
|
||||
| `read` | 返回的行、返回行数、总行数、目标显示路径、文件版本、部分视图标志 | 带行号的文本加分页脚注 |
|
||||
| `write` | create/update 操作、目标显示路径、新文件版本 | 简洁的 create/update 成功文本 |
|
||||
| `edit` | 替换次数、replace-all 标志、目标显示路径、新文件版本 | 简洁的 edit 成功文本 |
|
||||
| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer |
|
||||
| `write` | create/update operation, target display path, new file version | concise create/update success text |
|
||||
| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text |
|
||||
|
||||
结构化结果不重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。token 感知的截断属于模型投影的职责,不属于后端的规范结果。
|
||||
结构化结果不会重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。面向 token 的截断属于模型投影的职责,而非后端规范结果的一部分。
|
||||
|
||||
## 延后
|
||||
## 延后事项
|
||||
|
||||
以下内容被明确排除在首版文件系统 schema 之外:
|
||||
以下内容被明确排除在首次文件系统 schema 实现之外:
|
||||
|
||||
- 面向模型的 `expected_hash`、`expected_version` 或 `create_only` 参数。
|
||||
- 目录列表、glob、grep 和搜索工具。
|
||||
@@ -95,18 +95,18 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为
|
||||
|
||||
## 测试
|
||||
|
||||
schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文本中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。
|
||||
schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文字中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。
|
||||
- **camelCase 参数名(OpenCode 风格)**:snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开表面。
|
||||
- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。stale 检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。
|
||||
- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。
|
||||
- **camelCase 参数名(OpenCode 风格)**:snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开接口。
|
||||
- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。过期检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。
|
||||
|
||||
## 后果
|
||||
|
||||
**首版 schema 有意小于 Claude Code。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非在初始 schema 上叠加重载。
|
||||
**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非对初始 schema 的重载。
|
||||
|
||||
**v1 没有显式的面向模型 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:stale 检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非来自模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非通过模型提供的版本字段。
|
||||
**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:过期检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。
|
||||
|
||||
**命名成为公开表面。** 一旦发布,将 `file_path` 改为 `filePath` 或将 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选定 snake_case 并将其视为稳定的面向模型契约。
|
||||
**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选择 snake_case,并将其视为稳定的面向模型的契约。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-acp-terminal-and-tool-rendering.md: cab89aa690c2068399ea5429a9c467410c744ce8
|
||||
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 5a545b7a53f0814bc0e6071430c367047dc83f7f
|
||||
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 4047c493e63ac23f758de718616fd1f4bb29f7d4
|
||||
@@ -1,48 +1,48 @@
|
||||
# RFC:丰富的 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
|
||||
|
||||
Status: implemented
|
||||
# RFC:富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
|
||||
|
||||
[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
ACP 桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 展示](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
|
||||
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 ` 围栏文本块中。
|
||||
|
||||
参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格输出和退出状态;纯文本丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏了原始输入,而人类可读的描述保留为卡片上方的独立块。
|
||||
参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。
|
||||
|
||||
## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create`
|
||||
|
||||
ACP 规范有一个*客户端侧*终端子协议:agent 调用客户端的 `terminal/create`,传入 `{ command, args, cwd, env }`,由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境变量清洗、后台任务所有权、按会话的 cwd)。把执行路由到编辑器会绕过所有这些机制,并将执行分裂为两个后端。
|
||||
ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`(传入 `{ command, args, cwd, env }`),由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。
|
||||
|
||||
研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理:
|
||||
|
||||
- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 和 `_meta.terminal_info.{ terminal_id, cwd }`;输出/退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 和 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。
|
||||
- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量)发送,由同一个 `_meta.terminal_output` 能力选择。
|
||||
- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 与 `_meta.terminal_info.{ terminal_id, cwd }`;输出和退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 与 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。
|
||||
- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量),由同一个 `_meta.terminal_output` 能力选择。
|
||||
|
||||
Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。它将能力声明为 `clientCapabilities._meta.terminal_output = true`。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范——但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一途径。
|
||||
Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。客户端通过 `clientCapabilities._meta.terminal_output = true` 声明此能力。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范,但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式。
|
||||
|
||||
## 决策
|
||||
|
||||
保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。
|
||||
|
||||
1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。
|
||||
2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可以返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出 + 退出从运行结果解析)。
|
||||
3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会**替换**调用的 content 集合,因此重发围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。
|
||||
4. **退出标记从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态标记(`_meta.terminal_exit.{exit_code,signal}`)会被发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。dispose 不受影响:没有新资源需要清理,因为桥接层从未创建客户端侧终端。
|
||||
2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出与退出从运行结果解析)。
|
||||
3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析后的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会**替换**调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。
|
||||
4. **退出信息从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态(`_meta.terminal_exit.{exit_code,signal}`)确实会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境变量清洗、后台任务所有权和按会话的 cwd,并将执行分裂为两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
|
||||
- **通过事件 schema 透传结构化退出**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,在同一文件中共同演进并由往返测试守护。
|
||||
- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
|
||||
- **通过事件 schema 传递结构化退出信息**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进,由往返测试守护。
|
||||
|
||||
## 后果
|
||||
|
||||
- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们只在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端永远不会变差。如果 ACP 日后标准化了 agent 执行的终端,迁移到该标准并移除约定键。
|
||||
- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对所有其他客户端的契约,绝不能退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。
|
||||
- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们仅在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,则迁移到该标准并移除约定键。
|
||||
- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不可退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。
|
||||
- **terminalId 冲突。** 从每次调用的 `callId` 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。
|
||||
- **退出从渲染文本中解析。** 退出标记通过解析 `renderResult` 的状态标记来恢复 `exit_code`/`signal`,而非通过事件 schema 透传结构化退出(纯 `presentResult` seam 看不到结构化退出)。解析是标记发出的精确逆操作,位于同一文件中;一个往返测试固定了这对关系,标记格式的变更如果破坏了解析就会使测试套件失败。如果标记将来需要与退出标记的需求分歧,改为在 result 事件上暴露结构化退出。
|
||||
- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方也会需要的丰富度。
|
||||
- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传递结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。
|
||||
- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。
|
||||
|
||||
## 不在范围内 / 非目标
|
||||
## 超出范围 / 非目标
|
||||
|
||||
文本块基线仍是无能力声明时的默认行为。两个后续工作有意不在此处构建,各自需要独立 RFC:**实时增量流式传输**(`_meta.terminal_output_delta`,在分片到达时发送,需要 `dsh-bash` 上的增量输出 seam),以及**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片、将 `grep` 解析为 `search` 等,回退到终端卡片——仅展示,绝不改变实际执行的内容)。
|
||||
文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 RFC:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-compaction-capability-seam.md: 31b06905924a07a7f0c2af427d8868585966f1a7
|
||||
2026-06-18-compaction-capability-seam.zh.md: ef71b3df39f02221f1bd25beb5e026cb056b95a0
|
||||
2026-06-18-compaction-capability-seam.zh.md: 1675484ed65e5cd890f420d4bdd1e16e2a95b2ef
|
||||
@@ -6,37 +6,37 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口——模型随即在响应中途截断(`max-tokens`)或质量退化。**压缩(compaction)**是缓解手段:用一段简洁的摘要替换一段较早的历史,保持近期上下文完整。
|
||||
长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即截断响应(`max-tokens`)或性能退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。
|
||||
|
||||
[会话 surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而建的基础设施:它是事件日志之上的链表,带有一个 `surfaceOp: { op: 'replace', start, end }` 操作,专门用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。
|
||||
[session surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一条建立在事件日志之上的链表,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。
|
||||
|
||||
两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——这些与*何时*压缩、*压缩哪段*彼此独立变化。第二,`SurfaceEventType` 是封闭的,只有五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有它们可以携带 `surfaceOp`。因此一个专属的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上放 `surfaceOp`,invariants 插件在运行时也会拒绝。
|
||||
两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——它们独立于*何时*以及*压缩哪段范围*而变化。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上附加 `surfaceOp`,invariants 插件在运行时也会拒绝。
|
||||
|
||||
## 决策
|
||||
|
||||
### 压缩是一个能力 seam,接口与实现分离
|
||||
|
||||
按照[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进:
|
||||
遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进:
|
||||
|
||||
1. **接口** — `@deepseek-ai/dsh-compact`:一个抽象的 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约阐述压缩*做什么*,而非*怎么做*。
|
||||
2. **实现** — `@deepseek-ai/dsh-compact-basic`:一个具体的 `BasicCompactService`,拥有完整算法——token 估算(每 token 字符数——`charsPerToken` 配置,默认 4——加逐块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 的摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是兄弟包(或覆写两个 protected 估算/摘要钩子的子类)。
|
||||
1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。
|
||||
2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,拥有完整算法——token 估算(每 token 字符数,即 `charsPerToken` 配置,默认 4,加上每块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 进行摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是同级包(或覆盖两个 protected 估算/摘要钩子的子类)。
|
||||
3. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 RFC 范围之外,以便 seam 先稳定下来。
|
||||
|
||||
### 契约依赖 `dsh-session` 和 `dsh-llm`——有意的偏离
|
||||
### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离
|
||||
|
||||
能力 seam RFC 规定接口包「只依赖 cordis」(对 `dsh-bash` 成立,其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义在 `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约无法表达。
|
||||
能力 seam RFC 规定接口包"仅依赖 cordis"(对 `dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义*在* `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约就无法表达。
|
||||
|
||||
这不是耦合异味——而是契约的领域本身。「只依赖 cordis」的指导原则本来就是「接口只依赖契约真正命名的东西,绝不依赖实现」的简写。`dsh-session` 和 `dsh-llm` 本身就是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。
|
||||
这不是耦合异味,而是契约的领域所在。"仅 cordis"的指导原则一直是"接口仅依赖契约真正需要命名的东西,绝不依赖实现"的简写。`dsh-session` 和 `dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。
|
||||
|
||||
### 抽象的 `compactIfNeeded` / `compactRegion`,算法在后端
|
||||
### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端
|
||||
|
||||
早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,只有 `estimateContentTokens()` 和 `summarize()` 是抽象的。这会把契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端不得不与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——接口则保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆写而无需重新实现遍历——但这种分层是后端的私有关注,不是契约的。
|
||||
早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,仅 `estimateContentTokens()` 和 `summarize()` 为抽象。这会将契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——并让接口保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆盖而无需重新实现遍历——但那是后端的私有关注点,不是契约的。
|
||||
|
||||
`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接受**必填**参数(而非最初的全可选形态)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)以及轮次的 abort signal,因此可选性只会在 seam 处引入隐藏默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件已在那里选择实际模型。
|
||||
`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接收**必需**参数(而非最初的全可选形式)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)和轮次的 abort signal,因此可选性只会在 seam 处引入隐藏的默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件在那里已经选择了实际模型。
|
||||
|
||||
### 自动压缩运行在 `agent/pre-step`,一个专用的 surface 变更 seam
|
||||
### 自动压缩在 `agent/pre-step` 运行——一个专用的 surface 变更 seam
|
||||
|
||||
压缩会变更会话 surface,因此它在步骤开启之前、消息派生之前运行。`agent/request` 仍然是调用配置变换,永远不需要在 surface 变更后重建历史。
|
||||
压缩会变更 session surface,因此在步骤开启之前、消息派生之前运行。`agent/request` 保持为调用配置变换,无需在 surface 变更后重建历史。
|
||||
|
||||
解决方案是一个专用的循环 seam:**`agent/pre-step`**(`@mode serial`),由循环在系统组装*之后*、步骤开启(`step/start`)*之前*触发:
|
||||
|
||||
@@ -48,29 +48,29 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction
|
||||
request = waterfall agent/request ⟵ pure request transform (hooks, model switch)
|
||||
```
|
||||
|
||||
循环在 `agent/pre-step` 之后只派生一次消息。在 `step/start` 之前运行使压缩记录落在任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且 serial 的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。
|
||||
循环在 `agent/pre-step` 之后派生一次消息。在 `step/start` 之前运行,使压缩记录位于任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且串行的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。
|
||||
|
||||
### 保留是轮次无关的;工具配对平衡是唯一的结构守卫
|
||||
|
||||
自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:一个工具密集的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,surface 在*一轮之内*就会增长。单独一轮就可能超出窗口(「失控轮次」)——而在下一次模型调用溢出之前能挽救它的唯一时机,就是下一步的 `pre-step` 检查点。如果把压缩限制在轮次的第一步(或更糟,逐字保留整个进行中的轮次),就恰好重新打开了压缩存在的意义所要堵住的那个缺口:harness 会在最需要压缩的时候崩溃。
|
||||
自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 在*一轮之内*就会增长。单独一轮就可能超出窗口("失控轮次"),而在下一次模型调用溢出之前唯一能挽救的时机是下一步的 `pre-step` 检查点。如果将压缩限制在轮次的第一步(或者更糟,逐字保留整个进行中的轮次),恰好重新打开了压缩存在的意义所要堵住的缺口:harness 会在最需要压缩时崩溃。
|
||||
|
||||
`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小尾部完整 surface 单元,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到截断处工具配对平衡。平衡按 surface 顺序检查,而非日志序列号,因为替换摘要在旧 surface 位置有新的序列号。`compactRegion` 拒绝将工具调用与其结果拆开的边界。进行中的轮次不享有特殊保留。
|
||||
`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。
|
||||
|
||||
因此失控轮次的压缩方式与任何其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持逐字。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`),待该步骤关闭后重试。
|
||||
因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`)并在该步骤关闭后重试。
|
||||
|
||||
**单单元溢出不在范围内,这是有意的。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点如粘贴的 `user/message`——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地命名了这个边界,而非掩盖它。
|
||||
**单单元溢出不在范围内,这是有意为之。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点(如粘贴的 `user/message`)——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地指出这一点,而非掩盖。
|
||||
|
||||
### 头部锚定:一个自动检查点,始终在头部
|
||||
|
||||
自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,使自动检查点始终只有一个。因此 `shadowedRange` 是位置性的而非数值序列区间:一个更新的摘要序列号可能占据更旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。
|
||||
自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,因此只保留一个自动检查点。`shadowedRange` 因此是位置性的而非数值序号区间:一个较新的摘要序号可能占据较旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。
|
||||
|
||||
### 近似收敛不变式
|
||||
|
||||
`resolveConfig` 校验数值参数但**不**基于假想的摘要长度不变式拒绝。收敛是动态的:提供方的输出上限可能被隐藏或外显的推理 token 消耗,模型可能输出不可预测大小的摘要。`maxTokens` 只是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于它遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能把保留尾部推过预算)——这恰好是上面声明的范围外关注点,而非抖动 bug。
|
||||
`resolveConfig` 校验数值参数,但**不**基于虚构的摘要长度不变式来拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显式的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 仅是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于其遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能将保留尾部推过预算),这恰好是上述范围外的关注点,而非抖动 bug。
|
||||
|
||||
### Surface 替换:`compact/*` 事件仅存于日志;一条 `user/message` 承载摘要
|
||||
### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要
|
||||
|
||||
由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,其 `sourceEventSeqs` 覆盖被遮蔽的节点*以及*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件:
|
||||
由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的节点*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件:
|
||||
|
||||
```
|
||||
compact/start → log-only. Acquires the lock.
|
||||
@@ -81,47 +81,47 @@ user/message → surfaceOp { op:'replace', start, end }. THE surface mutatio
|
||||
compact/end → log-only. Releases the lock (carries `error` on a recoverable failure).
|
||||
```
|
||||
|
||||
`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*就是* user 角色的上下文。
|
||||
`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。
|
||||
|
||||
### 检查点框架 + 增量合并(后端私有)
|
||||
|
||||
基础后端将摘要包装为已建立的检查点上下文,并标记它以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 只承诺一条替换 user 消息承载可能带框架的摘要。
|
||||
基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 仅承诺一条替换 user 消息承载可能带框架的摘要。
|
||||
|
||||
### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败分类
|
||||
### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败的分类
|
||||
|
||||
`compact/start … compact/end` 括号的合理性,按实际承担的工作排序:
|
||||
`compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序:
|
||||
|
||||
1. **可检测的崩溃孤儿 + 来源记录**(首要)。摘要生成是一次慢模型调用,在 `compact/start` *之后*持久化。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先释放)将崩溃窗口从*静默损坏*转化为可检测的孤儿。
|
||||
2. **防止并发压缩。** 如果当前轮次持有一个未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是一个重入绊线——抛出的「already in progress」信号意味着真正的 bug。)
|
||||
1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。
|
||||
2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是重入绊线——抛出"already in progress"表示真正的 bug。)
|
||||
|
||||
两种失败路径,均有文档记录:
|
||||
|
||||
- **崩溃**(循环在摘要生成中途死亡):一个悬空的 `compact/start`,没有关闭者。因为 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,所以完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。
|
||||
- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,surface 不受影响,模型调用继续使用完整历史。
|
||||
- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,因此完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。
|
||||
- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,surface 保持不变,模型调用以完整历史继续。
|
||||
|
||||
`compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。
|
||||
|
||||
**核心会话修复保持对压缩无感知——这是有意的。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。因为仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。
|
||||
**核心 session 修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **完整算法作为接口上的具体方法**(只有估算/摘要是抽象的)——早期草案;否决,因为它把契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。
|
||||
- **压缩运行在 `agent/request` waterfall(瀑布式事件)上**——早期方案;否决,因为它强制了双重派生,且交给监听器的上下文在结构上无法压缩。专用的 `agent/pre-step` seam 使分层在构造上正确。
|
||||
- **完整算法作为接口的具体方法**(仅估算/摘要为抽象)——早期草案;否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。
|
||||
- **在 `agent/request` waterfall(瀑布式事件)上执行压缩**——早期方案;否决,因为它强制双重派生,且将监听器上下文交给了结构上无法压缩的对象。专用的 `agent/pre-step` seam 从构造上使分层正确。
|
||||
- **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。
|
||||
- **教导核心轮次修复认识 `compact/*`**——否决:仅日志的孤儿是惰性的,而一个为每个未来 `xxx/start … xxx/end` 插件对打补丁的核心模块,恰好是能力 seam 架构存在的意义所要避免的耦合。
|
||||
- **教导核心轮次修复识别 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。
|
||||
|
||||
## 后果
|
||||
|
||||
- **新包**:`packages/compact/compact`(接口)和兄弟包 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。
|
||||
- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。
|
||||
- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **不受影响**。这些是会话事件而非 cordis `Events`,因此事件分类门禁无需新增条目。
|
||||
- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆开步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在,直接复用。
|
||||
- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩会将高序列号的替换节点放在更旧范围的*位置*,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次包含不变式原样复用。
|
||||
- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中交付(此前未在任何地方加载)。
|
||||
- **新包**:`packages/compact/compact`(接口)和同级的 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。
|
||||
- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是对循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。
|
||||
- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。
|
||||
- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆分步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在并被复用。
|
||||
- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩将高序号替换节点放在较旧范围的*位置*上,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次封闭不变式原样复用。
|
||||
- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中生效(此前它未被任何地方加载)。
|
||||
|
||||
## 测试
|
||||
|
||||
- **单元测试:** 真实 Loader 和 invariant 插件覆盖整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。
|
||||
- **循环测试:** 测试固定每步在 `turn/start` 和 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在那里的 surface 变更落在步骤之外,并出现在单次派生的请求中。
|
||||
- **带密钥 e2e:** 真实模型和 bash 会话在降低限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。
|
||||
- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错的摘要调用回放仍是后续工作。
|
||||
- **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。
|
||||
- **循环测试:** 测试固定每步在 `turn/start` 与 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在该处的 surface 变更落在步骤之外,并出现在单次派生的请求中。
|
||||
- **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。
|
||||
- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-21-subagent-capability-seam.md: 2bed84cd9166e8aa1ad5fa65b3afa44b8a842045
|
||||
2026-06-21-subagent-capability-seam.zh.md: a99d0fe894dca485452dd266752785a26815bb8a
|
||||
2026-06-21-subagent-capability-seam.zh.md: a5917c14141dd06c14b4f45c5f6e4703f0eb661f
|
||||
@@ -1,74 +1,74 @@
|
||||
# RFC:Subagent 能力 seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-21-subagent-capability-seam.md) | 中文
|
||||
|
||||
> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外 `dsh-subagent-acp` 后端([其 RFC](2026-06-22-acp-subagent-backend.md))。
|
||||
Status: implemented
|
||||
|
||||
> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 RFC](2026-06-22-acp-subagent-backend.md))。
|
||||
|
||||
## 问题
|
||||
|
||||
harness 有一个长期搁置的 subagent seam:一个 agent 将工作委派给另一个 agent。意图已在 `Agent`/`AgentLoop` 接口中勾勒([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):创建选项引用父 agent(fork = 用父会话的事件日志为子会话播种;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅统一工作。本 RFC 实现该 seam;上方横幅列出了已交付的内容。
|
||||
harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智能体)将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 RFC 实现了这个 seam;上方横幅列出了已交付的内容。
|
||||
|
||||
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。我们预见的传输方式:
|
||||
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP(Agent Client Protocol))。我们预见的传输方式:
|
||||
|
||||
- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于已有的 agent 工厂几乎零成本);
|
||||
- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
|
||||
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
|
||||
- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外「启动子 agent、发送提示词、流式更新、取消」形态。
|
||||
- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 为什么不用 bash seam 的形态
|
||||
### 为何不采用 bash seam 的形状
|
||||
|
||||
bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**:每个实现以唯一名称注册,调用方按名称选取。这与 **LLM 适配器注册表**(`LlmService.registerAdapter`)同构,而非单服务的 bash 执行器。seam 仍然是三包结构(接口 / 实现 / 消费方);唯一不同的轴是「单实现 vs. 多实现」。
|
||||
bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。
|
||||
|
||||
## 决策
|
||||
|
||||
### 三包 seam
|
||||
### 由三个包构成的 seam
|
||||
|
||||
新增包组 `packages/subagent/`:
|
||||
新建包(package)组 `packages/subagent/`:
|
||||
|
||||
| 包 | 角色 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService`(`ctx.subagents`)、`SubagentProvider`、`SubagentRun`、请求/结果/能力词汇表、`subagent/*` 事件 |
|
||||
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService`(`ctx.subagents`)、`SubagentProvider`、`SubagentRun`、请求/结果/能力词汇、`subagent/*` 事件 |
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | 实现:以父会话日志快照为种子的进程内子 agent |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 |
|
||||
| `@deepseek-ai/dsh-subagent-mock` | 支撑:脚本化的提供方,用于通过真实加载路径测试 seam |
|
||||
| `@deepseek-ai/dsh-subagent-mock` | 辅助:用于通过真实加载路径测试 seam 的脚本化提供方 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 |
|
||||
|
||||
### 基本原语:异步 `start → SubagentRun`
|
||||
### 原语:异步 `start → SubagentRun`
|
||||
|
||||
提供方暴露 `start(request) → Promise<SubagentRun>`。完成后发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()` 取消剩余工作并等待静默。启动失败时清理部分资源,不发出生命周期事件。`start` 是传输无关的;`spawn` 仅命名全新进程内后端。
|
||||
提供方暴露 `start(request) → Promise<SubagentRun>`。完成时发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()`(资源释放)取消剩余工作并等待静止。启动失败时清理部分资源,不发出生命周期事件。`start` 与传输方式无关;`spawn` 仅指代全新的进程内后端。
|
||||
|
||||
### 两类可选能力,两种发现方式
|
||||
|
||||
- **启动时特性**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态 `provider.capabilities` 描述符上。服务在委派之前检查每一项请求的特性,若提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不「接受后静默忽略」。它们必须在 run 存在之前被检查,这就是为什么不能做成运行时方法。
|
||||
- **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续交互)是 `SubagentRun` 上的**可选方法**。方法的存在即是能力,TypeScript 窄化即是发现机制:消费方不经窄化就无法调用不存在的方法,因此不存在静默降级路径,也不需要一个单独的 flags 对象来保持同步。
|
||||
- **启动时特性**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派**之前**检查每个被请求的特性,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些特性必须在 run 存在之前检查,因此不能是运行时方法。
|
||||
- **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。
|
||||
|
||||
### Fork 与 fresh 是独立后端,而非一个 flag
|
||||
|
||||
全新子 agent 和 fork 子 agent 是独立的提供方,而非请求上的 flag。`dsh-subagent-spawn` 启动隔离的子 agent;`dsh-subagent-fork` 以仅包含已完成父轮次的平衡前缀为种子。进行中的轮次被排除,因为其 subagent 调用尚无结果,无法构成有效的回放历史。
|
||||
全新子 agent 与 fork 子 agent 是独立的提供方,而非请求中的一个 flag。`dsh-subagent-spawn` 启动隔离的子 agent;`dsh-subagent-fork` 用一个平衡前缀初始化子 agent,该前缀仅包含已完成的父轮次。进行中的轮次被排除,因为其 subagent 调用尚无结果,无法构成有效的回放历史。
|
||||
|
||||
### 子 agent 隔离与父日志
|
||||
|
||||
每个 subagent 运行在自己的 **`Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn 的 `tool/call` 及其 `tool/result`(子 agent 的最终输出);子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,从不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 保持传输无关。
|
||||
每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,绝不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关。
|
||||
|
||||
### 同步收集(第一版)
|
||||
### 同步收集(首版)
|
||||
|
||||
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在 `finally` 中 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出。这个前台消费方不使用 run 的可选 steering 方法。
|
||||
|
||||
### 提供方选择是配置,不面向模型
|
||||
|
||||
`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选取其中一个。本版 schema 中没有 provider/type 参数。
|
||||
`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——本版 schema 中没有 provider/type 参数。
|
||||
|
||||
## 测试
|
||||
|
||||
seam 通过真实的 Cordis Loader/export 路径测试,这能捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e。
|
||||
seam 通过真实的 Cordis Loader/export 路径测试,这能捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状错误。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。
|
||||
|
||||
## 后果
|
||||
|
||||
- **递归。** 若无限制,进程内子 agent 能看到委派工具并递归。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有它们的确切语义和安全限制。
|
||||
- **阻塞父轮次。** 同步收集在子 agent 的整个持续期间保持父 agent 的 `runStep` 打开。这对第一版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该重新设计将统一 subagent 与 bash 的长时运行工具处理**(一个 sub-agent 和一个长时间运行的 `bash` 后台任务面临相同的「模型启动了一个慢操作,之后如何收集结果」问题,应共享一套机制而非各自发明)。
|
||||
- **实时进度。** 本版仅暴露生命周期事件和最终结果;逐分片的子→父更新流推迟到后台重新设计。
|
||||
- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;第一版不声明这两项能力,子 agent 在自己的进程中自给自足。
|
||||
- **递归。** 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭状态,并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界。
|
||||
- **阻塞父轮次。** 同步收集在子 agent 的整个持续时间内保持父 agent 的 `runStep` 打开。这对首版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该设计将统一 subagent 和 bash 的长时间运行工具处理**(一个 subagent 和一个长时间运行的 `bash` 后台任务面临相同的问题——「模型启动了一个慢操作,之后如何收集结果」——应共享一套机制,而非各自发明)。
|
||||
- **实时进度。** 本版仅暴露生命周期事件与最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理。
|
||||
- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;首版不声明这两项能力,子 agent 在自己的进程中自行服务。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-22-acp-subagent-backend.md: 7eb03ddf68f54c29524944e7b8bc801eb1724fe6
|
||||
2026-06-22-acp-subagent-backend.zh.md: 6f7b95318a2c714fea43a584ba49da00a7c12040
|
||||
2026-06-22-acp-subagent-backend.zh.md: 249f5a5ebf18d42f3d83d2159f6c2bcb52a245c3
|
||||
@@ -1,57 +1,57 @@
|
||||
# RFC:ACP subagent 后端(进程外委派)
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-22-acp-subagent-backend.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
subagent seam(见 [seam RFC](2026-06-21-subagent-capability-seam.md))的设计使得多个后端可以按名称共存于 `ctx.subagents` 上。进程内后端(`-spawn`/`-fork`)将子 agent 作为同一个 Cordis 上下文上的第二个 `Agent` 运行——开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义正是还要支持通过协议到达的进程外子 agent,以证明这层抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。
|
||||
subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在**同一个** Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的**进程外**子 agent,以证明该抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个**派生的子进程**中,以 ACP *客户端*身份驱动。它是现有服务端桥接 `@deepseek-ai/dsh-acp`(ACP *agent*)的方向反转孪生体:桥接**应答** `initialize`/`newSession`/`prompt`;本后端**调用**它们并**实现** `Client` 回调(`sessionUpdate`、`requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程对话。
|
||||
`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个**派生的子进程**中,并以 ACP *客户端*身份驱动它。它是现有服务端桥接 `@deepseek-ai/dsh-acp`(ACP *agent*)的方向反转孪生体:桥接**应答** `initialize`/`newSession`/`prompt`;本后端**调用**它们并**实现** `Client` 回调(`sessionUpdate`、`requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程通信。
|
||||
|
||||
### 每次运行启动新进程
|
||||
### 每次运行启动全新进程
|
||||
|
||||
每次 `start` 都 spawn 一个新子进程,运行恰好一个 ACP 会话(`initialize` → `newSession` → `prompt`),`dispose` 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。
|
||||
每次 `start` 都 spawn 一个新的子进程,运行恰好一个 ACP 会话(`initialize` → `newSession` → `prompt`),`dispose` 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。
|
||||
|
||||
### 最小客户端桩
|
||||
### 最小化客户端桩
|
||||
|
||||
客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费——后端累积 `agent_message_chunk` 文本作为结果输出,在本次实现中忽略其余内容(思考、工具调用卡片),仅呈现子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝每个提示,`allow` 通过第一个 allow 形态的选项批准)——本次实现不将任何提示呈现给人类。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍是未来工作,如 seam RFC 所述。
|
||||
客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam RFC 所述。
|
||||
|
||||
### 无启动时能力
|
||||
|
||||
提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无法访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),且本次实现未实现 `outputSchema`。服务在 `start` 运行之前就会拒绝需要上述任何能力的请求。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`。
|
||||
提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无权访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),本阶段也未实现 `outputSchema`。如果请求需要其中任何一项,服务在 `start` 运行前即拒绝。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`。
|
||||
|
||||
### StopReason 映射
|
||||
|
||||
ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`max_tokens`→`max-tokens`、`refusal`→`refusal`、`cancelled`→`aborted`、`max_turn_requests`→`error`(无对等语义——任务未完成)、未知→`error`。spawn/传输/RPC 失败解析为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 永远不会因子 agent 级别的失败而 reject。
|
||||
ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`max_tokens`→`max-tokens`、`refusal`→`refusal`、`cancelled`→`aborted`、`max_turn_requests`→`error`(无对等语义,任务未完成)、未知→`error`。spawn/传输/RPC 失败解析为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 在子 agent 级别失败时从不 reject。
|
||||
|
||||
### 安全:清洗子进程环境
|
||||
|
||||
子 agent 是独立进程,因此会继承环境变量。凭证形态的环境变量(`/KEY|SECRET|TOKEN/i`)默认**不**转发——父 harness 自身的密钥不得隐式泄漏到派生进程中(与 bash 执行器采用的策略相同)。子 agent **自身**的凭证(它需要模型密钥)通过 `config.env` **显式**提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 不会。子进程 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞争,使错误命令解析为 `error` 而非以未处理错误崩溃父进程。
|
||||
子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认**不**转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent **自己**的凭证(它需要模型密钥)通过 `config.env` **显式**提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。
|
||||
|
||||
## 测试
|
||||
|
||||
- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载,以及命名空间导出。
|
||||
- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`、写入 `proof.txt`,父进程验证该文件。
|
||||
- **快照缺口:** 每个 ACP 子 agent 是独立进程、拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock-server 覆盖已有;`TODO(acp-subagent-replay)` 跟踪父 agent 对回放中子 agent 的回放支持。
|
||||
- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
|
||||
- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。
|
||||
- **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 为何继续使用 SDK 0.25.1?
|
||||
|
||||
后端仅需 `ClientSideConnection`、`ndJsonStream`、`PROTOCOL_VERSION` 和客户端协议类型,0.25.1 均已支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,但不会改善本后端,因此升级作为独立变更保留。
|
||||
后端只需要 `ClientSideConnection`、`ndJsonStream`、`PROTOCOL_VERSION` 和客户端协议类型,0.25.1 全部支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,却不会改善本后端,因此升级作为独立变更保留。
|
||||
|
||||
### 为何不使用持久子进程?
|
||||
|
||||
持久进程池(跨运行复用热子进程)是一项性能优化,推迟到未来工作——它引入会话生命周期和崩溃恢复的复杂性,本次实现不需要;每次 `start` spawn 新子进程与进程内「每次运行一个子 agent」的形态一致。
|
||||
持久进程池(跨运行复用热子进程)是一项性能优化,推迟到后续工作。它增加了会话生命周期和崩溃恢复的复杂度,本阶段不需要;每次 `start` spawn 全新子进程与进程内「每次运行一个子 agent」的形态一致。
|
||||
|
||||
## 后果
|
||||
|
||||
每次运行都要付出一个新子进程的开销(spawn + `initialize` + `newSession`)。父 agent 仅呈现子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示永远不会到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥须通过 `config.env` 显式提供。
|
||||
每次运行都要付出一个全新子进程的代价(spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 `config.env` 显式提供。
|
||||
|
||||
## 未来提供方
|
||||
## 后续提供方
|
||||
|
||||
同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam RFC 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-25-ask-user-question.md: 06673233038d10214f8de3d1f29766d43b575442
|
||||
2026-06-25-ask-user-question.zh.md: 01d1284dba3622984d5403f94e3edd0ba02583b6
|
||||
2026-06-25-ask-user-question.zh.md: a036220fd54e3f634ab4be80a45964b076d3fd2d
|
||||
@@ -1,51 +1,51 @@
|
||||
# RFC:ask-user 提问能力
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-25-ask-user-question.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
agent(智能体)有时仅凭模型推理(inference)无法安全地继续:它需要人类选择路径、确认有风险或默认的操作,或提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这会打断正常的工具调用循环:agent 没有结构化的暂停手段,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的方式。
|
||||
agent(智能体)有时仅凭模型推理(inference)无法安全地继续执行:它需要人类选择路径、确认有风险的或默认的操作,或者提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这打断了正常的工具调用循环:agent 没有结构化的暂停方式,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的途径。
|
||||
|
||||
这是一个面向用户的能力,但它也跨越了包(package)边界。模型侧的工具需要一套提供方无关的请求词汇;每个 UI 表面需要决定如何展示和收集答案;agent loop(智能体循环)应保持不变,因为工具调用本身已具备正确的异步形态。
|
||||
这是一个面向用户的能力,但它也跨越了包(package)边界。面向模型的工具需要一套提供方无关的请求词汇;每个 UI 界面需要决定如何展示和收集答案;agent loop(智能体循环)应保持不变,因为工具调用本身已具备正确的异步形状。
|
||||
|
||||
## 决策
|
||||
|
||||
引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与模型侧消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之:向人类提问是一种由 UI 支撑的产品能力,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品表面提供收集答案的具体 provider。工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。
|
||||
引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与面向模型的消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之的:向人类提问是一种由 UI 支撑的产品功能,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品界面提供收集答案的具体 provider。该工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。
|
||||
|
||||
模型侧的请求词汇有意与产品研究 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`。`id` 按问题提供并在结果中回传,使批量请求可以路由而不依赖问题文本。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。
|
||||
面向模型的请求词汇有意与产品调研 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`。`id` 按问题提供并在结果中回传,使批量请求无需依赖问题文本即可路由。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。
|
||||
|
||||
provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形态。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖所有已选选项,`selected` 为空。
|
||||
Provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形状。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。
|
||||
|
||||
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER`、`ASK_ABORTED`、ACP 取消或会话路由缺失等失败会以可机器路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能区分「用户取消」与通用抛出异常。
|
||||
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER`、`ASK_ABORTED`、ACP(Agent Client Protocol)取消或会话路由缺失等失败会以机器可路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能够区分「用户取消」与一般的抛出异常。
|
||||
|
||||
## UI 映射
|
||||
|
||||
`dsh-stdio-demo` 的包内 readline 模块逐题渲染每个问题,在下一行展示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序逐题询问,合并为一个答案对象返回。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个 prompt 占用 stdin。
|
||||
`dsh-stdio-demo` 的包内 readline 模块渲染每个问题,在下一行显示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序询问,作为一个答案对象整体解析。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个提示占用 stdin。
|
||||
|
||||
`dsh-acp` 为 ACP(Agent Client Protocol)会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由到对应会话,并为每个问题调用 ACP `unstable_createElicitation`(携带会话作用域的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation 的情况都会变为结构化的 `UserInteractionError`。
|
||||
`dsh-acp` 为 ACP 会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由出去,并为每个问题调用 ACP `unstable_createElicitation`(附带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项的问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation,都会转为结构化的 `UserInteractionError`。
|
||||
|
||||
ACP 映射有意使用 elicitation 而非 `session/request_permission`。`request_permission` 仍保留给独立的权限门禁:它是围绕工具执行的 yes/no 或策略式授权协议。`ask_user_question` 是一个通用的信息收集工具,支持可选的自由格式答案,因此 ACP 表单 elicitation 是更贴合的协议。bridge 的会话路由与未来的权限门禁共享,但用户意图不同。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化的选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user prompt 到达,而非作为需要答案的那次操作的结果。
|
||||
**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user prompt 到达,而非作为需要答案的那次操作的结果。
|
||||
|
||||
**核心包拥有 ask-user 相关包。** 最初实现将 seam 和模型侧工具分别放在 `packages/core` 和 `packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互能力。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction` 和 `dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包结构与产品边界一致:应用和 bridge 提供人类答案的 provider,stdio 应用选择性加载模型侧工具。
|
||||
**核心拥有的 ask-user 包。** 最初实现将 seam 和面向模型的工具分别放在 `packages/core` 和 `packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互功能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction` 和 `dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包的划分与产品边界一致:应用和 bridge 提供人类答案的 provider,stdio 应用选择性加载面向模型的工具。
|
||||
|
||||
**ACP `session/request_permission`。** 权限请求是围绕工具执行的授权;`ask_user_question` 是带可选自由格式答案的信息收集。将权限用于通用提问会混淆两个不同的产品概念,并使未来的权限门禁更难推理。
|
||||
|
||||
**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。新增一个循环特例会重复这一异步形态,并迫使每个循环实现都了解一个 UI 关注点。
|
||||
**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。添加新的循环特殊分支会重复这一异步形状,并迫使每个循环实现都了解一个 UI 关注点。
|
||||
|
||||
## 后果
|
||||
|
||||
ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应留在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。
|
||||
ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应限制在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。
|
||||
|
||||
该特性赋予模型一个强大的暂停原语,因此提示词引导很重要。工具描述告诉模型提问要简洁、尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
|
||||
该功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
|
||||
|
||||
`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和模型侧工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶子节点必须在其客户端能够完成 elicitation 请求后才有意加载模型侧工具。
|
||||
`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 请求后才有意加载面向模型的工具。
|
||||
|
||||
## 测试
|
||||
|
||||
单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc` 的验证)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流程。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。
|
||||
单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc`)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-29-todo-write-tool.md: 69f81cf6fd93df63ce53bb82c97dbac16dbbd486
|
||||
2026-06-29-todo-write-tool.zh.md: a687f5ab4bc5b9fcd5583ca4aac2857ab4c3f513
|
||||
2026-06-29-todo-write-tool.zh.md: eb3c6fb8a9ddc7d26e4a620761c96a8d35ddf469
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:`todo_write` 工具——将模型任务列表建模为事件溯源的会话状态
|
||||
# RFC:`todo_write` 工具——将模型任务列表作为事件溯源的会话状态
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,59 +6,59 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 为模型提供了 bash 和 subagent 工具,但没有任何方式记录结构化的任务列表。todo 列表服务于两个同等重要的目的:引导模型规划多步骤工作并保持当前任务明确(最多一个 in_progress,有未完成工作时恰好一个),以及为人类提供实时进度清单。ACP(Agent Client Protocol)协议有原生的 `plan` sessionUpdate,编辑器(Zed)已经在渲染它,但 bridge 从未发出过。调研的每个参考编码 agent(智能体)实现(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;而 harness 什么都没有。
|
||||
harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为人类提供实时进度清单。ACP(Agent Client Protocol)协议原生支持 `plan` sessionUpdate,编辑器(Zed)已能渲染它,但 bridge 从未发出过。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;本 harness 此前没有。
|
||||
|
||||
## 决策
|
||||
|
||||
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其全量列表状态以新的 `todo/write` `SessionEventMap` 变体存在于事件溯源的会话日志上。stdio UI 和 ACP bridge 都从既有的 `session/event` 渲染——ACP bridge 将列表映射为 `plan` sessionUpdate。
|
||||
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。stdio UI 和 ACP bridge 均从现有的 `session/event` 渲染;ACP bridge 将列表映射为 `plan` sessionUpdate。
|
||||
|
||||
### 全量替换,三态 status
|
||||
### 整列表替换,三态 status
|
||||
|
||||
模型每次调用发送**完整**列表;新列表替换旧列表(回放时 last-write-wins)。这是 claude-code V1、opencode 和 codex `update_plan` 共同使用的形态,也是模型训练最多的形态——没有逐项 id,没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`:与 codex `update_plan` 相同的三元组,且关键的是**与 ACP `PlanEntryStatus` 完全一致**,因此 bridge 做 1:1 映射,无损失转换。
|
||||
模型每次调用发送**完整**列表;新列表替换旧列表(回放时 last-write-wins)。这是 claude-code V1、opencode 和 codex `update_plan` 共同采用的形状,也是模型训练最多的形状——没有逐项 id,没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`:与 codex `update_plan` 相同的三元组,且关键的是**与 ACP `PlanEntryStatus` 完全一致**,bridge 因此可以 1:1 映射,无需有损转换。
|
||||
|
||||
### 状态在会话日志上,而非服务
|
||||
|
||||
列表以 `todo/write` 事件追加,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明所有这些。
|
||||
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。
|
||||
|
||||
### 不是 surface 事件
|
||||
|
||||
`todo/write` 被刻意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;一次 todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入 surface 链表,不进入 `deriveMessages()`——它是持久的、可回放的 *UI* 状态,伴随对话传播但不属于对话的一部分。(开发模式的不变式仍要求它位于一个打开的轮次内,事实也确实如此:它在工具调用的 mid-step 阶段追加。)
|
||||
`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入 surface 链表,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。)
|
||||
|
||||
### priority 仅在 ACP 边界合成
|
||||
### Priority 仅在 ACP 边界合成
|
||||
|
||||
ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段,不如让 bridge 在构建 `plan` 时为每个条目合成一个常量 `priority: 'medium'`。priority 是 ACP 协议格式(wire format)的要求,不是 harness 的概念,因此它恰好存在于需要它的边界处。
|
||||
ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段,bridge 在构建 `plan` 时为每条条目合成常量 `priority: 'medium'`。Priority 是 ACP 协议格式(wire format)的要求,不是 harness 概念,因此它恰好存在于需要它的边界上。
|
||||
|
||||
### 相比 claude-code V1 去掉的字段:`activeForm`、id、priority
|
||||
### 相比 claude-code V1 舍弃的字段:`activeForm`、id、priority
|
||||
|
||||
claude-code V1 的 item 是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但那只是为了支持 agent *集群*(磁盘持久化、锁保护、逐项变更)。本工具将 item 保持在最小集:`{ content, status }`。没有 `activeForm`(现在进行时标签)——UI 直接展示 `content`;没有 id——全量替换不需要稳定标识;没有 priority——见上文。每去掉一个字段,模型每次调用就少产出一项。
|
||||
claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但仅为支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`。不要 `activeForm`(现在进行时标签)——UI 直接展示 `content`;不要 id——整列表替换不需要稳定标识;不要 priority——见上文。每舍弃一个字段,模型每次调用就少产出一项。
|
||||
|
||||
### 单一所有者——无集群机制(YAGNI)
|
||||
|
||||
每个列表属于调用方 agent 会话,非 agent 调用会被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。
|
||||
每个列表属于调用它的 agent 会话,非 agent 调用被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。
|
||||
|
||||
### 校验:低成本的中间路线
|
||||
|
||||
schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content` 以及多于一个 `in_progress` 任务。claude-code 将 single-in-progress 留给 prompt;oh-my-pi 在代码中强制。我们取中间路线:强制那些使计划*连贯*的低成本不变式(无空白任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述留给模型。被拒绝的写入返回 `isError` 结果,模型可自行修正。
|
||||
schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给 prompt 约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。
|
||||
|
||||
## 为什么没有 cordis-catalog 条目 / 没有 `@mode`
|
||||
## 为何没有 cordis-catalog 条目 / 没有 `@mode`
|
||||
|
||||
`todo/write` 是 `SessionEventMap` 的成员,不是一等的 cordis `interface Events` 事件。catalog 生成器(`scripts/gen-cordis-catalog.ts`)扫描 `interface Events` 声明;`SessionEventMap` 变体搭载既有的 `session/event` emit,不产生新的 catalog 行。因此它不携带 `@mode` 标签(生成器仅对 `interface Events` 成员要求此标签)——加上它也没有意义。
|
||||
`todo/write` 是 `SessionEventMap` 的成员,不是一等的 cordis `interface Events` 事件。catalog 生成器(`scripts/gen-cordis-catalog.ts`)扫描 `interface Events` 声明;`SessionEventMap` 变体搭载现有的 `session/event` emit,不产生新的 catalog 行。因此它不携带 `@mode` 标签(生成器仅对 `interface Events` 成员要求该标签)——添加一个毫无意义。
|
||||
|
||||
## 测试
|
||||
|
||||
四层,预先设计:
|
||||
- **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR 安全性);ACP `todosToPlan` 映射;stdio 渲染分支。
|
||||
- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。
|
||||
- **全链路集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write`;`todo/write` 事件落地,第二次调用替换它。
|
||||
- **`session/load` 回放**——一条持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。
|
||||
- **带 key 的 e2e + 快照**——一个真实 prompt 诱导 `todo_write`;快照 golden 新增 `plan` 通知和日志事件。
|
||||
四个层级,预先设计:
|
||||
- **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR(热模块替换)安全性);ACP `todosToPlan` 映射;stdio 渲染分支。
|
||||
- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它**有** `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。
|
||||
- **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write`;`todo/write` 事件落地,第二次调用替换它。
|
||||
- **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。
|
||||
- **带密钥 e2e + 快照**——真实 prompt 诱导一次 `todo_write`;快照 golden 获得 `plan` 通知和日志事件。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **内存中的 `ctx.todos` 服务**——需要重新发明日志免费提供的持久性、回放和 `session/load` 重建。
|
||||
- **逐项 delta 协议**——仅在共享多所有者列表时需要,不在本次范围内;全量替换更简单且与参考实现一致。
|
||||
- **工具放在 `core/`**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它与其他工具族一样放在自己的 `packages/todo/` 分组中。
|
||||
- **逐项 delta 协议**——仅在共享多所有者列表时需要,超出当前范围;整列表替换更简单,且与参考实现一致。
|
||||
- **工具放在 `core/` 中**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它像其他工具族一样位于自己的 `packages/todo/` 分组中。
|
||||
|
||||
## 后果
|
||||
|
||||
todo 列表是持久的、可回放的会话状态:一条持久化的 `todo/write` 在 `session/load` 时重新向编辑器发出 `plan` 更新,日志(而非插件内存)是唯一真源。全量替换意味着每次更新一次工具调用、last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。
|
||||
todo 列表是持久、可回放的会话状态:持久化的 `todo/write` 在 `session/load` 时重新发出编辑器的 `plan` 更新,日志(而非插件内存)是唯一真源。整列表替换意味着每次更新一次工具调用,last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-hook-bridges.md: 17ff57307c34121c845592efa93c723e66c98886
|
||||
2026-06-30-hook-bridges.zh.md: 2a94d5cca2f490e4aac493fe357a825ad3b4d271
|
||||
2026-06-30-hook-bridges.zh.md: a4b8c12593cdac35deb882ba15a58876650c1653
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:dsh-hooks-claude + dsh-hooks-codex——Claude Code / Codex 钩子桥接插件
|
||||
# RFC:dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,65 +6,65 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation`、`subagent/start`、`subagent/end`。但用户带着**已有的** Claude Code(CC)和 Codex 钩子配置到来——一个 `hooks.json`(或设置文件中的 `hooks` 键)里满是 shell 命令钩子——并且希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,基于共享的协议格式(wire format)库(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md))构建。
|
||||
harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation`、`subagent/start`、`subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md))。
|
||||
|
||||
贯穿整个设计的定位是:**桥接是兼容性适配器,不是高级工具。**桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能更强力地完成——有类型化返回值、完整的 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射到 seam 的 Decision。各 package 的 README 记录了当前相对官方协议的不支持事件与部分字段清单。
|
||||
贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各 package 的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/hooks/` 分组下两个独立插件,各自为函数/命名空间插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`:
|
||||
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`:
|
||||
|
||||
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形状的每事件 stdin payload(基础字段为 `session_id`/`cwd`/`hook_event_name`,加上每事件特有字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则匹配模式。CC 钩子的 stdin 带有**尾随换行**。
|
||||
- **`dsh-hooks-codex`**——Codex 当前钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形状的 snake_case payload(带 `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾随换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接的精简 `tool_input: { command }` 形状中携带真实的 `tool_name`。
|
||||
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。CC 钩子的 stdin 带有**尾部换行**。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
|
||||
### 结果 → Decision 映射
|
||||
### Outcome → Decision 映射
|
||||
|
||||
每个桥接将共享库返回的中性 `MergedHookOutcome` 映射到 seam 的类型化 Decision:
|
||||
|
||||
| Seam | CC | Codex |
|
||||
|---|---|---|
|
||||
| `agent/session-start`(emit) | additionalContext → `agent.inject()` | plain-stdout 输出 → additionalContext → `agent.inject()` |
|
||||
| `agent/session-start`(emit) | additionalContext → `agent.inject()` | plain-stdout output → additionalContext → `agent.inject()` |
|
||||
| `agent/prompt-submit` | `deny`→`block`;仅上下文→delegate+fold | `block`→`block`;仅上下文→delegate+fold |
|
||||
| `tools/pre-execute` | `deny`→`deny`;`ask`→`ask` | `block`→`deny`(无 allow/ask) |
|
||||
| `tools/post-execute` | `deny`→`block`+feedback;仅上下文→delegate+fold | 同上 |
|
||||
| `agent/turn-continuation` | 阻塞式 Stop → `continue`(reason = 下一步 steering(中途引导)) | 同上 |
|
||||
| `subagent/start`(emit) | additionalContext → 注入进程内活跃子 agent;远程子 agent 没有本地注入目标 | 本桥接不支持 |
|
||||
| `agent/turn-continuation` | 阻塞的 Stop → `continue`(reason = next-step steering(中途引导)) | 同上 |
|
||||
| `subagent/start`(emit) | additionalContext → 注入到存活的进程内 subagent;远程 subagent 无本地注入目标 | 本桥接不支持 |
|
||||
| `subagent/end`(emit) | 仅观察 | 本桥接不支持 |
|
||||
|
||||
CC 桥接的 `ask` 结果是一条真正的权限路径,而非桥接的终态决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 解析它。组合式 ACP 应答器会向拥有者编辑器会话发起提示,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 关闭。
|
||||
CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 来解析它。组合式 ACP 应答器向拥有该会话的编辑器会话发起提示,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。
|
||||
|
||||
### 上下文来源始终是插件(错标防护)
|
||||
### 上下文来源始终是插件(误标签防护)
|
||||
|
||||
`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()` 和 `HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定了最终 `context/message.source` 为插件而非用户。
|
||||
`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()` 和 `HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。
|
||||
|
||||
### 添加上下文不是否决——先 delegate,再 fold
|
||||
|
||||
仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext` 折入下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。
|
||||
仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext` 折叠进下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。
|
||||
|
||||
### CLAUDE_PROJECT_DIR 默认为会话工作区
|
||||
|
||||
Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `$CLAUDE_PROJECT_DIR` 来构造项目相对路径。显式的 `config.projectDir` 优先;当它被省略时(默认的 ACP 接线只配置 `configPath`),桥接将该环境变量按每次运行默认为 agent 的会话工作区——即钩子已经运行其中的 `session.header.cwd`——而不是留空。因此一个标准的项目相对钩子在默认配置下即可工作。
|
||||
Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `$CLAUDE_PROJECT_DIR` 来构造项目相对路径。显式的 `config.projectDir` 优先;当它被省略时(默认 ACP 接线只配置 `configPath`),桥接将该环境变量按每次运行默认为 agent(智能体)的会话工作区——即钩子已经在其中运行的 `session.header.cwd`——而非留空。这样,一个标准的项目相对路径钩子在默认配置下即可正常工作。
|
||||
|
||||
### 隔离
|
||||
|
||||
配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非崩溃启动(一个拼错的路径不得拖垮 agent)。CC 只运行 shell 形式的 `type: 'command'` 钩子;`http`、`mcp_tool`、`prompt` 和 `agent` 处理器被解析后跳过。Codex 只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start`、`subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。
|
||||
配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非崩溃启动(一个拼错的路径不应拖垮 agent)。CC 桥接只运行 shell 形式的 `type: 'command'` 钩子;`http`、`mcp_tool`、`prompt` 和 `agent` 处理器被解析后跳过。Codex 桥接只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start`、`subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。
|
||||
|
||||
### 钩子的运行位置与配置来源
|
||||
### 钩子在哪里运行,配置从哪里来
|
||||
|
||||
钩子在 agent 的会话工作区中运行,因此相对路径指向用户的项目。`configPath` 相对于进程启动 cwd 解析一次,适用于所有会话。按会话的项目本地发现仍推迟在 `TODO(per-session-hook-config)` 下。
|
||||
钩子在 agent 的会话工作区中运行,因此相对路径指向用户的项目。`configPath` 相对于进程启动时的 cwd 解析一次,适用于所有会话。按会话的项目本地发现仍推迟在 `TODO(per-session-hook-config)` 下。
|
||||
|
||||
## 推迟的兼容性缺口
|
||||
|
||||
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不生效——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为预执行参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。
|
||||
- **Stop 循环防护**(`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active` 但文档中没有等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——钩子作者必须自行限制,直到状态追踪落地。
|
||||
- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享 merge 将其折入 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一起推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(decision/上下文)。
|
||||
- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型均未重新实现(`TODO(per-session-hook-config)`)。
|
||||
- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动之外,因此其上下文在就绪时注入,但可能错过第一个请求或短命子 agent。保证首请求送达需要一个 awaited 的启动 seam。
|
||||
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。
|
||||
- **Stop 循环防护**(`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active` 但未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。
|
||||
- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(决策/上下文)。
|
||||
- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未被重新实现(`TODO(per-session-hook-config)`)。
|
||||
- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动过程之外,因此其上下文在就绪时注入,但可能错过首个请求或短命的 subagent。要保证首请求送达,需要一个 awaited 的启动 seam。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**同一点的钩子并发执行。** 参考引擎对同一点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行它们(匹配循环内逐钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)且逐钩子超时不重叠——对真实配置使用的钩子数量而言可接受;如果某天配置扇出到足以影响挂钟时间,再重新审视。
|
||||
**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。
|
||||
|
||||
## 后果
|
||||
|
||||
匹配语义、退出码处理与合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支加上通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形状。原生插件绕过协议格式,直接返回类型化决策。
|
||||
匹配语义、退出码处理和合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支以及通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形态。原生插件绕过协议格式,直接返回类型化决策。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-hook-protocol-lib.md: 924c320f7ef9fdb55b20ff06f492addbf42d1720
|
||||
2026-06-30-hook-protocol-lib.zh.md: 2f1cf0f1e4c99eaf1172642475e3b9bc8c8aed39
|
||||
2026-06-30-hook-protocol-lib.zh.md: 81315cbe8767e9a3cc07cdef92359734e4e20f31
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:dsh-hook-protocol——Claude Code / Codex 钩子协议格式的共享核心库
|
||||
# RFC:dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库
|
||||
|
||||
[English](2026-06-30-hook-protocol-lib.md) | 中文
|
||||
|
||||
@@ -6,27 +6,27 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
钩子子系统提供两个桥接插件:一个运行用户已有的 Claude Code(CC)钩子,一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。**它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型——Codex 的源码甚至以 Claude 的引擎命名自己的引擎,并在注释中标注了「有意偏离」之处。因此两个桥接插件如果各自实现,将重复协议的大部分内容。
|
||||
hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了"有意偏离"之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。
|
||||
|
||||
本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的、真正相同的原语。共享与方言各自持有的部分之间的切分,是本设计的重心所在。
|
||||
本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。
|
||||
|
||||
## 决策
|
||||
|
||||
在 `packages/hooks/` 下新建一个组,`hook-protocol` 作为纯库存在。它拥有四个原语族以及 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。
|
||||
在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。
|
||||
|
||||
**共享(本库):**
|
||||
- **Matcher**——`matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的交替),其他视为正则;`codex` 始终为无锚定正则。缺失/`''`/`'*'` 时匹配全部;无效正则匹配空集(绝不向循环抛出异常)。
|
||||
- **Execution**——`runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已经提供了经过清理但可覆盖的 env、进程组 kill 和超时——正是协议所需的能力,而 `dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),尊重钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛出异常(执行器的 rejection 变为 non-blocking-error 的 `HookOutput`)。
|
||||
- **Decode**——`parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 作为原因(以 `decision: 'block'` 呈现,调用方无需单独的 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只尊重对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此从不进入 transcript,因此没有什么可抑制的;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
|
||||
- **Merge**——`mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block 原因以 `\n\n` 拼接,context/system-messages 按序累积。
|
||||
- **`hook/*` 会话事件**——`hook/invoked` / `hook/result`,通过 declaration-merge 加入 `SessionEventMap`(仅记录日志,类似 `compact/*`——不是 `SurfaceEventType`),附带 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对和轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义——决策字符串(钩子解析出的 decision,否则在 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从此处的 `HookOutput` 导出,而非在各桥接插件中分别实现。
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。
|
||||
- **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
|
||||
- **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
|
||||
- **Merge** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block reason 以 `\n\n` 拼接,context/system-messages 按序累积。
|
||||
- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与 turn 包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。
|
||||
|
||||
**方言各自持有(桥接插件):**构建每个事件的 stdin payload(CC 的 base + per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射到 harness 的 seam 特定类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。
|
||||
**方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**一个参数化引擎。** 否决,因为 payload 构建和决策映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;各桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。
|
||||
**单一参数化引擎。** 否决,因为 payload 构建与 decision 映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;每个桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。
|
||||
|
||||
## 后果
|
||||
|
||||
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 和 merge 逻辑、映射决策、追加 `hook/*` 事件。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已被解析,但在 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地之前仅记录日志并发出警告。
|
||||
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与 merge 逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-interception-seams.md: fb8efe1e1c2057db13b440881f110ca7f579a81e
|
||||
2026-06-30-interception-seams.zh.md: b22b3d61bd14b6708e5a063f02537e981fead0fc
|
||||
2026-06-30-interception-seams.zh.md: 668b96dba282ecdcbe85cc0b1dc56c2de293b3b3
|
||||
@@ -6,55 +6,55 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 需要一套钩子子系统:用户在生命周期节点扩展或拦截 agent(智能体),方式类似 Claude Code(CC)和 Codex。驱动本设计的关键重构是:**"原生钩子"不是一个 package**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是把外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件都能直接做——而且更强大(没有序列化边界、完整的 `ctx`、类型化的返回值)。
|
||||
harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**"原生钩子"不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。
|
||||
|
||||
这个表面需要为以下各阶段提供不同的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及附带面向模型原因的继续。如果把这些阶段混为一谈,插件就会获得不需要的修改通道,终态也会依赖监听器顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则和类型化 Decision 惯用法;本 RFC 将它们应用到生命周期 seam 上。
|
||||
该表面需要为以下场景提供各自独立的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则与类型化 Decision 惯用法;本 RFC 将其应用于生命周期 seam。
|
||||
|
||||
## 决策
|
||||
|
||||
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回归一化结果;通知接收不可变快照,不能影响结果。覆盖范围包括本次纳入的钩子点(`session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`),同时将非钩子的执行策略留给独立组合。
|
||||
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。
|
||||
|
||||
**Agent 事件**(`dsh-agent`):
|
||||
- `agent/session-start(agent, source)`——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知——它**不能**阻塞启动(这是有意的缺口:桥接用于记录/注入,不用于拦截启动)。监听器通过 `agent.inject()` 注入上下文。
|
||||
- `agent/prompt-submit(agent, content, source, next) → PromptDecision`——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。
|
||||
- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,**不能**阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。
|
||||
- `agent/prompt-submit(agent, content, source, next) → PromptDecision` ——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。
|
||||
|
||||
**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化的孪生。
|
||||
**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。
|
||||
|
||||
### 工具流水线为每个阶段赋予一种权限
|
||||
|
||||
每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`。注册表快照调用方输入、物化并冻结参数、分配不透明 token。嵌套调用只携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「运行了什么」达成一致。
|
||||
每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`。注册表快照调用方输入、实体化并冻结参数、分配一个不透明 token。嵌套调用仅携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「执行了什么」达成一致。
|
||||
|
||||
- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 和核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均归一化为拒绝。每种结果仍会到达后策略和最终观测者。
|
||||
- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的作用域感知策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。
|
||||
- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已归一化的抛出或未知工具结果;返回自己的有效结果可短路调度。
|
||||
- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的就地修改不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。
|
||||
- **`tools/result`** 是每次变换、无损 JSON 物化和外层错误边界之后的同步受限通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者失败按监听器隔离,不能改变或拒绝 `ToolRegistry.execute()` 返回的结果。
|
||||
- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每种结果仍会到达后策略与最终观测者。
|
||||
- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。
|
||||
- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已规范化的抛出或未知工具结果;返回自己的有效结果则短路调度。
|
||||
- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的原地 mutation 不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。
|
||||
- **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。
|
||||
|
||||
核心调度和工具体位于归一化边界内,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状失败都解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查抛出异常的工具,最终观测者看到的恰好是调用方收到的、会话日志可持久化的内容。
|
||||
核心调度与工具体位于规范化边界内部,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具,最终观测者看到的正是调用方收到的、会话日志可以持久化的内容。
|
||||
|
||||
**`TurnEndReason.rejected`**(`dsh-session`):整个 prompt 批次被 `prompt-submit` 阻止的轮次。
|
||||
**`TurnEndReason.rejected`**(`dsh-session`):整批 prompt 均被 `prompt-submit` 阻止的轮次。
|
||||
|
||||
### 三个承重的循环决策
|
||||
|
||||
1. **在 prompt 策略之前开启轮次。** 被完全阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP 提供持久的终止事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留了被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。
|
||||
1. **在 prompt 策略之前开启轮次。** 全部被阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。
|
||||
|
||||
2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤缓冲每次调用的上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。
|
||||
2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可以携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤的每次调用缓冲上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。
|
||||
|
||||
3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使下一步骤的循环顶部 drain 将其记录为继续轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与既有的 `hasSteering` force-continue 覆盖一致)。
|
||||
3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与现有的 `hasSteering` 强制继续覆盖一致)。
|
||||
|
||||
### Pre-tool 输入重写是一个独立的一致性决策
|
||||
|
||||
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
|
||||
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
|
||||
|
||||
### 边界
|
||||
|
||||
seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终止的单调停止由 `agent/turn-stop` 独立负责。
|
||||
seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(compaction)(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终结性的单调停止由 `agent/turn-stop` 独立负责。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **将 pre-tool 输入重写作为本 seam 集的一部分交付**——推迟,视为过度扩展信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。
|
||||
- **将持久的 `hook/*` SessionEvent 与 seam 一起声明**——否决:原生插件使用类型化 Decision 而完全不需要钩子日志(工作示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。
|
||||
- **将 pre-tool 输入重写作为本 seam 集的一部分发布**:推迟,视为越界信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。
|
||||
- **将持久的 `hook/*` SessionEvents 与 seam 一起声明**:否决。原生插件使用类型化 Decision 而完全不需要钩子日志(实际示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。
|
||||
|
||||
## 后果
|
||||
|
||||
规范的拦截表面实现了统一类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终止 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存和五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) 和[工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。
|
||||
规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、各 package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) 与[工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-session-store-fork-api.md: 4bf5c3fe43821570fd947034358e54d0a0a602f9
|
||||
2026-06-30-session-store-fork-api.zh.md: 3dd15f5beb095fecb7fdaa7d80abcf4b99c07920
|
||||
2026-06-30-session-store-fork-api.zh.md: a3ffb881a446647861fa5fbaf57dde291838a090
|
||||
@@ -1,20 +1,20 @@
|
||||
# RFC:SessionStore fork API
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-30-session-store-fork-api.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
事件溯源的会话日志已经具备 fork 所需的原语:创建一个新会话并带上种子事件前缀,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法的种子,但普通的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以复制、子会话打上什么元数据、错误如何分类。
|
||||
事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。
|
||||
|
||||
语义风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且被轮次封闭。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`,可能还有未关闭的 `step/start`,以及悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子会话历史——看起来像是参与了父会话中一个未完成的轮次。现有的 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次尚未关闭时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝。
|
||||
语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-session` 直接在 `ctx.sessions` 上拥有普通活跃会话的 fork 能力。没有独立的 `dsh-session-fork` 包(package),也没有 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久性工作都委托给现有的会话存储与持久化后端。
|
||||
`dsh-session` 直接在 `ctx.sessions` 上拥有常规活跃会话 fork 的能力。不设独立的 `dsh-session-fork` 包(package),也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的 session store 和持久化后端。
|
||||
|
||||
存储暴露一个操作:
|
||||
store 暴露一个操作:
|
||||
|
||||
```ts ignore-check
|
||||
type SessionForkSource = Session | SessionId
|
||||
@@ -24,20 +24,20 @@ class SessionStore extends Service {
|
||||
}
|
||||
```
|
||||
|
||||
`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 会创建一个空的子会话。fork 专有的校验只检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 标记为源会话 id,并将 `seedLength` 设为复制的前缀长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。
|
||||
`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验仅检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。
|
||||
|
||||
空前缀可以 fork;任何非空边界必须是一个安全的、已存在的、位于 `turn/end` 处的序号,无论结束原因是什么。类型化的错误区分源不存在、对象陈旧、子会话 id 重复和边界无效。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。
|
||||
空前缀可以被 fork;任何非空边界都必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因为何。类型化的错误区分源缺失、对象陈旧、子 id 重复和边界无效等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**独立的 `ctx.sessionFork` 服务。** 这是第一版实现,但评审表明它过度套用了能力 seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方发现并安装第二个服务,仅仅为了在会话存储原语之上执行策略。
|
||||
**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在 session store 原语之上执行一层策略而去发现并安装第二个服务。
|
||||
|
||||
**两个函数:`snapshot()` 加 `fork()`。** 这保留了可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还让接口感觉比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 保持了 API 的直接性,同时仍支持对先前时间点的 fork。
|
||||
**两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。
|
||||
|
||||
**静默裁剪未关闭的轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的,因为委托通常在父轮次尚未关闭时开始,子会话应只继承已完成的前缀。但对普通的用户/会话分支来说是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并静默丢弃了父轮次的尾部。
|
||||
**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的——委托通常在父轮次仍然打开时开始,子会话应只继承已完成的前缀。但对常规的用户/会话分支而言是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并且静默丢弃了父轮次的尾部。
|
||||
|
||||
## 后果
|
||||
|
||||
公开接口保持小巧且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或两步辅助函数对。持久化继续通过现有的 `session/created` 和 `session/flush` 行为工作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化一次该种子,并在头部保留 `parentSession` / `seedLength`。
|
||||
公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。
|
||||
|
||||
v1 范围仍排除 ACP `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 获得专注的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
|
||||
v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-30-subagent-observe-enrich.md: b48a1fff32130345e669a3b3b905c4fda987e41e
|
||||
2026-06-30-subagent-observe-enrich.zh.md: 59dc555ce4acff30f4ba0b5929b605dc5252fe38
|
||||
2026-06-30-subagent-observe-enrich.zh.md: 8ce070001e219572658fd4e94c660de1094ddee5
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:Subagent 生命周期充实——lastAssistantMessage(仅观测)
|
||||
# RFC:Subagent 生命周期丰富化——lastAssistantMessage(仅观察)
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,26 +6,26 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
钩子子系统([拦截 seam RFC](2026-06-30-interception-seams.md))允许插件在生命周期节点观测和门控 agent(智能体)。Claude Code 和 Codex 都暴露了 **SubagentStart / SubagentStop** 钩子,且 CC 的钩子携带 subagent 的最终消息。harness 已经发出 `subagent/start` 和 `subagent/end` 生命周期事件([subagent 能力 seam](2026-06-21-subagent-capability-seam.md)),但其载荷极为精简(`provider`、`id`,以及 end 时的 `stopReason`)——不足以让钩子桥接层在不另行访问活跃运行的情况下报告 subagent 产出了什么。
|
||||
钩子子系统([拦截 seam RFC](2026-06-30-interception-seams.md))允许插件在生命周期节点观察和拦截 agent(智能体)。Claude Code 和 Codex 都暴露了 **SubagentStart / SubagentStop** 钩子,且 CC 的钩子携带 subagent 的最终消息。harness 已经发出 `subagent/start` 和 `subagent/end` 生命周期事件([subagent 能力 seam](2026-06-21-subagent-capability-seam.md)),但其载荷极为精简(`provider`、`id`,以及 end 时的 `stopReason`),不足以让钩子桥接层在不单独访问活跃 run 的情况下报告 subagent 产出了什么。
|
||||
|
||||
本 RFC 充实 end 载荷。它刻意限定为**仅观测**:不改变控制流,不引入 waterfall(瀑布式事件)。影响运行的 subagent-stop 决策(续行、注入改变运行的内容)属于另一项更大的重新设计,不在本 RFC 范围内。
|
||||
本 RFC 丰富 end 载荷。它刻意限定为**仅观察**:不改变控制流,不引入 waterfall(瀑布式事件)。影响 run 的 subagent-stop 决策(续行、改变 run 的注入)属于另一个更大的重设计,不在本 RFC 范围内。
|
||||
|
||||
## 决策
|
||||
|
||||
**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结算路径上,它是只读的类型化 `SubagentResult.output`,观测者无需持有运行即可看到子 agent 的产出。在基础设施拒绝、不存在 `SubagentResult` 的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听者是受信任的同进程协作者,遵守借用不可变载荷的契约。
|
||||
**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结束路径上,它是只读的类型化 `SubagentResult.output`,观察者无需持有 run 即可看到子 agent 产出了什么。在基础设施拒绝(不存在 `SubagentResult`)的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听方是受信任的同进程协作者,遵守借用不可变载荷的契约。
|
||||
|
||||
两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观测附加到就绪的提供方运行上,发出 `subagent/start`,然后返回该运行;因此进程内监听者可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程提供方无需在本地注册表中有条目。提供方启动被拒绝时不发出任何事件。回调保持仅观测,逐监听者隔离确保一个坏订阅者不会阻塞活跃运行或饿死后续监听者。
|
||||
两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观察附加到就绪的 provider run 上,发出 `subagent/start`,然后返回该 run;进程内监听方因此可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程 provider 无需在本地注册表中有对应条目。provider 启动被拒绝时不发出任何事件。回调保持仅观察,且逐监听方隔离确保一个异常订阅者不会阻塞活跃 run 或饿死后续监听方。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物)放在请求和两个生命周期载荷上——早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(这里没有任何代码解释它,唯一的消费方是 CC 方言桥接层)。CC 桥接层改为向 Claude Code 自身的 SubagentStart/Stop `agent_type` 匹配器喂入其默认值 `"general-purpose"`,因此本 RFC 只交付一项充实:`lastAssistantMessage`。
|
||||
**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物),放在请求与两个生命周期载荷上。早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(此处没有任何逻辑解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为直接为其 SubagentStart/Stop 的 `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 RFC 只交付一项丰富化:`lastAssistantMessage`。
|
||||
|
||||
**控制流式 `subagent/end`**——推迟;见下文。
|
||||
**控制流式 `subagent/end`**:推迟;见下文。
|
||||
|
||||
## 为何仅观测,以及推迟了什么
|
||||
## 为何仅观察,以及推迟了什么
|
||||
|
||||
控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听者、在进程内提供方中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重新设计(同一项重新设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观测充实;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在该重新设计发生时将落地的位置。
|
||||
控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听方、在进程内 provider 中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重设计(同一个重设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观察丰富化;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在重设计发生时的落点。
|
||||
|
||||
## 后果
|
||||
|
||||
钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器——无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)和两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件的触发方式与之前完全相同,end 载荷多了一个(可选的)字段——因此不需要快照或 e2e 测试变更。
|
||||
钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器,无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)与两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件触发方式与之前完全一致,end 载荷上多了一个可选字段——因此无需更新快照或 e2e 测试。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-05-dynamic-workflows.md: 67ceebf7017f197bd800fd339b575390b3b936c1
|
||||
2026-07-05-dynamic-workflows.zh.md: 0ea8e88bfa750a9bb253c7dd3061766fe15d3630
|
||||
2026-07-05-dynamic-workflows.zh.md: 54ba0d903de228e14a53e6f64ead0f5156e61289
|
||||
@@ -1,80 +1,80 @@
|
||||
# RFC:动态工作流——脚本驱动的多 agent 编排 seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-05-dynamic-workflows.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立片段的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划没有持久存放处,每一步的协调都要消耗一次模型往返。Claude Code 以[动态工作流](https://code.claude.com/docs/en/workflows)的形式提供这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。
|
||||
harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划无处持久存储,每一步的协调都要消耗一次模型往返。Claude Code 以 [dynamic workflows](https://code.claude.com/docs/en/workflows) 的形式提供了这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。
|
||||
|
||||
## 决策
|
||||
|
||||
在 `packages/workflow/` 下以 bash seam 的形态(接口/实现/消费方)提供一组工作流能力,加上 subagent seam 上它所需的结构化输出基础。
|
||||
在 `packages/workflow/` 下以 bash seam 的形态(接口/实现/消费方)提供一组工作流能力,以及它在 subagent seam 上所需的结构化输出基础。
|
||||
|
||||
### 脚本契约(兼容 Claude Code)
|
||||
|
||||
一次工作流调用包含 JSON `meta`(`name`、`description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被求值。正文接收 `agent(prompt, options)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`phase(title)`、`log(message)` 和 `args`。pipeline 各阶段接收 `(prev, item, index)`,阶段间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过 journaling 延后处理,因此兼容的脚本正文在将 meta 头移入参数后,可以使用时钟和随机数。
|
||||
一次工作流调用包含 JSON `meta`(`name`、`description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`phase(title)`、`log(message)` 和 `args`。pipeline 各阶段接收 `(prev, item, index)`,阶段之间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过日志化延迟处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。
|
||||
|
||||
与 Claude Code 的一处刻意**偏离**:钩子误用——未知或延后的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——抛出 `fatal: true` 的 `WorkflowError`,组合器对 fatal 错误**重新抛出**而非将 item 置为 null。如果不这样做,一个拼错的选项会溶解为与子 agent 失败无法区分的 `null`——正是本仓库禁止的「接受后静默忽略」失败模式。一处**新增**:工具的 `args` 参数是 JSON 对象(裸列表会被包装为一个字段),以保持协议格式(wire format)的诚实。
|
||||
与 CC 有一处刻意的严格性**差异**:钩子误用——未知或延迟的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——会抛出带 `fatal: true` 的 `WorkflowError`,组合器会**重新抛出** fatal 错误而非将 item 置为 null。如果不这样做,一个拼错的选项会悄然变成一个与子 agent 失败无法区分的 `null`——这正是本仓库禁止的「被接受后被忽略」的失败模式。另有一处新增:工具的 `args` 参数是一个 JSON **对象**(裸列表被包装为一个字段),使协议格式(wire format)保持诚实。
|
||||
|
||||
### seam(dsh-workflow)
|
||||
|
||||
`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`:每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出异常;返回的 `WorkflowRun` 的 `result` 永不 reject(失败解析为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅供观察的 emit,携带数据快照(id + meta;`workflow/end` 不含 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇细节见 [core-data-structures/workflow.md](../../../core-data-structures/workflow.md)。
|
||||
`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`——每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出;返回的 `WorkflowRun` 的 `result` **永不** reject(失败解析为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅观察的 emit,携带**数据快照**(id + meta;`workflow/end` 省略 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇详情见 [core-data-structures/workflow.md](../../../core-data-structures/workflow.md)。
|
||||
|
||||
### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程
|
||||
|
||||
**信任前提**:工作流脚本与模型的 bash 访问享有相同信任级别。引擎约束有 bug 的脚本,保证 result 必定 settle、值 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 之后放置一个独立进程或 isolated-vm 引擎。
|
||||
**信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎容纳有缺陷的脚本,并保证结果已 settled、值为 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。
|
||||
|
||||
**为何选择 `node:worker_threads`**:每次运行获得一个非池化 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。
|
||||
**为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。
|
||||
|
||||
宿主在发布前校验元数据并解析正文。私有枚举键的 payload map 定义协议格式;待启动记录、已发布的子记录、单一取消信号、worker 死亡回收、result 优先级和 dispose 静默在协议两侧维持 subagent run 契约。[agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 拥有这些竞态算法。
|
||||
宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 静默,在此协议上保持 subagent run 契约。这些竞态算法归 [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 所有。
|
||||
|
||||
引擎暴露一条进程内 `MessageChannel` 测试路径,因为主进程 V8 覆盖率无法观测 worker 执行。
|
||||
|
||||
**Meta 是数据**:经 schema 校验的 `meta` 字段以 JSON 形式到达 seam,仅做形状校验。宿主从不对元数据字面量求值——否则脚本控制的访问器会在 worker 隔离之外运行。
|
||||
**Meta 是数据**:经 schema 校验的 `meta` 字段以 JSON 形式到达 seam,仅做形状校验。宿主从不执行元数据字面量,否则脚本控制的访问器可以在 worker 隔离之外运行。
|
||||
|
||||
**值边界**:`materializeFromRealm` 复制出站值,拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数。数据属性复制使 `"__proto__"` 安全;getter 正常读取,抛出异常的 getter 会大声失败。`args` 通过 `workerData` 传入,暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用全量渲染器以确保 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`,因此脚本按 `name` 或 `code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和 grace 限制均为经校验的配置。
|
||||
**值边界**:`materializeFromRealm` 复制出站值,并拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数字。数据属性复制使 `"__proto__"` 安全;getter 正常读取,抛出异常的 getter 会大声失败。`args` 通过 `workerData` 传入,暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用全量渲染器,因此 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`,脚本应基于 `name` 或 `code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和宽限限制均为经校验的配置。
|
||||
|
||||
### 消费方(dsh-tool-workflow)
|
||||
|
||||
一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、等待、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略作为工具自身的 `tool:<toolName>` prompt 段随工具一起交付(显式请求才使用的指导——工具指导存在于工具插件中,从不放在部署 persona 里);harness 没有 ultracode 风格的 effort 门控。
|
||||
一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述**即**面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` prompt 段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。
|
||||
|
||||
### 基础:subagent seam 上的结构化输出
|
||||
|
||||
`SubagentStartRequest.outputSchema` 由 `dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同 schema 而不共享可变策略,dispose 子 agent 时整个附件被移除。
|
||||
`SubagentStartRequest.outputSchema` 由 `dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。
|
||||
|
||||
输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用的外层 `run_code` 结果),在捕获进入 pending 状态后拒绝后续副作用,并在提交后不再请求模型步骤即停止子 agent。校验失败仍为可重试的工具错误;干净完成但没有已提交捕获的情况 settle 为错误。
|
||||
输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。
|
||||
|
||||
`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。[agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 拥有组装、提交、守卫和终止停止的正确性算法。
|
||||
`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归 [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 所有。
|
||||
|
||||
## 测试
|
||||
|
||||
worker 侧逻辑通过进程内 `MessageChannel` 运行,以便 V8 覆盖率能度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带 key 的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。
|
||||
worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带密钥的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。
|
||||
|
||||
## 延后(本轮明确的非目标)
|
||||
## 延迟(本轮明确的非目标)
|
||||
|
||||
- **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。
|
||||
- **Journaling + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会将 Claude Code 的确定性禁令作为脚本契约收紧重新引入(脚本今天可以读取时钟)。
|
||||
- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到 run 目录**(tool-call 事件已经持久记录了脚本)。
|
||||
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延后项的消息大声拒绝)。
|
||||
- **整体运行的挂钟超时**:取消总能释放调用方(result 在 grace 内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。
|
||||
- **超越 worker 线程的引擎加固**:在同一 seam 之后放置 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
|
||||
- **ACP 进度 UI**:基于 `workflow/*` 事件(`/workflows` 风格的视图);事件已为此存在。
|
||||
- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍为能力门控 `false`)。
|
||||
- **日志化 + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本契约收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。
|
||||
- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(tool-call 事件已经持久记录了脚本)。
|
||||
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延迟的消息大声拒绝)。
|
||||
- **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。
|
||||
- **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
|
||||
- **ACP 进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。
|
||||
- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **宿主侧的恶意值防御**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆并带结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上使跨 realm 值全量化。
|
||||
- **进程内 `node:vm` 执行**:机制最简——无 RPC、无线程——但 `start()` 会在脚本首段同步切片期间阻塞调用方,首个 await 之后的同步自旋无法在进程内被杀死(vm `timeout` 仅覆盖首段切片),`dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。
|
||||
- **后台执行作为默认**(Claude Code 的形态):延后。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具各做一套。
|
||||
- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 的关注点,而 seam 的能力标志仍不诚实地为 `false`。
|
||||
- **Meta 嵌入脚本内作为 `export const meta = {...}`**(Claude Code 的精确格式):保持脚本自包含且 Claude Code 脚本可直接使用,但获取 meta 需要在宿主上对模型编写的文本求值。即使是空的限时 vm 上下文,在宿主读取结果对象时也无法约束脚本控制的 getter。JSON 参数消除了扫描器、求值和宿主自旋漏洞;代价是 Claude Code 脚本的 meta 头必须移入参数(正文保持可直接使用)。
|
||||
- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,且无法在不丢失转换精度的情况下对其校验。
|
||||
- **schema 对象库(zod 或仓库的 schemastery)用于结构化输出子集**:schema 是协议数据——纯 JSON,跨越 `agent({schema})` 中的 vm realm 边界,逐字落入强制工具的 parameters——正是活 schema 对象无法存在的位置;在运行时消费原始 JSON Schema 需要在其上叠加第三方转换器(zod core 只输出 JSON Schema,不做反向),且会在 schemastery 的配置角色之外引入第二种 schema 语言。
|
||||
- **ajv 做值校验**:它校验完整 JSON Schema,因此子集门控——模块的真正要点,因为每个被接受的关键字都必须是 harness 所强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的首个运行时依赖,所有这些只为替换约 70 行的值遍历器,而路径限定的、报告每一处违规的错误输出无论如何都是自定义的。
|
||||
- **提供方 JSON 模式代替捕获工具**:它保证有效 JSON,不保证 schema 一致性,且它与工具调用的交互尚不明确。捕获工具保留了轮次内的校验重试。提供方侧的严格工具 schema 可以在不改变本设计的前提下进一步收窄接受的子集。
|
||||
- **宿主侧的恶意值防护**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆加结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上使跨 realm 值全量化。
|
||||
- **进程内 `node:vm` 执行**:机械上最简——无 RPC、无线程——但 `start()` 会在脚本的初始同步切片期间阻塞调用方,第一个 await 之后的同步自旋无法在进程内终止(vm `timeout` 仅覆盖第一个切片),且 `dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。
|
||||
- **后台执行作为默认**(CC 的形态):延迟。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具设计。
|
||||
- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`。
|
||||
- **Meta 嵌入脚本中作为 `export const meta = {...}`**(CC 的确切格式):保持脚本自包含且 CC 脚本可直接使用,但获取 meta 需要在宿主上执行模型编写的文本。即使一个空的限时 vm 上下文也无法约束脚本控制的 getter(当宿主读取结果对象时)。JSON 参数消除了扫描器、执行和宿主自旋漏洞;代价是 CC 脚本的 meta 头必须移入参数(正文保持可直接使用)。
|
||||
- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,也无法在不丢失转换精度的情况下对其进行校验。
|
||||
- **schema 对象库(zod 或本仓库的 schemastery)用于结构化输出子集**:schema 是协议数据——纯 JSON,跨越 `agent({schema})` 中的 vm realm 边界并逐字落入强制工具的 parameters——正是活 schema 对象无法存在的位置;在运行时消费原始 JSON Schema 需要在其上加一个第三方转换器(zod core 只输出 JSON Schema,不能反向),且会在 schemastery 的配置角色旁边放置第二种 schema 语言。
|
||||
- **ajv 用于值校验**:它校验完整 JSON Schema,因此子集门控——模块的真正要点,因为每个被接受的关键字都必须是 harness 强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的第一个运行时依赖,仅为替换约 70 行的值遍历器,而路径限定的、报告每一处违规的错误报告无论如何都是自定义的。
|
||||
- **提供方 JSON 模式代替捕获工具**:它保证有效 JSON,不保证 schema 一致性,且它与工具调用的交互不明确。捕获工具保留了轮次内的校验重试。提供方侧的严格工具 schema 后续可以在不改变本设计的情况下收窄接受的子集。
|
||||
|
||||
## 后果
|
||||
|
||||
扇出计划现在存在于可重新运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和 message-port RPC 的开销,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。Worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run 句柄保持控制,观察者仅接收快照。
|
||||
扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和 message-port RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-05-skill-system.md: 6cfd1f977ae5a1e1ad646a707a4201e57d46bc38
|
||||
2026-07-05-skill-system.zh.md: d491899e03854140c93f67d11e4079a5b6525185
|
||||
2026-07-05-skill-system.zh.md: f59fd5d850a38d7324b391a116c72c4e71ef7401
|
||||
@@ -1,55 +1,55 @@
|
||||
# RFC:Skill 系统——面向 agent 的渐进式指令披露
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-05-skill-system.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
各 agent 产品已趋同于一种 skill 模式:保持请求提示词精简,仅列出可用的指令包,待模型判定任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 和 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用行为而无需在每个轮次支付全量提示词成本。
|
||||
Agent(智能体)产品已趋同于一种 skill(技能)模式:保持请求提示词精简,仅列出可用的指令包,当模型判定某任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 与 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用的行为而无需在每个轮次支付全量提示词开销。
|
||||
|
||||
DeepSeek Harness 使用同一原语,让项目级的评审指导、插件编写指导和工具使用指导存放在工作区或用户的 agent 配置旁,而非硬编码进 agent loop(智能体循环)。
|
||||
DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和工具使用指南存放在工作区或用户的 agent 配置旁,而非硬编码到 agent loop(智能体循环)中。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录和面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACP 应用获得相同行为,同时嵌入式或远程提供方可在不改动注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的负责方。
|
||||
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACP(Agent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的所有者。
|
||||
|
||||
提供方插件在 `apply()` 期间同步注册。提供方成员关系是直接由 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射,而非监听注册表变更事件。提供方目录从 awaited `list()` 调用返回排序后的候选项,远程提供方在此期间执行初始化、认证和发现,同时遵守查找的 abort signal。注册表校验每个候选项,对同名 skill 按 rank、提供方注册顺序和提供方内部顺序执行 first-wins 解析,然后按 skill 名称排序摘要以保证消费方获得确定性结果。注册表仅缓存已完成的目录快照,当提供方/运行时修订版本在发现过程中发生变化时重试,因此 unload 不会将一个陈旧、不可解析的 skill 冻结进会话前缀。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project-over-user 优先级;`runtime` 作为注册表持有的提供方名称被保留。
|
||||
提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话前缀中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。
|
||||
|
||||
本地提供方按 first-wins 的 rank 顺序扫描对 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,使系统持有的目录不被当作普通用户内容。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。
|
||||
本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。
|
||||
|
||||
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md`。`name` 和 `description` 为必填;`whenToUse`、`disableModelInvocation` 和 `metadata` 为可选。名称使用 kebab-case。YAML frontmatter 使用 `yaml` 包解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求所声明的现代解析器,手写窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
|
||||
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md`。`name` 和 `description` 为必填;`whenToUse`、`disableModelInvocation` 和 `metadata` 为可选。名称采用 kebab-case。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
|
||||
|
||||
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。对于未挂载 fs seam 的最小上下文,Node 文件系统仍作为回退。缺失的根目录、不可读或格式错误的 skill 文件,以及提供方 `list()` 的瞬态失败均降级为 warn-and-skip,使单个坏源不会导致每个 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
|
||||
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
|
||||
|
||||
`dsh-tool-skill` 通过 [`agent/session-prefix`](2026-07-07-session-prefix.md) 贡献一条 user-role `<system-reminder>` 目录。目录仅包含排序后的 skill 名称和描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 限制,其默认值为 `500`,最小值为 `3`。会话前缀 seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整 skill 正文从不包含在目录中。
|
||||
`dsh-tool-skill` 通过 [`agent/session-prefix`](2026-07-07-session-prefix.md) 贡献一个 user-role `<system-reminder>` 目录。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。session-prefix seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整的 skill 正文从不包含在目录中。
|
||||
|
||||
`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的披露路径。
|
||||
`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。
|
||||
|
||||
数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md),服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。
|
||||
数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**将完整 skill 正文注入每条系统提示词。** 否决,因为这破坏了渐进式披露,使每个请求都为可能不适用的指令付出代价。
|
||||
|
||||
**仅将 skill 暴露为斜杠命令。** 否决,因为模型主动加载才是核心能力;斜杠/ACP 命令广播不改变发现机制。
|
||||
**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;斜杠/ACP 命令广播不改变发现机制。
|
||||
|
||||
**将本地文件系统扫描直接放在 `ctx.skills` 内。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 同构:注册表负责冲突解析和消费方,实现负责加载。
|
||||
**将本地文件系统扫描直接放入 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解决和消费方,实现拥有加载。
|
||||
|
||||
**使用系统提示词段落。** 否决,因为渲染后的系统提示词是单一字符串,而目录是一条具有仅请求生命周期要求的 user-role `<system-reminder>` 消息。[`agent/session-prefix`](2026-07-07-session-prefix.md) 是选定的机制:它将目录置于派生历史之前,并将组合后的消息记录在请求头中。
|
||||
|
||||
**将内置 DSH 编写 skill 物化到 `~/.dsh/skills/.system`。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
|
||||
**在 `~/.dsh/skills/.system` 下物化内置 DSH 编写 skill。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
|
||||
|
||||
**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包已覆盖配置的根目录,同时保持重复处理和目录顺序易于推理。
|
||||
**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包覆盖了配置的根目录,同时使重复处理和目录顺序易于推理。
|
||||
|
||||
**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
|
||||
**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
|
||||
|
||||
## 后果
|
||||
|
||||
agent-core 主干包含一个会话前缀贡献者、一个本地提供方和一个面向模型的工具。skill 发现对 cwd 敏感,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
|
||||
agent-core 主干包含一个 session-prefix 贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
|
||||
|
||||
目录在固定的根目录集和运行时注册修订版本下是确定性的,但不监听磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。
|
||||
目录对于固定的根目录集合和运行时注册修订版本是确定性的,但不监视磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。
|
||||
|
||||
## 延后
|
||||
|
||||
fork 式 skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`),以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、不强制执行这些字段。
|
||||
Fork 的 skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-06-approval-seam.md: 3ef51c31216bf9f0c5d945748ab3f901ec82147c
|
||||
2026-07-06-approval-seam.zh.md: cec1692d509a7c9a0680773fbdbbb18e1c90ab8c
|
||||
2026-07-06-approval-seam.zh.md: 1bf679426a434c36f5363c3b70f13a8f24534df3
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:审批 seam——通过应答者瀑布式事件实现一次性权限决策
|
||||
# RFC:审批 seam——基于 waterfall(瀑布式事件)应答者的一次性权限决策
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,17 +6,17 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 RFC](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们不必各自发明结果词汇、UI 路由、取消机制和审计追踪,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。
|
||||
两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 RFC](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、UI 路由、取消机制和审计轨迹,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。
|
||||
|
||||
路由问题的本质是归属:审批提示必须到达拥有发起请求的 agent 的那个编辑器会话(ACP 桥在一条连接上复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)默认拒绝(fail-closed),并且不介入没有组合 UI 的部署(无头模式、CI)。
|
||||
路由问题的核心是归属:审批提示必须到达拥有发起请求的 agent(智能体)的编辑器会话(ACP(Agent Client Protocol)桥在一条连接上多路复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)失败关闭,并且不侵入没有组合 UI 的部署(headless、CI)。
|
||||
|
||||
## 决策
|
||||
|
||||
一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制(MECHANISM)。策略(POLICY)——谁来应答、以及某个会话是否被询问——位于其外部:应答者是 `approval/request` waterfall(瀑布式事件)监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭的结果,并从中派生各自的工具结果。刻意只用一个包,而非能力 seam 的三包拆分(见「曾考虑的替代方案」)。
|
||||
一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即**机制**。**策略**——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为**一个**包,而非能力 seam 的三包拆分(见「替代方案」)。
|
||||
|
||||
### 部署如何使用它
|
||||
|
||||
一条 `cordis.yml` 条目挂载该 seam。不加载它即为 fail-closed 退出方式:消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。
|
||||
一条 `cordis.yml` 条目挂载该 seam。不加载它就是失败关闭的退出方式:消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。
|
||||
|
||||
```yaml
|
||||
- id: approval
|
||||
@@ -25,11 +25,11 @@ Status: implemented
|
||||
# policy: never # deployment default for sessions without an override; 'ask' when omitted
|
||||
```
|
||||
|
||||
仅有这条条目提供的是机制而非通道:没有组合应答者时,每次 ask 解析为 `unavailable`,发起 ask 的工具调用被拒绝——默认拒绝无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭合回路:其桥注册一个应答者,通过 `session/request_permission` 向拥有该会话的编辑器发出提示,于是钩子的 `ask` 或升级请求会以一次性 Allow/Reject 提示的形式出现在已流式输出的工具调用上。`policy: never` 是无人值守姿态——每次 ask 确定性地自动拒绝,在系统提示词中声明,无人类参与。`policy` 在插件加载时针对封闭列表做校验;其他值直接抛异常。
|
||||
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其桥注册一个应答者,通过 `session/request_permission` 向拥有该会话的编辑器发出提示,于是钩子的 `ask` 或升级请求会以一次性 Allow/Reject 提示的形式呈现,附着在已流式输出的工具调用上。`policy: never` 是无人值守姿态:每次 ask 确定性地自动拒绝,在系统提示词中声明,无人类参与。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
|
||||
|
||||
组合后的部署观察到的行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同的原因拒绝,模型可以区分它们;每次 ask 都在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided`;授权不会在发起请求的那次调用之后持续存在。
|
||||
组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;每次 ask 在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。
|
||||
|
||||
以下是在此组合下的一次 ask,逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,桥向拥有该会话的编辑器发出提示,用户点击 Allow once:
|
||||
以下是该组合下的一次 ask,逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,桥向拥有该会话的编辑器发出提示,用户点击 Allow once:
|
||||
|
||||
```
|
||||
tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
|
||||
@@ -45,94 +45,94 @@ approval/decided {"outcome": "allowed-once"}
|
||||
tool/result "escalated" — this one call ran under the wider mode; the grant died with it
|
||||
```
|
||||
|
||||
`escalation-rejected` 的孪生场景以 `{"outcome": "rejected"}` 结束:什么都不执行,模型的结果携带发起方逐字的 fail-closed 文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。无头模式下,同一请求完全跳过提示并以 `unavailable` 结算。
|
||||
`escalation-rejected` 孪生场景以 `{"outcome": "rejected"}` 结束:不执行任何操作,模型的结果携带发起方的逐字失败关闭文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。在 headless 环境下,同一请求完全跳过提示,直接结算为 `unavailable`。
|
||||
|
||||
### 设计细节
|
||||
|
||||
#### seam:机制与策略分离
|
||||
|
||||
经过校验并追加 `approval/asked` 后,`request()` 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读请求、运行应答者 waterfall、与取消竞争,并将抛出异常或无效应答归一化为 `unavailable`。随后追加匹配的 `approval/decided`,通过 `ApprovalRequestId` 配对。
|
||||
经过校验并追加 `approval/asked` 后,`request()` 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读请求,运行应答者 waterfall,与取消竞速,并将抛出异常或无效应答规范化为 `unavailable`。然后追加匹配的 `approval/decided`,以 `ApprovalRequestId` 配对。
|
||||
|
||||
两个审计事件都必须在一个打开的轮次内;接受或 pre-commit 追加失败会拒绝该请求。Post-commit 观察者被会话所包含。`allowed-once` 仅授权所请求的操作,服务不保留任何授权状态。
|
||||
两个审计事件都必须在一个打开的轮次内;接受或预提交追加失败会拒绝该请求。提交后的观察者由会话容纳。`allowed-once` 仅授权所请求的操作,服务不保留任何授权状态。
|
||||
|
||||
应答者是 `approval/request` waterfall 监听器。监听器为其拥有的 agent 返回结果,否则调用 `next()`。没有应答者时默认为 `unavailable`;因此卸载 UI 即默认拒绝,不会留下通道。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,仅对「决定或委托」门禁使用 `prepend`。
|
||||
应答者是 `approval/request` waterfall 监听器。监听器为它拥有的 agent 返回结果,否则调用 `next()`。没有应答者时默认为 `unavailable`;因此卸载 UI 即失败关闭,不会留下悬空通道。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,仅对「先决策或委派」门禁使用 `prepend`。
|
||||
|
||||
`ApprovalRequest` 携带 agent、工具名、可选的 `callId`、原因和 signal。agent 同时路由提示和审计事件。请求使用 `dsh-llm` 的 `CallId` 而不导入 `dsh-tools`,避免包循环。工具参数被省略,因为 UI 应答者附着在已渲染的调用上。
|
||||
|
||||
#### dsh-tools 中的 Ask 路由
|
||||
|
||||
`ToolRegistry.execute()` 在拒绝路径之前将 `ask` 发送到审批 seam。只有 `allowed-once` 才继续执行;拒绝、取消和通道不可用产生三种模型可见的不同原因。注册表按调用查找可选服务,因此缺失或未加载的服务默认拒绝,不会阻塞注册表 fiber。无 agent 的执行同样默认拒绝,因为无法路由或审计。
|
||||
`ToolRegistry.execute()` 在进入拒绝路径之前,将 `ask` 发送到审批 seam。只有 `allowed-once` 才继续执行;拒绝、取消和通道不可用产生三种模型可见的不同原因。注册表按调用查找可选服务,因此服务缺失或未加载时失败关闭,不会阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为无法路由或审计。
|
||||
|
||||
#### 每会话策略层
|
||||
|
||||
seam 拥有会话策略 `'ask' | 'never'`,遵循[沙箱 RFC](2026-07-06-sandbox.md) 中的切换契约。生效的会话或配置策略在应答者之前应用:`'never'` 在 `request()` 内部拒绝,而 `'ask'` 派发请求,无人应答时降级为 `unavailable`。系统提示词仅声明确定性的 `'never'`;叙述者报告切换,每个请求仍然收到其审计对。
|
||||
seam 拥有会话策略 `'ask' | 'never'`,遵循[沙箱 RFC](2026-07-06-sandbox.md) 中的切换契约。生效的会话或配置策略在应答者之前应用:`'never'` 在 `request()` 内部直接拒绝,`'ask'` 则派发请求,无人应答时降级为 `unavailable`。提示词仅声明确定性的 `'never'`;叙述者报告切换,每个请求仍收到其审计对。
|
||||
|
||||
#### ACP 应答者
|
||||
|
||||
ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/request_permission`,并将一次性 allow、reject 和 cancel 响应映射到 seam 词汇。未知选项永远不授权。外部 agent 和没有 `callId` 的请求通过 `next()` 委托;RPC 失败变为 `unavailable`。桥应答请求但不决定哪些调用需要审批。
|
||||
ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/request_permission`,并将一次性 allow、reject、cancel 响应映射到 seam 词汇。未知选项永远不授权。外部 agent 和没有 `callId` 的请求通过 `next()` 委派;RPC 失败变为 `unavailable`。桥应答请求,但不决定哪些调用需要审批。
|
||||
|
||||
应答者通过 [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 描述的桥反向映射归属 seam 进行路由,实现了[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) 所要求的每会话权限归属。
|
||||
应答者通过 [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 描述的桥反向映射归属 seam 进行路由,实现了[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
|
||||
|
||||
#### 审计,以及模型看到什么
|
||||
|
||||
`approval/asked` 和 `approval/decided` 是持久的仅日志事件。模型只看到发起方记录的 `tool/result`。每个被接受的请求追加一条匹配的决策,包括取消和被包含的应答者失败。
|
||||
`approval/asked` 和 `approval/decided` 是持久的仅日志事件。模型只看到发起方派生的已记录 `tool/result`。每个被接受的请求追加一条匹配的决策,包括取消和被容纳的应答者失败。
|
||||
|
||||
#### 实体与依赖
|
||||
|
||||
`dsh-user-approval` 拥有固定的派发与审计机制;`dsh-tools` 发起请求,`dsh-acp` 应答。可替换的应答者作为监听器留在其通道拥有者插件中,因此三包能力拆分只会增加一个空的实现层。沙箱执行器仍然只负责传输,静态能力授权与交互式审批保持分离。
|
||||
`dsh-user-approval` 拥有固定的派发与审计机制;`dsh-tools` 发起请求,`dsh-acp` 应答。可替换的应答者作为监听器留在其通道拥有者插件中,因此三包能力拆分只会多出一个空的实现层。沙箱执行器仍然只负责传输,静态能力授权与交互式审批保持分离。
|
||||
|
||||
### 测试
|
||||
|
||||
- **单元/集成测试:** 覆盖先到先得的委托、fail-closed 默认值、格式错误和抛异常的应答者、取消竞争与迟到应答丢弃、观察者失败下的审计配对、不可绕过的 `'never'`、不同的工具拒绝原因,以及 ACP 每会话路由/结果映射。
|
||||
- **快照测试:** 通过沙箱升级的两个分支编排权限应答并固定 `'never'` 提示词加策略切换通知。没有组合应答者时钩子产生的 ask 仍作为 fail-closed 拒绝被覆盖。
|
||||
- **单元/集成测试:** 覆盖先到先得的委派、失败关闭默认值、畸形和抛异常的应答者、取消竞速与迟到应答丢弃、观察者失败时的审计配对、不可绕过的 `'never'`、不同的工具拒绝原因,以及 ACP 每会话路由/结果映射。
|
||||
- **快照测试:** 对沙箱升级的两个分支编排权限应答并固定 `'never'` 提示词加策略切换通知。无组合应答者时钩子产生的 ask 仍作为失败关闭拒绝被覆盖。
|
||||
|
||||
## 延后
|
||||
|
||||
- **`allow_always` 授权存储**——兑现持久授权意味着设计存储、范围标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只宣告一次性选项([沙箱 RFC](2026-07-06-sandbox.md) § 升级记录了开放的范围问题)。
|
||||
- **有组合应答者时录制的钩子产生的 ask**——升级录制了人类提示的协议格式(wire format),而当前钩子 fixture(测试前置数据)固定的是无服务拒绝;它们组合的生产者/应答者路径仍由单元测试覆盖。
|
||||
- **将子 agent 的审批路由到父会话**——`subagent-acp` 的子端自动应答自己的 `permission` 请求;将它们呈现给父端编辑器是独立的设计。
|
||||
- **`allow_always` 授权存储**:兑现持久授权意味着设计存储、作用域标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只展示一次性选项([沙箱 RFC](2026-07-06-sandbox.md) § Escalation 记录了开放的作用域问题)。
|
||||
- **有组合应答者时录制的钩子产生的 ask**:升级场景录制了人类提示的协议格式(wire format),而当前钩子 fixture(测试前置数据)固定的是无服务拒绝;二者组合的生产者/应答者路径仍由单元测试覆盖。
|
||||
- **将子 agent 的审批路由到父会话**:`subagent-acp` 的子侧自动应答自己的 `permission` 请求;将其呈现给父会话的编辑器是独立的设计。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **单个注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——白名单预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时默认拒绝和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 用约定固定单决策槽语义,而非发明一个提供方注册表。
|
||||
- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会把发起 ask 的策略硬编码到 UI 插件中,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时机),且让钩子产生的 `ask` 决策没有共享机制。
|
||||
- **通用用户交互 seam(`ctx.userInteraction`)**:否决作为审批机制。两者共享骨架(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时默认拒绝、以及审计事件。因此审批不走已发布的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果两者未来趋同,共享提供方管道仍然开放。
|
||||
- **在 `dsh-tools` 中静态可选注入**:否决。vendor 的 cordis `Inject` 类型没有可选标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,在 HMR 下无需额外机制即可正确降级。
|
||||
- **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。这里服务体是固定机制,可变部分是留在各自通道拥有者中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。
|
||||
- **现在就提供 `allow_always`**:否决。协议可以表达它,但兑现它意味着设计授权存储、范围标识和撤销(§ 延后)。宣告一个 harness 无法兑现的选项只会制造注定失败的授权。
|
||||
- **单一注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——允许列表预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时失败关闭和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 以约定固定单决策槽语义,而非发明一个提供方注册表。
|
||||
- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会将请求**策略**硬编码进 UI 插件,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时刻),且钩子产生的 `ask` 决策没有共享机制。
|
||||
- **通用用户交互 seam(`ctx.userInteraction`)**:否决作为审批机制。二者骨架相似(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时失败关闭、以及审计事件。因此审批不走已交付的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果二者将来趋同,共享提供方管道仍然开放。
|
||||
- **`dsh-tools` 中的静态可选注入**:否决。vendor 的 Cordis `Inject` 类型没有 optional 标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,跨 HMR 正确降级,无需额外机制。
|
||||
- **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。此处服务体是固定机制,可变部分是留在各自通道拥有者插件中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。
|
||||
- **现在就提供 `allow_always`**:否决。协议能表达它,但兑现它意味着设计授权存储、作用域标识和撤销(§ 延后)。展示 harness 无法兑现的选项只会制造注定失败的授权。
|
||||
|
||||
## 后果
|
||||
|
||||
- 只有 `allowed-once` 才会派发被询问的操作;缺失、拒绝、取消或应答失败的路径均拒绝。
|
||||
- 只有 `allowed-once` 才会派发被询问的操作;缺失、拒绝、取消或应答失败的路径一律拒绝。
|
||||
- 会话归属路由提示、策略和审计事件,不跨越编辑器会话。
|
||||
- 被接受的请求追加一对持久审计事件;模型只看到最终的工具结果。
|
||||
- 没有加载该服务的部署不会发出审批提示或审计事件,并在工具边界拒绝每个 `ask`。
|
||||
- 没有该服务的部署不产生审批提示或审计事件,在工具边界拒绝每一个 `ask`。
|
||||
|
||||
代价与已接受的局限:
|
||||
|
||||
- **两个急于决策的应答者争抢同一个槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者——通过约定缓解(每个部署一个终端应答者;仅对「决定或委托」门禁使用 `prepend`),而非事件总线不具备的优先级机制。
|
||||
- **生产环境的验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,以及沙箱升级通过其自身门禁——协议格式录制在沙箱示例的快照套件中,因此 seam 的真实覆盖率就是这一种组合,直到更多部署组合它。
|
||||
- **归属以 `Agent` 对象同一性为键。** 应答者通过桥现有的 WeakMap 解析会话;当前所有路径在 loop 和各 seam 之间传递同一个对象,但未来如果某个边界克隆或代理了 agent,桥会委托并默认拒绝——安全但静默无 UI——届时需要改用 session-id 匹配。
|
||||
- **两个急于决策的应答者竞争同一槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。
|
||||
- **生产环境验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中;因此在更多部署组合它之前,seam 的真实覆盖面就是这一种组合。
|
||||
- **归属以 `Agent` 对象标识为键。** 应答者通过桥已有的 WeakMap 解析会话;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭——安全,但静默无 UI——届时需要改用 session-id 匹配。
|
||||
|
||||
## FAQ
|
||||
|
||||
- **在完全没有应答者的部署中(无头模式、CI)会发生什么?** 每次 ask 穿过空的 waterfall 降级为 `unavailable`,工具调用以「no approval channel is available」原因被拒绝。默认拒绝是零监听器的默认行为,不是配置。
|
||||
- **授权能持久化吗——「始终允许」?** 不能。`allowed-once` 仅授权单次被询问的操作,服务在请求之间不存储任何东西;`allow_always` 在授权存储设计完成之前刻意不宣告(§ 延后)。
|
||||
- **模型看到审批的什么?** 只看到发起方从结果派生的工具结果——审计对永远不进入 transcript(文本记录)。三种非授权原因各不相同,模型可以区分人类说「不」、提示被关闭、以及通道缺失。
|
||||
- **谁决定一次调用是否首先发起 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;两者都不注入自己对「什么值得弹出提示」的判断。
|
||||
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled`,有自己的拒绝文本。已中止的 signal 以 `cancelled` 结算而不派发;ask 进行中的中止丢弃迟到的应答——无论如何只有一对审计事件,绝不会有两对。
|
||||
- **如果客户端以 harness 从未提供的选项应答会怎样?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
|
||||
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委托并默认拒绝——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 子端的自动应答是独立的;将子端的 ask 路由到父端编辑器已延后(§ 延后)。
|
||||
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次自动拒绝仍然落一对审计事件。
|
||||
- **在完全没有应答者的部署中(headless、CI)会发生什么?** 每次 ask 穿过空的 waterfall 降级为 `unavailable`,工具调用以「no approval channel is available」原因被拒绝。失败关闭是零监听器的默认行为,不是配置。
|
||||
- **授权能持久化吗——「始终允许」?** 不能。`allowed-once` 仅授权单次被询问的操作,服务在请求之间不存储任何内容;`allow_always` 在授权存储设计完成之前刻意不展示(§ 延后)。
|
||||
- **模型看到审批的什么?** 只看到发起方从结果派生的工具结果——审计对永远不进入 transcript(文本记录)。三种非授权原因各不相同,模型可以区分人类说「不」、提示被关闭、通道缺失。
|
||||
- **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。
|
||||
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答——无论哪种情况都恰好一对审计事件,绝不会两对。
|
||||
- **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
|
||||
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父会话的编辑器已延后(§ 延后)。
|
||||
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次自动拒绝仍落一对审计事件。
|
||||
- **热重载或 UI 插件在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
|
||||
- **用户在哪里看到自己在批准什么?** 在工具调用本身上:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本。
|
||||
- **用户在哪里看到自己在批准什么?** 在工具调用本身:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本。
|
||||
|
||||
## 先例
|
||||
|
||||
本设计复用或对比的仓库内先例:
|
||||
本设计复用或对照的仓库内先例:
|
||||
|
||||
- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占位决策槽 waterfall 语义(先到先得、通过 `next()` 委托),应答者契约复用了它。
|
||||
- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 RFC](2026-06-30-hook-bridges.md) 发布了 `permissionDecision: ask`,即第一个生产者。
|
||||
- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。
|
||||
- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 RFC](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。
|
||||
- [拦截 seam RFC](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。
|
||||
- [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)——应答者路由所经过的 `WeakMap<Agent, sessionId>` 归属 seam;[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
|
||||
- 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-06-explicit-tool-order.md: 9d94496ffdcbc4c7df820581b02e3e075ec1c0be
|
||||
2026-07-06-explicit-tool-order.zh.md: fa3a5bbf83115c25f87471b8a3847b9347d42934
|
||||
2026-07-06-explicit-tool-order.zh.md: 0b020f969299799289e18ed93c81db08cabc0b2d
|
||||
@@ -1,50 +1,50 @@
|
||||
# RFC:显式的模型侧工具顺序
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-06-explicit-tool-order.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于彼此独立的插件在并发模块加载时的竞态。这一竞态导致 CI 和快照录制中产生不同的请求头。由于顺序影响请求字节、缓存和持久化的头部,需要一个显式的确定性策略。
|
||||
模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于相互独立的插件的并发模块加载。这种竞态在 CI 和快照录制中产生了不同的请求头。由于顺序影响请求字节、缓存和持久化的 header,因此需要一个显式的确定性策略。
|
||||
|
||||
## 决策
|
||||
|
||||
系统提示词组装拥有模型侧工具的权威顺序,正如它已经拥有 section 顺序一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略:
|
||||
系统提示词的组装逻辑拥有模型侧工具顺序的权威定义,正如它已经拥有 section 顺序的权威定义一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略:
|
||||
|
||||
- 列表中已注册的工具取其列出的位置。
|
||||
- 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 rest 条目或名称重复)在服务构造器中快速失败;未注册的名称在每次 `assemble()` 时拒绝——这是已注册工具集存在可供检查的最早时刻(工具插件在服务构造之后注册),也是唯一的通用时刻(注册随时可能变化;Cordis 没有「所有插件已加载」事件)。在已交付的 agent loop 下,第一个轮次在任何模型请求之前就会失败——确切的影响范围见下文「后果」。
|
||||
- 已注册但不在列表中的工具,插入到 `'<unlisted-tools>'` rest 条目(`TOOL_ORDER_REST`)处,在其他未列出的工具之间按名称字典序排列。
|
||||
- 任何已收集的工具不得使用 `TOOL_ORDER_REST` 作为其 `ToolSchema.name`;组装在排序之前就会拒绝该保留名称。
|
||||
- 列表必须恰好包含一个 rest 条目,且名称不得重复。
|
||||
- 列表中已注册的工具按列表位置排列。
|
||||
- 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 rest 条目或名称重复)在服务构造器中快速失败;未注册的名称则在每次 `assemble()` 时拒绝——这是已注册工具集存在并可供检查的最早时刻(工具插件在服务构造之后才注册),也是唯一的通用时刻(注册随时可能变化;Cordis 没有「所有插件已加载」事件)。在已交付的 agent loop(智能体循环)下,第一个轮次在发出任何模型请求之前就会失败——确切的影响范围见下文「后果」。
|
||||
- 已注册但不在列表中的工具,插入到 `'<unlisted-tools>'` rest 条目(`TOOL_ORDER_REST`)的位置,与其他未列出的工具按名称字典序排列。
|
||||
- 任何已收集的工具不得使用 `TOOL_ORDER_REST` 作为其 `ToolSchema.name`;组装逻辑在排序之前就会拒绝这个保留名称。
|
||||
- 列表必须恰好包含一个 rest 条目,且不得有重复名称。
|
||||
- 当 `toolOrder` 未设置时,权威顺序为纯字典序(code-unit 比较,与 locale 无关),因此无需配置即可保证确定性。
|
||||
|
||||
`assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前规范化提供方工具,从源头消除注册顺序差异。waterfall 从这个确定性列表出发;未被改变的顺序随后流入请求头、冻结请求和重建检查,无需循环特有的排序逻辑。
|
||||
`assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前对提供方工具进行规范化排序,从源头消除注册顺序的差异。waterfall 从这个确定性列表开始;不变的顺序随后流入请求头、冻结的请求和重建检查,无需 loop 特有的排序逻辑。
|
||||
|
||||
范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍可添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已要求监听器具有确定性(可重建性不变式会捕获在构建与回放之间表现不一致的监听器)。
|
||||
范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍然可以添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已经要求监听器是确定性的(可重建性不变式会捕获在构建与回放之间行为不一致的监听器)。
|
||||
|
||||
配置传递沿用 `persona` 的先例,`toolOrder` 与它并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节是关键的:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链上的每个 schema 都将默认值强制为 `undefined`。
|
||||
配置传递沿用 `persona` 的先例,`toolOrder` 与之并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节至关重要:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链路上每个 schema 都将默认值强制为 `undefined`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 不稳定),评审中不可见。
|
||||
- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件之间不可比较;上述不稳定发生时偏序已完全满足。
|
||||
- **每个插件在工具贡献上设 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。
|
||||
- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个被组装之外的更多消费方使用的成员存储;排序是 prompt 组合的关注点,而组装已经拥有 section 的组合策略。
|
||||
- **`LlmService` 配置 + 循环在记录头部前调用的 `orderTools()` 方法**:可行,但仅为在远处应用策略就增加了一个公开服务方法和一处循环改动;每个未来的请求组合者都必须记得调用。在列表诞生处规范化使无序列表不可表示,且零新增接口。
|
||||
- **在 `llm.stream()` 内部规范化**:在头部事件记录之后才运行(不稳定仍存在),且需要重建深度冻结的信封,静默地解除了重建不变式。
|
||||
- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 抖动),评审中不可见。
|
||||
- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件不可比较;抖动发生时偏序已完全满足。
|
||||
- **每个插件在其工具贡献上标注 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。
|
||||
- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个成员存储,被组装之外的多方消费;排序是 prompt 组合的关注点,而组装逻辑已经拥有 section 的组合策略。
|
||||
- **在 `LlmService` 上加配置 + `orderTools()` 方法,由 loop 在记录 header 前调用**:可行,但仅为在远处应用一个策略就增加了一个公开服务方法和一处 loop 改动;每个未来的请求组合者都必须记得调用。在列表诞生处进行规范化使得无序列表不可表示,且零新增接口。
|
||||
- **在 `llm.stream()` 内部规范化**:在 header 事件已记录之后才运行(抖动仍然存在),且需要重建深度冻结的信封,静默地解除了重建不变式。
|
||||
- **穷举列表(无 rest 条目)**:每个新加载的工具插件都会导致启动失败;强制的 rest 条目使未列出的工具保持确定性,且其位置是显式的。
|
||||
- **启动时校验(`dsh-app-boot` 在 `loader.await()` 之后调用 `SystemPrompt.assertToolOrderSatisfied()`)**:能将配置错误变为启动死亡而非首轮失败,但需要一个公开服务方法加上通用启动胶水对单一服务的结构耦合,且无论如何不能替代组装时检查(嵌入式调用者从不运行 app boot;注册在 boot 之后仍会变化)。也没有现成事件可以承载该检查:Cordis v4 没有 ready 类事件,`loader/entry-init`/`internal/status` 在加载中途触发(与工具注册竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设一个执行点被判定值得接受较晚的失败时刻。
|
||||
- **启动时校验(由 `dsh-app-boot` 在 `loader.await()` 之后调用 `SystemPrompt.assertToolOrderSatisfied()`)**:能将错误配置变为启动时死亡而非首轮次失败,但代价是一个公开服务方法加上通用启动胶水对单个服务的结构耦合,且无法替代组装时检查(嵌入式调用者从不运行 app boot;注册在 boot 之后仍会变化)。也没有现成事件可以承载该检查:Cordis v4 没有 ready 类事件,`loader/entry-init`/`internal/status` 在加载中途触发(与工具注册存在竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设置单一执行点被判定值得接受较晚的失败时刻。
|
||||
|
||||
## 后果
|
||||
|
||||
- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转在结构上被消除,默认为字典序。
|
||||
- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序出发;提供方注册顺序在该协作 seam 之前的任何地方都不可观测。
|
||||
- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(基于名称键的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀保留。
|
||||
- `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 转发链传递,因此部署时在 app 配置中与 `persona` 并列设置;`dsh-llm` 和 agent loop 不受影响。
|
||||
- `toolOrder` 中拼写错误或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:循环在轮次内组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带消息,`agent/error` 镜像它,不开启步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(与仓库规则一致:显式配置引用不得被静默忽略——执行点在组装处,因为不存在更早的通用时刻)。
|
||||
- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的列出名称相同。这防止哨兵值变成歧义的真实工具,并保持「从不丢弃工具」的排序契约。
|
||||
- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转从结构上被消除,默认为字典序。
|
||||
- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序开始;提供方注册顺序在该协作 seam 之前无处可观测。
|
||||
- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(按名称索引的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀存在。
|
||||
- `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 的转发链传递,因此部署时将其放在 app 配置中 `persona` 旁边即可;`dsh-llm` 和 agent loop 无需改动。
|
||||
- `toolOrder` 中拼错或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。
|
||||
- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。
|
||||
|
||||
## 测试
|
||||
|
||||
系统提示词测试覆盖字典序默认顺序、列出/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。循环测试固定跨注册排列的已记录和已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结请求,以及在未知配置名称下的平衡轮次失败(无步骤、无头部、无适配器调用)。快照回放仅在固定的 `text-turn` 头部中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。
|
||||
系统提示词测试覆盖:字典序默认顺序、列表/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。Loop 测试固定:跨注册排列的已记录与已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结的请求,以及在配置了未知名称时的平衡轮次失败(无步骤、无 header、无适配器调用)。快照回放仅在固定的 `text-turn` header 中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-07-mcp-client-plugin.md: 7706190257b54730532e4aa46cc9c47453c59871
|
||||
2026-07-07-mcp-client-plugin.zh.md: 4d2ea8532afbf6160a98020b8cc480e1bf683981
|
||||
2026-07-07-mcp-client-plugin.zh.md: b5fee7eff7f12de5658f0a10c32cfeed71482fdf
|
||||
@@ -6,15 +6,15 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 此前无法消费 MCP(Model Context Protocol)生态的工具。MCP 是工具服务器的新兴标准:GitHub、文件系统、数据库、代码搜索以及数百个社区服务器都通过 MCP 暴露工具。用户希望将 harness 指向一个或多个 MCP 服务器,让它们的工具以原生的模型可见工具形式出现,而无需为每个服务器编写胶水代码。
|
||||
harness 此前无法消费 MCP(Model Context Protocol)生态中的工具。MCP 是工具服务器的新兴标准——GitHub、文件系统、数据库、代码搜索以及数百个社区服务器都通过 MCP 暴露工具。用户希望将 harness 指向一个或多个 MCP 服务器,让其工具以原生的模型可见工具形式出现,而无需为每个服务器编写胶水代码。
|
||||
|
||||
`ToolRegistry` 已经接受原始 JSON Schema 工具定义(见 `dsh-tools` README:"Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"),扩展实操手册(cookbook)也勾勒了预期模式("MCP | one plugin per server: discover tools → `ctx.tools.register()`")。基础设施已就绪,缺的是桥接插件。
|
||||
`ToolRegistry` 已经接受原始 JSON Schema 工具定义(`dsh-tools` README 中有记录:"Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"),扩展实操手册(cookbook)也勾勒了预期模式("MCP | one plugin per server: discover tools → `ctx.tools.register()`")。基础设施已就绪,缺的是桥接插件。
|
||||
|
||||
## 决策
|
||||
|
||||
### 包
|
||||
|
||||
单个包 `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 三包拆分:可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」(见[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md))。
|
||||
单个包(package) `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 的三包拆分——可预见范围内不会有第二种 MCP 客户端实现,且约定是"不要预防性拆分"([能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md))。
|
||||
|
||||
### SDK
|
||||
|
||||
@@ -22,11 +22,11 @@ harness 此前无法消费 MCP(Model Context Protocol)生态的工具。MCP
|
||||
|
||||
### 范围
|
||||
|
||||
仅 MCP 客户端(不含服务器端——ACP 已覆盖「将 harness 暴露为 agent」的角色)。仅桥接 **Tools**:Resources 和 Prompts 推迟(它们需要 harness 侧尚不存在的消费机制,且设计空间很大)。
|
||||
仅 MCP Client(不含 server 端——ACP 已承担"将 harness 暴露为 agent"的角色)。仅桥接 **Tools**——Resources 和 Prompts 延后处理(它们需要 harness 侧尚不存在的消费机制,且设计空间较大)。
|
||||
|
||||
### 插件形态
|
||||
|
||||
命名空间插件(具名导出 `name`/`inject`/`Config`/`apply`,无 `export default`)。`inject: ['tools']`。每个 MCP 服务器在 `cordis.yml` 中是一个插件实例:同一个包以不同配置加载 N 次,与 `dsh-tool-subagent` 相同。
|
||||
命名空间插件(具名导出 `name`/`inject`/`Config`/`apply`,无 `export default`)。`inject: ['tools']`。每个 MCP 服务器对应 `cordis.yml` 中的一个插件实例——同一个包以不同配置加载 N 次,与 `dsh-tool-subagent` 相同。
|
||||
|
||||
### 配置
|
||||
|
||||
@@ -54,7 +54,7 @@ interface StreamableHttpConfig {
|
||||
type Config = StdioConfig | StreamableHttpConfig
|
||||
```
|
||||
|
||||
`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具划定命名空间。它有意设计为用户配置,**不是**远端的 `serverInfo.name`:远端名称是不可信输入,跨部署不唯一(同一服务器的 prod 和 staging 实例报告相同名称),且可能在服务器升级时变化——这些都不得静默地重命名模型可见工具。多个活跃实例使用相同 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)同时也是缩短公开名称的旋钮。
|
||||
`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而**非**远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的调节手段。
|
||||
|
||||
`cordis.yml` 用法示例:
|
||||
|
||||
@@ -83,28 +83,28 @@ type Config = StdioConfig | StreamableHttpConfig
|
||||
|
||||
### 生命周期
|
||||
|
||||
启动时从 `cordis.yml` 加载。HMR(`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目会触发旧实例的 dispose(断开连接、注销工具),并创建新实例(连接、发现、注册)。目前不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——且添加或移除一个无关服务器绝不会重命名已有工具。
|
||||
启动时从 `cordis.yml` 加载。HMR(热模块替换)(`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目触发旧实例的 dispose(资源释放)(断开连接、注销工具),并创建新实例(连接、发现、注册)。目前不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——而添加或移除不相关的服务器永远不会重命名已有工具。
|
||||
|
||||
### 工具发现与注册
|
||||
|
||||
每个 MCP 工具有两个名称:
|
||||
|
||||
- `rawName`:MCP `Tool.name` 的原始值,仅在协议层(`tools/call`)使用。
|
||||
- `publicName`:在 `ToolRegistry` 中注册的全局唯一模型可见名称:
|
||||
- `rawName`——MCP `Tool.name` 的原始值,仅用于协议通信(`tools/call`)。
|
||||
- `publicName`——在 `ToolRegistry` 中注册的全局唯一模型可见名称:
|
||||
|
||||
mcp__<serverName>__<rawName>
|
||||
|
||||
这种按服务器限定的形式是多服务器 agent 客户端的事实标准:所有被调研的终端用户产品都按服务器限定 MCP 工具([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp__<server>__<tool>` 的确切拼写沿用 Claude Code 和 Codex。`mcp__` 前缀将 MCP 注册隔离在原生工具命名空间之外,并为权限/遥测规则提供稳定的匹配形状(`mcp__*`、`mcp__github__*`)。
|
||||
这种按服务器限定的形式是多服务器 agent 客户端的事实标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp__<server>__<tool>` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。
|
||||
|
||||
1. 连接时:遍历 `client.listTools()` 的分页,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和 description 原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。
|
||||
2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性的名称意味着未变化的工具在重新同步后保持原名。
|
||||
3. 执行器闭包持有 `rawName`;公开名称从不发送给服务器,也从不被解析以恢复原始名称。
|
||||
4. 不提供 `presentCall`/`presentResult`:ACP 桥接的通用卡片回退负责渲染。
|
||||
5. 工具在系统提示词中是透明的:除名称本身外不添加 "[via MCP]" 之类的标注。
|
||||
1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。
|
||||
2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。
|
||||
3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。
|
||||
4. 无 `presentCall`/`presentResult`——ACP 桥接的通用卡片兜底负责渲染。
|
||||
5. 工具在系统提示词中是透明的——除名称本身外不附加 "[via MCP]" 标注。
|
||||
|
||||
### 公开名称规范化
|
||||
|
||||
MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名契约允许 `[A-Za-z0-9_-]` 且最长 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会折叠为同一个公开名称:
|
||||
MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名契约允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会坍缩为同一个公开名称:
|
||||
|
||||
```typescript
|
||||
function publicToolName(serverName: string, rawName: string): string {
|
||||
@@ -118,97 +118,97 @@ function publicToolName(serverName: string, rawName: string): string {
|
||||
|
||||
### 名称冲突处理
|
||||
|
||||
MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names)唯一;跨服务器冲突是常态而非例外(一项[微软研究院调研](https://www.microsoft.com/en-us/research/blog/tool-space-interference-in-the-mcp-era-designing-for-agent-compatibility-at-scale/#namespacing-issues-and-naming-ambiguity)覆盖 1,470 个服务器,发现 775 个冲突工具名;仅 `search` 就出现在 32 个服务器中,官方 GitHub 服务器发布的是裸 `create_issue`)。始终启用的命名空间从结构上杜绝冲突,而非在冲突发生时再处理:
|
||||
MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names)唯一;跨服务器冲突是常态而非例外(一项[微软研究院调查](https://www.microsoft.com/en-us/research/blog/tool-space-interference-in-the-mcp-era-designing-for-agent-compatibility-at-scale/#namespacing-issues-and-naming-ambiguity)覆盖 1,470 个服务器,发现 775 个冲突的工具名;仅 `search` 就出现在 32 个服务器中,官方 GitHub 服务器发布的是裸名 `create_issue`)。始终启用的命名空间从结构上杜绝冲突,而非在冲突发生时再处理:
|
||||
|
||||
- 两个服务器都发布 `search` → 共存为 `mcp__github__search` 和 `mcp__web__search`。
|
||||
- 名为 `search` 的原生 harness 工具不受影响。
|
||||
- 重复的 `serverName` 配置导致后加载的实例在启动时失败(见「配置」一节)。
|
||||
- 同一服务器列出重复的工具名属于无效工具列表:同步抛出异常,上一代注册保持不变。
|
||||
- 替换期间的注册表冲突只可能意味着外部工具占用了本服务器的 `mcp__<serverName>__` 命名空间:部分生成被回滚(该服务器零工具注册),错误被醒目地记录。
|
||||
- 重复的 `serverName` 配置使后加载的实例在启动时失败(见配置一节)。
|
||||
- 服务器列出重复的工具名属于无效工具列表:同步抛出异常,上一代注册保持不变。
|
||||
- 替换期间的注册表冲突只可能意味着外部工具占据了该服务器的 `mcp__<serverName>__` 命名空间:部分代注册被回滚(该服务器零工具),并以醒目日志记录错误。
|
||||
|
||||
工具永远不会被静默跳过;哪些工具可用永远不取决于插件加载顺序。
|
||||
|
||||
### 命名不变式
|
||||
|
||||
1. 每个 MCP 工具有稳定标识 `(serverName, rawName)`;每个活跃标识恰好对应一个公开名称。
|
||||
1. 每个 MCP 工具拥有稳定标识 `(serverName, rawName)`;每个活跃标识恰好对应一个公开名称。
|
||||
2. 公开名称是确定性的、全局唯一的,且满足 DeepSeek 64 字符 `[A-Za-z0-9_-]` 契约。
|
||||
3. MCP `tools/call` 始终接收原始的 raw name。
|
||||
4. 连接、断开或重新同步一个无关服务器,绝不会重命名已有工具。
|
||||
5. 注册顺序绝不决定哪个工具可用。
|
||||
4. 连接、断开或重新同步不相关的服务器永远不会重命名已有工具。
|
||||
5. 注册顺序永远不决定哪个工具可用。
|
||||
|
||||
### 工具执行
|
||||
|
||||
为来自同一 MCP 服务器的所有工具提供统一的 `execute` 处理器:
|
||||
为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器:
|
||||
|
||||
1. 解析 `rawName`(执行器闭包持有),以配置的超时调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称从不发送给服务器。
|
||||
1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。
|
||||
2. 映射结果:
|
||||
- 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 不带分隔符,多个块会丢失块间边界)。
|
||||
- `image` 内容块 → 丢弃并记录 `ctx.logger.warn`(harness 没有图片内容块类型;见 [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md))。
|
||||
- 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 无分隔符,多块会丢失块间边界)。
|
||||
- `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md))。
|
||||
- `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。
|
||||
3. 取消:`exec.signal`(来自 agent loop 的 cancel)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。
|
||||
3. 取消:`exec.signal`(来自 agent loop(智能体循环)的取消)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。
|
||||
|
||||
### 子进程环境(stdio 传输)
|
||||
|
||||
复用 `dsh-subagent-acp` 的 `buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖在上面。显式配置的 env 不受清洗影响。
|
||||
复用 `dsh-subagent-acp` 的 `buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖合并到顶层。显式配置的 env 不受清洗影响。
|
||||
|
||||
### 断开连接 / 崩溃
|
||||
### 断连 / 崩溃
|
||||
|
||||
不自动重连。如果 MCP 服务器进程退出或传输层关闭:
|
||||
|
||||
1. effect dispose → 所有已注册工具被注销(fiber 作用域的 disposer)。
|
||||
2. 后续模型对这些工具的调用 → `ToolNotFoundError` → `isError: true`。
|
||||
3. 恢复方式:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。
|
||||
3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。
|
||||
|
||||
这与 ACP subagent 的模式一致:「崩溃即终态,报告错误,清理资源,不重试。」
|
||||
这与 ACP subagent 模式一致:"崩溃即终态,报告错误,清理资源,不重试。"
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### MCP 服务器端(向外部 MCP 客户端暴露 harness 工具)
|
||||
### MCP Server 端(将 harness 工具暴露给外部 MCP 客户端)
|
||||
|
||||
推迟。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP 服务器会用不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。
|
||||
延后。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP server 会以不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。
|
||||
|
||||
### 能力 seam 三包拆分(接口 / 实现 / 消费方)
|
||||
### 能力 seam 三包拆分(interface / impl / consumer)
|
||||
|
||||
否决。可预见范围内不会有替代的 MCP 客户端实现:MCP 只有一个协议、一个 SDK。约定是「在第二种实现出现之前不要预防性拆分」。
|
||||
否决。可预见范围内不会有替代的 MCP 客户端实现——MCP 只有一个协议、一个 SDK。约定是"不要预防性拆分",直到出现第二种实现。
|
||||
|
||||
### 指数退避自动重连
|
||||
|
||||
v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,未来可作为 `reconnect: boolean` 配置项加入。
|
||||
v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,可在未来作为 `reconnect: boolean` 配置项添加。
|
||||
|
||||
### 桥接 Resources 和 Prompts
|
||||
|
||||
推迟。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 目前缺少的「prompt 模板」概念。两者都需要独立设计;Tools 是高价值、低风险的起点。
|
||||
延后。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 尚不具备的"提示词模板"概念。两者都需要独立设计;Tools 是高价值、低风险的起点。
|
||||
|
||||
### 原始模型可见工具名加可选 `toolPrefix`
|
||||
|
||||
否决。这是最初的提案,建立在「大多数 MCP 服务器已在工具名中使用语义前缀(如 `github_create_issue`)」的前提上。该前提不成立:官方 GitHub 服务器发布的是 `create_issue`,参考文件系统服务器是 `read_file`,Sentry 是 `search_issues`——且上述微软调研表明冲突在生态规模下很常见。冲突时再加前缀(或 warn-and-skip)还会使可用工具集取决于插件加载顺序,且添加一个无关服务器可能静默重命名已有工具——在对话中途使会话历史和权限规则失效。所有被调研的多服务器 agent 产品都不使用裸名称。
|
||||
否决。这是最初的提案,基于"大多数 MCP 服务器已在工具名中使用语义前缀(如 `github_create_issue`)"这一前提。该前提不成立:官方 GitHub 服务器发布的是 `create_issue`,参考文件系统服务器发布 `read_file`,Sentry 发布 `search_issues`——且上述微软调查表明冲突在生态规模下很常见。冲突时再加前缀(或 warn-and-skip)还会使可用工具集取决于插件加载顺序,且添加不相关服务器时工具可能被静默重命名——在对话中途使会话历史和权限规则失效。所有被调研的多服务器 agent 产品都不使用裸名。
|
||||
|
||||
### 仅服务器命名空间(`github__create_issue`,无 `mcp__` 前缀)
|
||||
|
||||
v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 harness 工具隔离,也放弃了 MCP 全局策略匹配形状(`mcp__*`)。前缀仅消耗 5 个字符;`mcp__<server>__<tool>` 的拼写与 Claude Code 和 Codex 一致,最大化模型的熟悉度。如果 ToolRegistry 将来增加源感知的命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。
|
||||
v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 harness 工具分离,也丧失了 MCP 全局策略匹配模式(`mcp__*`)。前缀仅多花 5 个字符;`mcp__<server>__<tool>` 拼写与 Claude Code 和 Codex 一致,最大化模型的熟悉度。如果 ToolRegistry 未来引入源感知命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。
|
||||
|
||||
### 从服务器公告的 `serverInfo.name` 推导命名空间
|
||||
### 从服务器公告的 `serverInfo.name` 派生命名空间
|
||||
|
||||
否决。远端名称不可信、跨部署不唯一、升级时可变;工具标识和权限规则不得静默跟随它。命名空间是本地配置。
|
||||
|
||||
### 在工具结果中保留多个 TextBlock
|
||||
|
||||
否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 展平为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性 bug。所有现有工具返回单个 TextBlock;MCP 桥接遵循同样做法。
|
||||
否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 扁平化为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性缺陷。所有现有工具返回单个 TextBlock;MCP 桥接遵循同一做法。
|
||||
|
||||
## 测试
|
||||
|
||||
覆盖按层级命名;每个行为放在能表达它的最低成本层级。
|
||||
覆盖率按层级命名;每个行为放在能表达它的最低成本层级。
|
||||
|
||||
- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净路径、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、代际替换/回滚、重新同步失败时的保留、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
|
||||
- **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用仓库内 fixture 服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem` 通过 stdio 运行真实 MCP 协议,以及通过进程内 `StreamableHTTPServerTransport` 服务器运行 Streamable HTTP——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose(资源释放)。
|
||||
- **快照**:刻意不做。MCP 工具不引入新的 transcript 渲染面——它们注册为原始 `ToolDefinition`,通过 ACP 桥接的通用卡片回退渲染,而桥接的单元测试套件已固定了该行为(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器加入快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture(迫使每条录制的 golden 都需要带密钥重新录制),并使每次回放依赖于 spawn 一个外部 MCP 服务器进程——而新增的渲染行为为零。如果后续变更为 MCP 工具引入专属的渲染意图,该变更届时自行命名其快照覆盖。
|
||||
- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、代切换/回滚、重新同步失败时的保留、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
|
||||
- **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose。
|
||||
- **快照**:刻意不做。MCP 工具不引入新的 transcript(文本记录)呈现面——它们以原始 `ToolDefinition` 注册,通过 ACP 桥接的通用卡片兜底渲染,该兜底已由桥接的单元测试套件固定(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器添加到快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture(迫使每条录制的 golden 都需要带密钥重新录制),且使每次回放依赖于 spawn 外部 MCP 服务器进程——而新增渲染行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。
|
||||
|
||||
## 后果
|
||||
|
||||
- 每个 MCP 服务器只需一条 `cordis.yml` 条目即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL),就能把 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议层使用原始的 `read_file`。
|
||||
- 公开名称是会话历史与权限/配置界面的一部分;命名算法是由测试固定的 v1 契约,发布后修改它是破坏性变更。
|
||||
- `mcp__<serverName>__` 限定符在每个名称上消耗 token。已接受:description 和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配形状(`mcp__*`、`mcp__github__*`)。
|
||||
- **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。
|
||||
- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的 description、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的责任。
|
||||
- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号可能卡住 dispose。Cordis fiber 的 dispose 有有界静默期;卡住的传输层最终会在框架层面超时。
|
||||
- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置项作为未来工作保持开放。
|
||||
- 每个 MCP 服务器只需 `cordis.yml` 中的一条配置即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL),就能将 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议上使用原始的 `read_file`。
|
||||
- 公开名称是会话历史和权限/配置表面的一部分;命名算法是由测试固定的 v1 契约,发布后变更即为破坏性变更。
|
||||
- `mcp__<serverName>__` 限定符在每个名称上消耗 token。已接受:描述和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配模式(`mcp__*`、`mcp__github__*`)。
|
||||
- **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。
|
||||
- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。
|
||||
- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 有有界静默期;卡住的传输层最终在框架层面超时。
|
||||
- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置作为未来工作保持开放。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-07-session-prefix.md: ffa0fecb86e84ed64d45b24e1b6943d421757fb2
|
||||
2026-07-07-session-prefix.zh.md: 292c74fac75d8f2c29628fc5e90c72dadcaf4fdb
|
||||
2026-07-07-session-prefix.zh.md: ca38ebf337e16f4f2368ca74e394fcc1031fbf1f
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC:会话前缀——置于派生历史之前的仅请求消息
|
||||
# RFC:会话前缀——派生历史之前的仅请求消息
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,39 +6,39 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在这个 seam 出现之前,harness 只提供两个归属位置,但对这类内容来说两个都不对。系统提示词是一个渲染后的单字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对对话消息与系统文本的权重处理不同。持久化历史(`agent.inject()`、会话开始时的 `context/message`)会让开场内容变成永久记录:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会把它以陈旧状态烘焙进去,resume 无法刷新它——一份在会话诞生时捕获的目录会比它所描述的世界活得更久。
|
||||
插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,resume 也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。
|
||||
|
||||
显而易见的第三个选项——让插件在请求发出时编辑 `messages`——被[可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md) 禁止:每个由循环构建的请求都是会话日志的纯函数,因此承载开场内容的通道必须精确记录它所发送的内容。缺失的是一个带持久记录的仅请求消息通道。
|
||||
显而易见的第三种选项——让插件在请求发出途中编辑 `messages`——被[可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md) 禁止:每个由循环构建的请求都是会话日志的纯函数,因此无论哪个通道承载开场内容,都必须精确记录它所发送的内容。缺失的是一个带有持久记录的仅请求消息通道。
|
||||
|
||||
## 决策
|
||||
|
||||
`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回一个扩展(规范的贡献方式是前置,`[mine, ...await next()]`,在协议格式上产生注册顺序)。循环([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,延迟到该实例首次 `agent/pre-step` 之前;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发送的每个请求中置于**整个**派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。
|
||||
`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于**整个**派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。
|
||||
|
||||
三个属性承载了这一设计:
|
||||
|
||||
- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已经为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + 边界派生`;未记录的前缀无法到达协议格式。
|
||||
- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存在构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 是一个新实例:它重新组合,任何漂移都可归因地落在 `'resume'` header 快照上。这就是该 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性持久化的 `context/message`,之后被前缀缓存覆盖。
|
||||
- **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值传递下去:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算——如果门禁读取的是上一个实例折叠后的前缀,那么在一个贡献者增长了的 resume 或 fork 实例的首步上会低估压力,跳过压缩并发出超窗口的首请求。组合过程中如果 cancel/dispose 落入 waterfall 内部,组合结果被丢弃、永不缓存:一个感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活跃 signal 下重新组合。
|
||||
- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + 边界派生`;未记录的前缀无法到达协议格式。
|
||||
- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 产生新实例:它重新组合,任何漂移都可追溯地落在 `'resume'` header 快照上。这就是本 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性支付的持久 `context/message`,之后被前缀缓存覆盖。
|
||||
- **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值透传:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算。如果改为让门禁读取上一个实例折叠后的前缀,则在 resume 或 fork 后的实例中(贡献者可能已增长),门禁会低估压力、跳过压缩,发出超窗口的首个请求。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。被 cancel/dispose 中断的组合(中断落在 waterfall 内部)会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。
|
||||
|
||||
由于组合在边界快照之前运行,组合监听器的会话追加会加入**当前**请求的派生历史。压缩在结构上无法触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。
|
||||
由于组合在边界快照之前运行,组合监听器的会话追加会加入**当前**请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。
|
||||
|
||||
## 测试
|
||||
|
||||
[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了无 header 增量时的组合一次复用、前置顺序、空前缀省略、不可变性,以及组合先于 pre-step;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。会话编解码器、不变式和压缩测试覆盖 header 往返、请求重建和前缀感知的压力计算。快照规范化保留前缀计数,而[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。不需要前缀专属的 e2e 测试,因为该 seam 是确定性的且与提供方无关;带密钥的[请求缓存 e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。
|
||||
[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了以下行为:无 header delta 时的组合一次复用、前置插入顺序、空前缀省略、不可变性,以及组合先于 pre-step;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。会话编解码器、不变式和压缩测试覆盖 header 往返、请求重建与前缀感知的压力核算。快照归一化保留前缀计数,[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。无需前缀专属的 e2e 测试,因为该 seam 是确定性的且与提供方无关;带密钥的 [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:每个请求触发一次 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入静默漂移——除非每步记录一个 header 增量,否则没有东西将其锚定到日志——而 `after` 槽位位于不断增长的历史之后,其 token 在每个请求中重新支付,且其后的所有内容不可缓存。与各替代方案对比衡量,当前每种更新模式都能由持久追加更廉价地服务(支付一次,此后缓存读取),唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。
|
||||
- **系统提示词分区**(`system-prompt/assemble`):对此类内容否决。组装渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header 增量),而开场内容需要的是按实例冻结的语义。
|
||||
- **持久化历史开场**(会话开始时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处回放、可被压缩、跨 resume 陈旧。
|
||||
- **按轮次而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化产生一个 header 增量,且它每次触发都会破坏提供方缓存;合理的刷新点是实例边界,`'resume'` 快照已经在那里可归因地记录漂移。
|
||||
- **在首请求时延迟组合,让压缩读取折叠后的 header**(首次合入时的形态):评审中被取代。折叠值只从实例的第二个请求起才与活跃前缀匹配,因此在 resume/fork 实例的首步上,压力门禁读取的是**上一个**实例的前缀,可能低估压力。在首次 pre-step 之前组合并通过 seam 传递活跃值,使估算在每一步都精确。
|
||||
- **承载前缀的专用会话事件**:否决。header 事件在设计上就是请求的非历史记录;第二个事件会成为同一事实的第二个归属,以及又一个需要保持完整的编解码器。
|
||||
- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:一个每请求触发的 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入静默漂移——除非每步记录一个 header delta,否则没有东西将其锚定到日志;`after` 槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。
|
||||
- **系统提示词分段**(`system-prompt/assemble`):对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header delta),而开场内容需要按实例冻结的语义。
|
||||
- **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、跨 resume 陈旧。
|
||||
- **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化都产生 header delta;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,`'resume'` 快照已在那里可追溯地记录漂移。
|
||||
- **在首次请求时惰性组合,让压缩读取折叠后的 header**(最初合并时的形态):评审中被取代。折叠值仅从实例的第二个请求起才与活前缀匹配,因此在 resume/fork 后的实例首步,压力门禁读取的是**上一个**实例的前缀,可能低估压力。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。
|
||||
- **专用会话事件承载前缀**:否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。
|
||||
|
||||
## 后果
|
||||
|
||||
- `agent/pre-step` 和 `CompactService.compactIfNeeded` 携带 `sessionPrefix` 参数:每个 pre-step 监听器和压缩后端都能看到真实的每实例值(所有仓库内实现在同一个变更中更新,遵循预发布立场)。
|
||||
- 内容在会话中途变化的贡献者不会被重新读取,直到下一个实例——这是设计意图。需要会话中途目录更新的部署应将变更通知路由到仅追加历史通道,支付一条持久化 `context/message`。
|
||||
- 被放弃的 `after` 槽位使请求尾部没有仅请求通道;仓库中没有任何东西需要它,且加回它会重新引入该设计旨在避免的每步重复支付成本。
|
||||
- `request/header-delta` 的 `messagePrefix` 分支(整数组替换,空数组编码向缺失的过渡)为编解码器完备性而存在;循环从不行使它,因为缓存的前缀在实例内不可变。
|
||||
- 空组合是规范的缺失状态:无贡献者的部署不记录额外 header 字节,其请求就是裸派生。
|
||||
- `agent/pre-step` 与 `CompactService.compactIfNeeded` 携带 `sessionPrefix` 参数:每个 pre-step 监听器和压缩后端都能看到真实的按实例值(所有仓库内实现在同一个变更中更新,遵循预发布立场)。
|
||||
- 贡献者的内容在会话中途变化时,直到下一个实例才会被重新读取——这是设计意图。需要会话中途目录更新的部署,应将变更通知路由到仅追加历史通道,支付一条持久 `context/message`。
|
||||
- 被放弃的 `after` 槽位意味着请求尾部附近没有仅请求通道;仓库中没有任何功能需要它,且恢复它会重新引入本设计旨在避免的每步重复支付成本。
|
||||
- `request/header-delta` 的 `messagePrefix` 分支(整数组替换,空数组编码向缺失的过渡)为编解码器完整性而存在;循环从不触发它,因为缓存的前缀在实例内不可变。
|
||||
- 空组合即为规范缺失:无贡献者的部署不记录额外的 header 字节,其请求就是裸派生。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-08-repeat-tool-guard.md: 04d5d077a42b54ca7dc04a1efc9ea2f4034b642b
|
||||
2026-07-08-repeat-tool-guard.zh.md: e20bd06a6902f9fadb77a90e719aaf703d7067cc
|
||||
2026-07-08-repeat-tool-guard.zh.md: 917d958ca1019eb464b72b0201219de9dde7f658
|
||||
@@ -6,32 +6,32 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
模型陷入循环时会反复发出参数逐字节相同的工具调用——重新运行一个失败的 grep、重新读取一个未变化的文件、轮询一个已经给出答案的命令——每一轮往返都消耗 token、挂钟时间和(对付费 API 而言)金钱,却不带来新信息。harness 目前没有任何机制能察觉这一点:循环没有步骤预算,没有插件追踪调用重复,模型只有在碰巧自行改变行为时才能脱困。这种失败模式真实存在且易于检测——[pi-repeat-tool-guard](https://github.com/Kingwl/pi-repeat-tool-guard) 正是将此作为 pi coding-agent 扩展发布的:统计连续相同调用次数,超过阈值后追加一条 `<system-reminder>` 告知模型停止重复、改变策略。
|
||||
模型陷入循环时,会以字节级相同的参数反复发起同一个工具调用——重新运行一条失败的 grep、重新读取一个未变化的文件、轮询一条已经给出答案的命令——每一轮往返都消耗 token、挂钟时间以及(对付费 API 而言)金钱,却不带来新信息。harness 目前没有任何机制能察觉这一点:循环没有步骤预算,没有插件追踪调用重复,模型只有在碰巧改变自身行为时才能跳出。这种失败模式真实存在且检测成本极低——[pi-repeat-tool-guard](https://github.com/Kingwl/pi-repeat-tool-guard) 正是以 pi coding-agent 扩展的形式提供了这一功能:统计连续相同调用次数,超过阈值后追加一条 `<system-reminder>` 告诉模型停止重复并换个方向。
|
||||
|
||||
harness 已经具备 pi 扩展所用的全部 seam,且更好:[拦截 seam RFC](2026-06-30-interception-seams.md) 赋予 `tools/post-execute` 一种正式途径,可以在已完成的调用上附加面向模型的上下文;循环缓冲并注入该上下文,保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺的只是插件本身。
|
||||
harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 seam RFC](2026-06-30-interception-seams.md) 赋予 `tools/post-execute` 一种经过认可的方式,将面向模型的上下文附加到已完成的调用上;循环缓冲并注入该上下文,同时保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺少的只是插件本身。
|
||||
|
||||
## 决策
|
||||
|
||||
守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻塞或改写调用;模型自行决定是否换一种方式重试或结束。
|
||||
该守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻止或改写调用;模型自行决定是换种方式重试还是结束。
|
||||
|
||||
该插件为 `@deepseek-ai/dsh-repeat-tool-guard`,位于 `packages/guard/repeat-tool-guard/`,开辟 `guard/` 分组用于循环卫生插件(单包分组有先例:[todo-write RFC](2026-06-29-todo-write-tool.md) 发布了 `todo/tool-todo`)。它注册三个监听器,所有状态保存在以 `AgentId` 为键的插件局部 map 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent 的调用(subagent 运行在同一 context 上),因此按 agent 分键是正确性要求,而非锦上添花。
|
||||
插件为 `@deepseek-ai/dsh-repeat-tool-guard`,位于 `packages/guard/repeat-tool-guard/`,开辟 `guard/` 分组用于循环卫生插件(单包(package)分组有先例:[todo-write RFC](2026-06-29-todo-write-tool.md) 发布了 `todo/tool-todo`)。它注册三个监听器,所有状态保存在以 `AgentId` 为键的插件局部 map 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent(智能体)的调用(subagent 运行在同一个 context 上),因此按 agent 分键是正确性要求,而非锦上添花。
|
||||
|
||||
- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要 pending map 仅因其 `tool_call`/`tool_result` 钩子是独立事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒折叠到下游决策的 `additionalContext` 上——这正是[钩子桥接](2026-06-30-hook-bridges.md)已在使用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,是因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一流水线),而模型反复锤击一个被拒绝的调用恰恰是值得打破的循环。
|
||||
- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要它,仅因为其 `tool_call`/`tool_result` 钩子是分开的事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒折叠到下游决策的 `additionalContext` 上——这正是[钩子桥接](2026-06-30-hook-bridges.md)已采用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一条流水线),而模型反复敲击一个被拒绝的调用恰恰是值得打破的循环。
|
||||
- **`agent/prompt-submit`(waterfall)**——纯重置钩子:通过 `next()` 委托,清除提交 agent 的链。用户介入改变了上下文;跨越介入的重复不是循环。
|
||||
- **`agent/status`(emit)**——在 `disposed` 时丢弃该 agent 的状态,限制 map 在 harness 生命周期内的增长。
|
||||
- **`agent/status`(emit)**——在 `disposed` 时丢弃该 agent 的状态,使 map 在 harness 生命周期内有界。
|
||||
|
||||
### 检测语义
|
||||
|
||||
链的键为 `(tool name, canonical arguments)`;与前一次被追踪的调用相同则递增该 agent 的连续计数器,不同则重置为 1。规范化方式为深度键排序加 `JSON.stringify`:`ToolExecution.arguments` 按构造即为循环中 `JSON.parse` 的输出(或参数 JSON 格式错误时的原始字符串回退,其本身也是可比较的值),因此 pi 原版对 bigint/循环引用/`undefined` 的处理在此没有输入,被有意去除。
|
||||
链的键是 `(tool name, canonical arguments)`;与前一个被追踪调用相同的调用递增该 agent 的连续计数器,不同的被追踪调用将其重置为 1。规范化方式为深度键排序加 `JSON.stringify`:`ToolExecution.arguments` 按构造就是循环中 `JSON.parse` 的输出(或格式错误的参数 JSON 的原始字符串回退,其本身也是可比较的值),因此 pi 原版对 bigint/循环引用/`undefined` 的处理在此没有输入,被有意去除。
|
||||
|
||||
两条刻意的规则,均记录在[包 README](../../../../packages/guard/repeat-tool-guard/README.md) 中,因为它们是读者不看文档会猜测的行为:
|
||||
两条刻意的规则,均记录在[包 README](../../../../packages/guard/repeat-tool-guard/README.md) 中,因为它们是读者否则只能猜测的行为:
|
||||
|
||||
- **未追踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增也不重置计数器,因此 `grep X → todo_write → grep X` 在 `todo_write` 被排除时仍计为两次连续的 `grep X`。这正是排除有用的原因——夹在循环中的记账工具不得洗白循环——也是 pi 扩展的(未文档化的)语义,有意保留并写明。
|
||||
- **未追踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增也不重置计数器,因此 `grep X → todo_write → grep X` 在 `todo_write` 被排除时仍计为两次连续的 `grep X`。这正是排除功能有用的原因——穿插在循环中的簿记工具不得为循环洗白——也是 pi 扩展的(未文档化的)语义,有意保留并明确写下。
|
||||
- **没有 agent 的调用被忽略。** 直接调用 `ctx.tools.execute()` 的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的 `AgentId`。
|
||||
|
||||
### 提醒投递
|
||||
|
||||
提醒使用 `additionalContext` 并标注插件来源,保留原始 `tool/result`。首次阈值发出简短提示;后续阈值包含工具名、计数和有长度上限的参数预览,而比较仍使用完整的规范化字符串。已有的下游上下文在守卫的 source 下拼接,因为 `HookContext` 支持单一 source。
|
||||
提醒使用带插件 source 的 `additionalContext`,保留原始 `tool/result`。第一个阈值发出简短提示;后续阈值包含工具名、计数和有界的参数预览,而比较仍使用完整的规范化字符串。已有的下游上下文在守卫的 source 下拼接,因为 `HookContext` 只支持一个 source。
|
||||
|
||||
### 配置
|
||||
|
||||
@@ -45,32 +45,32 @@ harness 已经具备 pi 扩展所用的全部 seam,且更好:[拦截 seam RF
|
||||
argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
|
||||
```
|
||||
|
||||
`thresholds` 在加载时校验,空列表、非整数、小于 2 的值或重复值都会抛出异常——配置错误大声失败,取代 pi 原版的静默回退到默认值。`include`/`exclude` 条目支持 `*` 通配符。模式是对调用时实际存在的工具名的谓词,而非对注册表条目的引用,因此匹配不到任何当前已注册工具的条目不是错误——与 `toolOrder` 的引用检查不同,`exclude: [mcp_*]` 在未加载 MCP 工具的部署中必须保持有效。
|
||||
`thresholds` 在加载时校验,遇到空列表、非整数、小于 2 的值或重复项时抛出异常——配置错误快速失败,取代 pi 原版的静默回退到默认值。`include`/`exclude` 条目支持 `*` 通配符。模式是对调用时实际存在的工具的谓词,而非对注册表条目的引用,因此匹配不到当前已注册工具的条目不是错误——与 `toolOrder` 的引用检查不同,`exclude: [mcp_*]` 在未加载 MCP 工具的部署中也必须保持有效。
|
||||
|
||||
## 测试
|
||||
|
||||
- **单元测试:** 使用脚本化适配器的真实循环覆盖计数与重置规则、未追踪透明性、dispose 清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游阻塞或替换决策,达到逐文件 100% 覆盖率。
|
||||
- **快照测试:** keyless 的 `repeat-tool-guard` 场景发出五次相同的 `todo_write` 调用,将第三次的温和提醒和第五次的详细提醒固定在 ACP 输出和会话日志中。该插件在实时示例中加载,但在其他场景中保持静默。
|
||||
- **E2e:** 无;该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。
|
||||
- **单元测试:** 使用脚本化适配器的真实循环,覆盖计数与重置规则、未追踪透明性、dispose(资源释放)清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游 block 或 replacement 决策,达到逐文件 100% 覆盖率。
|
||||
- **快照测试:** keyless 的 `repeat-tool-guard` 场景发起五次相同的 `todo_write` 调用,在 ACP 输出和会话日志中固定第三次调用的温和提醒与第五次调用的详细提醒。该插件在实时示例中加载,但在其他场景中保持静默。
|
||||
- **E2e 测试:** 无。该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **将提醒追加到工具结果中**(`accept` 并替换 `content`——pi 扩展的机制,它修改结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContext` 正是为 post-execute 评注设计的独立正式通道,循环级缓冲保持了调用/结果的邻接关系。
|
||||
- **在 `tools/pre-execute` 中计数并使用 pending-reminder map**(pi 的两阶段形态):否决。post-execute 单独就能同时看到 `(exec, result)` 且也会为被拒绝的调用触发,因此一个监听器、无跨事件状态,以更少的机制覆盖严格更多的尝试。
|
||||
- **在最高阈值升级为 `block`**:在初始范围内否决。阻塞调用会惩罚合理的相同重复(轮询长时间运行的终端、重新检查 agent 预期会变化的文件),而建议性提醒让模型保持控制权。待有证据后重新审视;决策形状(`PostToolDecision`)已支持此选项。
|
||||
- **通过 CC/Codex 桥接的按部署外部钩子**(`PostToolUse` 脚本):否决作为最终答案。它对单个部署有效,但一个已发布、有单元测试、可通过 `cordis.yml` 配置的插件才是 harness 原生形式,且无逐调用的子进程开销。
|
||||
- **在 `agent-loop` 中设置循环级步骤或重复预算**:否决。「用插件,不改循环」;硬性步骤预算是更粗粒度的正交控制,需要单独的提案。
|
||||
- **模糊/近似相同检测**(路径归一化、相似但不完全相同的参数):否决。规范化后的精确匹配廉价、确定性强且可向模型解释;相似度阈值会引入误报,在复杂度得到证据支撑之前不应引入。
|
||||
- **将包放在 `core/`**:否决。core 是产品主干;行为守卫是可选的叶子插件,`todo/` 先例表明每个插件家族用一个小型专属分组。
|
||||
- **将提醒追加到工具结果中**(以替换 `content` 的方式 `accept`——pi 扩展的机制,它修补结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContext` 的存在正是作为 post-execute 评注的独立认可通道,循环级缓冲保持了调用/结果的邻接关系。
|
||||
- **在 `tools/pre-execute` 中计数并使用 pending-reminder map**(pi 的两阶段形态):否决。post-execute 单独就能同时看到 `(exec, result)` 且也为被拒绝的调用触发,因此一个监听器、无跨事件状态即可以更少的机制覆盖严格更多的尝试。
|
||||
- **在最高阈值升级为 `block`**:在初始范围内否决。阻止调用会惩罚合法的相同重复(轮询长时间运行的终端、重新检查 agent 预期会变化的文件),而建议性提醒让模型保持控制权。待有证据后重新审视;决策形状(`PostToolDecision`)已支持此选项。
|
||||
- **通过 CC/Codex 桥接的逐部署外部钩子**(一个 `PostToolUse` 脚本):否决作为最终答案。它对单个部署有效,但一个已发布、有单元测试、可通过 `cordis.yml` 配置的插件才是 harness 原生的形式,且没有逐调用的子进程开销。
|
||||
- **在 `agent-loop` 中设置循环级步骤或重复预算**:否决。「用插件,不改循环」;硬性步骤预算是一种更粗粒度的正交控制,需要自己的提案。
|
||||
- **模糊/近似相同检测**(路径归一化、相似但不完全相同的参数):否决。规范化后的精确匹配成本低、确定性强、且可向模型解释;相似度阈值引入误报风险,需要证据才能换取复杂度。
|
||||
- **将包放在 `core/`**:否决。core 是产品主干;行为守卫是可选的叶子插件,`todo/` 的先例是每个插件族一个小型专属分组。
|
||||
|
||||
## 后果
|
||||
|
||||
- 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式在超过阈值后仍会收到提示,减压阀是配置(`thresholds`、`exclude`)加上提醒文本中明确允许「在已收集足够证据时结束」的措辞。每次触发在下一次请求中增加提醒 token 开销;阈值限制了触发频率。
|
||||
- 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——可接受,守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。
|
||||
- 当多个 post-execute 生产者在同一次调用上附加上下文时,折叠在守卫的 `source` 下拼接;插件间的顺序遵循监听器注册顺序。该 seam 无法表示混合来源——这是继承自 `HookContext` 的限制,不属于本插件。
|
||||
- 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式仍会在超过阈值后收到提示,减压阀是配置(`thresholds`、`exclude`)加上明确允许「在已收集足够证据时结束」的提醒文本。每次触发在下一次请求中增加提醒 token 的开销;阈值限制了触发频率。
|
||||
- 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——可以接受,守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。
|
||||
- 当多个 post-execute 生产者在同一次调用上附加上下文时,折叠在守卫的 `source` 下拼接;插件间的顺序遵循监听器注册顺序。该 seam 无法表示混合来源——这是继承自 `HookContext` 的限制,不归本插件所有。
|
||||
|
||||
## 延后
|
||||
## 延后事项
|
||||
|
||||
- 上下文压缩(compaction)不重置链:压缩后的历史改变了模型所见,但重复风险通常在压缩后仍然存在。
|
||||
- 在高阈值升级为 `block` 未实现;`PostToolDecision` 已支持此选项,待证据出现后可启用。
|
||||
- subagent 的链按 agent 隔离;在出现具体需求之前不引入共享机制。
|
||||
- 压缩(compaction)不重置链:压缩后的历史改变了模型所见的内容,但重复风险通常在压缩后仍然存在。
|
||||
- 在高阈值升级为 `block` 未实现;`PostToolDecision` 已支持此选项,待证据到来时启用。
|
||||
- subagent 的链按 agent 隔离;在出现具体用例之前不提供共享机制。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-08-self-referential-cordis-toolset.md: 62b97dc4bdbd0e0b5b1f67f77c763065c79964ed
|
||||
2026-07-08-self-referential-cordis-toolset.zh.md: ce220d256dbb3d43514702e57e71728fdc82a788
|
||||
2026-07-08-self-referential-cordis-toolset.zh.md: 44648d7a3f195f2dc84121c0d014e5193484b106
|
||||
@@ -1,82 +1,82 @@
|
||||
# RFC:自引用 Cordis 工具集
|
||||
|
||||
Status: implemented
|
||||
# RFC:自引用 cordis 工具集
|
||||
|
||||
[English](2026-07-08-self-referential-cordis-toolset.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
本 harness 中的一切都是 Cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。把这种能力交给模型值得探索——一个能审视并修改自身运行时的自引用 agent——但它同时引出三个正确性问题,而本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。
|
||||
本 harness 中的一切都是 cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。赋予模型这种能力值得探索——一个能审视并修改自身运行时的自引用 agent——但这同时引发三个正确性问题,本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。
|
||||
|
||||
第一,模型编写的注册必须在注册发生时就被校验:格式错误的工具 schema 必须在注册时失败,而非等到后续请求尝试将其组装进提示词时才暴露。第二,模型编写的代码需要调用它从未见过源码的服务 API——猜测方法签名,更糟的是猜测返回值形状,会耗费大量盲目试探步骤。第三,模型挂载的一切都必须完全可 dispose(资源释放):模型可以按需释放,宿主插件重载时普通的插件生命周期也能释放,否则长会话会积累遗留的监听器和工具。
|
||||
第一,模型编写的注册必须在注册发生时就完成校验:格式错误的工具 schema 必须在注册时失败,而不是等到后续请求尝试将其组装进提示词时才报错。第二,模型编写的代码需要调用它从未见过源码的服务 API——靠猜测方法签名、更糟糕的是猜测返回值结构,会消耗大量盲目试探的步骤。第三,模型挂载的一切都必须完全可释放:模型可以按需释放,普通的插件生命周期在宿主插件重载时也会释放,否则长会话会积累遗留的监听器和工具。
|
||||
|
||||
## 决策
|
||||
|
||||
该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 Cordis 运行时:审视它、向其中挂载模型编写的插件、再将它们 dispose。
|
||||
该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 cordis 运行时:审视它、将模型编写的插件挂载进去、再将其释放。
|
||||
|
||||
vm 隔离了意外的全局污染,上下文门面隐藏了框架内部实现。二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能触及真实文件系统和网络服务。这是一个需要主动启用的开发工具,信任等级与 bash 等同,既不是安全边界,也不是产品默认配置。
|
||||
vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。
|
||||
|
||||
### 三个工具
|
||||
|
||||
| 工具 | 契约 |
|
||||
|---|---|
|
||||
| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则返回全部段落)。从不修改状态。 |
|
||||
| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数体);代码必须 `return` 一个 Cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新生成的 id(`dyn-1`、`dyn-2`、……)追踪。 |
|
||||
| `cordis_unmount` | 按 id dispose 一个动态挂载,并等待 disposal 达到静止——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 |
|
||||
| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。从不产生变更。 |
|
||||
| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数的函数体);代码必须 `return` 一个 cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新 id(`dyn-1`、`dyn-2`……)跟踪。 |
|
||||
| `cordis_unmount` | 按 id 释放一个动态挂载,并等到释放达到静止状态后才返回——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 |
|
||||
|
||||
`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的 owner 会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)、`events`(harness 事件及其分发模式和签名)。面向模型的工具描述携带模型在调用时所需的操作规则;[生成的工具目录](../../../tool-catalog.md)是其完整渲染。
|
||||
`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的所有者会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)和 `events`(harness 事件及其分发模式和签名)。面向模型的工具描述携带了模型在调用时所需的操作规则;[生成的工具目录](../../../tool-catalog.md)是其完整呈现。
|
||||
|
||||
### 沙箱语义
|
||||
|
||||
挂载代码作为异步函数体在一个新的 vm realm 中运行。其文档化的接口面将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可 dispose。宿主 realm 的辅助手段仍使 Node 逃逸成为可能,与信任姿态一致。`vmTimeoutMs` 仅约束同步执行部分。
|
||||
挂载代码以异步函数体的形式在一个新的 vm realm 中运行。其文档化的接口面将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可释放。宿主 realm 的辅助手段仍然使 Node 逃逸成为可能,这与信任姿态一致。`vmTimeoutMs` 仅约束同步执行部分。
|
||||
|
||||
沙箱全局变量刻意精简:一个带标签的直通 `console`(在宿主 stdout/stderr 上输出 `[cordis:<id>] …`,使得挂载调用结束很久后触发的监听器仍能输出到用户可见之处)、`harness.defineTool` / `harness.registerTool` 注册对、新 vm 上下文缺少的编码原语(`btoa`/`atob` 作为宿主闭包封装 `Buffer`——这是一个经过批准的例外,`Buffer` 本身从不暴露——加上 `TextEncoder`/`TextDecoder`),以及对被扣留的 Node API 的可调用陷阱(`require`、`setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`、`fetch`),调用时抛出错误并指名 Cordis 替代方案。只有函数形状的全局变量被陷阱拦截;`process` 和 `Buffer` 保持 `undefined`,使 `typeof` 特性探测保持惰性而非触发抛出异常的访问器。
|
||||
沙箱全局变量刻意精简:一个带标签的直写 `console`(在宿主 stdout/stderr 上输出 `[cordis:<id>] …`,这样在挂载调用之后很久才触发的监听器输出仍能落到用户可见的地方)、`harness.defineTool` / `harness.registerTool` 注册对、新 vm 上下文缺少的编码原语(`btoa`/`atob` 作为基于 `Buffer` 的宿主闭包——这是一个经过审批的例外,`Buffer` 本身从不暴露——加上 `TextEncoder`/`TextDecoder`),以及对被扣留的 Node API 的可调用陷阱(`require`、`setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`、`fetch`),这些陷阱会抛出一条重定向消息指明 cordis 替代方案。只有函数形态的全局变量才设陷阱;`process` 和 `Buffer` 保持 `undefined`,这样 `typeof` 特性探测保持惰性而不会引爆一个抛异常的访问器。
|
||||
|
||||
挂载代码通过三道控制跨越 vm 边界。双 realm `instanceof` 同时识别宿主和 vm 对象。`harness.defineTool` 将结果规范化为宿主 realm 的 JSON,并在记录日志前校验 `ToolExecuteReturn` 形状。挂载的插件接收一个白名单上下文门面,而非原始或直通的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取要求声明 `inject`,保持 Cordis 的激活和卸载语义。`ctx.tools.get` 仅暴露 schema 视图,使挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。
|
||||
挂载代码通过三道控制跨越 vm 边界。双 realm `instanceof` 同时识别宿主和 vm 对象。`harness.defineTool` 将结果规范化为宿主 realm 的 JSON,并在记录日志前校验 `ToolExecuteReturn` 形状。挂载的插件接收的是一个白名单上下文门面,而非原始或透传的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取需要声明 `inject`,保留 Cordis 的激活与卸载语义。`ctx.tools.get` 仅暴露 schema 视图,因此挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。
|
||||
|
||||
边界将无歧义的 JSON-Schema 形式规范化为 `SchemaSpec`,包括对象包装、`integer` 和可选字段。无效词汇会失败并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 错误和重复工具错误会包含相关源代码行或纠正性契约,但不叙述实现内部细节。
|
||||
边界将无歧义的 JSON-Schema 形式规范化为 `SchemaSpec`,包括对象包装器、`integer` 和可选字段。无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。
|
||||
|
||||
### 动态分组与挂载生命周期
|
||||
|
||||
所有动态挂载都是工具插件下方一个 `cordis-dynamic` 分组的子节点,因此普通的 fiber disposal 即可处理重载和卸载。挂载会等待 settlement;启动失败会在返回错误前 dispose 该 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的 disposal。
|
||||
所有动态挂载都是工具插件下方 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理重载和卸载。挂载会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的释放完成。
|
||||
|
||||
### 通过 provide/inject 实现跨挂载组合
|
||||
|
||||
挂载之间通过普通的 Cordis 服务语义相互关联,以各自的 id 作为生命周期句柄:挂载 A 调用 `ctx.provide('foo', value)`,挂载 B 声明 `inject: ['foo']` 并在 `foo` 存在的瞬间激活;如果 B 先挂载,它会保持 pending 状态并列出缺失的服务;卸载 A 会使 B 回到 pending(其注册被撤销),之后重新 provide 会通过一个新的沙箱门面重新运行 B 的 `apply`;重复 provide 会大声失败并指名拥有该服务的 fiber。一个 realm 注意事项:挂载提供的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设其上有宿主原型。
|
||||
挂载之间通过普通的 cordis 服务语义相互关联,以各自的 id 作为生命周期句柄:挂载 A 调用 `ctx.provide('foo', value)`,挂载 B 声明 `inject: ['foo']` 并在 `foo` 存在的瞬间激活;如果 B 先挂载,它保持 pending 状态并列出缺失的服务;卸载 A 使 B 回到 pending(其注册被撤销),之后重新 provide 会通过一个新的沙箱门面重新运行 B 的 `apply`;重复 provide 会明确报错并指出拥有该服务的 fiber。一个 realm 注意事项:由挂载 provide 的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设它具有宿主原型。
|
||||
|
||||
### 生成的 API 目录
|
||||
|
||||
`cordis_inspect` 从生成的目录而非重复的表格提供 API 和事件数据。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、事件模式、引用的类型声明和继承的上下文接口面。有歧义的类型名被省略,过大的声明被标记为截断。
|
||||
`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、事件模式、引用的类型声明以及继承的 context 接口面。有歧义的类型名被省略,过大的声明被标记为截断。
|
||||
|
||||
新鲜度像所有生成产物一样受门禁保护:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此修改了公开签名的 JSDoc 变更在不重新生成模型所读目录的情况下无法发布。运行时,inspect 工具将目录与活跃运行时取交集而非直接转储:有目录条目的活跃服务渲染摘要 + 签名,没有目录条目的活跃服务(挂载提供的)渲染名称 + 所属 fiber,有目录条目但没有活跃提供方的服务简要列出,引用的类型形状随后附上。
|
||||
新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此修改了公开签名的 JSDoc 变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:有目录条目的活跃服务渲染摘要 + 签名,没有目录条目的活跃服务(挂载提供的)渲染名称 + 所属 fiber,有目录条目但无活跃提供方的服务简要列出,引用的类型形状随后附上。
|
||||
|
||||
### 配置、渲染与可观测性
|
||||
|
||||
该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名称、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。
|
||||
该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。
|
||||
|
||||
「模型可见 ⟺ 已记录」成立,且不引入新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它),而挂载引起的工具集变化则由循环在 schema 在步骤间变化时已有的请求头 delta 日志记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复持久化的会话会重建对话但不会重新挂载插件。
|
||||
「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它们),而挂载引起的工具集变化由循环在 schema 在步骤间发生变化时已有的 request-header delta 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**用结构化的逐能力注册工具替代 `cordis_mount`。** 最诱人的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`、……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景省去插件样板——不足以抵偿其代价,而单一的挂载原语能一次性覆盖所有能力。
|
||||
**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。
|
||||
|
||||
| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` |
|
||||
|---|---|---|
|
||||
| Schema 正确性 | `parameters` 仍是模型编写的 JSON 对象,需要 SchemaSpec 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误 |
|
||||
| 代码字段 | `execute` 体仍是 vm 中模型编写的 JS;realm 和服务调用正确性问题不变 | 一个沙箱、一条规范化路径、一道受守护的注册 |
|
||||
| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(一个 Cordis 插件)覆盖当前和未来的所有效果 |
|
||||
| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通 Cordis 语义 |
|
||||
| 可审视性 | 注册的东西在插件列表中无法作为插件展示 | 模型挂载的东西正是 `cordis_inspect` 渲染的东西 |
|
||||
| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过挂载描述中的规范示例加上教导正确做法的边界错误来缓解 |
|
||||
| Schema 正确性 | `parameters` 仍然是模型编写的 JSON 对象,需要 SchemaSpec 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 |
|
||||
| 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 |
|
||||
| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(cordis 插件)覆盖当前和未来的所有效果 |
|
||||
| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 |
|
||||
| 可审视性 | 注册的东西无法在插件列表中显示为插件 | 模型挂载的正是 `cordis_inspect` 渲染的 |
|
||||
| 模型人机工程学 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 |
|
||||
|
||||
因此,正确性投入放在能一次性覆盖所有能力的地方:通过 `cordis_inspect` 暴露的生成 API 目录,以及沙箱边界校验——其错误消息教导正确的调用方式。结构化注册工具日后仍可作为语法糖添加,合成挂载代码即可;本设计不排斥它。
|
||||
因此正确性投入放在能一次性为所有能力带来回报的地方:通过 `cordis_inspect` 呈现的生成 API 目录,以及沙箱边界校验(其错误信息教会正确的调用方式)。结构化注册工具日后仍可作为语法糖添加,由它合成 mount 代码;本设计不排斥这一可能。
|
||||
|
||||
**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一张手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节,且没有门禁检测这种漂移;而生成产物的新鲜度由与文档使用同一 AST 的检查来保证。
|
||||
**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。
|
||||
|
||||
**新增 `cordis/mount` 会话事件。** 记录每次挂载(源码、名称)的持久溯源事件有明确先例(`hook/invoked`、`compact/start`)。v1 中否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为请求头 delta 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源与工具调用分离,日后仍可添加。
|
||||
**新增 `cordis/mount` 会话事件。** 一个持久的溯源事件记录每次挂载(源码、名称)有明确先例(`hook/invoked`、`compact/start`)。v1 中予以否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为 request-header delta 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源从工具调用中分离出来,日后仍可添加。
|
||||
|
||||
**加固的 / 能力受限的沙箱。** 拦截 Node 内置模块并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。明确声明并非如此:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 Cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未守护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)对一个开发/主动启用的工具集来说超出范围,且与其核心目标——将活跃运行时交给模型——相悖。
|
||||
**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。
|
||||
|
||||
## 后果
|
||||
|
||||
该工具集是刻意需要主动启用的,具有完全权限的 `ctx`,因此部署方采用它的意识程度与采用 bash 工具相同。以下事实由工具描述直接告知模型:waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。
|
||||
该工具集是刻意的显式启用设计,具有完全特权的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此一个挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-10-session-query-service.md: 8b742ac19fea21d8404f5f44aa64f8c3cb3efccc
|
||||
2026-07-10-session-query-service.zh.md: 43175b05d82ad758a16e516f3fd8b7b650f9d762
|
||||
2026-07-10-session-query-service.zh.md: 73f47d0cc0306b3dcb6552c686f8f1a71ffbdc87
|
||||
@@ -1,43 +1,43 @@
|
||||
# RFC:精确会话查询服务
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-10-session-query-service.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
会话历史存在于两处:当前的 `SessionStore` 对象和可选的持久化后端。需要精确检查的消费方如果不借助统一服务,就得各自重复实现活跃/持久化优先级、持久化生命周期处理、原始事件 surface 分类和防御性克隆。检查点之间持久状态可能落后于活跃日志,因此单靠持久化并不是可信的当前数据源。
|
||||
会话历史存在于两处:当前的 `SessionStore` 对象与可选的持久化后端。需要精确检查的消费方若无统一服务,就不得不各自重复实现活跃/持久化优先级判定、持久化生命周期处理、原始事件的 surface 分类以及防御性克隆。在检查点之间,持久化状态可能落后于活跃日志,因此仅靠持久化并非当前状态的可靠来源。
|
||||
|
||||
全文搜索与此相关但规模大得多。在真正的后端出现之前就设计提供方注册、抽取、同步、失效、排序和游标契约,会产生两个投机性的状态机:一个在接口服务中,另一个在最终的数据库包中。
|
||||
全文搜索与此相关,但规模大得多。在真实后端尚不存在时就设计提供方注册、提取、同步、失效、排序和游标契约,会产生两个投机性的状态机:一个在接口服务中,另一个在最终的数据库包(package)中。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`:一个面向单一逻辑语料库的小型可信精确读取服务。它暴露 `listSessions()`、`listEvents(sessionId)` 和有界的 `readEvent(request)`。它不暴露过滤器、血缘/溯源遍历、文本抽取器、搜索请求、提供方注册或派生索引同步。
|
||||
`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`,这是一个小型的、受信任的精确读取服务,面向单一逻辑语料库。它暴露 `listSessions()`、`listEvents(sessionId)` 和有界的 `readEvent(request)`。它不暴露过滤器、血缘或溯源遍历、文本提取器、搜索请求、提供方注册或派生索引同步。
|
||||
|
||||
该服务动态观察可选的 `ctx.sessionPersistence` 绑定,但不保留持久化缓存或失效监听器。每次跨语料库列举都向活跃后端请求权威元数据,然后叠加一份新鲜的活跃 store 列表。id 匹配的条目合并为一条 `SessionRecord`:活跃 header 优先,`live`/`persisted` 独立报告来源可用性。不可变 header 不一致时报 `SESSION_QUERY_SOURCE_CONFLICT`。
|
||||
该服务动态观察可选的 `ctx.sessionPersistence` 绑定,但不保留持久化缓存或失效监听器。每次跨语料库列表操作向活跃后端请求权威元数据,然后叠加一份新鲜的活跃 store 列表。id 匹配的条目合并为一条 `SessionRecord`:活跃 header 优先,`live`/`persisted` 各自独立报告来源可用性。不可变 header 不一致时产生 `SESSION_QUERY_SOURCE_CONFLICT`。
|
||||
|
||||
精确目标读取首先检查活跃 store,快照活跃 header 和事件日志。此路径从不查询持久化,因此持久化后端故障不会使已知的活跃历史变得不可读。当活跃 store 中无目标时,服务列举当前持久化元数据、证明该 id 存在、加载它,并在列举/加载的 header 不一致时拒绝。所有返回的 header 和事件都经过一次 structured-clone 边界。
|
||||
精确目标读取首先检查活跃 store,快照活跃 header 与事件日志。此路径从不查询持久化,因此持久化后端故障不会导致已知的活跃历史不可读。若活跃 store 中无目标,服务列出当前持久化元数据、证明该 id 存在、加载它,并在列表/加载 header 不一致时拒绝。所有返回的 header 与事件都经过一次 structured-clone 边界。
|
||||
|
||||
## Surface 语义
|
||||
|
||||
`dsh-session` 导出 `foldSurface(events)`,`SurfaceManager` 对其增量缓存使用相同的转换函数。fold 返回分离的当前节点以及每次替换实际移除的 seq。`listEvents()` 利用该结果将每个原始事件分类为 `current`、`shadowed` 或 `log-only`,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。
|
||||
`dsh-session` 导出 `foldSurface(events)`,`SurfaceManager` 使用相同的转换函数维护其增量缓存。fold 返回分离的当前节点以及每次替换实际移除的 seq。`listEvents()` 利用该结果将每个原始事件分类为 `current`、`shadowed` 或 `log-only`,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。
|
||||
|
||||
`readEvent()` 返回完整的目标事件以及按连续 seq 排列的原始邻居。`before` 和 `after` 默认为零,各自受 `readWindowMax`(默认 50)约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。
|
||||
`readEvent()` 返回完整的目标加上按连续 seq 排列的原始相邻事件。`before` 和 `after` 默认为零,各自受 `readWindowMax`(默认 50)约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。
|
||||
|
||||
## 安全边界
|
||||
|
||||
该服务是上下文范围内的可信基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话作用域。本阶段不添加面向模型的工具,也不改变 transcript(文本记录)或快照 surface。
|
||||
该服务是上下文级别的受信任基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话范围。本阶段不添加面向模型的工具,也不改变 transcript(文本记录)或快照的 surface。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **让每个消费方自行实现逻辑语料库解析**:否决。来源优先级、冲突处理、可选服务生命周期、克隆和 surface 分类是共享的正确性规则。
|
||||
- **只查询持久化**:否决。检查点之间持久化可能落后于当前活跃日志。
|
||||
- **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求之前就引入了生命周期和并发状态。
|
||||
- **将逻辑语料库解析直接放在每个消费方中**:否决。来源优先级、冲突处理、可选服务生命周期、克隆与 surface 分类是共享的正确性规则。
|
||||
- **仅查询持久化**:否决。检查点可能落后于当前活跃日志。
|
||||
- **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求时就引入了生命周期与并发状态。
|
||||
- **现在就定义提供方无关的搜索协议**:否决。目前没有提供方消费它。第一个 SQLite FTS 包应自行拥有一个协调/事务状态机;只有当第二个实现证明了边界时,才提取更小的共享 seam。
|
||||
- **在第一阶段就包含血缘、溯源和通用过滤器**:否决。当前没有消费方需要它们,且规范日志足以在有证据时再行添加。
|
||||
- **在第一阶段就包含血缘、溯源和通用过滤器**:否决。当前没有消费方需要它们,且规范日志足以在日后有证据时再行添加。
|
||||
|
||||
## 后果
|
||||
|
||||
第一阶段只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、抽取器注册表、观察代次或派生索引更新。精确读取在纯活跃部署中仍然可用,在持久化存在时具有确定性。
|
||||
第一阶段只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、提取器注册表、观察代次或派生索引更新。精确读取在纯活跃部署中仍然可用,在持久化存在时具有确定性。
|
||||
|
||||
跨语料库列举和持久化精确读取每次调用都执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于第二阶段的数据库。全文搜索在该包定义并实现其完整契约之前不可用。
|
||||
跨语料库列表与持久化精确读取在每次调用时执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于第二阶段的数据库。在该包定义并实现其完整契约之前,全文搜索不可用。
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.md: 368f3a3592c5e241bb9357d4d4ce32e175c3de45
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6c1ce8ac08fe3d37c400d489808e592570ebd6c7
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: cc78a472df014ce1eb9114277e0520c7e9c051bb
|
||||
+39
-39
@@ -1,94 +1,94 @@
|
||||
# RFC:配置 subagent 的 persona、工具可见性与深度
|
||||
|
||||
Status: implemented
|
||||
# RFC:配置 subagent 的人设、工具可见性与深度
|
||||
|
||||
[English](2026-07-12-subagent-persona-tool-filter-and-depth.md) | 中文
|
||||
|
||||
Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
一个可复用的 subagent 提供方解决的是「如何运行子 agent」的问题,但不同的委派工具需要不同的子 agent 行为。某个部署可能需要一个评审者 persona、一组仅限研究的工具集,或一个硬性递归上限,而不必为每种组合都创建新的提供方。
|
||||
一个可复用的 subagent 提供方解决的是「如何运行子 agent(智能体)」的问题,但不同的委派工具需要不同的子 agent 行为。某个部署可能需要评审者人设、仅限研究的工具集,或硬性递归上限,而不必为每种组合创建新的提供方。
|
||||
|
||||
这些控制影响子 agent 的第一次模型请求,因此不能在子 agent 可见之后才安装。它们还需要提供方诚实地声明支持:ACP 后端不能静默接受一个仅适用于进程内的工具过滤器,而过滤器也不应在所有插件运行于同一可信进程时被描述为安全边界。
|
||||
这些控制影响子 agent 的第一次模型请求,因此不能在子 agent 可见之后再安装。它们还需要提供方的诚实支持:ACP(Agent Client Protocol)后端不能默默接受一个仅限进程内的工具过滤器,而过滤器在所有插件运行于同一可信进程的情况下也不应被描述为安全边界。
|
||||
|
||||
## 决策
|
||||
|
||||
subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持,服务在启动运行前拒绝不支持的请求,而进程内提供方在子 agent 尚未发布时安装所请求的组合。
|
||||
subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持情况,服务在启动运行之前拒绝不受支持的请求,进程内提供方在子 agent 尚未发布时安装所请求的组合。
|
||||
|
||||
这些控制回答不同的问题:
|
||||
|
||||
| 控制 | 问题 | 结果 |
|
||||
|---|---|---|
|
||||
| `persona` | 哪些角色指令替换该子 agent 的部署 persona? | 一个子 agent 局部的 prompt 段落遮蔽 `deployment:persona` |
|
||||
| `toolFilter` | 哪些部署全局工具进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 |
|
||||
| `maxDepth` | 这棵委派树最深可以长到多少层? | 当子 agent 深度超过绝对上限时,启动请求被拒绝 |
|
||||
| `persona` | 什么角色指令替换该子 agent 的部署人设? | 一个子 agent 局部的 prompt 段落遮蔽 `deployment:persona` |
|
||||
| `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 |
|
||||
| `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 |
|
||||
|
||||
`dsh-tool-subagent` 将这些控制作为插件配置暴露,并将它们复制到每个创建的请求中。直接调用 `SubagentService` 的调用方可以按请求选择。提供方能力描述符仍然是后端能否兑现各字段的真源。
|
||||
`dsh-tool-subagent` 将这些控制作为插件配置暴露,并复制到它创建的每个请求中。直接调用 `SubagentService` 的调用方可以按请求选择这些控制。提供方的能力描述符仍然是后端能否兑现各字段的真源。
|
||||
|
||||
### Persona 是有作用域的遮蔽
|
||||
### 人设是有作用域的遮蔽
|
||||
|
||||
persona 控制改变一个子 agent 而不改变部署级别的 prompt 组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者胜出解析规则仅在该子 agent 的组装中替换全局段落。
|
||||
人设控制改变一个子 agent 的行为,而不改变部署级的 prompt 组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。
|
||||
|
||||
其值具有与部署 persona 相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局 persona。父 agent 和兄弟 agent 的 persona 永远不会进入子 agent 的扁平作用域。
|
||||
其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。
|
||||
|
||||
这使用的是正常的系统提示词注册机制,而非第二条 persona 通道。因此第一次 prompt 看到的命名贡献与后续 prompt 和 prompt 检查工具看到的相同。
|
||||
这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次 prompt 看到的命名贡献与后续 prompt 和 prompt 检查工具看到的一致。
|
||||
|
||||
### 工具过滤是一条实时的全局视图规则
|
||||
### 工具过滤是一条作用于全局视图的活规则
|
||||
|
||||
工具过滤器同时控制能力可见性与可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRegistry.restrict()`,注册表的单一解析器将相同结果应用于协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成。独立注册的系统提示词段落不在 `ToolRegistry` 内,因此过滤一个工具不会移除该插件的独立指导文本。
|
||||
工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRegistry.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRegistry` 内,因此过滤一个工具不会移除该插件的独立指导文本。
|
||||
|
||||
解析遵循以下规则:
|
||||
|
||||
1. 每个限制对实时的部署全局工具注册表先应用 `allow` 再应用 `deny`。
|
||||
2. 多个限制取交集,因此每个已安装的限制都必须放行一个全局工具。
|
||||
1. 每条限制对活跃的部署全局工具注册表先应用 `allow` 再应用 `deny`。
|
||||
2. 多条限制取交集,因此每条已安装的限制都必须放行一个全局工具。
|
||||
3. 子 agent 作用域的工具在全局过滤之后添加,可以遮蔽一个已放行的全局工具。
|
||||
4. 保留的 `run_code` 呈现和其他作用域局部的协议贡献不在全局过滤器范围内。
|
||||
4. 保留的 `run_code` 呈现和其他作用域局部的协议贡献不受全局过滤器影响。
|
||||
|
||||
当过滤器既不提供 `allow` 也不提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅作用域局部或保留的名称)时,配置会大声失败。`allow: []` 是合法的,它有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来有效。
|
||||
当过滤器既未提供 `allow` 也未提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅作用域局部或保留名称)时,配置会显式失败。`allow: []` 合法,且有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来仍然有效。
|
||||
|
||||
全局注册表保持实时。仅 deny 的过滤器会放行后续注册的全局名称(除非显式 deny 该名称);allow 列表会排除后续注册的全局名称(除非显式 allow 该名称)。移除一个全局工具会将其从所有解析视图中移除。这些语义在保持热注册的同时,使 allow 与 deny 的区别显式化。
|
||||
全局注册表保持活跃。仅 deny 的过滤器会放行后来注册的全局名称(除非显式 deny 该名称);allow 列表会排除后来注册的全局名称(除非显式 allow 该名称)。移除一个全局工具会将其从所有已解析视图中移除。这些语义在保持热注册的同时,使 allow 与 deny 的区别显式化。
|
||||
|
||||
### 深度是绝对的树上限
|
||||
|
||||
深度限制独立于工具可见性来约束递归委派。顶层 agent 的深度为零;进程内子 agent 的深度为其父 agent 经验证的深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。
|
||||
深度限制独立于工具可见性来约束递归委派。顶层 agent 深度为零;进程内子 agent 的深度为其父级已验证深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。
|
||||
|
||||
每个公开入口都验证值域,而不依赖单一的面向模型的配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的已存储父深度以及推导溢出都会被拒绝。省略上限则该机制不约束深度。
|
||||
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。省略上限时,此机制不约束深度。
|
||||
|
||||
部署可以组合深度与过滤。例如,可以在深度一时保留委派工具可见但设置 `maxDepth: 1`,或在子 agent 中完全 deny 委派工具。两种选择都不改变提供方的对话历史行为。
|
||||
部署可以组合深度与过滤。例如,可以在深度一时保持委派工具可见但设置 `maxDepth: 1`,或在子 agent 中完全 deny 委派工具。两种选择都不改变提供方的对话历史行为。
|
||||
|
||||
### 能力门控保持提供方诚实
|
||||
|
||||
能力将请求的特性与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中的每个字段。
|
||||
能力将请求的特性与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。
|
||||
|
||||
这使得 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择一个不支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。
|
||||
这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。
|
||||
|
||||
### 未发布的设置使第一次请求正确
|
||||
### 未发布设置使第一次请求正确
|
||||
|
||||
所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装 persona、工具限制和结构化输出贡献。只有设置成功后,创建才会发布会话和 agent 并允许驱动器启动。
|
||||
所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。
|
||||
|
||||
设置失败会回滚私有的子 agent。没有观察者能获取到一个「第一次 prompt 使用了部署 persona 或未过滤工具集、后续 prompt 才使用请求配置」的子 agent。
|
||||
设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次 prompt 使用了部署人设或未过滤工具集、后续 prompt 才使用所请求配置」的子 agent。
|
||||
|
||||
## 可见性不是授权
|
||||
|
||||
这些控制组合的是可信的同进程行为;它们不授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 是父 agent 的子集,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。
|
||||
这些控制组合的是同一可信进程内的行为,而非授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 是其父级的子集,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。
|
||||
|
||||
特别地,子 agent 局部工具在全局过滤之后添加,可能不在父 agent 的视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后续全局工具。这些是有意的实时组合语义,而非不可提权保证。
|
||||
具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升级保证。
|
||||
|
||||
安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父集合子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签都不在本特性范围内。
|
||||
安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签均不在本特性范围内。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**为每个 persona 或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍然需要递归机制。提供方的职责仍然是执行传输;请求承载每个子 agent 的组合。
|
||||
**为每种人设或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍需要递归机制。提供方的职责是执行传输;请求承载每个子 agent 的组合。
|
||||
|
||||
**复制父 agent 的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析的视图还会冻结动态全局注册,并在未完整定义任一契约的情况下混淆组合与授权。
|
||||
**复制父级的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析视图还会冻结动态全局注册,并在未完整定义任一契约的情况下混淆组合与授权。
|
||||
|
||||
**在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册一律不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为实时注册表谓词,并直接记录 allow 与 deny 的行为。
|
||||
**在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册统一不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为活跃的注册表谓词,并直接记录 allow 与 deny 的行为。
|
||||
|
||||
**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现与执行。
|
||||
**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现和执行。
|
||||
|
||||
**仅用工具过滤来阻止递归。** 移除委派工具有用但依赖特定提供方,且无法保护直接的服务调用方或替代委派工具。绝对深度是一个独立的结构性约束。
|
||||
**仅用工具过滤来阻止递归。** 移除委派工具有用但依赖特定提供方,且不保护直接服务调用方或替代委派工具。绝对深度是独立的结构性约束。
|
||||
|
||||
## 后果
|
||||
|
||||
贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始前失败,未发布的设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。
|
||||
贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。
|
||||
|
||||
代价是部署方必须理解实时 allow/deny 行为以及可见性与授权的区别。提供方作者必须准确声明每个支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可提权问题。
|
||||
代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升级问题。
|
||||
Reference in New Issue
Block a user