夜雨聆风学习资料网

ARTICLE · 988255

DeepSeek Harness插件开发入门:从零写一个插件并接入运行

DeepSeek Harness插件开发入门:从零写一个插件并接入运行

DeepSeek Harness(下称DSH)是一个"全插件"的Cordis微内核Agent框架——除了agent-loop本体,几乎所有能力(工具、权限策略、UI、协议适配)都以插件形式挂载。本文从零开始:写一个最小插件、写一个模型可调用的工具插件、再写一个能拦截工具调用的策略插件,把它们接入DSH并启动验证。全部命令与输出都是实际执行的结果,可照抄复现。

0. 前置条件

  • 一份DSH源码checkout(本文基于dsh-v0.1.2-alpha.1之后的master),并完成依赖安装与构建:

    git clone https://github.com/deepseek-ai/deepseek-harnesscd deepseek-harnesspnpm installpnpm run build        # 首次必须:Web 前端 dist 也是这一步产出
  • Node ^22.19 || >=24,pnpm 11.x。

  • 调用真实模型需要一个API Key(只验证插件加载则不需要):

    export DEEPSEEK_API_KEY=sk-xxxx
  • 从源码启动DSH的方式是pnpm dsh <子命令>,它通过tsx的ESM hook直接运行TypeScript源码。

1. 插件是什么

一个插件就是一个导出apply(ctx) 函数的ES模块。框架加载插件时调用apply,把上下文对象ctx传进来,你在apply里通过ctx注册能力:

importtype { Context } from'@deepseek-ai/cordis'exportconst name = 'my-plugin'// 插件名(必填)exportconst inject = ['tools']       // 依赖的服务(可选):就绪后才会调用 applyexportfunctionapply(ctx: Context) {// 在这里注册事件监听、工具、服务……}

三种等价写法:函数模块(最常用)、对象{ name, inject, apply }、class extends Service(需要向其他插件提供服务时)。

两个关键语义:

  • 注册即 effect:ctx.on()、ctx.tools.register()等注册都返回清理函数,插件卸载时自动执行。定时器这类自管理资源用ctx.effect(() => () => clearInterval(timer))包一层即可获得同样的自动清理。
  • inject 是服务依赖声明:声明了['tools'],框架会等工具注册表服务就绪后才调用apply,所以函数体内可以直接用ctx.tools。

2. 第一个插件:hello

在仓库里建一个独立目录(不要放进packages/,那里是受管理的 workspace):

mkdir -p my-plugins/src

my-plugins/package.json:

{"name": "my-plugins","version": "0.0.1","private": true,"type": "module"}

name和version都不能省:DSH 会把每个活跃插件的"所属包身份"随模型请求上报(plugin-package-inventory 扩展),清单缺version会在发请求时报错(详见 §7 踩坑 4)。type: module保证模块按 ESM 加载。

my-plugins/src/hello.ts:

importtype { Context } from'@deepseek-ai/cordis'exportconst name = 'hello-plugin'exportfunctionapply(ctx: Context) {console.log('[hello-plugin] plugin loaded!')// 演示自动清理:每 5 秒打印一次心跳,插件卸载时 interval 被清掉  ctx.effect(() => {const timer = setInterval(() =>console.log('[hello-plugin] heartbeat'), 5000)return() => clearInterval(timer)  })}

3. 接入 DSH:cordis.yml 覆盖层 + --patch

插件写好后,用一张 patch 覆盖层把它插入任意 profile 的插件树。my-plugins/cordis.yml:

-insert:-id:helloname:'file:///C:/Users/you/deepseek-harness/my-plugins/src/hello.ts'
  • insert表示向插件树追加条目;id 是树内的行标识,name是模块路径。
  • 路径必须是绝对路径。Windows 上必须写成file:///C:/... URL(ESM import 的要求)。
  • 同一张YAML里可以继续列出更多插件(下一步的工具插件就加在这里)。

启动 Web UI 验证(注意 flag 顺序,见 §7 踩坑 1):

pnpm dsh web --patch ./my-plugins/cordis.yml --no-open --port 3081

启动日志里出现:

[hello-plugin] plugin loaded!dsh web: http://127.0.0.1:3081/?token=xxxx[hello-plugin] heartbeat[hello-plugin] heartbeat

第一行说明apply被调用了;随后的心跳证明effect生效。到这里,"写插件 → 接入 → 启动"的闭环已经通了。

4. 工具插件:让模型调用你的代码

工具注册在ctx.tools上,用defineTool定义——它会从schema推导execute参数的类型,并在执行前完成参数校验。my-plugins/src/greet.ts:

importtype { Context } from'@deepseek-ai/cordis'import { defineTool } from'../../../packages/core/tools/src/index.ts'exportconst name = 'greet-tool'exportconst inject = ['tools']exportfunctionapply(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' },                   // execute 返回值的规范类型      render: (_args, value) => [{ type: 'text', text: value }],  // 转成模型可读内容    },async execute(args) {// args 的类型由 parameters 推导而来:{ name: string }return`Hello, ${args.name}!`    },  }))console.log('[greet-tool] tool "greet" registered')}

要点:

  • parameters是统一schema DSL,execute的args类型由它推导,模型传入的参数会先校验再进入函数体;
  • execute只返回output.schema声明的规范值,render负责把值变成模型可读内容——两者分开是刻意设计;
  • 注册是effect:插件卸载时工具自动注销。

把它加进覆盖层my-plugins/cordis.yml:

-insert:-id:helloname:'file:///C:/Users/you/deepseek-harness/my-plugins/src/hello.ts'-id:greetname:'file:///C:/Users/you/deepseek-harness/my-plugins/src/greet.ts'

为什么用相对导入而不是import { defineTool } from '@deepseek-ai/dsh-tools'?官方教程里的裸导入在my-plugins/这种非 workspace 目录下不可靠:pnpm 的严格node_modules 布局下,裸说明符沿着目录向上找不到@deepseek-ai/dsh-tools。指向packages/core/tools/src/index.ts的相对导入在从源码启动时总是可用。import type { Context } from '@deepseek-ai/cordis'没问题——类型导入会被擦除,不发生运行时解析。

5. 验证方式一:headless一次性任务(最省事)

不用开浏览器,直接让模型跑一个任务并调用你的工具:

pnpm dsh --profile headless --patch ./my-plugins/cordis.yml \"Call the greet tool with name Ada. Report the tool result verbatim."

实际输出:

[hello-plugin] plugin loaded![greet-tool] tool "greet" registereddsh: reasoning:The user wants me to call the greet tool with name Ada...`greet` with name **Ada**: Hello, Ada!

模型真实发起了greet({name:"Ada"})调用,execute的返回值经render回到了模型,再由模型转述——工具插件完整闭环。

6. 验证方式二(进阶):用插件拦截插件——tools/pre-execute 策略门

DSH 的权限/沙箱体系都挂在同一条扩展点上:tools/pre-execute是一个瀑布式 allow/deny/ask 门。

"瀑布式"是什么意思:同一个扩展点可以被多个插件同时监听,它们按注册顺序排成一条链;工具每次被调用时,请求像瀑布一样逐层下落。每个监听器拿到(exec, next),有三条路——

工具调用  ▼监听器1(如沙箱插件)── 返回 deny/ask ──► 链条终止(短路)  │ 调用 next(),委托给下一层  ▼监听器2(如本文的黑名单门)── 同上三条路  │ next()  ▼链尾无裁决 ──► 放行,进入执行
  • 自己裁决,不调next():返回{ kind: 'deny', reason }直接拒绝(reason 会回传给模型)、{ kind: 'ask', ... }转人工审批,或者allow放行——一旦返回,后面的监听器不再执行;
  • 委托:调用next()把决定权交给下一层,链条的最终裁决会作为next()的返回值冒泡回来。

这和 Express/Koa 中间件是同一族机制,两条纪律也随之而来:一是忘了调next()就等于无声吞掉整条链(后面所有策略失效),所以它是硬规则;二是瀑布是"可排序的策略层"而非最终防线——需要"任何后注册插件都无法翻案"的拒绝时用ctx.tools.guard()(单调门)。

加一个黑名单插件演示拒绝语义,my-plugins/src/policy-gate.ts:

importtype { Context } from'@deepseek-ai/cordis'importtype { PreToolDecision } from'@deepseek-ai/dsh-tools'exportconst name = 'my-policy-gate'exportconst inject = ['tools']const DENY_FOR = new Set(['forbidden', 'root'])exportfunctionapply(ctx: Context) {  ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {if (exec.name === 'greet') {const args = exec.arguments as { name?: string }if (args?.name !== undefined && DENY_FOR.has(args.name.toLowerCase())) {return { kind: 'deny', reason: 'Denied by my policy gate: this name is on the blocklist.' }      }    }return next()   // 瀑布监听必须调用 next() 委托,否则短路整个链  })}

同样加进cordis.yml后运行:

pnpm dsh --profile headless --patch ./my-plugins/cordis.yml \"Call the greet tool twice: once with name Ada, once with name forbidden. Report both results verbatim."

实际输出:

1. `greet` with name **Ada**:   Hello, Ada!2. `greet` with name **forbidden**:   Error: Denied by my policy gate: this name is on the blocklist.

放行与拒绝两条路径都由真实模型触发;deny 的reason会作为工具错误回传给模型。把这个模式里的exec.name === 'greet'换成任何工具名/参数条件,就是一个最小的权限策略层。

想确认"到底注册了哪些工具"?再插一个三行插件:在apply里(Loader 就绪后)调用ctx.tools.schemas().map(s => s.name)打印即可——schemas()是工具注册表的公开投影方法。注意工具可见性是分层的:web profile的内置工具挂在agent作用域,root作用域只能看到你全局注册的工具;headless profile的工具在root,一次能列出全部(本例列出26个,含我们的greet)。

7. 踩坑记录

  1. launcher的flag必须在前:dsh web --no-open --patch x.yml 会报unknown option '--patch'。launcher(apps/cli/src/args.ts)只认识自己的 flag,遇到第一个不认识的 token 就把剩余全部交给应用。正确顺序:dsh web --patch x.yml --no-open。
  2. Windows路径必须用file:/// URL:C:/... 会报protocol 'c:'。
  3. scratch目录按CJS加载时裸导入解析不到:见 §4 的相对导入说明。
  4. **package.json 必须有非空version**:缺了它,任何模型请求都会以REQUEST_EXTENSION: DeepSeek request extension preparation failed 失败,而且错误信息不带原因(headless 只打印 message)。这是因为plugin-package-inventory-deepseek默认启用,会把每个活跃插件解析到所属包清单(packages/llm/plugin-package-inventory-deepseek/src/index.ts的identityFromManifest要求 name、version 均非空)。

8. 小结

DSH插件开发的心智模型可以压成三句话:

  1. 写一个导出apply(ctx)的ESM模块,服务依赖写进inject;
  2. 用一张- insert: 的cordis.yml覆盖层 + --patch接入任意profile,不需要改DSH本体;
  3. 用 headless一次性任务验证:模型真实调用你的工具,日志里能看到注册与执行的全过程。

相关学习资料