系列实战篇,基于 DeepSeek Harness(dsh)的 Cordis 插件系统,手把手带你写第一个插件:从 apply(ctx) 函数开始,到服务、事件、配置校验,最后注册一个模型可调用的真实工具。


目录

  1. 插件开发前必须搞懂的三个概念
  2. 环境搭建:clone 仓库 + 安装依赖
  3. 第一个插件:apply 函数 + cordis.yml
  4. 生命周期管理:ctx.effect 与 Fiber 状态机
  5. 服务(Service):插件间如何互相调用
  6. 事件(Events):5 种分发模式
  7. 配置校验:Schema 让插件更健壮
  8. 实战:注册一个模型可调用的真实工具
  9. 完整案例:做一个代码仓库分析工具
  10. 调试与热更新
  11. 发布与安装插件
  12. 本篇总结

1. 插件开发前必须搞懂的三个概念

1.1 插件就是一个函数

在 Cordis 的世界里,插件本质是一个函数

export function apply(ctx: Context) {
  // 在这里描述这个插件贡献了什么能力
}
  • ctx上下文(Context),插件通过它访问其他插件、注册服务、监听事件
  • 插件没有框架启动代码——启动、加载、销毁全部由 Cordis 内核管理
  • 多个插件由 cordis.yml 组合成一个应用,加载顺序由依赖关系决定,与文件位置无关

1.2 三种插件形态

import { Service, type Context } from '@deepseek-ai/cordis'

// 形态 1:函数插件(最常见)
export const name = 'hello'
export function apply(ctx: Context) {
  console.log('hello from my first plugin')
}

// 形态 2:对象插件(带 apply 方法的对象)
export const objectPlugin = {
  name: 'object-plugin',
  apply(ctx: Context) {},
}

// 形态 3:类插件(Service 子类,用于对外公开服务)
export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myService')
  }
}

1.3 插件树:整个 Harness 就是一株插件树

根 Context
 ├── Loader 插件(读入 cordis.yml)
 ├── hello 插件
 ├── tools 服务
 ├── llm 适配器
 └── agent loop

模型、工具、会话、沙箱、UI……所有能力都是挂在这棵树上的一枚插件。


2. 环境搭建:clone 仓库 + 安装依赖

插件开发需要拿到 Harness 的源码和类型定义:

# 1. 克隆官方仓库
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness

# 2. 安装依赖(仓库使用 pnpm 管理)
pnpm install

# 3. 创建你的插件开发目录
mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial

开发插件不需要 API 密钥——插件本身是纯代码逻辑,只有真正调用模型时才需要密钥。

验证环境是否可用(不调用任何模型):

node --import tsx ../../vendor/cordis/bin.js

3. 第一个插件:apply 函数 + cordis.yml

3.1 创建插件文件 hello.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello'

export function apply(ctx: Context) {
  console.log('hello from my first plugin')
}

3.2 创建组合文件 cordis.yml

- name: './hello.ts'

3.3 运行

node --import tsx ../../vendor/cordis/bin.js
# 输出: hello from my first plugin

就这么简单——一个插件跑起来了。

关键认知:

  • cordis.yml 里各个配置项并发启动
  • 加载顺序由**服务依赖(inject)**决定,不是文件写的先后顺序
  • 插件文件里不需要任何启动逻辑,apply 被调用即插件加载

4. 生命周期管理:ctx.effect 与 Fiber 状态机

插件有时会创建定时器、打开连接、启动 watcher 等"外部资源"。这些资源不受 Cordis 管理,必须包装进 ctx.effect(),并提供销毁函数(disposer)。

4.1 正确管理定时器的例子

// lifecycle.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    // 返回 disposer:插件卸载时自动清理
    return () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  const fiber = ctx.plugin(heartbeat)   // 子插件

  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

4.2 Fiber 状态机

每个插件加载后都是一个 Fiber,状态流转:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                        ↘ FAILED(加载或运行出错)

4.3 已内建 effect 的 API(不用手动销毁)

API说明
ctx.on(event, listener)事件监听,插件卸载时自动移除
ctx.plugin(child)子插件,随父插件一起销毁
ctx.tools.register(...)工具注册,自动附着到插件生命周期

注意:disposer 按注册逆序启动,但多个异步 disposer 是并发运行的。需要严格按顺序拆除时,应放在同一个 disposer 中依次 await


5. 服务(Service):插件间如何互相调用

