Skip to content
My Blog
Go back

第 08 课 · 能力接缝:三角色与两种形态

前置:第 02、07 课 | 预计时长:60 分钟 | 动手环节:必须完成

你将学会

问题引入

前七课你理解了骨架、事件、主干、组合。现在看扩展模型的核心抽象: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-localterminal-bashlsp-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.cwdsandbox-policy.workspaceRoot 必须指向同一远程目录。

8.4 十大能力域速览

(逐域细节见维基 《capability-providers》,这里给课程用的骨架表)

形态关键接口代表后端 → 消费者
llm注册表LlmAdapter.stream(options): AsyncIterable<StreamChunk>deepseek-official / pi-ai / replay → agent-loop
shell子类resolve(request)→specrunstartbash-local / pwsh-local → tool-bash
subprocess子类进程树管理,terminate 是唯一终止动词subprocess-local(win32 有 win32-process)
fs子类resolve/readText/writeText…fs-local / fs-e2b → tool-fs
subagent注册表SubagentRuntime.registerProviderspawn/fork-in-process、acp、codex、claude-code、dsh-sdk → tool-subagent
terminal注册表TerminalSessionService.registerBackendterminal-bash → tool-terminal
lsp注册表恰好 4 个归一化操作(definition/references/implementation/hover)lsp-stdio → tool-lsp
web注册表registerSearchProvider / registerFetchProviderexa/perplexity/deepseek + http → tool-web
workflow子类(单引擎)WorkflowEngine.start(request): WorkflowRunworker-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 拆分——它是全仓库包边界默认值的样板。

动手环节

  1. 填三角色表:从 packages/ 里挑 shell、web、terminal 三个域,各自找出 Definition / Provider / Consumer 三个包名与关键类名,与 8.4 表核对。
  2. 追踪孵化链:从 tool-bash 出发,沿 inject 链追到 ctx.subprocess,列出途经的每一跳(包名 + 键名)。
  3. 推演题:把 subprocess-local 换成 subprocess-e2b 后,下列哪些组件的行为会改变、哪些不会?为什么?——a) 模型流式输出 b) bash -c 命令的执行位置 c) 会话 JSONL 的写入位置 d) PTY 终端。

自检清单

常见误解

延伸阅读


Share this post:

Previous Post
第 07 课 · Profile 与 Bundle:组合即配置
Next Post
第 09 课 · 工具执行管线与安全三旋钮