DeepSeek Harness 插件开发实战:从 Hello World 到自定义 Tool

0 阅读5分钟

上一篇介绍了 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模型用来决定是否调用的描述
parametersJSON 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 会:

  1. 卸载旧 fiber(ctx.tools.register 的 disposer 自动执行,tool 注销)
  2. 创建新 fiber
  3. 用新 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预构建,最简单
tarballdsh plugin add ./pkg-0.1.0.tgzpnpm pack 打包
GitHubdsh plugin add github:you/repo需要 prepare 脚本构建
本地开发dsh plugin add ./local-dirpnpm 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 服务隔离的通用能力——适用于 toolsshellfsllm 等任何服务。

事件系统

插件间松耦合通信通过事件:

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 开发循环

  1. 启动 pnpm dsh web --patch ./scratch-plugin/cordis.yml
  2. 修改 scratch-plugin/src/my-plugin.ts
  3. 保存——观察终端中旧插件卸载 + 新插件加载的日志
  4. 在 Web UI 中立即使用新行为

不需要重启进程。

进阶方向

方向文档
能力三层拆分develop/practice
LLM 适配器develop/practice/llm-adapter
事件系统develop/framework/events
Cordis 框架教程develop/cordis-tutorial
Tool 编写参考reference/cookbook/adding-a-tool

参考链接


上一篇:DeepSeek Harness:一切皆插件的开源 AI Agent 运行时

下一篇:MCP 和 Skill 如何被统一为 Cordis 插件