服务是插件对外暴露能力的方式。提供服务消费服务解耦,两个插件可以互相不认识,却能协作。

5.1 提供服务:greeter.ts

import { Service, type Context } from '@deepseek-ai/cordis'

// 声明合并:给 Context 加上类型提示
declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')   // 注册到 ctx.greeter
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

5.2 消费服务:consumer.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'consumer'
export const inject = ['greeter']   // 声明硬依赖

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

5.3 组合运行

- name: './greeter.ts'
- name: './consumer.ts'

输出:Hello, world!——哪怕把顺序交换,结果也一样

5.4 依赖要点

场景做法
服务必须存在export const inject = ['greeter'](硬依赖)
服务缺失时插件保持 PENDING,不崩溃、不部分运行
服务运行中消失依赖它的插件被卸载,恢复后重新加载(支持热替换)
服务可有可无不用 inject,用 ctx.get('greeter') 探测
命名冲突服务名是扁平命名空间,建议加前缀,如 my-tutorial-greeter

6. 事件(Events):5 种分发模式

插件之间除了服务调用,还能通过事件松耦合通信

6.1 声明、发出、监听

// stats.ts —— 声明事件并发出
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    stats: StatsService
  }
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

export class StatsService extends Service {
  private counts = new Map<string, number>()

  constructor(ctx: Context) {
    super(ctx, 'stats')
  }

  bump(name: string) {
    const next = (this.counts.get(name) ?? 0) + 1
    this.counts.set(name, next)
    this.ctx.emit('stats/report', name, next)   // 发出事件
  }
}

export const name = 'stats'

export function apply(ctx: Context) {
  ctx.plugin(StatsService)
}
// reporter.ts —— 监听
import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts'   // 仅为类型,无运行时导入

export const name = 'reporter'
export const inject = ['stats']

export function apply(ctx: Context) {
  ctx.on('stats/report', (name, count) => {
    console.log(`[stats] ${name} -> ${count}`)
  })
  ctx.stats.bump('tool_call')
  ctx.stats.bump('prompt')
}

6.2 五种分发模式对比

模式调用方式行为
emitctx.emit(name, ...args)同步广播,不等待返回值
parallelawait ctx.parallel(name, ...args)所有监听器并发运行并一起等待
serialawait ctx.serial(name, ...args)顺序运行,第一个非空返回值胜出并短路
bailctx.bail(name, ...args)serial 的同步版本
waterfallctx.waterfall(name, ...args, next)环绕中间件,可转换或短路

6.3 Waterfall:拦截/变体模式

declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}

export const name = 'waterfall-demo'

export function apply(ctx: Context) {
  // 监听器 1:把下游结果转大写
  ctx.on('demo/transform', async (input, next) => {
    const downstream = await next()
    return downstream.toUpperCase()
  })

  // 监听器 2:命中关键词直接短路(veto)
  ctx.on('demo/transform', async (input, next) => {
    if (input.includes('blocked')) return '** blocked **'
    return next()
  })

  void (async () => {
    console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
    console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
  })()
}

输出:

HELLO
** BLOCKED **

纪律:只做观察/修改的 waterfall 监听器必须调用 next();不调用即代表有意短路(否决)。


7. 配置校验:Schema 让插件更健壮

插件可以声明配置项,用 Schema 做运行时校验。

// config-demo.ts
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'config-demo'

export interface Config {
  greeting: string
  targets: string[]
}

export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  targets: Schema.array(String).default(['world']),
})

export function apply(ctx: Context, config: Config) {
  for (const target of config.targets) {
    console.log(`${config.greeting}, ${target}!`)
  }
}
- name: './config-demo.ts'
  config:
    targets: ['alpha', 'beta']

输出:

Hello, alpha!
Hello, beta!

校验失败的后果:

  • 配置无效 → 抛出 ValidationError → fiber 进入 FAILED → 进程以码 1 退出
  • 支持 !!js 标签计算配置值:greeting: !!js process.env.DEMO_GREETING ?? 'Hello'

8. 实战:注册一个模型可调用的真实工具

前面都是地基,现在进入真正的 Harness 开发:给 Agent 注册一个模型可以调用的工具。全程无需 API 密钥。

