Skip to content

DeepSeek Harness 插件开发入门:从入口到第一个工具 ​

如果你会写 Java 或 JavaScript,但还不知道 Harness 插件从哪里开始,本系列按四个步骤带你理解并修改 @litongjava/dsh-plugin-deepseek-browser-use:建立插件入口、接入基础浏览器命令、处理会话和异步作业、安装到宿主。

本篇的目标是先看懂一个工具如何被 Harness 调用,并运行一个不需要浏览器的最小示例。下一篇再接入真实浏览器服务。示例以本仓库插件 0.2.0 和它固定的 Harness 0.2.0-rc.2 API 为基线,不代表其他版本接口完全相同。

1. 先分清三个程序 ​

text
Harness:与模型对话,管理 Agent,选择并执行工具
  └─ TypeScript 插件:注册 dsb_*,校验参数,管理任务,发送 HTTP
       └─ Java 浏览器服务:接收 /playwright/command,操作真实浏览器

插件运行在 Harness 后端的 Node.js 进程中,不运行在用户打开的 Web 页面里。Java 服务可以在同机,也可以在远程。插件通过 HTTP 使用已有浏览器能力,因此这里不需要重新用 TypeScript 实现 Playwright,也不需要调用 Python 或 dsb 子进程。

先认识后面会反复出现的几个词:

名称在本项目中的含义
Cordis宿主使用的插件和服务机制;入口通过 apply(ctx) 接收上下文
Context / ctx获取宿主服务、注册资源及其清理回调的对象
Agent当前正在执行工具的活跃智能体对象;本插件用它确定浏览器任务归属
ToolDefinition工具定义:名称、说明、参数、执行函数、输出与展示方式
ToolRuntime注册和派发工具的宿主运行时
Schema描述数据结构;配置和工具调用分别使用不同的校验入口
bundle / patch安装包声明及插件配置补丁;让宿主知道应该加载哪个插件

dsb_navigate 是模型看见的工具名,go_to_url 是 Java 服务的方法名,二者不必相同。插件承担的一个职责就是把适合模型使用的接口转换成服务协议。

2. 准备工程,而不是先安装一堆不确定的依赖 ​

需要 Node.js >= 22.19、npm 和仓库源码。本篇的最小工具不需要 Java 或 Chrome;第二篇联调时才需要按 02 启动 Java 服务。已有仓库可以直接进入插件目录:

powershell
cd deepseek-browser-use\plugins\deepseek-browser-use
node --version
npm --version
npm ci --ignore-scripts --legacy-peer-deps
npm run build

没有源码时,先克隆,再进入 plugins/deepseek-browser-use:

powershell
git clone https://gitee.com/ppnt/deepseek-browser-use.git
cd deepseek-browser-use/plugins/deepseek-browser-use

上面两个路径入口二选一。npm ci 依据现有 package-lock.json 安装;新建空目录还没有锁文件时不能直接使用它。本系列以现有插件为可运行底座,逐步讲解和扩展,避免读者拼接片段后缺少依赖或辅助函数。

--legacy-peer-deps 用于独立插件开发目录的依赖安装,不表示任意 Harness 版本都兼容。部署到宿主时仍需匹配包中声明的 peerDependencies。

3. package.json 为什么这样写 ​

下面是插件当前的完整 package.json:

json
{
  "name": "@litongjava/dsh-plugin-deepseek-browser-use",
  "version": "0.2.0",
  "description": "Native DeepSeek Harness tools for the deepseek-browser-use HTTP service",
  "type": "module",
  "license": "MIT",
  "engines": {
    "node": ">=22.19.0"
  },
  "exports": {
    ".": "./dist/index.js",
    "./client": "./dist/client.js",
    "./package.json": "./package.json",
    "./locale/*.json": "./locale/*.json"
  },
  "types": "./dist/index.d.ts",
  "files": [
    "dist",
    "locale",
    "cordis.patch.yml",
    "scripts/backend.ps1",
    "README.md"
  ],
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "test": "npm run build && node --test test/*.test.mjs",
    "prepack": "npm run build",
    "smoke": "npm run build && node scripts/smoke.mjs"
  },
  "dependencies": {
    "@deepseek-ai/schemastery": "3.18.4",
    "zod": "4.1.12"
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.4",
    "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
    "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
    "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
    "@deepseek-ai/dsh-tools": "0.2.0-rc.2"
  },
  "peerDependenciesMeta": {
    "@deepseek-ai/dsh-attachment": {
      "optional": true
    },
    "@deepseek-ai/dsh-llm": {
      "optional": true
    }
  },
  "devDependencies": {
    "@deepseek-ai/cordis": "4.0.4",
    "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
    "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
    "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
    "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.2",
    "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
    "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
    "@types/node": "22.19.0",
    "typescript": "5.9.3"
  }
}

不要只把它当成 npm 的名字和版本。对插件来说,几个字段直接决定能否加载:

  • type: "module":输出使用 ESM 的 import/export。
  • exports["."]:宿主加载包时进入 dist/index.js,所以只写 src/index.ts 还不能安装运行。
  • types:让 TypeScript 使用者拿到声明文件,不负责执行代码。
  • files:决定打包进入 .tgz 的文件;scripts/backend.ps1 是后端自动管理实际要运行的脚本,不能漏掉。
  • dsh.bundle.patch:指向安装配置;只有编译入口而没有激活配置,仍不等于插件已启用。
  • dependencies:插件自己的运行依赖,如 Zod、Schemastery。
  • peerDependencies:由宿主提供、需要版本兼容的服务包。开发目录也在 devDependencies 声明它们,便于编译与测试。
  • peerDependenciesMeta:图片相关服务是可选依赖;没有这些服务时仍可使用 DOM 工具。
  • prepack:打包前编译,减少把旧 dist 当作新代码发布的机会。

tsconfig.json 同样是实际文件:

