数据截至 (上游 commit 99f6f02fecdb)
第 5 章 · 能力接缝:换一个 Provider,整个产品换一个执行世界
30 秒导读: 这一章讲 dsh 复用性的总规律——它把每一样"可换的能力"都拆成三个角色放进三个包。 结果是:把
ctx.fs和ctx.subprocess两个服务换成远端实现,Bash、终端、LSP 会一起搬到远端沙箱里, 而模型看见的工具名字、参数、返回格式一个字都没变。
前四章讲的是"一个产品怎么跑起来":插件树(第 1 章)、 事件日志(第 2 章)、主循环(第 3 章)、 工具流水线(第 4 章)。这一章反过来问:这些跑起来的东西,有多少是可以整块换掉的?
1. 先讲清楚:什么叫「接缝」
1.1 一句话定义
接缝(capability seam)= 一整个可替换的能力,由三个角色共同构成。少一个角色,就不是接缝,只是一个类。
仓库自己把这条写进了词汇表:seam 指"完整能力,从来不是单个角色"
(docs/glossary.md:9,## capability-seam 条目)。
1.2 三个角色
| 角色 | 白话 | 它拥有什么 | 典型包 |
|---|---|---|---|
| Service Definition(服务定义) | 「这个能力是什么」 | ctx.<key> 这个键、抽象方法、词汇类型 | dsh-shell |
| Service Provider(服务提供方) | 「它怎么真的跑起来」 | 具体实现、平台细节、进程/网络机制 | dsh-bash-local |
| Consumer(消费方) | 「模型和其它插件对着什么编程」 | 工具名、JSON schema、提示词、渲染 | dsh-tool-bash |
三者的关系是这样连的(从左到右是依赖方向,中间那个键是唯一的会合点):
Service Definition Service Provider
┌──────────────────┐ ┌──────────────────┐
│ 抽象类 + 词汇类型 │◀── 继承/注册 ──│ 本地 / 沙箱 / 远端│
│ 拥有 ctx.shell │ │ 实现抽象方法 │
└────────┬─────────┘ └──────────────────┘
│ inject: ['shell']
▼
┌──────────────────┐
│ Consumer │ 模型看见的那一面:
│ 工具名 + schema │ bash(command, timeout_ms, ...)
└──────────────────┘
怎么读这张图: Provider 和 Consumer 之间没有箭头——它们互不认识,只认识中间的服务定义。 这正是"换一个 Provider 不动工具 schema"的全部原因。
1.3 为什么非要拆成三个
因为这三件事变化的速度和理由完全不同。把它们塞进一个包,就等于把三条互不相干的变更曲线焊死: 换个执行后端,本来跟模型无关,却会连带改动模型看见的 schema。
设计笔记把这句话写在最前面:三个关切"以不同速率、因不同理由而变"
(.agents/notes/implemented/architecture/2026-06-13-capability-seams.md,Problem 一节)。
1.4 判定方法:三个问题
拿到一段代码,想知道它是不是接缝、属于哪个角色,问这三句:
| 问题 | 答"是"意味着 |
|---|---|
它是否声明了 ctx.<key> 并且只依赖契约本身需要的词汇? | Service Definition |
| 它是否注册/继承了别人的服务,并带进了平台或网络细节? | Service Provider |
它是否 inject 了服务键、且从不 import provider 专属类型? | Consumer |
还有一条反向判据,仓库把它写成"气味":一个公共 service 方法只有一个内部调用者,
说明它根本不该出现在契约上,应该改传一个私有能力闭包(packages/AGENTS.md,
"Design Service Definitions for all current Consumers" 一条)。
1.5 一个不显然的取舍:不要预拆
拆包是有成本的——多一份 package.json、tsconfig、README 和注入接线。
所以规矩是:只有一个可想象的 Provider、只有一个 Consumer 时,就先合成一个包,等第二个出现再拆
(同一份笔记的 Decision 一节,原文 "Don't split preemptively")。
LLM 接缝就是被允许折叠的那个:dsh-llm 同时是 Service Definition 和 Consumer,
因为它的"消费方"是主循环本身,不是一层可替换的 schema。
2. 用包名布局证明:三件套是真的
抽象说完了,看真东西。packages/ 下面的目录结构本身就是这套规矩的证据。
2.1 文件系统三件套
| 包 | 角色 | 关键符号 |
|---|---|---|
packages/fs/fs | Service Definition:ctx.fs | FileSystem(packages/fs/fs/src/index.ts:86) |
packages/fs/fs-local | Provider:本地文件系统 | LocalFileSystem(packages/fs/fs-local/src/index.ts:64) |
packages/fs/fs-sandbox | Provider:在本地实现上加路径围栏 | SandboxedFileSystem(packages/fs/fs-sandbox/src/index.ts:59) |
packages/e2b/fs-e2b | Provider:远端 E2B 沙箱 | 注册 ctx.fs |
packages/fs/tool-fs | Consumer:模型面的 read/write/edit | apply(packages/fs/tool-fs/src/index.ts:54) |
抽象方法就是这个契约的全部内容,例如原子编辑:
// packages/fs/fs/src/index.ts:243
abstract editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome>
注意最后一个参数:沙箱策略是按调用传进来的,不是后端自己的全局状态。 会围栏的后端按它设限,裸后端直接忽略——同一个签名同时服务两种 Provider。
2.2 Bash 三件套
| 包 | 角色 | 关键符号 |
|---|---|---|
packages/shell/shell | Service Definition:ctx.shell | ShellExecutor(packages/shell/shell/src/index.ts:65) |
packages/shell/bash-local | Provider:走本地子进程 | LocalBashExecutor(packages/shell/bash-local/src/index.ts:102) |
packages/shell/bash-sandbox | Provider:先套沙箱再执行 | 注册 ctx.shell |
packages/shell/pwsh-local | Provider:PowerShell 语义 | 注册 ctx.shell |
packages/shell/tool-bash | Consumer:bash 工具 + 后台任务 | apply(packages/shell/tool-bash/src/index.ts:190) |
服务定义只有三个抽象方法,一眼看完:resolve(补默认值和上限,
packages/shell/shell/src/index.ts:85)、run(前台,:93)、start(后台句柄,:100)。