第六部:能力接入——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是”给模型调用的操作”,两者进入上下文的路径完全不同。

Capability seam 是能力注入的边界,cordis.yml 驱动组合,Skills 自动发现注入,MCP tool 注册为运行时工具,Repository/Plugin 支持自修改,Agent preset 封装预设配置

所有证据来自固定commit的源码。实验命令只读不写,用$DSH_SOURCE_DIR指向官方checkout。

本节源码依据(1 处)

从Cordis最核心的Capability Seam开始。你会发现,一个看似普通的依赖注入容器,背后有强制显式依赖声明、服务隔离、cleanup-aware effect等多层设计。

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

能力接入的接缝:Service、Provider 与 Consumer

一个工具函数想执行 shell 命令,它怎么拿到"能跑命令的东西"?答案是 ctx.shell——编译时它是抽象类 ShellExecutor 的类型,运行时才是某个具体子类。这套"抽象 Service 定义契约 + declare module 挂载类型 + static inject 声明依赖"的三件套,就是 DeepSeek Harness 全部能力接入的接缝:一个 context 只许一个实现,加载第二个就抛。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,一个工具函数要执行一条 shell 命令。它怎么拿到”能跑命令的东西”?如果它 import 某个具体的 shell 实现,那换沙箱实现就要改工具代码——能力和工具就焊死了。DeepSeek Harness 的答案是:它只写 this.ctx.shell.execute(...)。编译时 ctx.shell 的类型是抽象类 ShellExecutor,运行时这个属性上挂着哪个子类,由加载了哪个插件决定。工具不认识实现,实现不认识工具,它们只共同认识 Context 上的一个名字。

这是第六部的开篇。第五部讲的是”谁在掌舵”(Goal、多 Agent、拆除);第六部讲”能力从哪接进来”——Skills、MCP、插件,全部走同一套接缝。这一章先把接缝本身讲透。

能力接缝三层:① 类型挂载——declare module '@deepseek-ai/cordis' 给 Context 接口加属性(ctx.shell / ctx.workflowEngine / ctx.ptcRuntime / ctx.sandboxPolicy / ctx.agents);② 抽象定义——abstract class extends Service,super(ctx,'shell'),一个 context 只许一个实现;③ 具体插件——static inject 数组声明依赖,构造里再校验 language;④ 边界即生命周期——进程归 ctx.subprocess 管,executor 重载不杀进程。

三件套之一:抽象 Service 定义契约

看 ShellExecutor 的完整定义骨架:

export abstract class ShellExecutor extends Service {
  constructor(ctx: Context) {
    super(ctx, 'shell')
  }
  abstract resolve(request: ShellExecRequest): ShellExecSpec
  abstract execute(spec: ShellExecSpec): ShellExecution
}

两个动作:extends Service 让它获得 cordis 的生命周期(fiber 归属、effect 注册、dispose 传播);super(ctx, 'shell') 把这个类以名字 'shell' 注册到 Context 上。注释把规则写得明明白白:“Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as ctx.shell (one implementation per context; loading a second throws, which is cordis’ standard duplicate-service behavior).”

注意契约里连”前台后台”的语义都定义了:“Foreground is a property of what the caller awaits, not of the spawn”——await result 就是前台,留着 handle 就是后台。抽象类不只定义方法签名,它定义整个能力的行为语义,具体实现只是填空。

WorkflowEngine 是同构的另一个样本:abstract class WorkflowEngine extends Service,super(ctx, 'workflowEngine'),只留一个 abstract start(request): WorkflowRun。契约注释同样精确:“Invalid requests throw before publication; a live run is holder-owned, its result never rejects, and disposal waits for script and child cleanup.”

本节源码依据(5 处)

三件套之二:declare module 挂载类型

抽象类定义了”是什么”,但 TypeScript 怎么知道 ctx 上有 shell 这个属性?靠的是模块增强:

declare module '@deepseek-ai/cordis' {
  interface Context {
    shell: ShellExecutor
  }
}

