上一篇介绍了 Harness 的整体架构。本篇动手写代码——从最小插件到一个模型可调用的工具,涵盖配置、生命周期和打包发布。
前置准备
环境要求
- Node.js ^22.19 或 >=24
- pnpm(
packageManager: "pnpm@11.7.0") - DeepSeek API Key(实际运行需要)
从源码启动
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
# 配置 .env
echo "DEEPSEEK_API_KEY=sk-your-key" > .env
# 启动 Web UI 验证环境正常
pnpm dsh web
# 打开 http://127.0.0.1:3080
第一步:最小插件
插件的本质
在 Harness 中,插件是一个导出 apply 函数的 TypeScript 模块:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export function apply(ctx: Context) {
// 在这里注册能力
}
框架在加载时调用 apply,传入 ctx——通过它注册的一切在插件卸载时自动清理。
创建项目
在仓库根目录创建临时项目:
mkdir -p scratch-plugin/src
创建 scratch-plugin/src/my-plugin.ts:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
console.log('[hello-plugin] plugin loaded!')
}
注册到 cordis.yml
创建 scratch-plugin/cordis.yml,将 /absolute/path/to/deepseek-harness 替换为仓库实际绝对路径:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
插件路径必须是绝对路径。patch 文件贡献配置,不改变 loader 的模块解析基目录。
启动
pnpm dsh web --patch ./scratch-plugin/cordis.yml
终端打印 [hello-plugin] plugin loaded! 即为成功。
第二步:开发一个 Tool
将 scratch-plugin/src/my-plugin.ts 替换为:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
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 `Hello, ${args.name}!`
},
}))
}
关键点解析
inject: ['tools']:声明依赖 ctx.tools 服务。Cordis 保证该服务就绪后才调 apply。如果 ctx.tools 的 provider 被热替换,本插件自动 dispose 并重新 apply。
defineTool DSL:
| 字段 | 作用 |
|---|---|
name | 模型可见的工具名(64 字符内,[A-Za-z0-9_-]) |
description | 模型用来决定是否调用的描述 |
parameters | JSON Schema,自动推导 args 类型并运行时校验 |
output.schema | 规范值的 schema 声明 |
output.render | 将规范值转换为模型可见的 ContentBlock |
execute | 实际执行逻辑,接收校验后的 args + ToolExecution context |
ctx.tools.register() 返回 disposer:插件卸载时自动调用,tool 从注册表移除,模型下次请求不再看到它。
运行验证
pnpm dsh web --patch ./scratch-plugin/cordis.yml
在 Web UI 中输入:Use the greet tool to greet Ada.
模型会调用 greet,工具返回 Hello, Ada!。
Tool 执行管道
你注册的 tool 会走完整的执行管道:
模型返回 tool_call → tools/pre-execute (waterfall)
→ tools/execute (waterfall)
→ 你的 execute() 函数
→ tools/post-execute (waterfall)
→ tool/result (session event)
这意味着:
- 权限策略可以在
tools/pre-execute拦截你的 tool - 审批机制可以在执行前要求用户确认
- 超时/取消通过
ToolExecution.signal传递 - 结果自动记录到 session log
和 MCP bridge 注册的 tool、和其他原生 tool——走的是完全相同的管道。
第三步:添加可配置项
让问候语可定制。更新插件:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
// 定义 Config interface
export interface Config {
greeting: string
emoji: boolean
}
// 导出同名 Schema(Cordis 用它校验 + 填充默认值)
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
emoji: Schema.boolean().default(true),
})
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 suffix = config.emoji ? ' 👋' : ''
return `${config.greeting}, ${args.name}!${suffix}`
},
}))
}
更新 scratch-plugin/cordis.yml:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
config:
greeting: 'Hey'
emoji: false
设计原则
无硬编码可调参数:凡是不同部署可能需要不同值的参数,必须定义为 Config 字段。检验标准:能否在 cordis.yml 中改变这个值而不修改代码?
配置错误要响亮:Schema 在插件加载时执行校验。不合法配置让插件加载失败(fiber → FAILED),给出明确错误信息,而非运行时静默行为异常。
HMR 行为
修改 cordis.yml 中的 config 字段后,Cordis 会:
- 卸载旧 fiber(
ctx.tools.register的 disposer 自动执行,tool 注销) - 创建新 fiber
- 用新 config 调
apply(tool 重新注册)
结果:配置变更实时生效,不重启进程。
第四步:ctx.effect() 管理外部资源
假设你的 tool 需要维持一个长连接:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'db-query-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
let pool: ConnectionPool | undefined
ctx.effect(() => {
pool = createConnectionPool({ host: 'localhost', port: 5432 })
return () => {
pool?.close()
pool = undefined
}
})
ctx.tools.register(defineTool({
name: 'db_query',
description: 'Run a read-only SQL query.',
parameters: {
sql: { type: 'string', required: true, description: 'SQL query' },
},
output: {
schema: { type: 'array', items: { type: 'object' } },
render: (_args, rows) => [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
},
async execute(args) {
if (!pool) throw new Error('Database pool not available')
return await pool.query(args.sql)
},
}))
}
ctx.effect 的返回函数在以下场景自动执行:
- 插件被手动 dispose
- 依赖的服务消失(provider 热替换)
- HMR 触发配置变更
- 整个应用关闭
你不需要自己跟踪"什么时候该关连接"。
第五步:打包为可安装的 Bundle
Bundle 文件结构
hello-plugin/
├── package.json # 声明 dsh.bundle
├── cordis.patch.yml # 该 bundle 贡献的配置层
└── index.js # 插件入口
package.json
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
dsh.bundle 声明告诉 dsh plugin 命令:这是一个可安装的组合包。
index.js
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'hello-plugin'
export const inject = ['tools']
export function apply(ctx) {
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 `Hello, ${args.name}!`
},
}))
}
cordis.patch.yml
- insert:
- id: hello
name: dsh-hello-plugin
注意:这里用包名而非文件路径——Node 的模块解析会在已安装依赖中找到它。
安装到 Profile
dsh plugin --profile demo add ./hello-plugin
首次使用会初始化 profile(自动带上 @deepseek-ai/dsh-base 作为基础层),pnpm 链接该包,并将其追加到 dsh.profile.bundles。
验证:
dsh --profile demo --dump-config # 可以看到 hello 层
dsh --profile demo # 启动并观察 tool 可用
加载顺序
1. profile.bundles 列表中各 bundle(按顺序)
2. profile 的 cordis.patch.yml
3. $DSH_HOME/cordis.patch.yml(机器级)
4. --patch overlay(命令行)
后应用的层按行胜出,且替换整个 config(非深度合并)。
分发方式
| 方式 | 命令 | 说明 |
|---|---|---|
| npm 发布 | dsh plugin add your-package | 预构建,最简单 |
| tarball | dsh plugin add ./pkg-0.1.0.tgz | pnpm pack 打包 |
| GitHub | dsh plugin add github:you/repo | 需要 prepare 脚本构建 |
| 本地开发 | dsh plugin add ./local-dir | pnpm link |
插件的三种形态
函数形式(推荐大多数场景)
export const name = 'my-plugin'
export const inject = ['tools']
export function apply(ctx: Context) { /* ... */ }
对象形式
export default {
name: 'my-plugin',
inject: ['tools'],
apply(ctx: Context) { /* ... */ },
}
类形式(当你要提供服务时)
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
myService: MyService
}
}
export default class MyService extends Service {
static inject = ['tools']
constructor(ctx: Context) {
super(ctx, 'myService')
}
// 其他插件通过 ctx.myService.doSomething() 调用
doSomething() { /* ... */ }
}
类形式让你的插件成为其他插件的依赖——它们 inject: ['myService'] 就能在 apply 中使用 ctx.myService。
服务隔离
同一个服务可以有多个实例,不同插件组看到不同实例:
- id: coding-agent
name: '@deepseek-ai/cordis-plugin-group'
group: true
isolate:
shell: true
config:
- name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 5000
- name: './my-strict-tool.ts'
- id: research-agent
name: '@deepseek-ai/cordis-plugin-group'
group: true
isolate:
shell: true
config:
- name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 60000
- name: './my-research-tool.ts'
两个组各看到自己的 ctx.shell 实例,超时配置互不影响。这是 Cordis 服务隔离的通用能力——适用于 tools、shell、fs、llm 等任何服务。
事件系统
插件间松耦合通信通过事件:
export const inject = ['tools']
export function apply(ctx: Context) {
// 监听 tool 执行后事件
ctx.on('tools/post-execute', (toolName, result) => {
console.log(`Tool ${toolName} executed, result length: ${result.length}`)
})
// Waterfall 事件必须调 next()
ctx.waterfall('tools/pre-execute', async (toolName, args, next) => {
console.log(`About to execute: ${toolName}`)
return next() // 必须调用,否则短路整个链
})
}
事件分三种:
- emit:广播通知,监听器不影响流程
- serial:按注册顺序执行,无
next() - waterfall:链式传递,必须调
next()才往下走,可以在中间拦截或改写
调试技巧
查看最终配置树
pnpm dsh web --dump-config
打印出所有层合并后的完整插件树——确认你的插件是否被正确加载。
检查服务注册
在插件中:
export function apply(ctx: Context) {
console.log('Available services:', Object.keys(ctx.root))
}
HMR 开发循环
- 启动
pnpm dsh web --patch ./scratch-plugin/cordis.yml - 修改
scratch-plugin/src/my-plugin.ts - 保存——观察终端中旧插件卸载 + 新插件加载的日志
- 在 Web UI 中立即使用新行为
不需要重启进程。
进阶方向
| 方向 | 文档 |
|---|---|
| 能力三层拆分 | develop/practice |
| LLM 适配器 | develop/practice/llm-adapter |
| 事件系统 | develop/framework/events |
| Cordis 框架教程 | develop/cordis-tutorial |
| Tool 编写参考 | reference/cookbook/adding-a-tool |