前置:第 02、07 课 | 预计时长:60 分钟 | 动手环节:必须完成
你将学会
- Capability Seam 的三角色(Service Definition / Provider / Consumer)与「缺一不算完整接缝」的纪律;
- Provider 的两种注册形态:服务子类(单实现)与注册表(多后端);
- 为什么切换 subprocess 的 Provider 会把 Bash/PTY/LSP 一起带走。
问题引入
前七课你理解了骨架、事件、主干、组合。现在看扩展模型的核心抽象:dsh 怎么让「文件系统在本地还是远程」「模型是 DeepSeek 还是别的」这类问题变成改两行补丁的事? 答案是 Capability Seam:把一种能力切成三个角色,让接口、实现、消费者独立演进。
正文
8.1 三角色定义
(根 AGENTS.md + docs/glossary.md)
| 角色 | 职责 | 代码形态 |
|---|---|---|
| Service Definition | 拥有 ctx.<key> 声明合并的包,定义抽象接口 + 服务事件词表 | declare module '@deepseek-ai/cordis' { interface Context { fs: FileSystem } } |
| Service Provider | 实现,挂载后成为 ctx.<key> 的活体 | class LocalFileSystem extends FileSystem |
| Consumer | 只依赖 ctx 键(工具 / 其他服务),从不见具体实现 | inject = ['tools', 'fs', 'systemPrompt'] |
纪律:一个 seam 必须三角色齐全,缺一不算完整接缝;只有当角色独立演进时才拆分。 反模式是「只有一个内部调用者的公共服务方法」——那应该改成私有能力闭包,不配拥有接缝。
8.2 Provider 两种注册形态
形态一:服务子类(单实现互斥)——以 fs 为例:
// Service Definition:packages/fs/fs/src/index.ts
declare module '@deepseek-ai/cordis' {
interface Context { fs: FileSystem } // :44-47 声明合并
}
abstract class FileSystem extends Service { // :86
constructor(ctx) { super(ctx, 'fs') } // :87-89 构造即挂载
abstract resolve(...); abstract readText(...)
}
// Service Provider:packages/fs/fs-local/src/index.ts:64
class LocalFileSystem extends FileSystem { ... } // 插件加载即成为 ctx.fs
// Consumer:packages/fs/tool-fs/src/index.ts:22
export const inject = ['tools', 'fs', 'systemPrompt'] // 只依赖键,不见实现
ctx.fs.writeText(target, content, intent, signal, sandboxPolicy)
同一 context 同键只允许一个实现,加载第二个抛重复服务错误。
形态二:注册表(多后端共存)——以 llm 为例:LlmRuntime 内部是 Map<provider, AdapterRegistration>(packages/llm/llm/src/index.ts:327),ctx.llm.registerAdapter('deepseek-official', adapter) 注册(llm-deepseek/src/index.ts:475),经 ctx.effect() 绑定生命周期。subagent(6 个后端)、web(搜索/抓取多 provider)同理。
选择依据:同一时刻只需要一个实现 → 子类形态;需要并存多个后端(按名字路由)→ 注册表形态。
8.3 Consumer 只见键:切换 Provider 的爆炸半径
Consumer 从不 import Provider 包,只声明 inject 键。于是切换 Provider 不靠配置枚举,靠组合:cordis.yml 补丁层决定加载哪个 provider 插件行(第 07 课);互斥用 disabled: !!js process.platform === 'win32' 表达。
爆炸半径由依赖图决定。最经典的例子是 e2b 远程沙箱(packages/e2b/e2b/tests/fixtures/composition/cordis.yml):只换两行——subprocess-e2b + fs-e2b——bash-local、terminal-bash、lsp-stdio 原样保留,但执行全部落到远程:
// Bash 执行器、PTY 后端、LSP 宿主、外部 agent 后端都经 ctx.subprocess 孵化
// packages/shell/bash-local/src/index.ts:103
class BashLocal { static inject = ['subprocess'] } // 底层一换,上层全迁移
即「执行世界是远程的,但 harness 进程、模型调用、会话状态从不移动」(packages/e2b/README.md:12)。配套约束是单世界不变量:e2b.cwd 与 sandbox-policy.workspaceRoot 必须指向同一远程目录。
8.4 十大能力域速览
(逐域细节见维基 《capability-providers》,这里给课程用的骨架表)
| 域 | 形态 | 关键接口 | 代表后端 → 消费者 |
|---|---|---|---|
| llm | 注册表 | LlmAdapter.stream(options): AsyncIterable<StreamChunk> | deepseek-official / pi-ai / replay → agent-loop |
| shell | 子类 | resolve(request)→spec、run、start | bash-local / pwsh-local → tool-bash |
| subprocess | 子类 | 进程树管理,terminate 是唯一终止动词 | subprocess-local(win32 有 win32-process) |
| fs | 子类 | resolve/readText/writeText… | fs-local / fs-e2b → tool-fs |
| subagent | 注册表 | SubagentRuntime.registerProvider | spawn/fork-in-process、acp、codex、claude-code、dsh-sdk → tool-subagent |
| terminal | 注册表 | TerminalSessionService.registerBackend | terminal-bash → tool-terminal |
| lsp | 注册表 | 恰好 4 个归一化操作(definition/references/implementation/hover) | lsp-stdio → tool-lsp |
| web | 注册表 | registerSearchProvider / registerFetchProvider | exa/perplexity/deepseek + http → tool-web |
| workflow | 子类(单引擎) | WorkflowEngine.start(request): WorkflowRun | worker-thread(线程隔离、可强杀,「遏制而非安全边界」)→ tool-workflow |
| webhook | 注册表 | register(rule) + dispatch(delivery) | webhook-github → Workspace 会话 |
两条横切规律:执行类域(shell / terminal / lsp / 部分 subagent)都经 ctx.subprocess 孵化,共享同一套进程树管理与沙箱迁移效应;LLM 与 Subagent 是注册表形态,其余多为单实现 + 平台替换(win32 上 shell 换 pwsh)。
8.5 显式 > 隐式的模板
ctx.shell 的默认值是显式的 resolve(request): Spec 步骤,绝不藏在 run() 里做 ?? default(根 AGENTS.md「显式>隐式」的模板)。读 shell 域代码时留意这个 request/spec 拆分——它是全仓库包边界默认值的样板。
动手环节
- 填三角色表:从
packages/里挑 shell、web、terminal 三个域,各自找出 Definition / Provider / Consumer 三个包名与关键类名,与 8.4 表核对。 - 追踪孵化链:从
tool-bash出发,沿 inject 链追到ctx.subprocess,列出途经的每一跳(包名 + 键名)。 - 推演题:把
subprocess-local换成subprocess-e2b后,下列哪些组件的行为会改变、哪些不会?为什么?——a) 模型流式输出 b)bash -c命令的执行位置 c) 会话 JSONL 的写入位置 d) PTY 终端。
自检清单
- 三角色各自「拥有」什么?为什么 Consumer 不允许 import Provider 包?
- 子类形态与注册表形态各自解决什么问题?llm 为什么不能是子类形态?
- 「切换 subprocess = 整个执行世界迁移」的机制根源是什么?
- 什么样的公共方法不配拥有接缝?
常见误解
- 「接缝是一个接口 + 一个实现」——三角色缺一不可;没有独立 Consumer 的「接口」只是内部抽象。
- 「切 Provider 要改调用方代码」——不需要,改补丁层即可;调用方只依赖
ctx键。 - 「e2b 模式下所有东西都在远程」——harness 进程、模型调用、会话状态始终在本地,只有执行世界迁移。
延伸阅读
- 维基:《capability-seam》、《capability-providers》
- 仓库:
docs/capability-seams.md(生成的权威服务图) - 下一课:第 09 课 · 工具执行管线与安全三旋钮