用 Schemastery 让 DeepSeek-Harness 插件可配置
如何从一个 DeepSeek-Harness 插件导出 Config schema、通过 cordis.patch.yml 设置值,以及为什么 patch 的 config 字段是整体替换而不是合并。
一个 DeepSeek-Harness(dsh)插件通过导出一个 Config 值——一份 Schemastery schema——来声明它面向用户的可配置选项。在你的 apply(ctx, config) 函数真正运行之前,dsh 会用这份 Config schema 去校验当前加载的 cordis.patch.yml 塞进那一行 config 字段里的内容,并为没设置的部分填上默认值。
为什么插件需要这个
Config 背后的设计原则在 dsh 自己的插件文档里说得很直白:如果同一个插件的两次部署可能合理地想给某个东西设不同的值,那这个东西就必须是一个配置项——而不是写死在源码里的常量。问候语前缀、超时时间、API base URL、功能开关:这些都是 Config schema 存在的意义——不用 fork 你插件的代码就能调整它们。
在插件里声明 Config
import type { Context } from '@deepseek-ai/cordis'
import { Schema } from 'schemastery'
export const name = 'greet-tool'
export const inject = ['tools']
export interface Config {
greeting: string
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string()
.default('Hello')
.description('The word used to greet someone.'),
})
export function apply(ctx: Context, config: Config) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `${config.greeting}, ${args.name}!`
},
}))
}
apply 现在多了第二个参数 config——这是 dsh 根据你的 Config schema 和当前生效的 cordis.patch.yml 那一行构建出的、已校验、已填充默认值的对象。注意 Config 这个导出名字被用了两次,一次是 TypeScript 类型,一次是运行时的 schema 值——这种"类型 + 同名 schema"的搭配,正是 dsh 插件文档自己描述"这个插件有可配置项"的方式。Schema 具体是从哪个模块导入的,我们审阅的官方工具教程里并没有明确写出来——schemastery 是该库自己在 npm 上的包名,所以这是一个安全、可核实的导入方式;如果 dsh 自己的文档是通过 @deepseek-ai/cordis 重新导出它的,请查阅 docs/user/develop/basic/config.md 确认它们实际使用的约定。
Schema.object、Schema.string、.default、.description
Schema.object({...})、Schema.string()、.default(...) 和 .description(...) 都是 Schemastery 自己的通用 API——是 dsh 依赖的那个库本身的能力,不是 dsh 在其上额外添加的东西。Schema.object 描述一组具名字段的记录,每个字段用像 Schema.string() 这样的基础类型构建,.default() / .description() 可以链式挂在任意 schema 上,分别设置兜底值和一句人类可读的说明。如果你需要 string 之外的类型(数字、布尔值、嵌套对象、联合类型),那都是普通的 Schemastery 能力——直接参考 Schemastery 仓库,不要假设 dsh 对它做过扩展或限制,因为我们审阅的文档并没有列出一个 dsh 专属的子集。
通过 cordis.patch.yml 设置值
schema 只定义什么是合法的——真正的值来自 cordis.patch.yml 里插件那一行的 config 字段:
- insert:
- id: greet-tool
name: 'dsh-greet-plugin'
config:
greeting: 'Howdy'
这个值会作为 config.greeting 流入 apply(ctx, config)。如果整行都不写 config,Schemastery 的 .default('Hello') 会替你填上——你插件的 apply 函数完全不需要自己写 config.greeting ?? 'Hello' 这种判断。
整体替换的坑,同样会砸到你的 Config 头上
这是一个专门会坑到插件作者、而不只是 profile 操作者的细节:后面某一层如果按 id 覆盖了你插件的那一行,它替换的是那一行整个 config 对象,而不只是它提到的那几个字段。如果你的 Config schema 有两个字段,而一份机器级的 cordis.patch.yml 只设置了其中一个就去覆盖这一行:
- id: greet-tool
config:
greeting: 'Yo'
另一个字段会回退成你 Config schema 自己 .default() 里写的值——而不是某个更低层(比如 profile 自己的 patch)之前设置的那个值。Schemastery 的默认值是防止字段完全缺失的安全网;它们保护不了"高层覆盖抹掉低层对同一行里另一个字段的显式设置"这种情况。《cordis.patch.yml 详解》讲了这里涉及到的完整四层加载顺序,《DeepSeek Harness 配置指南》讲了如何用 --dump-config 调试它。
加一个第二字段
真实的插件很少只停在一个选项上。给上面的例子扩展一个布尔开关遵循同样的模式——每个字段各自调用自己的 Schema.*(),按需要链上 .default() 和 .description():
export interface Config {
greeting: string
enthusiastic: boolean
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string()
.default('Hello')
.description('The word used to greet someone.'),
enthusiastic: Schema.boolean()
.default(false)
.description('Append an exclamation mark to the greeting.'),
})
export function apply(ctx: Context, config: Config) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
const punctuation = config.enthusiastic ? '!' : '.'
return `${config.greeting}, ${args.name}${punctuation}`
},
}))
}
现在用户(或者另一层 cordis.patch.yml)可以设置任意一个字段、两个都设、或者都不设——只要那一行的 config 有被设置,没设的部分都会各自回退到自己的 .default()。记住下面这条整体替换规则:如果后面某一层要在这一行设置 config,它必须把想保留的每一个字段都重新写一遍,而不只是它要改的那些。
两个不同的配置面:Config 对比工具的 parameters
很容易把这个和 defineTool 里的 parameters 对象搞混,但它们回答的是不同的问题。插件的 Config schema 是由部署这个插件的人(通过 cordis.patch.yml)设置一次,在这个插件实例的整个生命周期里保持固定——它是部署期配置。工具的 parameters 则是模型在每一次调用时提供的,每次调用都可能不一样——它是调用期输入。在上面的例子里,greeting 和 enthusiastic 属于 Config,因为是操作者设置一次;name 是工具参数,因为每次调用 greet 时都是模型提供不同的值。parameters 那一侧的完整拆解见《如何用 defineTool 给 DeepSeek-Harness 添加自定义工具》。
如果一个配置值校验失败会怎样
工具教程确认了 dsh 会在加载时用你的 schema 校验 config 字段并填充默认值,但对于一个值确实存在、但不符合 schema 时具体的失败行为——是拒绝整个插件、记录日志然后回退到默认值、还是别的处理方式——我们审阅的范围里没有细节。如果这个区别对你的插件很重要,不要在没有直接查阅 docs/user/develop/basic/config.md 的情况下假设某种具体的失败模式。
FAQ
Config 必须是一个 object schema 吗?
所有有文档记录的示例都把选项包在 Schema.object({...}) 里,和 config: 在 YAML 里写成一组具名字段映射的方式对应。除非你有特殊理由要偏离,否则应该照这个模式来。
我能在 apply 之外读取 config 吗?
apply(ctx, config) 是把校验过的 config 交给你插件的地方。教程没有记录一个可以在模块其他地方单独读取它的访问器——需要它的任何函数,都得显式把它传进去。
如果我的插件没有选项,还需要 Config 吗?
不需要——Config 是可选的。一个只导出 name 和 apply 的插件是合法的;只有当确实有值得按部署调整的东西时,你才需要加一份 Config schema。
Schemastery 是 dsh 专属的吗?
不是——Schemastery 是 dsh 依赖的一个通用 schema 库,不是 dsh 自己编写的。它完整的 API 面(Schema.object、Schema.string、.default、.description 之外的部分)在它自己独立于 dsh 的仓库和文档里。
下一步
在《cordis.patch.yml 详解》里看看带 Config 的插件那一行到底是怎么写、怎么分层的,在《DeepSeek Harness 配置指南》里看四层配置如何组合以及如何调试。如果你在从零构建周边的插件,从《从零构建一个 DeepSeek-Harness 插件》开始,或者到 FindHarness 的 Development & Runtime(开发与运行时)分类浏览真实上线的例子。