第六部:能力接入——Skills、MCP和插件
能力不是硬编码进Agent的。Cordis用Service/Provider/Consumer一条"缝"组织能力,YAML五层patch叠加组合,Skills注入指令上下文,MCP工具走统一Registry,Repository Plugin带仓库级能力,Agent Preset给单个会话换整套运行时。
![[图片占位:一座模块化空间站,不同舱段(Skills/MCP/Plugins/Presets)通过Cordis接口对接,蓝鲸工程师穿着宇航服在对接舱段。能力流像光束在模块间传递。色调:太空蓝+霓虹接口光。]](/static/images/handbook/deepseek-harness-internals/parts/06-capability-composition.png)
展开阅读路线与实验入口
你以为Agent的能力是写死在代码里的——工具列表在初始化时注册,Skills是某个目录下被自动加载的文件,MCP服务器连上就能用。错。
在DeepSeek Harness里,能力是一条”缝”。Cordis把Service、Provider、Consumer缝在一起,YAML通过patch叠加组合能力,Skills注入的是指令上下文而不是可调用操作,MCP工具经过JSON Schema映射才进入Registry,仓库级插件通过.dsh/目录自动发现,Agent Preset让单个会话在不重启进程的情况下换一整套运行时。
没有任何能力是硬编码依赖的,全部通过组合声明。
这一部解决什么
读完这六章,你能回答六个关键问题:
- Cordis的Context为什么是Proxy,为什么get一个没inject的service直接throw
- isolate(name)怎么创建子Context实现服务隔离,labels join REALMS是什么意思
- root cordis.yml为什么每次启动被覆写为空数组[],真正的组合来自哪五层patch
- Skills和Tools有什么本质区别,Skills是怎么发现、装载并进入模型上下文的
- MCP工具为什么不是连上就自动可用,需要经过哪些转换才进入ToolRuntime
- Agent Preset怎么做到给单个会话换一套persona和工具集而不影响其他会话
你可能在别的框架里见过”插件系统”——一个register方法加一个enable/disable开关。Harness不是这样做的。Cordis的Capability Seam强制显式声明依赖,Provider和Consumer解耦,effect管理cleanup-aware disposer,HMR通过Fiber dispose/reload实现热替换。Skills是”给模型看的指令”,Tools是”给模型调用的操作”,两者进入上下文的路径完全不同。
accTitle: 第六部阅读路径
accDescr: Capability seam 是能力注入的边界,cordis.yml 驱动组合,Skills 自动发现注入,MCP tool 注册为运行时工具,Repository/Plugin 支持自修改,Agent preset 封装预设配置
accDescription: 第六部阅读路径流程图,从 Capability Seam(Service/Provider/Consumer/Proxy)开始,经过 cordis.yml 组合(五层patch叠加+isolate realm)、Skills发现与注入、MCP工具进Registry、Repository Plugin,最后到 Agent Preset(单会话换整套运行时)。
flowchart TD
A["Capability Seam\nService/Provider/Consumer/Proxy"] --> B["cordis.yml组合\n五层patch叠加+isolate realm"]
B --> C["Skills发现与注入\n指令上下文≠工具"]
C --> D["MCP工具进Registry\nJSON Schema映射+生命周期绑定"]
D --> E["Repository Plugin\n仓库级能力自动挂载+Self-mod边界"]
E --> F["Agent Preset\n单会话换整套运行时+standing mount"]
style A fill:#1a3a5c,stroke:#d4af37,color:#fff
style F fill:#1a3a5c,stroke:#d4af37,color:#fff
所有证据来自固定commit的源码。实验命令只读不写,用$DSH_SOURCE_DIR指向官方checkout。
从Cordis最核心的Capability Seam开始。你会发现,一个看似普通的依赖注入容器,背后有强制显式依赖声明、服务隔离、cleanup-aware effect等多层设计。
Capability Seam 与 cordis.yml 组合
Cordis 的能力接入不是"注册一个插件"那么简单。Capability Seam 定义了 Service Definition / Provider / Consumer 三角色架构,cordis.yml 用声明式 YAML 组合替代命令式代码注册,isolate 实现多实例隔离,disabled 互斥实现平台切换。
引言:为什么需要 Capability Seam
你可能已经注意到,DeepSeek Harness 中的 bash 执行、LLM 调用、文件系统访问都可以被替换——本地执行器换成沙箱版本、DeepSeek 适配器换成第三方多模型路由,而模型看到的工具 schema 完全不变。这种可替换性不是靠 TypeScript interface 或 IoC 容器实现的;它靠的是一个叫做 Capability Seam 的三角架构,加上 cordis.yml 声明式组合来完成运行时装配。
下面我们把这套机制拆开:三个角色怎样在包层面分离,Cordis 怎样靠 Service 基类和 inject 做依赖解析,以及 cordis.yml 里 id / patch / insert / isolate 这些声明到底怎样落地成运行时组合。
第一部分:三角色架构——Service Definition / Service Provider / Consumer
1.1 核心概念
一个 Capability Seam 不是一个接口,也不是一个包。它是一个完整的可替换能力的三角:
- Service Definition —— 拥有
ctx.<key>的 CordisService子类和词汇类型。它定义契约(是什么),不包含实现细节。 - Service Provider —— 一个或多个插件,提供或注册 Service Definition 的具体实现(怎么做)。
- Consumer —— 模型和其他插件面向的编程接口。Consumer 通过
inject声明依赖 service key,从不导入 Provider 的内部类型。
“seam”这个词命名的是整个三角,不是某个接口。包 packages/shell 是典型范例:
| 角色 | 包名 | 职责 |
|---|---|---|
| Service Definition | @deepseek-ai/dsh-shell | 拥有 ctx.shell、ShellExecutor 抽象类、词汇类型 |
| Service Provider | @deepseek-ai/dsh-bash-local / dsh-bash-sandbox / dsh-pwsh-local | 具体的执行器实现 |
| Consumer | @deepseek-ai/dsh-tool-bash / dsh-tool-pwsh | 模型面向的工具 schema 和调用桥接 |
1.2 Service Definition 的代码形态
Service Definition 的核心是一个继承自 Service 的抽象类,在构造时自动通过 super(ctx, 'shell') 注册到指定名称:
// packages/shell/shell/src/index.ts (简化)
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) {
super(ctx, 'shell') // ← 注册 ctx.shell = this
}
abstract resolve(request: ShellExecRequest): ShellExecSpec
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
abstract start(spec: ShellExecSpec): ShellProcess
}
关键点:
declare module '@deepseek-ai/cordis' { interface Context { shell: ShellExecutor } }在 TypeScript 层为所有 Consumer 提供类型。SHELL_SETTINGS_NAMESPACE由 Service Definition 拥有(而非 Provider),因为它命名的是能力本身,不是某个实现。
1.3 Service Provider 的代码形态
Provider 是一个 Cordis 插件,它子类化(或组合)Service Definition 并注册具体实现。dsh-bash-local 的开头展示了典型模式:
// packages/shell/bash-local/src/index.ts (简化)
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
// 继承 ShellExecutor,构造时 super(ctx, 'shell') 自动注册
// 实现 resolve / run / start
注意:Provider 导入的是 Service Definition 的公共类型(ShellExecutor、ShellExecRequest),而 Consumer 反过来也只导入这些类型——双方都不导入对方。
1.4 Consumer 的代码形态
Consumer 是注册工具 schema 的插件。它通过 inject: ['shell'] 声明对 ctx.shell 的依赖,其 fiber 在 service 可用前挂起:
// packages/shell/tool-bash/src/index.ts (概念模型)
export const inject = ['shell', 'tools']
export function apply(ctx: Context) {
ctx.tools.register('bash', {
execute: (args) => ctx.shell.run(ctx.shell.resolve({ command: args.command }))
})
}
Consumer 永远不 import Provider 的具体类——它只通过 ctx.shell 编程,所以换一个 Provider(本地换沙箱、POSIX 换 PowerShell)对 Consumer 零影响。
第二部分:Cordis Service 基类——自动注册的机制
2.1 构造即注册
Service 基类的构造函数是整个 DI 的入口:
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
let self = this
// ... callable service handling ...
self.ctx = ctx
self.name = name
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}
ctx.reflect.provide(name, self, check) 做了三件事:
- 在当前 isolation scope 的 store 中注册
name → self。 - 唤醒所有因
inject: ['name']而挂起的 fiber。 - 在 owning fiber 卸载时自动反注册(waking dependents)。
这意味着你永远不需要手动注册或注销 service——生命周期完全由 fiber 的 load/unload 驱动。
2.2 inject 驱动的隐式等待
当一个插件声明 inject: ['shell'],Cordis Loader 创建其 fiber 时会检查依赖:如果 ctx.shell 尚未 provide,fiber 进入 pending 状态。一旦某个 Provider 的 super(ctx, 'shell') 执行,pending 的 fiber 立即被激活。
这解释了为什么 cordis.yml 中的行顺序不承载语义——加载顺序由 service availability 驱动,不是配置文件中的位置。
2.3 Context Proxy 的 inject 守卫
Cordis 的 Context 构造返回 new Proxy(this, ReflectService.handler),其 get 陷阱对非内置属性执行 inject 检查:如果当前 fiber 没有声明对该 service 的依赖,直接抛出 "cannot get property without inject" 而不是返回 undefined。这是一个编译时(类型级)和运行时双重的显式依赖强制。
第三部分:cordis.yml——声明式组合
3.1 文件结构
cordis.yml 是一个 Loader Entry 数组。每个 entry 是一个对象,核心字段:
| 字段 | 作用 |
|---|---|
id | 行标识符,patch 靠它定位目标 |
name | 包名或相对路径,Loader 解析为插件 |
config | 传给插件的配置,支持 !!js 表达式 |
disabled | 布尔值或 !!js 表达式,控制是否加载 |
group | 分组标签 |
inject | 额外的 inject 声明 |
isolate | 服务隔离声明 |
intercept | 拦截配置 |
一个典型的最小组合(SDK bundled config):
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
3.2 !!js 表达式
config 和 disabled 字段中的 !!js 标量是 YAML 自定义 tag。Loader 在插件上下文就绪后(所有 inject 满足后)对 config 表达式求值,而 disabled 在每次挂载决策时对 Loader 上下文求值。
这意味着:
- 环境变量解析(
process.env.DSH_CWD)发生在运行时,不是构建时。 - 条件平台选择(
process.platform === 'win32')可以在同一个配置文件中声明所有平台的行,通过disabled选择性激活。
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'
3.3 cordis-plugin-include 与 Patch 机制
当一个组合需要基于另一个组合做增量修改时,使用 @deepseek-ai/cordis-plugin-include:
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
# 按 id 定位并禁用一行
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
# 按 id 定位并替换 config(整体替换,非合并)
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
model: deepseek-v4-flash
persistenceCompression: none
# 在末尾插入新行
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
Patch 的语义规则:
- 按
id定位:name字段是断言(assert)——如果 base 中该 id 的 name 不匹配,patch 跳过并发出警告。 - config 是整体替换:不是 deep merge。如果 base 行有 5 个 config key 你只想改 1 个,你必须重述所有 5 个。
insert追加新行:在 base 数组末尾添加。disabled: true禁用行:等效于从组合中移除。
3.4 Bundle Patch 层叠
生产部署使用多层 patch:
packages/bundle/base/cordis.patch.yml ← 所有 profile 共享的基座
packages/bundle/web-app/cordis.patch.yml ← Web 模式叠加层
packages/bundle/headless/cordis.patch.yml ← Headless 模式叠加层
~/.dsh/cordis.patch.yml ← 用户自定义最后一层
每层都是 patch 格式,只声明与前一层的差异。Base 使用一个巨大的 - insert: 声明所有共享行;Web 层通过 - id: xxx 覆盖特定行的 config:
# packages/bundle/web-app/cordis.patch.yml (片段)
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model.
Your working directory is {{cwd}}.
- id: hmr
disabled: true
第四部分:isolate——服务隔离与多实例
4.1 机制
ctx.isolate(name, label?) 创建一个子 Context,其 [symbols.isolate] map 中 name 映射到一个新 Symbol(或指定的 label Symbol)。之后在该子树中 provide/get 这个 service 时,使用这个 Symbol 作为 store key——与父级的同名 service 完全隔离。
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
4.2 用途:为什么需要隔离
在 Harness 中,isolate 解决两类问题:
- 多会话隔离:每个 agent session 在自己的 isolate realm 中获得独立的
ctx.shell、ctx.fs,互不干扰。 - Host/Preset 分离:host 进程级的 service(如
shell-env)和 session preset 中的同名 service 需要隔离,否则 preset 行会覆盖 host 的 registration。
4.3 cordis.yml 中声明 isolate
- id: my-plugin
name: '@deepseek-ai/dsh-my-plugin'
isolate:
shell: my-realm-label
Loader 看到 isolate 字段后,在加载该插件前先调用 ctx.isolate('shell', Symbol.for('my-realm-label')),使其子树中的 ctx.shell 解析到独立 realm。
4.4 “labels join REALMS” 规则
如果两个 isolate() 调用传入相同的 label,它们共享同一个 realm。这是有意为之——当你想让两个插件看到同一个隔离的 service 实例时,用相同 label。如果在同一个 realm 中尝试 provide 同名 service 两次,Cordis 直接抛出 duplicate-service 错误,因为相同 label 意味着你主动选择了共享,冲突就是配置错误。
第五部分:平台切换——同一份 cordis.yml 覆盖所有平台
5.1 disabled 驱动的平台选择
Base bundle 为 POSIX 和 Windows 各声明一行,通过 disabled 互斥:
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
注意 Provider(bash-sandbox / pwsh-sandbox)和 Consumer(tool-bash / tool-pwsh)各自有独立的 disabled 行。这正是 Seam 架构的体现:Provider 和 Consumer 独立切换。在 Windows 上,ctx.shell 由 dsh-pwsh-sandbox 提供,Consumer 是 dsh-tool-pwsh;在 POSIX 上,ctx.shell 由 dsh-bash-sandbox 提供,Consumer 是 dsh-tool-bash。Service Definition(dsh-shell、ShellExecutor)两者共享。
5.2 Settings Namespace 归属
你可能好奇:如果 bash 和 pwsh 是两个不同的 Provider,settings namespace 该谁来声明?
答案是:Service Definition 拥有 settings namespace。SHELL_SETTINGS_NAMESPACE 在 @deepseek-ai/dsh-shell 中定义,而非某个 Provider。理由是同一主机上只有一个 Provider 被加载,而用户的 settings 文档可能跨平台迁移——它需要一个平台无关的 namespace。
第六部分:Validate Cordis Config——静态保障
6.1 验证脚本
scripts/verify-cordis-config.ts 在 CI 中运行,对所有 cordis.yml / cordis.patch.yml 执行静态检查:
它检查的内容包括:
- Entry metadata 合法性:
id、name、group、inject、intercept、isolate必须是 static 值(不能是!!js表达式,因为只有config和disabled被 Loader 动态求值)。 - 包解析:每个
name必须能从其所属 workspace 的package.json解析到实际包。 - Preset/Host 平面分离:shipped preset 不能重复 host 平面已 active 的行。违规意味着同一 service 被双重注册——一次在进程级,一次在 session 级——根据 service 类型可能导致静默覆盖或 throw。
6.2 平面分离为什么重要
Harness 的 Web 模式有两个平面:
- Host 平面:进程级,所有 session 共享。
- Preset 平面:session 级,每个新 session 通过 isolate 获得独立实例。
如果一个 row(比如 shell-env)同时出现在两个平面,它会在 host 注册一次、在每个 session 再注册一次。shell-env 向 ctx.shell 注入环境变量——在 isolate realm 中它只到达 session 的 executor,host 的共享 executor 收不到;或者 tool-subagent-report 向每个 session 注册一次 report 工具,第二个 session 创建时就会因为 duplicate tool 而 throw。
验证脚本通过遍历 host + overlay 行集合、对照 preset 行集合来阻止这类问题。
第七部分:实战——替换一个 Provider
7.1 场景:从 bash-local 切换到 bash-sandbox
假设你有一份 SDK 用的最小 cordis.yml(使用 dsh-bash-local),现在要切换到沙箱执行器。步骤:
- 写一个 overlay cordis.yml:
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- id: bash
name: '@deepseek-ai/dsh-bash-local'
disabled: true
- insert:
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: workspace-write
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
config:
timeoutMs: 60000
-
不需要改 Consumer:
dsh-tool-bash声明inject: ['shell'],它不关心ctx.shell到底是dsh-bash-local还是dsh-bash-sandbox提供的——两者都是ShellExecutor的子类。 -
不需要改 Service Definition:
@deepseek-ai/dsh-shell包本身不变。
这就是 Seam 架构的价值:Provider 和 Consumer 独立版本化和部署。
7.2 场景:添加一个全新的 LLM 适配器
LLM seam 略有不同——Service Definition 和 Consumer 折叠在 @deepseek-ai/dsh-llm 中(因为 Consumer 是 agent loop 自身,不是可替换的工具 schema)。新适配器只需:
- 实现一个包,注册到
ctx.llm的路由表。 - 在
cordis.yml中添加一行:
- id: llm-new-provider
name: '@deepseek-ai/dsh-llm-new-provider'
无需触碰 loop 代码或已有的 DeepSeek 适配器行。
第八部分:Service 构造中的可调用服务
8.1 [Service.invoke] 模式
某些 service 不仅是对象,还是函数。ctx.logger('agent-loop') 就是一个例子——ctx.logger 本身既是 LoggerService 实例,又可以直接调用。
Service 构造函数中有一段关键逻辑:
if (self[symbols.invoke]) {
self = createCallable(name, joinPrototype(
Object.getPrototypeOf(this), Function.prototype
), tracker)
}
当 service 声明了 [Service.invoke] 方法,构造器返回的不是普通对象而是一个同时是 Function 又继承了原型链方法的 callable。这让 ctx.logger 既能 ctx.logger('name') 调用,又能 ctx.logger.warn(...) 访问实例方法。
8.2 [Service.filter] 与 isolate
protected [symbols.filter](ctx: Context) {
return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}
这是 service 的事件过滤器:当事件分发时,只有 isolation realm 匹配的 listener 才会收到通知。这保证了 session A 的 shell 事件不会泄漏到 session B。
第九部分:Fiber 生命周期与 Service 的关系
9.1 Fiber = 插件运行时实例
每个 cordis.yml 中的行在 Loader 处理后成为一个 Fiber。Fiber 的生命周期:
pending → active → (reload?) → disposing → disposed
- pending:inject 依赖尚未满足。
- active:所有 inject 满足,
apply()已执行。 - disposing:owning fiber 卸载或 HMR 触发。
9.2 Service 注册与 Fiber 绑定
ctx.reflect.provide(name, self, check) 注册的 service 在 fiber 进入 disposing 时自动反注册。这意味着:
- Provider fiber 卸载 → service 消失 → 所有依赖该 service 的 Consumer fiber 自动挂起(回到 pending)。
- 新 Provider fiber 加载 → service 重新出现 → Consumer fiber 自动恢复。
这就是 HMR 的基础:cordis-plugin-hmr 监听文件变化,重新加载 Provider fiber,Consumer 自动重连。整个过程不需要重启进程。
9.3 Effect 与 LIFO 清理
ctx.effect(execute, label) 注册的 disposer 在 Fiber dispose 时以 reverse order(LIFO) 执行。这确保后申请的资源先释放——比如先关闭子进程句柄,再关闭 IPC 通道。
第十部分:完整的组合示例分析
让我们追踪生产级 ACP agent 的 cordis.yml(约 140 行),看 Seam 如何在实践中运作:
# LLM seam: Service Definition + Provider 折叠在 dsh-llm + dsh-llm-deepseek
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
thinking: enabled
reasoningEffort: max
# Shell seam: subprocess Provider → bash Provider → tool Consumer
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
config:
timeoutMs: 60000
# FS seam: fs Provider → observation policy → tool Consumer
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
- id: fs-observation-policy
name: '@deepseek-ai/dsh-fs-observation-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
每一组三行就是一个 Seam 实例化:一个 Provider(注册 ctx.<service>)+ 一个 Consumer(inject 该 service 并注册 tool)。中间可能有策略层(如 fs-observation-policy)作为装饰器 service 叠加行为。
第十一部分:收口——五条硬规则
-
一个 Seam 是三角:Service Definition / Service Provider / Consumer。不要把单独一个角色叫做 “seam”。
-
cordis.yml 是声明式的:行顺序不承载语义。加载由 inject 依赖关系驱动。
-
Patch 替换整个 config:不是 merge。重述所有 key。
-
!!js只对config和disabled有效:metadata 字段(id/name/group/inject/isolate/intercept)必须是 static。 -
Provider 替换不影响 Consumer:这是整个架构的核心价值。如果你需要改 Consumer 才能换 Provider,说明你的 Seam 边界画错了。
延伸阅读
- 词汇表中的 Capability Seam 条目是规范定义。
packages/shell/目录是参考模板:shell/(Definition)、bash-local/+bash-sandbox/(Providers)、tool-bash/(Consumer)。- 用
scripts/verify-cordis-config.ts在本地验证你的自定义组合。