8.1 工具插件 greet-tool.ts

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  // 1. 注册工具:模型在对话中看到 name/description/parameters 后决定调用
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet the named person.',
    parameters: {
      name: { type: 'string', required: true, description: 'Who to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))

  // 2. 手动触发一次工具执行,验证链路
  void (async () => {
    const result = await ctx.tools.execute({
      callId: CallId('demo-1'),
      name: 'greet',
      arguments: { name: 'Cordis' },
      signal: new AbortController().signal,
    })
    console.log('tool replied:', JSON.stringify(result.content))
  })()
}

8.2 观察插件:监听工具执行事件

// tool-logger.ts
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'

export const name = 'tool-logger'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    const text = result.content
      .map(block => (block.type === 'text' ? block.text : ''))
      .join('')
    console.log(`[tool-logger] ${exec.name} -> ${text}`)
  })
}

8.3 组合运行(注意 dsh-tools 依赖 systemPrompt 服务)

- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'

输出:

[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]

这就是 Harness 插件开发的精髓:你的工具被注册进 ctx.tools 后,模型在对话中只要看到合适的场景就会自动调用它——你写的 execute 函数就是工具的"身体"。


9. 完整案例:做一个代码仓库分析工具

把前面的知识点串起来,写一个实用工具:统计项目代码量,让模型通过它了解一个仓库的规模。

// repo-stats-tool.ts
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'repo-stats-tool'
export const inject = ['tools']

const IGNORED = new Set(['node_modules', '.git', 'dist', '.next'])

async function countLines(dir: string): Promise<{ files: number; lines: number }> {
  let files = 0
  let lines = 0
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    if (IGNORED.has(entry.name)) continue
    const full = join(dir, entry.name)
    if (entry.isDirectory()) {
      const sub = await countLines(full)
      files += sub.files
      lines += sub.lines
    } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
      files += 1
      const content = await stat(full)
      lines += Math.max(content.size / 30, 1)  // 估算行数
    }
  }
  return { files, lines }
}

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'repo_stats',
    description: 'Count total TypeScript files and estimated lines in a directory.',
    parameters: {
      directory: { type: 'string', required: true, description: 'Absolute path to analyze' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      const { files, lines } = await countLines(args.directory)
      return `Found ${files} TS files, ~${lines} lines`
    },
  }))
}

工具设计的三个原则:

  1. 描述要具体description 直接决定模型何时调用它,写清楚参数含义
  2. 参数要少而清晰:模型靠 JSON Schema 决定填什么
  3. 执行要快、可失败:工具返回错误文本比抛异常更友好(模型能读懂并换策略)

10. 调试与热更新

10.1 常见错误处理行为

场景行为
apply 抛异常进程终止,不会只跳过该配置项
模块路径/包名拼写错误通过 logger 报告,不崩溃(但可能被吞掉,注意日志)
配置校验失败fiber 进入 FAILED,进程码 1 退出

10.2 热更新(HMR)

Cordis 支持配置热重载。当你修改 cordis.yml 或插件源码后:

  • 已加载的插件按需卸载、重新加载
  • 服务消失时依赖插件先卸载,服务恢复后自动重新加载
  • 插件停在 PENDING 时,可通过诊断工具查看等待哪个依赖

11. 发布与安装插件

插件开发完,可以发布成 npm 包让别人直接引用:

# 发布(用你项目的包管理器)
npm publish

安装方在 cordis.yml 里一行引入:

- name: 'my-company/hello-plugin'   # 已发布到 npm 的包名

Harness 官方也提供社区插件生态,开发者可以从官方插件里 fork 学习,再按自己的场景裁剪。


12. 本篇总结

要点内容
插件本质一个 apply(ctx) 函数,能力由 ctx 组合
组合方式cordis.yml 声明插件列表,依赖决定加载顺序
生命周期ctx.effect() 管理外部资源,Fiber 状态机自动流转
服务Service 子类公开能力,inject 声明硬依赖
事件emit / parallel / serial / bail / waterfall 五种分发
配置Schema 校验,配置错误明确报错
真实工具defineTool + ctx.tools.register,模型可自动调用
核心思想把能力拆成可组合、可替换、可热更新的插件

给初学者的路径建议:

  1. 先跑通 hello.ts,理解 apply + yml 的魔法
  2. 再写一个 Service + 一个 consumer,理解依赖注入
  3. 然后用 defineTool 注册你的第一个真实工具
  4. 最后去看 examples/headless-agent/cordis.yml,对照理解官方所有配置项

掌握了这四步,你就拥有了扩展 DeepSeek Harness 的完整能力——一切皆插件,现在轮到你写插件了。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