当你在 DeepSeek Harness 里第一次看到 Service Definition、Service Provider、Consumer 这三个词时,很可能会把它们当成一套抽象的架构术语,觉得跟实际写代码没什么关系。但只要你动手给 Harness 接入一个新能力,比如「把一段文本转成大写」这种简单到一眼看懂的功能,你就会发现:真正决定这套系统能不能长期演进的,不是能力本身有多强,而是这项能力有没有被切出清晰的缝(seam)。这里的「缝」指的不是代码缺陷,而是刻意留下的可替换接缝——它让接口、实现、面向模型的工具三者各自独立,换掉任何一环都不牵动另外两个。这篇文章面向已经用惯了 Harness、想深入理解其扩展机制的进阶读者,我们会先把能力和缝的关系讲透,再带着你从零写出一个真正可替换的能力 myCap,把 Definition / Provider / Consumer 三个包的边界、注册时机、类型声明合并、请求结果解耦、工具包装和依赖注入逐层拆开,让你亲手给 Harness 长出一个可以随时替换、独立演进的新器官。
Definition / Provider / Consumer:一个能力为什么必须拆成三个包
在官方文档里,这三个词经常会一起出现,但很少有人先讲清楚它们各自负责什么。先给一个最朴素的判断:三者合在一起,才构成一项完整的能力,也就是一个 seam;任何一个单一角色都不是 seam。 这句话是整个扩展体系的地基。Definition 只是接口和类型,Provider 只是实现,Consumer 只是给模型看的工具外壳——任何单独一个角色拉出来,都不能说「我实现了一项能力」。只有三者拼起来、各就其位,能力才算真正存在于 Harness 之中。
我把三个角色的职责边界重新梳理一遍,避免混淆:
- Service Definition:负责定义 Cordis 服务,以及请求 Request 和结果 Result 的类型。它只声明「有什么能力、长什么样」,完全不关心怎么实现。以 Bash 为例,Definition 是 dsh-shell 包,注册为 ctx.shell,并定义了 ShellExecRequest 与 ShellRunResult 两个类型。
- Service Provider:真正实现该能力,通常针对一种运行环境。它继承 Definition 的抽象类,填上具体行为。Bash 的 Provider 是 dsh-bash-local,在本地计算机上执行命令;同一份 Definition 还有 dsh-bash-sandbox(在沙箱里执行)和 dsh-pwsh-local(执行 PowerShell)等其它 Provider。
- Consumer:面向模型的工具,把能力公开为模型可调用的工具。它把能力包装成工具 schema,让模型能够调用,但自己并不干活。Bash 的 Consumer 是 dsh-tool-bash,把 ctx.shell 包装成模型可调用的 bash 工具。
官方参考文档里用一张表维护了 ctx.shell 的三角色归属,非常值得记住,因为它把「概念」落成了「CTX 键 + 包名」的具体映射:
| ctx 键 | 角色 | 所属包(Definition) | 实现(Provider) | 直接消费方(Consumer) |
|---|---|---|---|---|
| ctx.shell | seam | shell | bash-local / bash-sandbox / pwsh-local | tool-bash / tool-pwsh / hooks-claude-code / hooks-codex |
这张表里藏着一个容易被忽略的事实:消费方不只有 tool-bash。 还有 tool-pwsh、hooks-claude-code、hooks-codex——两个钩子桥接插件也算 Consumer。它们和 tool-bash 一样,只认 ctx.shell 这个接口,根本不关心背后跑的是本地执行器、沙箱执行器还是 PowerShell。这就是 seam 的价值:消费端与实现端之间没有任何直接依赖,唯一的中介是 Definition。
再把这个关系图看细一点,它的结构非常漂亮:Definition 在中间,Provider 与 Consumer 都只依赖它。Provider 继承实现 Definition,Consumer 通过 inject: ['shell'] 依赖它;而 Provider 与 Consumer 之间没有任何依赖。这意味着换 Provider 时,Definition 和 Consumer 一行都不用改。想从本地执行换成沙箱执行,只要在 cordis.yml 里替换一行加载项即可,工具层的代码连碰都不用碰。
但这里有个判断标准必须讲清楚:不是什么能力都要拆成三个包。 三个角色可以放在同一个包里,也可以拆进不同包,判断标准只有一个——这些角色是否需要独立演进或替换。如果一个能力只会有一种实现、永远不需要换执行器,那么把 Definition、Provider、Consumer 塞进一个包完全没问题。反过来,只要有一环将来可能要独立变化,就应当把它拆出去。拆包不是为了好看,而是为了让变化被隔离在最小范围内。
还有一个细节值得拎出来,它体现了「包边界处显式优于隐式」的设计哲学。在 Bash seam 里,面向模型的请求 ShellExecRequest 与执行器实际使用的完全解析后的规格 ShellExecSpec 被刻意分开:前者只有 workdir、timeoutMs 等可选字段,后者字段全部必填。工具层在两者之间调用 ctx.shell.resolve(request) 完成解析。为什么不让工具层直接构造一个全都填好的规格?因为面向模型的输入天然可能缺字段、需要补默认值,而执行器不愿也不能容忍缺字段。把「模型侧的松散请求」和「执行器侧的严格规格」在包边界上分开,并显式用一次 resolve 完成转换,是把隐式假设变成显式契约。 这条经验在你自建能力时会反复用到。
理解了三角色,接下来我们进入实战。官方教程给了一个叫 myCap 的目标能力:输入一段文本,输出全部大写。它小到一眼看懂,却又完整覆盖 Definition、Provider、Consumer 三个包。路径是三步走:先写 Service Definition(抽象类 + 类型),再写 Service Provider(实现子类),最后写 Consumer(defineTool),最后在 cordis.yml 里把 Provider 与 Consumer 组合加载。这一段的重点,是把前两步的代码与原理拆到底,第三步留到后半段结合 inject 机制一起讲。
Definition 只写契约:MyCapService 抽象类与 super(ctx, 'myCap') 的注册时机
Service Definition 声明的是能力本身:服务叫什么、怎么调用、请求与结果的类型是什么。它不包含任何实现逻辑,只有一个抽象方法和两个接口。 抽象类 MyCapService 继承自 Service,并通过 super(ctx, 'myCap') 把它注册为一个命名服务。我们先把这段代码贴出来,然后逐行讲清楚每处设计的原因。
// 文件路径:packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from '@deepseek-ai/cordis'
// 声明合并:让 ctx.myCap 在 TypeScript 里有类型提示
declare module '@deepseek-ai/cordis' {
interface Context {
myCap: MyCapService
}
}
// 抽象类:Definition 包只声明契约,不写实现
export abstract class MyCapService extends Service {
constructor(ctx: Context) {
super(ctx, 'myCap') // 注册为命名服务 ctx.myCap
}
/** Execute the capability. */
abstract execute(request: MyCapRequest): Promise<MyCapResult>
}
// 请求类型:调用方必须提供 input
export interface MyCapRequest {
input: string
}
// 结果类型:能力返回 output
export interface MyCapResult {
output: string
}
先看命名服务注册这一步。super(ctx, 'myCap') 里的字符串 'myCap' 就是服务名,它决定了这项能力在 ctx 上挂成哪个键。注册之后,整个 Harness 里任何拿到 Context 的代码都能通过 ctx.myCap 引用这项能力。注意注册时机:注册发生在构造函数里,也就是服务实例被创建的那一刻。这意味着只要 Provider 被加载,ctx.myCap 这个键就会出现在上下文上,其它模块就能 inject 它。你不用手动去某个中央注册表里登记,Cordis 的 Service 基类替你完成了这件事。
然后是抽象类本身。Definition 包里的抽象类只声明契约,不写任何实现代码——它只给出一个抽象方法 execute,签名是 execute(request: MyCapRequest): Promise<MyCapResult>。这里有两个设计要点。第一,方法是抽象的,强制每个 Provider 必须给出自己的实现,编译器会替你把关:子类不实现 execute 就编译不过。第二,签名里用到的类型全部来自定义包自己的 MyCapRequest 与 MyCapResult,而不是某个具体实现引入的类型。契约只描述形状,不泄漏实现细节,这是 Definition 包能长期稳定的前提。
很多人第一次写 Definition 时会犯一个错:忍不住在抽象类里加一点「公共逻辑」,比如参数校验、日志打印。这在短期内看起来省事,但会破坏 seam 的纯度。校验规则一旦写进 Definition,就绑定了所有 Provider;而不同 Provider 可能对同一请求有不同的前置检查(本地执行器关心命令是否存在,沙箱执行器更关心权限策略)。把校验留在 Provider 里,把形状留在 Definition 里,边界才干净。 如果你确实有跨 Provider 复用的纯函数,把它放到独立的工具包,而不是塞进 Definition 抽象类。
再看请求与结果类型放在 Definition 包这件事。为什么 MyCapRequest 和 MyCapResult 要跟抽象类待在同一个包,而不是各自散落?因为它们是契约的一部分。只要请求和结果的形状是契约,任何 Provider、任何 Consumer 引用的都是同一份类型定义。如果让 Consumer 自己定义一个入参类型、让 Provider 自己定义一个返回类型,那么三者之间就出现了隐式的平行类型,一旦契约演化,三处都要同步改,迟早漂移。把类型钉在 Definition 包里,是保证三角色引用同一份真相的最省力做法。
最后强调一下 Definition 包「不做的事」:它不读环境变量、不碰文件系统、不发起网络请求、不引入任何执行环境相关的依赖。它的依赖列表应当非常短,基本只有 Cordis 的 Service 和 Context 类型。你可以用一条很实用的标准来检查自己的 Definition 包是否合格——把它单独编译,如果它会拉进任何执行环境特有的运行时依赖,那就说明有实现逻辑渗进来了。 保持 Definition 包轻量,是 se am 能被反复复用的前提。
declare module 声明合并:让 ctx.myCap 在 TypeScript 里拿到类型提示
上面代码里有一段看起来有点「魔法」的东西,就是 declare module '@deepseek-ai/cordis' 那段。很多人会直接复制粘贴过去,但并不知道它到底解决了什么问题。这一节把它拆开讲清楚,因为不理解它,你写自建能力时会一直吃亏。
问题的起点是这样的:Cordis 的 Context 接口原本只声明了框架内置的那些服务,比如 ctx.shell、ctx.logger 等等。当我们通过 super(ctx, 'myCap') 在运行时把 myCap 挂上 ctx 时,TypeScript 编译器并不知道 ctx 上会多出一个 myCap 属性。 于是你在别处写 ctx.myCap.execute(...) 时,编译器会报「属性 myCap 不存在于类型 Context 上」。运行时明明有,类型系统却看不见——这就是断层的来源。
解决手段就是 声明合并(declaration merging)。TypeScript 允许同一个模块被多次声明,声明会被合并到一起,而不是覆盖。我们通过 declare module '@deepseek-ai/cordis' 重新打开 Cordis 的类型模块,往里补一个 interface Context,把 myCap: MyCapService 这个属性加进去。合并之后,所有引用 Cordis Context 的地方都会看到新属性,于是:
- 补全生效:输入 ctx. 之后,IDE 会把 myCap 列进候选,并显示它的类型是 MyCapService。
- 类型检查生效:ctx.myCap.execute(request) 会校验 request 是否满足 MyCapRequest,返回类型是不是 MyCapResult。
- 重命名安全:如果你把服务名从 myCap 改成别的,类型系统会连带提示所有引用点。
这里有三个工程细节值得记牢。第一,声明合并是全局生效的,只要这个 .d.ts 或 .ts 文件被包含进编译,它的类型补充就会作用于整个项目。所以不要把 declare module 写在一个别人永远不会 import 的角落文件里;通常把它放在 Definition 包的主入口 index.ts,这样任何引用该能力的地方都会顺带拿到类型。第二,接口里声明的属性类型应当是 Definition 包自己导出的抽象类类型(MyCapService),而不是某个具体 Provider 的子类类型。 如果写成具体子类,一旦换 Provider,类型就对不上了,声明合并反而成了换实现时的绊脚石。第三,运行时注册与类型声明必须成对出现。 只写 super(ctx, 'myCap') 不写声明合并,编译报错;只写声明合并不写运行时注册,运行时 ctx.myCap 是 undefined。两者缺一不可,而且必须用同一个名字。
还有一个容易被忽略的坑:服务名的字符串字面量和声明合并里的属性名必须严格一致,包括大小写。 如果你在 super 里写 'myCap',在 interface Context 里写 mycap,TypeScript 不会报冲突(因为这是两个不同的属性名),但运行时你通过 ctx.mycap 访问会得到 undefined,而 ctx.myCap 虽然能访问却没有任何类型保护。这类问题排查起来很烦,因为它不报编译错误,只在运行时炸。建议的做法是先定服务名,再让声明合并和运行时注册都照着同一个字符串写,必要时把服务名抽成一个常量复用。
把声明合并和前一节的抽象类放在一起看,你会发现 Definition 包的完整职责其实就三件事:用抽象类定义调用入口、用接口定义请求结果形状、用声明合并把服务名挂进 Context 类型。 三件事全部围绕「契约」展开,没有一件涉及「怎么做」。这也是为什么我们前文反复强调 Definition 包要轻——它承担的越少,被复用的面就越广。
MyCapRequest.input 与 MyCapResult.output:请求/结果类型为何要和实现解耦
来看这个最小能力的两个类型。MyCapRequest 里只有一个 input: string,表示调用方必须提供一段输入文本;MyCapResult 里只有一个 output: string,表示能力返回处理后的文本。就这么简单,但「简单」恰恰是我们要用它演示解耦的原因——在最小的例子里最容易看清接口类型到底该描述什么。
先问一个问题:这两个接口描述的是「形状」,还是「行为」?答案很明确,只描述形状。MyCapRequest 只说「调用方会给一段字符串」,它不关心这段字符串从哪来、是不是用户输入、有没有长度限制;MyCapResult 只说「会返回一段字符串」,它不关心大小写转换具体怎么算、用的是哪套字符集规则。接口类型是纯粹的数据契约,不含任何执行环境信息。
这种解耦带来三个直接好处:
- Provider 可以被替换而类型不变。 我们可以写一个本地实现的 Provider,也可以写一个调用远程服务的 Provider,甚至写一个永远返回固定字符串的假 Provider 用于测试——它们的 execute 签名完全一致,因为请求与结果类型都钉在 Definition 里。换 Provider 时,Consumer 的代码一行不改。
- Consumer 可以独立演化。 今天 Consumer 可能是给模型用的 defineTool,明天你可能想加一个给命令行用的 Consumer,或者给某个自动化流程用的 Consumer。它们都构造 MyCapRequest、消费 MyCapResult,互不干扰。
- 测试可以用最少的依赖完成。 因为类型只描述形状,你可以直接构造一个 { input: 'hello' } 的对象传给 execute,不需要启动任何执行环境。这让单元测试既快又稳。
再往深一层想,为什么请求和结果要分开成两个类型,而不是复用一个?因为它们描述的是生命周期的两端。请求是「输入侧」,往往带可选字段、需要补默认值;结果是「输出侧」,往往是执行完之后的确定产物。前文提到 Bash seam 里 ShellExecRequest 与 ShellExecSpec 被分开,正是这个思路的延伸:面向模型/调用方的松散输入,与面向执行器的严格规格,是两种不同性质的东西,不该硬塞进一个类型。 myCap 现在很简单,但如果将来要给 MyCapRequest 加一个可选的 locale 字段(控制大小写转换的语言环境),请求类型可以容纳可选字段,而结果类型依然只是 output,两者独立演化。
这里给出 myCap 的一个完整字段说明表,方便你在自建能力时对照:
| 类型 | 字段 | 是否必填 | 含义 | 解耦点 |
|---|---|---|---|---|
| MyCapRequest | input | 必填 | 调用方提供的输入文本 | 不绑定输入来源与校验规则 |
| MyCapResult | output | 必填(作为返回值的一部分) | 能力处理后的输出文本 | 不绑定处理算法与执行环境 |
要特别注意一个工程习惯:不要为了让类型「看起来更通用」而提前加字段。 很多人在设计 MyCapRequest 时会忍不住加上 metadata、options、context 之类的大口袋字段,想着「以后可能用得上」。结果每个 Provider 都要处理一堆它根本不需要的字段,Consumer 也要花心思去填。接口类型应当反映当前真实的契约,等到确实有第二个 Provider 需要额外信息时,再显式地把字段加进请求类型,并让所有 Provider 一起升级——这种「显式的破坏性变更」远比一个含糊的大口袋字段健康。
另外提醒一点:我们这里用的是 TypeScript 的 interface,它天然支持声明合并与扩展,适合作为公开契约。如果你需要更严格的封装语义,也可以用 type 加 readonly 字段,但那属于风格选择,不影响本文要讲的核心解耦思想。解耦的关键不在用 interface 还是 type,而在于这两个类型是否只描述形状、是否放在 Definition 包里、是否被所有角色共享引用。
Provider 继承抽象类:实现子类如何填上 execute 的具体行为
Definition 写完,契约就位,接下来是 Provider。Provider 的落地方式非常直接:继承 Definition 抽象类,并给出唯一的实现入口——也就是把抽象方法 execute 填上具体行为。 我们给 myCap 写一个本地 Provider,它做的事情就是把输入字符串转成大写。
// 文件路径:packages/my-cap/my-cap-local/src/index.ts
import { Context } from '@deepseek-ai/cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/my-cap'
export class MyCapLocal extends MyCapService {
constructor(ctx: Context) {
super(ctx) // 复用 Definition 的命名服务注册逻辑
}
async execute(request: MyCapRequest): Promise<MyCapResult> {
// 唯一实现入口:把输入转成大写
const output = request.input.toUpperCase()
return { output }
}
}
逐点看这段代码的讲究。第一,构造函数的 super(ctx) 并不是空的——它最终会走到 Definition 抽象类的构造函数,也就是那个 super(ctx, 'myCap')。命名服务的注册逻辑只在 Definition 里写一次,所有 Provider 通过继承免费获得。 这就是把注册放在抽象类构造函数里的价值:Provider 不必重复写字符串 'myCap',从而避免服务名在多个 Provider 之间手工同步、写歪一个字母就注册到错误键上的问题。留意上面代码里 Provider 的 constructor 其实可以省略(继承链会自动转发 ctx),我显式写出来只是为了让你看清调用链,实际工程里可以省掉。
第二,execute 的签名必须与抽象方法完全一致:参数是 MyCapRequest,返回 Promise<MyCapResult>。返回 Promise 是刻意的,因为真实的能力实现通常涉及 I/O(读文件、走网络、调进程),把接口设计成异步可以让 Provider 自由选择同步或异步实现,而不必在将来为了异步而改契约。myCap 这个例子里 toUpperCase 本身是同步的,但我们依然返回一个 async 函数包装的 Promise,就是为了和契约对齐。
第三,Provider 里可以做任何执行环境相关的事情:读环境变量、访问文件系统、加载原生模块。这些在 Definition 里被禁止的东西,在 Provider 里是允许的,因为这正是 Provider 存在的意义。但要注意,Provider 不应当关心「模型怎么调用我」。它只处理一个已经构造好的 MyCapRequest,返回 MyCapResult。把「面向模型的输入解析」留给 Consumer,是保持 Provider 可复用的关键——同一个 Provider 可以被工具型 Consumer 调用,也可以被钩子型 Consumer 调用,甚至被测试代码直接调用。
第四,换 Provider 的成本。假设我们要写一个 MyCapRemote,它把 input 发给远端服务做大写转换,那么代码结构完全一样,只是 execute 体里换成一次网络请求。由于它同样继承 MyCapService、同样注册为 ctx.myCap,Consumer 完全察觉不到自己背后的 Provider 换了。 这正是 seam 设计的回报:变化被隔离在一个包内。
一个常见的工程坑值得提醒:不要让 Provider 在 execute 里悄悄修改请求对象。 比如把 request.input 就地改成大写再返回。虽然在 myCap 这个简单场景里看不出问题,但当多个 Consumer 共享同一份请求对象、或者 Provider 被链式调用时,就地修改会造成难以追踪的副作用。正确做法是只读取、不修改,返回全新的结果对象,上面代码里的 return { output } 就是这个原则的体现。这个习惯在更复杂的 seam(比如 Bash 执行)里尤其重要,因为请求对象往往携带 workdir、timeoutMs 等对执行语义有影响的字段,一旦被 Provider 篡改,排查成本极高。
Consumer 用 defineTool 包装:把能力暴露为模型可调用的工具 schema
Definition 和 Provider 都有了,但模型还看不见这项能力。因为 Provider 提供的是程序化接口 ctx.myCap.execute(...),而模型能调用的是工具(tool)——一份带有名称、描述、参数 schema 的声明。把前者翻译成后者,就是 Consumer 的职责。Consumer 面向模型,把能力包装成工具 schema,让模型能够调用,而它自己并不干实际的活。
用 defineTool 来包装,大致会得到下面这样的形态。注意这段属于角色演示,重点是结构和职责分工,而不是某个框架的固定 API 细节。
// 文件路径:packages/my-cap/my-cap-tool/src/index.ts
import { defineTool } from '@deepseek-ai/dsh-tool'
import { MyCapService, type MyCapRequest } from '@deepseek-ai/my-cap'
export const myCapTool = defineTool({
name: 'my_cap',
description: '把输入文本转换为大写',
parameters: {
type: 'object',
properties: {
input: {
type: 'string',
description: '需要转换的文本',
},
},
required: ['input'],
},
async execute(args, ctx) {
// Consumer 只负责把参数翻译成请求,然后调用 ctx.myCap
const request: MyCapRequest = { input: args.input }
const result = await ctx.myCap.execute(request)
return result.output
},
})
这张代码里,几个职责的划分非常清晰:name 与 description 是给模型看的语义信息,模型据此判断什么时候该调用这个工具;parameters 是按 JSON Schema 描述的参数形状,它和 MyCapRequest 的形状保持一致(一个必填的字符串 input),但它们是两个层面的东西——前者面向模型的函数调用,后者面向程序内部的参数传递;execute 里做的是翻译,把模型给的 args 转成 MyCapRequest,再调用 ctx.myCap.execute,最后把 result.output 返回给模型。整个过程,Consumer 没有实现任何「大小写转换」逻辑,它只是搬运工。
这样做的价值在于:同一项能力可以有多个 Consumer,而它们共享同一个 Provider。 前文提到 Bash seam 的消费方除了 tool-bash,还有 tool-pwsh、hooks-claude-code、hooks-codex。这些 Consumer 面对模型或钩子暴露的形态各不相同,但底下调用的都是 ctx.shell。同样地,你完全可以在 myCapTool 之外再写一个命令行 Consumer,或者一个在 CI 里批处理的 Consumer,它们都调用 ctx.myCap,互不干扰。如果不把 Consumer 独立出来,而是把「工具 schema」直接写死在 Provider 里,那么每加一种消费形态,就要复制一份 Provider,Provider 与 Consumer 的演进也被绑死在了一起。
这里有一个很实用的工程检查清单,用来判断你的 Consumer 有没有越界:
- Consumer 里有没有出现执行环境相关的代码(读写文件、起进程)?如果有,说明你把 Provider 的活儿搬进了 Consumer。
- Consumer 里有没有重复实现参数校验逻辑,而这些逻辑本应在 Provider 里?如果有,把校验下沉到 Provider,Consumer 只做格式翻译。
- Consumer 有没有直接 import 某个具体 Provider 的类?如果有,说明它绕过了 Definition,seam 被破坏了;它应当只依赖 ctx.myCap 这个接口。
- 工具的参数 schema 是否和请求类型保持形状一致?如果两者开始漂移,模型会填出 Provider 无法处理的参数。
另外要注意 description 的质量直接影响模型是否会调用这个工具。 在 Bash 例子里,工具描述要让模型知道这是个执行 shell 命令的工具;在 myCap 例子里,描述要让模型明白「输入一段文本,得到大写版本」。工具名加参数名加描述,三者共同构成模型看到的全部信息,缺一不可。如果 description 写得含糊,模型可能永远不调用它,你在 Provider 里写得再漂亮也用不上。
inject: ['shell'] 与 ctx.shell.run(...):Consumer 只认接口不认执行器
最后我们来拆解官方 Bash 例子里最典型的一段调用链,把「Consumer 只认接口不认执行器」这句话落到代码上。tool-bash 这个 Consumer 并没有 import 任何执行器,它做的是两件事:用 inject 声明依赖 ctx.shell,然后在 execute 里调用 ctx.shell.run(...)。
先看依赖声明。inject: ['shell'] 表达的是「我这项能力依赖名为 shell 的服务」。注意注入的是服务名 shell,而不是某个包或某个类。这意味着 Consumer 对「谁来提供 shell 服务」一无所知,也毫不关心。运行时的装配由 cordis.yml 完成,你在配置文件里加载哪个 Provider,tool-bash 就自动用上哪个。下面是官方给出的一个配置片段,能直观看到切换 Provider 只改一行:
# 文件路径:cordis.yml
# 本地执行
- name: '@deepseek-ai/dsh-bash-local'
# 想换提供方时,替换上面这一行即可。
# 换成下面这一行,就换成沙箱执行器:
# - name: '@deepseek-ai/dsh-bash-sandbox'
这短短几行是整个 seam 设计最有力的证据。把 dsh-bash-local 换成 dsh-bash-sandbox,执行环境就从本机切到了沙箱,而 tool-bash 的代码、Definition 的定义,一个字符都不用改。 为什么会这样?因为 tool-bash 从头到尾只依赖 ctx.shell 这个接口,它调用的是 ctx.shell.run(...),而不是 new BashLocal().run(...)。运行时谁注册了 ctx.shell,它就用谁。
再看调用路径里的 resolve 环节。前文提过,在 Bash seam 里,面向模型的 ShellExecRequest(workdir、timeoutMs 可选)与执行器实际使用的 ShellExecSpec(字段必填)是分开的,工具层在二者之间调用 ctx.shell.resolve(request) 完成解析。所以完整路径大致是:模型给出松散参数 → Consumer 组装成 ShellExecRequest → 调用 ctx.shell.resolve(request) 得到严格规格 ShellExecSpec → 调用 ctx.shell.run(...) 交给当前注册的 Provider 执行。这个链路上,resolve 由 Definition 一侧提供(因为它是契约的一部分),run 的具体行为由 Provider 提供,而 Consumer 只负责在两者之间搬运。
把 Bash 的经验映射回 myCap,逻辑完全一致。myCapTool 应当通过 inject 声明它依赖 myCap 服务,然后在 execute 里调用 ctx.myCap.execute(request),而不是直接 new 一个 MyCapLocal。这样当你想把本地大写转换换成远端大写转换时,只需要在 cordis.yml 里换一行 Provider,工具层不动。myCap 现在还不涉及 resolve 这类解析步骤,但如果将来 MyCapRequest 加了可选字段、而 Provider 需要一份填满默认值的严格规格,你完全可以照搬 Bash seam 的做法,在 Definition 里加一个 resolve,把「松散请求 → 严格规格」的转换显式化。
最后用一张对比表收束这个机制,把「依赖接口」与「依赖实现」两种做法的差异摆在一起:
| 维度 | 只依赖接口(inject: ['shell']) | 直接依赖具体实现 |
|---|---|---|
| 换 Provider 是否要改 Consumer | 不需要,改 cordis.yml 一行即可 | 必须改,往往要重写 import 与调用 |
| Definition 是否要改 | 不需要 | 通常也不需要,但 seam 已被破坏 |
| 能否同时接入多种 Provider | 可以,由配置决定装载哪个 | 不行,被硬编码绑定 |
| Consumer 能否被复用 | 能,多个 Consumer 共享同一接口 | 难,强耦合导致复制粘贴 |
| 测试难度 | 低,可注入假 Provider | 高,必须构造真实实现 |
至此,三块拼图各自到位:Definition 用抽象类、请求结果类型和声明合并定义了契约;Provider 继承抽象类、填上 execute 的具体行为;Consumer 用 defineTool 把接口翻译成模型可调用的工具 schema,并通过 inject 只认接口、不认执行器。myCap 虽然小,但它完整地走了一遍 seam 的三角色流程,和 Bash 这样重量级的能力遵循的是同一套结构。理解了这套结构,你就掌握了给 Harness 长出新器官的基本手法。接下来,我们要把视线从「单个能力」抬到「能力体系」,看看当多个 seam 同时存在时,装配、依赖解析与生命周期会带来哪些新问题,以及自建能力在真实工程中还有哪些边界与坑需要处理。
上一段我们从零写完了一个完整能力 myCap 的 Definition、Provider 与 Consumer 三个包,也确认了 cordis.yml 里换一行就能换一个实现。这一段落我们把视角拉回到 DeepSeek Harness 自己的核心 seam 上,用官方仓库里证据最完整的 ctx.shell(Bash 执行能力)当解剖对象,把配置切换、包归属、请求与规格的边界、钩子型 Consumer、物理分包取舍、排查清单以及 2026 年 9 月的最新落地顺序一次讲透。
cordis.yml 里换一行就换执行器:本地 / 沙箱 / PowerShell 三种 Provider
在 Harness 里,「换一个执行器」从来不是去改工具代码,而是去改装配文件。cordis.yml 是 Cordis 的装配清单,它决定当前进程里加载哪些插件、以什么顺序挂载、装配成哪一套服务图。Bash 这个 seam 的设计目标之一,就是让提供方的选择完全下沉到配置层。同一个 ctx.shell 服务定义、同一个 dsh-tool-bash 工具,配上不同的 Provider 包,行为就从「本机直接执行」变成「沙箱里执行」再变成「调 PowerShell」。
素材里给出的最小切换片段是断言式的:本地执行加载 @deepseek-ai/dsh-bash-local,想换提供方时只替换这一行,换成 @deepseek-ai/dsh-bash-sandbox 就切换成沙箱执行器。我们把它扩展成一份可直接粘贴的完整配置,把三种 Provider 并列出来,注释掉其中两行,只留一行生效:
# 文件路径:cordis.yml
# ---------------------------------------------------------------
# Bash seam 的 Provider 选择:同一时间只启用其中一个
# 其余两行保留为注释,需要切换时「换一行」即可
# ---------------------------------------------------------------
plugins:
# Definition 侧:注册 ctx.shell 服务定义(dsh-shell 包)
- name: '@deepseek-ai/dsh-shell'
# ---- Provider:三选一 ----
# 方案 A:本地执行(在宿主机当前工作目录直接 spawn 命令)
- name: '@deepseek-ai/dsh-bash-local'
# 方案 B:沙箱执行(在隔离环境里执行同一条命令)
# - name: '@deepseek-ai/dsh-bash-sandbox'
# 方案 C:PowerShell 执行(面向 Windows / pwsh 环境的执行器)
# - name: '@deepseek-ai/dsh-pwsh-local'
# ---- Consumer:面向模型的工具层 ----
# 工具层只 inject 'shell',不关心上面选的是哪个 Provider
- name: '@deepseek-ai/dsh-tool-bash'
这份配置里有三个必须看清楚的工程细节。
第一个细节是Provider 的加载顺序要与 Definition 对齐。Cordis 的服务装配是有依赖顺序的:Definition 提供抽象服务,Provider 继承并注册具体实现,Consumer 通过 inject 声明对服务的依赖。如果 Provider 排在 Definition 之前,服务还没注册,插件会拿不到可用的 ctx.shell。生产配置里稳妥做法是把 Definition 放最前、Consumer 放最后,中间夹 Provider。
第二个细节是互斥加载。三种 Provider 理论上可以同时装进进程,但注册到同一个服务键 ctx.shell 上会发生覆盖或冲突:bash-local 与 bash-sandbox 都想成为 ctx.shell 的实现,最终哪个生效取决于加载顺序,这是典型的「隐式行为」。所以推荐做法始终是同一时间只启用一个 Provider,其余保持注释,让「选哪个执行器」在配置里一眼可读。这比在代码里用环境变量做分支判定要干净得多——代码分支是运行期的隐式选择,而配置文件是静态可审计的显式选择。
第三个细节是PowerShell Provider 的存在意义。它说明 seam 的分裂维度不是「命令行 vs 别的什么」,而是「执行环境」。bash-local 面向 POSIX 类环境,bash-sandbox 面向隔离环境,pwsh-local 面向 PowerShell 环境。三者共用同一份 ShellExecRequest 与 ShellRunResult 类型契约,所以 dsh-tool-bash 完全不需要知道当前背后站的是谁。这正是替换 Provider 时「Definition 和 Consumer 一行都不用改」的实证:切换动作在语义上等价于换了一颗可插拔的器官,而神经系统(工具层)与骨骼(类型定义)纹丝不动。
还可以顺势做一个更工程化的变体:把 Provider 选择提升为「按环境分文件」。例如 cordis.local.yml、cordis.sandbox.yml、cordis.ci.yml 各维护一份,启动时用 --config 指定。这样 CI 环境天然跑沙箱、开发者本机天然跑本地执行器,而所有业务代码与工具定义保持完全一致。
ctx.shell 三角色归属表:Definition、Provider、Consumer 各在哪个包
理解一个 seam 最快的方式,是把它按官方《能力 Seam 与核心服务》参考文档的表格结构摊开。ctx.shell 的归属可以用「ctx 键 / 角色 / 所属包(Definition) / 实现(Provider) / 直接消费方(Consumer)」五列讲清楚。下表按官方参考文档的结构重述:
| ctx 键 | 角色 | 所属包(Definition) | 实现(Provider) | 直接消费方(Consumer) |
|---|---|---|---|---|
ctx.shell |
seam(一项完整能力) | shell(即 dsh-shell,注册 ctx.shell) |
bash-local / bash-sandbox / pwsh-local |
tool-bash / tool-pwsh / hooks-claude-code / hooks-codex |
这张表的价值在于它一次性暴露了三件容易混淆的事。
第一,Definition 是单个包,Provider 和 Consumer 都是复数。Definition 侧只有 dsh-shell 一个包在维护契约,它定义请求类型 ShellExecRequest 与结果类型 ShellRunResult。而 Provider 侧至少有三个执行器,Consumer 侧至少有四个消费方。整条 seam 是「一对多对多」的星形结构,中心是 Definition。
第二,Consumer 不只有面向模型的工具。多数人第一反应是「Consumer 就是模型能调用的工具」,所以看到 tool-bash 与 tool-pwsh 时觉得很自然。但表格里还列了 hooks-claude-code 与 hooks-codex 这两个钩子桥接插件——它们同样是 Consumer,只不过面向的不是模型,而是外部工具链的钩子事件。这一点在下文会单独展开。
第三,Provider 与 Consumer 互不依赖。表格里 Provider 列和 Consumer 列没有任何交叉引用。Provider 继承 Definition 的抽象类并填充行为,Consumer 通过 inject: ['shell'] 依赖同一个 Definition,两者之间没有任何 import 关系。把 Provider 与 Consumer 之间的连线全部删掉,只保留它们各自到 Definition 的连线,整张图依然成立——这就是 seam 的形状。
需要特别强调一句:完整能力构成其 seam,任何单一角色都不是 seam。单独一个 dsh-shell 只是类型声明,单独一个 dsh-bash-local 只是一段实现,单独一个 dsh-tool-bash 只是一个工具壳子。只有三者齐备,才谈得上是「一项可替换的能力」。这个定义在排查问题时格外有用:当你发现某个能力换不掉,往往不是 Provider 写得不好,而是缺失了独立的 Definition——一旦类型契约被 Provider 顺手定义在实现包里,Consumer 就不可避免地把实现包当依赖导入,seam 就断了。
ShellExecRequest 与 ShellExecSpec:resolve(request) 在包边界处显式优于隐式
ctx.shell 里最值得玩味的设计,是把「模型提出的请求」和「执行器需要的规格」拆成了两个类型。素材明确指出:面向模型的请求是 ShellExecRequest,包含 workdir 与可选的 timeoutMs;执行器实际使用的、字段完全解析后必填的规格是 ShellExecSpec。二者之间由工具层调用 ctx.shell.resolve(request) 完成转换。作者的评语是「包边界处显式优于隐式」。
先看宽松的一侧。ShellExecRequest 是模型产生的输入,它天然是「不完整」的:模型可能给命令但不说工作目录,可能说超时也可能不说,可能给一个相对路径的 workdir(相对谁?)。如果直接把这个对象丢给执行器,执行器就得自己到处兜底:自己读默认工作目录、自己填默认超时、自己解析相对路径。兜底逻辑一旦散落在每个 Provider 里,bash-local 与 bash-sandbox 就会各填一套默认值,两者行为出现细微漂移,而且这种漂移极难测试。
再看严格的一侧。ShellExecSpec 是执行器真正需要的输入:所有字段必填,workdir 是已经解析好的绝对路径,超时是已经算好的具体毫秒值。执行器拿到它就可以直接 spawn,不需要任何二次推断。
中间那道关就是 resolve。它承担四类职责:
- 补默认值:把
request里省略的字段补成运行期约定值(如未提供timeoutMs时套用统一默认值),而不是让每个 Provider 各自决定。 - 做路径解析:把模型给的
workdir解析成一个确定的绝对路径,消掉「相对于谁」的歧义。 - 做参数校验:在跨包边界之前把明显非法的请求拦下来,抛出统一的错误,而不是让错误在 Provider 深处炸开。
- 为审计留锚点:因为所有请求都必须经过
resolve,所以在这一处集中记录日志、埋点、做策略检查,是唯一不会漏的审计点。
下面用一张表把两侧的差异压实:
| 维度 | ShellExecRequest(面向模型) |
ShellExecSpec(面向执行器) |
|---|---|---|
| 产生方 | 模型 / Consumer 工具层 | 由 ctx.shell.resolve(request) 产出 |
| 字段完整度 | 宽松,部分字段可省略 | 必填,字段全部解析完成 |
workdir |
可选,可能是相对路径 | 必填,已解析的确定路径 |
timeoutMs |
可选 | 必填,已补齐的具体值 |
| 谁负责兜底 | 不负责,交给 resolve |
不负责,拿到即是终值 |
| 是否需要校验 | 否,属于「用户意图」 | 是,跨边界前完成校验 |
为什么说这是「包边界处显式优于隐式」?因为如果省掉 resolve,让 Provider 自己去读 request.workdir ?? process.cwd(),那么「默认工作目录是什么」这个决策就被隐式地分散进了每个 Provider 的实现里。今天 bash-local 用进程当前目录,明天 bash-sandbox 用沙箱挂载根目录,后天换个新人接手 pwsh-local,他在 Windows 上填了盘符根目录——同一个模型请求,三个执行器三种行为,而调用方完全感知不到。
加一个显式的 resolve,等价于在包边界上立了一块牌子:「过了这道门,参数的每一项都有确定值」。跨边界之后的所有代码都不需要再问「如果为空怎么办」,这就是显式的收益。它对测试也极其友好:resolve 是纯函数式的输入输出转换,可以脱离真实执行环境单测;Provider 拿到 ShellExecSpec 后只测「给定的确定输入能否产生正确结果」,两类测试各自收敛。
在写自己的 seam 时,这个模式可以直接照抄:凡是需要跨包的接口,都定义「宽松请求 + 严格规格 + 显式 resolve」三件套。例如 myCap 如果以后要支持多种文本处理器,就可以把 MyCapRequest 保持在宽松侧,再引入 MyCapSpec 并把 ctx.myCap.resolve(request) 暴露出去,让所有 Processor 共享同一套默认值与校验规则。
hooks-claude-code 与 hooks-codex:不写工具也能消费 ctx.shell 的桥接插件
官方表格里 ctx.shell 的直接消费方除了 tool-bash、tool-pwsh,还有 hooks-claude-code 与 hooks-codex。这两个是钩子桥接插件,它们的存在本身就是对「Consumer 是什么」的一次重新定义。
如果按最直觉的理解,Consumer 就是「把能力包装成 defineTool 的模型工具」。那么 tool-bash 负责把 ctx.shell 包装成模型可调用的 bash 工具 schema,模型说「帮我列出目录」时,工具层在 execute 里调用 ctx.shell.run(...)。这是最标准的形态。
但 hooks 桥接插件走的是另一条路。它们面向的是外部 Agent 工具链的钩子事件——Claude Code 的 hooks 与 Codex 的 hooks 都属于这一类。当外部工具链的某个事件触发时,桥接插件需要执行命令来完成响应(比如做一次状态检查、跑一次自定义脚本)。它不需要把能力暴露给模型,也就不需要工具 schema、不需要模型可读的工具描述、不需要处理模型的参数拼装;它只需要直接调用 ctx.shell。
关键在于:它和 tool-bash 一样,只认 ctx.shell 这个接口,不关心背后是哪个执行器。素材原文的表述是「它们和 tool-bash 一样,只认 ctx.shell 这个接口,不关心背后是哪个执行器」。这句话有很强的工程含义:
- Consumer 的形态是开放的。工具是一种 Consumer,钩子桥接是一种 Consumer,未来还可能有定时任务、HTTP 入口、CLI 子命令等 Consumer。它们的共同点只有一个:依赖 Definition,不依赖 Provider。
- 新增 Consumer 不需要改 Provider 和 Definition。当你需要「在某个外部事件里执行一条 shell 命令」时,不需要给
bash-local加代码,只需要新写一个 Consumer 包并inject: ['shell']。 - 能力复用率被显著抬高。同一份 Bash 执行能力被四种以上消费方共享,如果每新增一种消费方式都复制一遍执行逻辑,代码会迅速发散;靠 seam 收口到 Definition,新增消费方式的边际成本才足够低。
从测试角度看,hooks 型 Consumer 也更容易验证。因为它不涉及模型交互,输入是钩子事件 payload,输出是一次命令执行的副作用,写成集成测试时可以直接构造事件、断言 ctx.shell 被以正确的参数调用过——把 ctx.shell mock 掉即可,完全不需要真实执行进程。
这也提示了一个设计上的反模式:如果 tool-bash 里内联了私有执行逻辑,那么 hooks 桥接插件就没法复用它,只能再写一份。两份执行逻辑意味着两套超时策略、两套错误处理、两套工作目录约定,最终一致性问题会以「为什么模型调用能跑通、钩子触发却失败」的形式浮现。把执行能力收进 ctx.shell、把消费方式拆成独立 Consumer,正是为了避免这种情况。
三个角色放一个包还是拆三个包:唯一判断标准是能否独立演进
素材给了明确答案:三个角色可以放在同一个包里,也可以拆进不同包;判断标准只有一个——这些角色是否需要独立演进或替换。这句话值得展开成一套可操作的决策方法。
先看「放同一个包」的合理性。对一个内部小工具型能力,三个角色都只有一份、不打算替换、也不打算扩展消费方式,放在一个包里是完全正当的。一个包意味着一次安装、一次构建、一次版本发布,维护成本最低。myCap 在演示场景里就完全可以三合一。
再看「拆三个包」的收益条件。只有当下面任意一条成立时,拆分才划算:
- Provider 需要多份。像 Bash 这样本地、沙箱、PowerShell 三种执行环境并存,Provider 必须能独立安装、独立发布、独立升级。用一个包塞三个执行器也行,但会让使用者被迫安装全部环境依赖——Windows 上装 POSIX 依赖就是纯粹的负担。
- Consumer 需要多份。
tool-bash、tool-pwsh、hooks-claude-code、hooks-codex四种消费方各有用例,如果合在一个包里,一个只用钩子的项目也不得不引入工具层依赖。 - Definition 的稳定性远高于实现。接口一旦发布就应该尽量少变,而 Provider 需要频繁适配环境变化。把两者放进同一个包,接口变更与实现变更会在同一个版本号里纠缠,语义化版本无法表达「接口没变、实现升级了」。
| 场景特征 | 推荐物理结构 | 理由 |
|---|---|---|
| 三角色各只有一份,不计划替换 | 单包三文件(definition / provider / consumer) | 维护成本最低,避免过度工程 |
| Provider 需要按环境切换 | Definition 单独成包,Provider 各自成包 | 让使用者按环境只装需要的执行器 |
| Consumer 形态多样(工具 + 钩子 + 其他) | Consumer 各自成包 | 避免引入用不到的依赖 |
| 接口需长期稳定、实现迭代频繁 | Definition 与 Provider 至少分开 | 版本号能独立表达接口稳定性和实现变更 |
这里要特别提醒一个判断误区:拆分不是目的,可替换才是目的。有些团队一上手就把三个角色拆成三个包,结果 Definition 包里除了一个抽象类和两个 interface 什么都没有,Provider 包和 Consumer 包又都在 import 对方——拆了包但没拆依赖,seam 依然不成立,反而多出两个包的构建配置与发布流水线。判断依据始终是「能否独立演进或替换」,而不是「官方拆了几个包」。
另一个实践要点是物理分包与依赖方向的关系。合法的依赖方向只有两条:Provider → Definition、Consumer → Definition。任何 Provider → Consumer 或 Consumer → Provider 的引用都是破约,无论单包还是多包。单包时用目录结构与 lint 规则约束;多包时用 package.json 的依赖声明天然约束——Provider 包里如果出现 Consumer 包的依赖,代码评审就应该直接拦下。
排查清单:Provider 与 Consumer 互不依赖,换 Provider 时哪些文件一行都不用改
把 Provider 换掉之后,怎么确认自己真的做对了?下面这份清单可以直接当替换操作后的验证脚本用。核心命题只有一条:Provider 与 Consumer 互不依赖,所以换 Provider 时 Definition 与 Consumer 应当保持零改动。
操作顺序建议如下:
- 替换前先冻结基线。记录
dsh-shell(Definition)与dsh-tool-bash(Consumer)当前的文件哈希或提交号,作为「一行都没改」的证据。 - 只改
cordis.yml。把@deepseek-ai/dsh-bash-local换成@deepseek-ai/dsh-bash-sandbox(或dsh-pwsh-local),其余行不动。 - 检查依赖声明。确认 Provider 包的依赖里不出现任何 Consumer 包,Consumer 包的依赖里不出现任何 Provider 包。若出现,说明 seam 已经漏了,先修依赖方向再继续。
- 跑同一组回归用例。同一批模型请求(含省略
workdir、省略timeoutMs、给相对路径等边界样例)在新 Provider 下应当得到语义一致的结果与错误。 - 核对
resolve行为未变。默认值、路径解析规则、校验错误信息来自 Definition 侧的resolve,换 Provider 后这些应当完全一致。如果出现差异,说明默认值逻辑被错误地放进了 Provider。 - 对比文件哈希。Definition 与 Consumer 的文件哈希与基线一致,替换才算通过。
下面这张表可以作为快速对照,帮助区分「哪些文件该改」「哪些文件一行都不该改」:
| 文件 / 包 | 角色 | 换 Provider 时是否改动 | 原因 |
|---|---|---|---|
cordis.yml |
装配清单 | 改(一般只改一行) | 提供方选择下沉到配置层 |
dsh-shell(Definition) |
接口与类型 | 零改动 | ShellExecRequest / ShellRunResult 与 Provider 无关 |
dsh-tool-bash(Consumer) |
模型工具 | 零改动 | 只 inject: ['shell'],从不 import 具体 Provider |
hooks-claude-code / hooks-codex |
钩子型 Consumer | 零改动 | 同样只认 ctx.shell 接口 |
| Provider 包本身 | 实现 | 不需要修改旧 Provider,只增删装配项 | 新增执行器就是新增包 + 新增一行配置 |
排查中最常见的三类「假通过」也要点出来。
第一类是把默认值写进了 Provider。表面上换 Provider 成功,但一旦新 Provider 对 timeoutMs 的默认值和旧的差 500 毫秒,行为就漂移了。发现方式是比对 resolve 产出的 ShellExecSpec 是否与 Provider 无关地一致。
第二类是Consumer 里用字符串硬编码了执行器名。例如工具层里写死「如果当前是 sandbox 就走另一条分支」。这是对 Provider 的隐式依赖,会让「换一行配置」的承诺失效。正确做法是让 Consumer 只调用 ctx.shell.run(...),把所有环境差异留给 Provider。
第三类是Provider 偷偷 import 了 Consumer 的辅助函数。这在单包三文件的结构里特别容易发生:Provider 想复用工具层的参数清洗逻辑,顺手 import 过来,于是依赖方向被反转。修复方式是把这段逻辑上移到 Definition 的 resolve,让它成为契约的一部分。
2026 年 9 月最新实践:能力缝合的接口先行与 Provider 可替换落地路径
结合 2026 年 9 月当前版本的实践,把一项新能力缝进 Harness 的推荐顺序是接口先行,再补 Provider,最后补 Consumer。这个顺序不是审美偏好,而是由依赖方向决定的:Definition 是唯一被两侧共同依赖的节点,先把它定稳,后面两边才能并行推进且互不阻塞。
第一阶段:写 Definition,只声明契约。参照素材里 myCap 的写法,抽象类 MyCapService 继承自 Service,通过 super(ctx, 'myCap') 注册为命名服务;用 declare module 做声明合并,让 ctx.myCap 在 TypeScript 里有类型提示;再定义 MyCapRequest 与 MyCapResult。这个阶段的关键纪律是包里不出现任何实现逻辑,只有一个抽象方法和两个 interface。同时建议尽早把 resolve(request) 的签名和默认值语义一起定下来,因为它是「宽松请求 → 严格规格」的收口点,晚定会导致后续 Provider 各自兜底。
第二阶段:写 Provider,填上具体行为。Provider 继承 Definition 的抽象类,实现 execute。如果预判会有多种执行环境,这时就把 Provider 单独成包,并用不同的包名区分环境,装配时通过 cordis.yml 选择。Provider 内部只处理「给定严格规格,如何执行」,不做参数推断。
第三阶段:写 Consumer,面向模型包装成工具。这一步的样板就是 defineTool:声明工具 schema,并在 execute 里调用 ctx.myCap.resolve(request) 完成参数收口,再调用 ctx.myCap.execute(spec)。依赖通过 inject 声明,绝不 import 任何 Provider 包。
第四阶段:装配与验证。在 cordis.yml 里把 Provider 与 Consumer 组合加载,跑通端到端用例,然后再按上一节的排查清单做一次「换 Provider 零改动」验证。这一步做完,能力才算真正缝合完成。
把这套顺序固化下来,会得到一个很有用的副产品:能力清单可以按 Definition 枚举,而不是按实现枚举。当每个 seam 都有独立 Definition 时,你随时能回答「当前系统具备哪些可替换能力」,这对架构评审和权限治理都很有价值。
最后补一个 2026 年实践中反复被验证的经验:Definition 的第一版不要追求完备。接口太大意味着实现方要填一堆用不到的字段,也意味着未来为了兼容不敢改。先定义最小可用的请求与结果类型,把 resolve 留作扩展点,后续新增字段时优先给默认值,这样接口的语义化版本可以长期停在主版本不变。反之,如果第一版就把所有可能的执行选项都塞进 ShellExecRequest,Provider 之间就会在「哪些字段必填、哪些可省」上产生分歧,seam 的整洁度会被迅速侵蚀。
总结与最佳实践
把全文要点压缩成一份可执行清单:
- 牢记 seam 的定义:完整能力由 Definition、Provider、Consumer 三者共同构成其 seam,任何单一角色都不是 seam。
- 背熟三角色的职责:Definition 只声明「有什么能力、长什么样」;Provider 继承抽象类填实现;Consumer 面向模型把能力包装成工具 schema。
- 守住依赖方向:只有
Provider → Definition与Consumer → Definition两条合法边,Provider 与 Consumer 之间互不依赖。 - 替换下沉到配置:换执行器只改
cordis.yml一行;bash-local、bash-sandbox、pwsh-local三选一,建议同一时间只启用一个 Provider。 - 按表核对包归属:
ctx.shell的 Definition 是dsh-shell,Provider 是bash-local/bash-sandbox/pwsh-local,Consumer 是tool-bash/tool-pwsh/hooks-claude-code/hooks-codex。 - 请求与规格分离:面向模型的
ShellExecRequest(workdir、timeoutMs可选)与执行器用的ShellExecSpec(字段必填)必须分开,中间由ctx.shell.resolve(request)收口,坚持「包边界处显式优于隐式」。 - 扩大 Consumer 的想象力:钩子桥接插件和工具一样是 Consumer,只认
ctx.shell接口、不关心背后执行器;因此新增消费方式时不要改 Provider,只新增 Consumer 包。 - 分包只认一个标准:三个角色能否独立演进或替换,是决定放一个包还是拆三个包的唯一判断依据,避免为了拆而拆。
- 替换后必须验证:换 Provider 时 Definition 与所有 Consumer 应保持零改动,用文件哈希比对、依赖声明检查、同组回归用例三重确认。
- 坚持接口先行:落地顺序为 Definition → Provider → Consumer → 装配验证,Definition 第一版保持最小可用,把
resolve留作扩展点。