第六部:能力接入——Skills、MCP和插件
能力不是硬编码进Agent的。Cordis用Service/Provider/Consumer一条"缝"组织能力,YAML五层patch叠加组合,Skills注入指令上下文,MCP工具走统一Registry,Repository Plugin带仓库级能力,Agent Preset给单个会话换整套运行时。
展开阅读路线与实验入口
你以为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是”给模型调用的操作”,两者进入上下文的路径完全不同。
所有证据来自固定commit的源码。实验命令只读不写,用$DSH_SOURCE_DIR指向官方checkout。
本节源码依据(1 处)
从Cordis最核心的Capability Seam开始。你会发现,一个看似普通的依赖注入容器,背后有强制显式依赖声明、服务隔离、cleanup-aware effect等多层设计。
能力接入的接缝:Service、Provider 与 Consumer
一个工具函数想执行 shell 命令,它怎么拿到"能跑命令的东西"?答案是 ctx.shell——编译时它是抽象类 ShellExecutor 的类型,运行时才是某个具体子类。这套"抽象 Service 定义契约 + declare module 挂载类型 + static inject 声明依赖"的三件套,就是 DeepSeek Harness 全部能力接入的接缝:一个 context 只许一个实现,加载第二个就抛。
Written in Chinese
第 0 秒,一个工具函数要执行一条 shell 命令。它怎么拿到”能跑命令的东西”?如果它 import 某个具体的 shell 实现,那换沙箱实现就要改工具代码——能力和工具就焊死了。DeepSeek Harness 的答案是:它只写 this.ctx.shell.execute(...)。编译时 ctx.shell 的类型是抽象类 ShellExecutor,运行时这个属性上挂着哪个子类,由加载了哪个插件决定。工具不认识实现,实现不认识工具,它们只共同认识 Context 上的一个名字。
这是第六部的开篇。第五部讲的是”谁在掌舵”(Goal、多 Agent、拆除);第六部讲”能力从哪接进来”——Skills、MCP、插件,全部走同一套接缝。这一章先把接缝本身讲透。
三件套之一:抽象 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.shell | ShellExecutor | 'shell' | 沙箱 Bash 插件(见 cordis.patch.yml) |
ctx.workflowEngine | WorkflowEngine | 'workflowEngine' | PtcWorkflowEngine |
ctx.ptcRuntime | PtcRuntime | — | NodePtcRuntime(language=typescript, isolation=process) |
ctx.sandboxPolicy | SandboxPolicyService | — | 默认模式随配置注入 |
ctx.agents | AgentRegistry | '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 叠成一份最终接线。