青雲的博客

第六部:能力接入——Skills、MCP和插件

能力不是硬编码进Agent的。Cordis用Service/Provider/Consumer一条"缝"组织能力,YAML五层patch叠加组合,Skills注入指令上下文,MCP工具走统一Registry,Repository Plugin带仓库级能力,Agent Preset给单个会话换整套运行时。

[图片占位:一座模块化空间站,不同舱段(Skills/MCP/Plugins/Presets)通过Cordis接口对接,蓝鲸工程师穿着宇航服在对接舱段。能力流像光束在模块间传递。色调:太空蓝+霓虹接口光。]
展开阅读路线与实验入口

你以为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等多层设计。

深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 33 章

Capability Seam 与 cordis.yml 组合

Cordis 的能力接入不是"注册一个插件"那么简单。Capability Seam 定义了 Service Definition / Provider / Consumer 三角色架构,cordis.yml 用声明式 YAML 组合替代命令式代码注册,isolate 实现多实例隔离,disabled 互斥实现平台切换。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

引言:为什么需要 Capability Seam

你可能已经注意到,DeepSeek Harness 中的 bash 执行、LLM 调用、文件系统访问都可以被替换——本地执行器换成沙箱版本、DeepSeek 适配器换成第三方多模型路由,而模型看到的工具 schema 完全不变。这种可替换性不是靠 TypeScript interface 或 IoC 容器实现的;它靠的是一个叫做 Capability Seam 的三角架构,加上 cordis.yml 声明式组合来完成运行时装配。

下面我们把这套机制拆开:三个角色怎样在包层面分离,Cordis 怎样靠 Service 基类和 inject 做依赖解析,以及 cordis.ymlid / patch / insert / isolate 这些声明到底怎样落地成运行时组合。


第一部分:三角色架构——Service Definition / Service Provider / Consumer

1.1 核心概念

一个 Capability Seam 不是一个接口,也不是一个包。它是一个完整的可替换能力的三角

  1. Service Definition —— 拥有 ctx.<key> 的 Cordis Service 子类和词汇类型。它定义契约(是什么),不包含实现细节。
  2. Service Provider —— 一个或多个插件,提供或注册 Service Definition 的具体实现(怎么做)。
  3. Consumer —— 模型和其他插件面向的编程接口。Consumer 通过 inject 声明依赖 service key,从不导入 Provider 的内部类型。

“seam”这个词命名的是整个三角,不是某个接口。包 packages/shell 是典型范例:

角色包名职责
Service Definition@deepseek-ai/dsh-shell拥有 ctx.shellShellExecutor 抽象类、词汇类型
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 的公共类型(ShellExecutorShellExecRequest),而 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) 做了三件事:

  1. 在当前 isolation scope 的 store 中注册 name → self
  2. 唤醒所有因 inject: ['name'] 而挂起的 fiber。
  3. 在 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 表达式

configdisabled 字段中的 !!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 的语义规则:

  1. id 定位name 字段是断言(assert)——如果 base 中该 id 的 name 不匹配,patch 跳过并发出警告。
  2. config 是整体替换:不是 deep merge。如果 base 行有 5 个 config key 你只想改 1 个,你必须重述所有 5 个。
  3. insert 追加新行:在 base 数组末尾添加。
  4. 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 解决两类问题:

  1. 多会话隔离:每个 agent session 在自己的 isolate realm 中获得独立的 ctx.shellctx.fs,互不干扰。
  2. 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.shelldsh-pwsh-sandbox 提供,Consumer 是 dsh-tool-pwsh;在 POSIX 上,ctx.shelldsh-bash-sandbox 提供,Consumer 是 dsh-tool-bash。Service Definition(dsh-shellShellExecutor)两者共享。

5.2 Settings Namespace 归属

你可能好奇:如果 bash 和 pwsh 是两个不同的 Provider,settings namespace 该谁来声明?

答案是:Service Definition 拥有 settings namespaceSHELL_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 合法性idnamegroupinjectinterceptisolate 必须是 static 值(不能是 !!js 表达式,因为只有 configdisabled 被 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-envctx.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),现在要切换到沙箱执行器。步骤:

  1. 写一个 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
  1. 不需要改 Consumerdsh-tool-bash 声明 inject: ['shell'],它不关心 ctx.shell 到底是 dsh-bash-local 还是 dsh-bash-sandbox 提供的——两者都是 ShellExecutor 的子类。

  2. 不需要改 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)。新适配器只需:

  1. 实现一个包,注册到 ctx.llm 的路由表。
  2. 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 叠加行为。


第十一部分:收口——五条硬规则

  1. 一个 Seam 是三角:Service Definition / Service Provider / Consumer。不要把单独一个角色叫做 “seam”。

  2. cordis.yml 是声明式的:行顺序不承载语义。加载由 inject 依赖关系驱动。

  3. Patch 替换整个 config:不是 merge。重述所有 key。

  4. !!js 只对 configdisabled 有效:metadata 字段(id/name/group/inject/isolate/intercept)必须是 static。

  5. Provider 替换不影响 Consumer:这是整个架构的核心价值。如果你需要改 Consumer 才能换 Provider,说明你的 Seam 边界画错了。


延伸阅读

  • 词汇表中的 Capability Seam 条目是规范定义。
  • packages/shell/ 目录是参考模板:shell/(Definition)、bash-local/ + bash-sandbox/(Providers)、tool-bash/(Consumer)。
  • scripts/verify-cordis-config.ts 在本地验证你的自定义组合。