ptc-runtime 包挂载的是 ptcRuntime: PtcRuntime;sandbox-policy 包挂载的是 sandboxPolicy: SandboxPolicyService。每个能力包自己负责把自己的名字缝进 Context 接口——挂载点是全局的,缝内容是分包的。消费方 import 这个能力包的类型,ctx.shell 就有类型了;它不需要知道哪个子类会被装进来。

这层的意义在编译时:写工具的人拿 ctx.shell,类型检查器知道这是 ShellExecutor,调 execute 参数错了立刻报错——但运行时换实现零成本。

本节源码依据(3 处)

三件套之三:static inject 声明依赖

接缝的另一半是消费方。一个能力插件经常自己也要别的能力——PtcWorkflowEngine 要起脚本,它需要 subagents(派孩子)、ptcRuntime(执行器)、sandboxPolicy(沙箱策略)。它不自己去找,而是声明:

class PtcWorkflowEngine extends WorkflowEngine {
  static inject = ['subagents', 'ptcRuntime', 'sandboxPolicy']
  static Config = z.object({
    provider: z.string().default('spawn'),
    maxConcurrentAgents: z.natural().default(0),
    maxTotalAgents: z.natural().min(1).default(1000),
    ...
  })
}

inject 数组是 cordis 的约定:这三个服务全部就位之后,这个类的构造函数才会被调用。缺一个,整个插件加载序列就停在那儿——你不会拿到一个”半接线”的 workflow 引擎。

NodePtcRuntime 是更细的样本:inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']——Node 执行器要文件系统、子进程、沙箱四个服务。而 AgentRegistry(第五部反复出场的那个注册表)自己也是 Service:super(ctx, 'agents'),还 inject 了 typert 做类型解析注册。

本节源码依据(4 处)

能力自己也是消费者:AgentRegistry 的双重身份

接缝上的玩家常常既是 Provider 又是 Consumer——AgentRegistry 就是样本。它自己 extends Service(Provider:ctx.agents),构造里却 ctx.inject(['typert'], ...)(Consumer:等类型解析服务就位)。等到 typert 就位,它注册的不只是服务,还有一套”类型接线”:

ctx.inject(['typert'], (typeCtx) => {
  typeCtx.typert.lookups.register('agent', {
    parameter: 'agent',
    wire: 'agentId',
    hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent',
    wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
    resolve: sessionId => this.get(sessionId),
  })
  typeCtx.typert.contexts.registerHost('agent', {
    wire: 'agentId',
    resolve: sessionId => this.get(sessionId)?.ctx,
  })
})

这段注册把”协议线类型 agentId”和”运行时活 Agent”焊在一起:别人拿到一个 agentId 字符串,typert 知道该去哪里 resolve 成真实的 Agent 对象、它的 Context。接缝不止是服务挂载,还包括类型系统的跨边界翻译。

它的 setFactory 同样是缝出来的:agent-loop 插件构造时调用,把 create/resume 委托注册进来——“Throws if a factory is already registered”,和 duplicate-service 同一个防呆哲学。返回的 disposer 直接 yield 进 composite effect,保证按序拆除。

本节源码依据(2 处)

接线之后还要对号:构造里的运行时校验

inject 只保证”服务存在”,不保证”服务是对的那个”。PtcWorkflowEngine 的构造函数里补了一刀:

constructor(ctx: Context, config: Config) {
  super(ctx)
  if (ctx.ptcRuntime.language !== 'typescript') {
    throw new Error('workflow-ptc requires the Node TypeScript PTC runtime')
  }
}

ctx.ptcRuntime 可能被装成别的语言的运行器(比如 Python 的)。inject 数组认的是名字 'ptcRuntime',不认它的 language 属性。这道构造校验就是把”接线顺序对了”升级成”接的人也对”。

NodePtcRuntime 自己的构造同样密:数字配置全部 positive and finite 校验、超时项不能超过 MAX_TIMER_DELAY_MS、maxMessageBytes 必须塞进 unsigned 32 位帧长、bootstrapPath 必须是绝对路径。接缝不挡蠢——配置蠢不蠢,构造函数逐个问。

把这条校验链摊开成表:

校验项判据失败行为
数值型配置每个 number 字段 finite && > 0抛 ptc-runtime-node: <key> must be positive and finite
计时器范围timeoutMs / maxTimeoutMs / graceMs ≤ MAX_TIMER_DELAY_MS抛 exceeds the supported timer range
输出上限maxOutputBytes 是 ≥4 的安全整数抛 must be an integer of at least 4
消息帧长maxMessageBytes ≤ 0xffff_ffff抛 must fit an unsigned 32-bit frame length
启动器路径bootstrapPath 已设时必须是绝对路径抛 bootstrapPath must be absolute
运行器对号ctx.ptcRuntime.language === 'typescript'(在 PtcWorkflowEngine 侧)抛 workflow-ptc requires the Node TypeScript PTC runtime
本节源码依据(3 处)

边界即生命周期:接缝不光管”拿到谁”

接缝最容易被忽略的一半是:它还管”什么时候一起死”。ShellExecutor 的契约注释里有一句关键设计:“A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is ctx.subprocess disposal, so a process survives an executor-only reload.”

正在跑的子进程不归 executor 管,归 ctx.subprocess 这个独立接缝管。所以只重载 executor 插件(换个实现),正在跑的进程不死——进程和实现之间隔着一层。

fiber unload 时的统一收尾则由 effect 注册:NodePtcRuntime 在构造里挂了一个 ctx.effect,卸载时 disposed = true、遍历 live 集合逐个 controller.abort('runtime disposed')、再 Promise.all 等全部结束。能力插件的死法和第 36 章讲的拆除纪律是同一套:先 abort,再等安静,最后除名。

WorkflowEngine 的事件分发也体现了接缝纪律——emitWorkflowEvent 对每个 listener 的失败 contained(try/catch + warn),“one listener throwing must not silence the workflow/end for everyone else”。

本节源码依据(3 处)

踩坑清单:接缝上最常误判的三件事

坑一:以为 ctx.shell 是全局单例。 不是。它是 per-context 的——插件挂在哪个 fiber 上,就属于哪个 fiber。子 context 要重新接线(或者通过继承拿到父的实现)。AgentRegistry 注册 closeInitiators 监听 fiber UNLOADING 就是这个语义的另一面:fiber 是能力的定义域,不是全局。

坑二:以为重载 executor 会杀掉正在跑的进程。 不会。进程归 ctx.subprocess 接缝管(见上一节 ShellExecutor 契约注释)。重载 ShellExecutor 只换实现,子进程继续跑——这是故意的,热更新不能变成误杀。

坑三:以为 inject 顺序无所谓、缺依赖也能加载。 缺依赖,Consumer 的构造函数根本不会被调用——cordis 把插件留在未就绪队列里。表现出来的现象是”功能没生效但没报错”,这正是 PtcWorkflowEngine 构造里还要补一道 language 校验的原因:接缝的错误是沉默的,构造函数要把它喊出来。

这一章的能力地图

把第六部要用到的接缝一次性摆开:

Context 属性抽象类注册名本仓样本
ctx.shellShellExecutor'shell'沙箱 Bash 插件(见 cordis.patch.yml)
ctx.workflowEngineWorkflowEngine'workflowEngine'PtcWorkflowEngine
ctx.ptcRuntimePtcRuntime—NodePtcRuntime(language=typescript, isolation=process)
ctx.sandboxPolicySandboxPolicyService—默认模式随配置注入
ctx.agentsAgentRegistry'agents'agent-loop 包注册

下一章就回答:这张地图上的名字和实现,是怎么从一个 cordis.yml 文件里被接起来的。

小结

  • 三件套:abstract Service 定义契约 → declare module 挂载类型 → static inject 声明依赖。
  • 一个名字一个实现:重复加载即抛 duplicate-service——这不是 bug,是接缝的防呆。
  • 消费方只 import 类型:工具和实现之间隔着 Context,换实现不改工具。
  • 接线对了还要对号:构造函数校验 language、配置逐项问。
  • 边界即生命周期:进程归 ctx.subprocess,executor 重载不杀进程;fiber unload 时 effect 统一收尾。

下一章:第 38 章——五层 patch 怎么把 bundle、profile、home、overlays、telemetry 叠成一份最终接线。