json
{
  "compilerOptions": {
    "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext",
    "strict": true, "skipLibCheck": true, "declaration": true,
    "outDir": "dist", "rootDir": "src", "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

rootDir/outDir 把源文件和输出分开;declaration 生成类型声明;strict 让类型问题尽早暴露。NodeNext 按 Node ESM 规则解析模块,所以源码里写 import ... from './client.js',不是误把 .ts 写成了 .js:编译后的文件确实叫 client.js。

4. 写一个完整、可编译的最小工具 ​

先不修改正式的 src/index.ts。在同一个插件目录新建 src/tutorial-hello.ts,写入以下完整文件:

typescript
import type { Context } from '@deepseek-ai/cordis';
import Schema from '@deepseek-ai/schemastery';
import type {} from '@deepseek-ai/dsh-tools';

export const name = 'tutorial-hello';
export const inject = ['tools'];

export interface Config { greeting: string; }
export const Config = Schema.object({
  greeting: Schema.string().default('你好'),
});

export function apply(ctx: Context, input: Partial<Config> = {}): void {
  const config = Config(input) as Config;
  ctx.tools.register({
    name: 'tutorial_hello',
    description: 'Return a greeting to a named learner.',
    parameters: {
      type: 'object',
      properties: { learner: { type: 'string' } },
      required: ['learner'],
      additionalProperties: false,
    },
    output: {
      schema: {
        type: 'object',
        properties: { message: { type: 'string' } },
        required: ['message'],
        additionalProperties: false,
      },
      render: (_args, value) => [
        { type: 'text', text: JSON.stringify(value) },
      ],
    },
    async execute(raw, exec) {
      exec.signal.throwIfAborted();
      if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
        throw new Error('Arguments must be an object');
      }
      const args = raw as Record<string, unknown>;
      if (Object.keys(args).some(key => key !== 'learner') ||
          typeof args.learner !== 'string' || !args.learner.trim()) {
        throw new Error('Provide a non-empty learner string');
      }
      return { message: `${config.greeting},${args.learner}!` };
    },
  });
}

逐步看这段代码的作用:

  1. inject = ['tools'] 声明需要宿主工具服务,ctx.tools.register 才有对应服务可用。import type {} 用于引入服务的 TypeScript 类型扩展,不是在创建服务实例。
  2. Config 接口只做编译期检查;同名的 Schema.object(...) 是运行时的值,会校验配置并补齐默认值。TypeScript 允许类型和值同名。
  3. parameters 告诉模型怎样调用工具;execute 才真正执行。不能因为模型看到了 schema 就省略运行时校验。正式插件用 z.toJSONSchema 再经一层投影生成 parameters(只保留 Harness 支持的 JSON Schema 子集),所以模型看到的 schema 会更简——minLength、minimum、default 这类约束不会出现在里面,执行时仍由 Zod 完整校验。
  4. exec.signal 是本次调用的取消信号。工具应该检查它,后续 HTTP 也要继续传递这个信号。
  5. return 是供程序使用的结果,output.render 是面向模型的内容块。把二者分开,后面才能只截短展示文本而保留完整结果。

这个问候工具没有浏览器资源,所以只依赖 tools。正式浏览器插件还需要 agents 验证调用者、管理任务归属。

5. 不启动完整 Harness,也能验证工具派发 ​

新建 scripts/tutorial-hello.mjs,以下是完整文件。它使用真正的 ToolRuntime,但只提供本例需要的最少宿主服务:

javascript
import assert from 'node:assert/strict';
import { Context } from '@deepseek-ai/cordis';
import { ToolRuntime } from '@deepseek-ai/dsh-tools';
import { apply } from '../dist/tutorial-hello.js';

const ctx = new Context();
ctx.provide('systemPrompt', { tools() {} });
const runtime = new ToolRuntime(ctx);
apply(ctx, { greeting: '欢迎' });

const result = await runtime.execute({
  name: 'tutorial_hello',
  arguments: { learner: '插件开发者' },
  callId: 'tutorial-1',
  signal: new AbortController().signal,
});
assert.equal(result.isError, false, JSON.stringify(result));
assert.equal(result.value.message, '欢迎,插件开发者!');
console.log(result.value);
await ctx.fiber.dispose();

在插件目录执行:

powershell
npm run build
node scripts/tutorial-hello.mjs

预期输出包含 欢迎,插件开发者!。这验证了“注册 → 派发 → 执行 → 规范结果”链路,不代表已经安装到目标 Harness。正式包入口仍是 dist/index.js;练习文件不会自动成为 bundle 入口。

6. 对照真正的浏览器插件入口 ​

正式 src/index.ts 中的以下片段,去掉了后端自动管理部分以突出装配关系;它依赖原文件的 import 和配置,不能单独复制成完整文件:

typescript
export const name = 'litongjava-browser-use';
export const inject = ['tools', 'agents'];

// apply 内部,在 config、backend 和 client 已创建之后:
const sessions = new BrowserSessions(client, {
  browser: config.browser,
  headless: config.headless,
});
ctx.effect(() => () => sessions.dispose(), 'deepseek-browser-use.sessions');
for (const tool of createTools(ctx, client, sessions, config)) {
  ctx.tools.register(tool);
}

入口负责把对象连接起来,不应把 HTTP、会话、上传全部堆在 apply 中。ctx.effect 的外层回调注册资源,返回的内层函数在作用域释放时清理会话;注册时不会立即执行 sessions.dispose()。

正式包的分工如下:

文件要解决的问题接下来在哪篇展开
src/index.ts如何被宿主加载,怎样创建服务对象本篇、安装篇
src/client.ts怎样请求 Java 服务并可靠读取响应基础功能
src/tools.ts怎样把浏览器能力转换成模型工具基础功能、核心功能
src/sessions.ts谁拥有页面,如何排队和释放任务核心功能
src/commands.ts通用入口允许哪些准确命令名核心功能
cordis.patch.yml怎样安装和启用安装篇
src/backend.ts、scripts/backend.ps1怎样自动准备并启动 Java 服务35

下一篇:开发 dsh-plugin-deepseek-browser-use:基础功能。