DeepSeek Harness 插件开发入门:从入口到第一个工具
如果你会写 Java 或 JavaScript,但还不知道 Harness 插件从哪里开始,本系列按四个步骤带你理解并修改 @litongjava/dsh-plugin-deepseek-browser-use:建立插件入口、接入基础浏览器命令、处理会话和异步作业、安装到宿主。
本篇的目标是先看懂一个工具如何被 Harness 调用,并运行一个不需要浏览器的最小示例。下一篇再接入真实浏览器服务。示例以本仓库插件 0.2.0 和它固定的 Harness 0.2.0-rc.2 API 为基线,不代表其他版本接口完全相同。
1. 先分清三个程序
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 服务。已有仓库可以直接进入插件目录:
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:
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:
{
"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 同样是实际文件:
{
"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,写入以下完整文件:
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}!` };
},
});
}
逐步看这段代码的作用:
inject = ['tools']声明需要宿主工具服务,ctx.tools.register才有对应服务可用。import type {}用于引入服务的 TypeScript 类型扩展,不是在创建服务实例。Config接口只做编译期检查;同名的Schema.object(...)是运行时的值,会校验配置并补齐默认值。TypeScript 允许类型和值同名。parameters告诉模型怎样调用工具;execute才真正执行。不能因为模型看到了 schema 就省略运行时校验。正式插件用z.toJSONSchema再经一层投影生成parameters(只保留 Harness 支持的 JSON Schema 子集),所以模型看到的 schema 会更简——minLength、minimum、default这类约束不会出现在里面,执行时仍由 Zod 完整校验。exec.signal是本次调用的取消信号。工具应该检查它,后续 HTTP 也要继续传递这个信号。return是供程序使用的结果,output.render是面向模型的内容块。把二者分开,后面才能只截短展示文本而保留完整结果。
这个问候工具没有浏览器资源,所以只依赖 tools。正式浏览器插件还需要 agents 验证调用者、管理任务归属。
5. 不启动完整 Harness,也能验证工具派发
新建 scripts/tutorial-hello.mjs,以下是完整文件。它使用真正的 ToolRuntime,但只提供本例需要的最少宿主服务:
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();
在插件目录执行:
npm run build
node scripts/tutorial-hello.mjs
预期输出包含 欢迎,插件开发者!。这验证了“注册 → 派发 → 执行 → 规范结果”链路,不代表已经安装到目标 Harness。正式包入口仍是 dist/index.js;练习文件不会自动成为 bundle 入口。
6. 对照真正的浏览器插件入口
正式 src/index.ts 中的以下片段,去掉了后端自动管理部分以突出装配关系;它依赖原文件的 import 和配置,不能单独复制成完整文件:
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 |
