我用 DeepSeek + Harness 做了一个自动写代码的 Agent,效果惊人

第10章:从零写一个 dsh 插件------实战指南

系列:DeepSeek Harness 源码实战

原文仓库:github.com/deepseek-ai...

理解了 Cordis 的原理,接下来动手。这一章带你从零写一个完整的 dsh 插件:定义工具、加配置、注册事件监听器、打包成 bundle(插件包)、安装到 profile(配置文件)、用 HMR(Hot Module Replacement,热模块替换)调试。

不是概念演示------每一步都有真实代码和命令,跟着做就能跑起来。

我是怕浪猫,这是系列第 10 章。我们直接上代码。

10.1 最简工具插件:greet

先写一个最简单的工具插件,让模型可以调用 greet 工具打招呼。

创建文件 greet-tool/src/index.ts(参考 docs/cordis-tutorial/07-into-the-harness.mddocs/user/develop/basic/tool.md):

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}!`
    },
  }))
}

逐行拆解:

作用
export const name = 'greet-tool' 诊断用的显示名称
export const inject = ['tools'] 声明依赖 tools 服务,等 ctx.tools 就绪才加载
ctx.tools.register(...) 注册工具,返回 disposer,自动绑到当前 Fiber
defineTool(...) 把 parameters spec 转成 JSON Schema,推断 args 类型,校验模型参数
parameters.name 工具参数定义,type + required + description
output.schema 声明 execute 返回值的 JSON Schema
output.render 把返回值转成模型可见的 content blocks
execute(args) 工具体,args 已经过类型校验和推断

创建 greet-tool/cordis.yml(本地 patch 覆盖):

yaml 复制代码
- insert:
    - id:
greet
      name:
'./src/index.ts'

--patch 加载到 dsh Web:

sh 复制代码
pnpm dsh web --patch ./greet-tool/cordis.yml

打开 http://127.0.0.1:3080,对模型说「Use the greet tool to greet Ada」,模型会调用 greet 工具,收到 Hello, Ada!

10.2 给插件加配置

工具写好了,但打招呼的内容写死了。让用户通过配置自定义。

参考 docs/user/develop/basic/config.mddocs/cordis-tutorial/05-config.md

ts 复制代码
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']

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) {
  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 `${config.greeting}, ${args.name}!`
    },
  }))
}

关键变化:

变化 说明
export interface Config TypeScript 类型,消费方拿类型
export const Config Schemastery schema,Cordis 拿校验器
apply(ctx, config) 第二参数接收已校验的配置
config.greeting 替代硬编码的 'Hello'

cordis.yml 中传配置:

yaml 复制代码
- insert:
    - id:
greet
      name:
'./src/index.ts'
      config:
        greeting:
'Hi there'

配置校验失败时的报错(docs/cordis-tutorial/05-config.md):

bash 复制代码
ValidationError: invalid config:
  - $.greeting expected string but got 42

插件进入 FAILED 状态,进程以退出码 1 终止。dsh 的设计原则是「fail loud(大声失败)」------宁可启动失败,不要带着错误配置静默运行。

金句:配置不是可选项,是必须项。dsh 要求任何两个部署可能想设不同的值都必须是配置字段------测试标准是「cordis.yml 能改这个值而不需要改代码」。

!!js 标签支持运行时计算配置值(docs/cordis-tutorial/05-config.md):

yaml 复制代码
- insert:
    - id:
greet
      name:
'./src/index.ts'
      config:
        greeting:
!!js
process.env.GREETING
??
'Hello'

!!js 只在 configdisabled 字段中可用。disabled: !!js process.platform === 'win32' 可以按平台门控插件。

10.3 加事件监听器:工具调用日志

工具能用了,现在加一个独立的监听器插件,记录所有工具调用的结果。

参考 docs/cordis-tutorial/07-into-the-harness.md 的 observer 插件:

ts 复制代码
// tool-logger/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import '@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}`)
  })
}

关键点:

要点 说明
import '@deepseek-ai/dsh-tools' 拉入 declaration merge,让 'tools/result' 事件有类型
ctx.on('tools/result', ...) 注册监听器,卸载时自动移除
exec 工具执行上下文(name、arguments、callId 等)
result 工具执行结果(content blocks)

tools/result 是 emit 事件------同步广播,返回值被忽略。监听器在结果物化时触发,早于 execute 的 Promise resolve。所以上面教程原文说:

The logger fired first: tools/result is emitted as part of result materialization, before execute's promise resolves to the caller.

组合到一起:

yaml 复制代码
- name:
'@deepseek-ai/dsh-system-prompt'
- name:
'@deepseek-ai/dsh-tools'
- name:
'./tool-logger/src/index.ts'
- name:
'./greet-tool/src/index.ts'

@deepseek-ai/dsh-tools 依赖 systemPrompt 服务(工具的 schema 会进系统提示),所以必须列出 dsh-system-prompt。不列的话,tools 插件会 PENDING。

运行结果:

bash 复制代码
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]

两个插件互不知道对方存在------registry 服务和事件把它们连起来。这就是松耦合扩展的威力。

10.4 打包成 Bundle

本地 --patch 够用了,但如果要分享插件或安装到不同环境,需要打包成 bundle。

参考 docs/user/develop/basic/publish.md,一个 bundle 的结构:

bash 复制代码
greet-plugin/
├── package.json       # 声明 dsh.bundle
├── cordis.patch.yml   # 配置层
└── index.js           # 插件代码

package.json

json 复制代码
{
  "name": "dsh-greet-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 这是一个 bundle 包,patch 指向配置层文件。

index.js

js 复制代码
export const name = 'greet-plugin'

export function apply() {
  console.log('[greet-plugin] plugin loaded!')
}

cordis.patch.yml

yaml 复制代码
- insert:
    - id:
greet
      name:
dsh-greet-plugin

注意 name 从相对路径变成了 npm 包名------Node 解析机制会在 node_modules 里找到已安装的包。

10.5 安装到 Profile

bundle 打好了,安装到 profile 里。profile 是 dsh 的可运行组合,由一组 bundle 组成。

安装命令(docs/user/develop/basic/publish.md):

sh 复制代码
dsh plugin --profile demo add ./greet-plugin

首次使用会初始化 profile,自动添加 @deepseek-ai/dsh-base 作为第一个 bundle。安装后 profile 的 package.json 长这样:

json 复制代码
{
  "name": "dsh-profile-demo",
  "private": true,
  "dependencies": {
    "dsh-greet-plugin": "link:/path/to/greet-plugin"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "dsh-greet-plugin"
      ]
    }
  }
}

验证配置:

sh 复制代码
dsh --profile demo --dump-config
# 输出中会看到 "# == dsh-greet-plugin" 层

启动:

sh 复制代码
dsh --profile demo

10.6 配置层加载顺序

理解配置层的加载顺序至关重要(docs/user/develop/basic/publish.md):

bash 复制代码
1. 每个 bundle 的 patch(按 dsh.profile.bundles 列表顺序)
   @deepseek-ai/dsh-base 先,然后每个安装的 bundle

2. profile 自己的 cordis.patch.yml
   (用户级配置)

3. $DSH_HOME/cordis.patch.yml
   (机器级偏好,所有 profile 共享)

4. 每个 --patch <path> overlay(按命令行顺序)
   (临时叠加)

关键规则:后层覆盖前层,按 id 匹配行,替换整个 config 值------不是深合并

两层叠加的示例:

id config
bundle 层 greet { greeting: 'Hello' }
profile 层 greet { greeting: 'Hi' }
最终结果 greet { greeting: 'Hi' }(整个 config 被替换)

注意:profile 层替换了整个 config,不是把 greeting 改成 Hi 保留其他字段。如果你的 bundle 层 config 有多个字段,profile 层想改一个就必须把其他字段也写上。

金句:patch 不是深合并,是整行替换。改一个字段要把整行 config 重写------这是 dsh 的设计选择,宁可显式也不隐式。

10.7 从 GitHub 安装的构建陷阱

发布到 npm 之前,可能想从 GitHub 直接安装。有一个重要陷阱(docs/user/develop/basic/publish.md):

sh 复制代码
dsh plugin --profile demo add github:you/greet-plugin

git install 拉取的是源码,不是构建产物。pnpm 不会自动运行 build 脚本。TypeScript 包没有 lib/ 目录就跑不起来。

两方各需一步:

插件作者 :在 package.json 里加 prepare 脚本:

json 复制代码
{
  "scripts": {
    "prepare": "tsdown src/index.ts --format esm --dts"
  }
}

pnpm 在 git install 后会运行 prepare。它必须自包含------不能假设有 monorepo 上下文。

插件用户 :pnpm >=10 默认拒绝运行 git 依赖的 prepare 脚本。需要在 profile 的 pnpm-workspace.yaml 里允许:

yaml 复制代码
allowBuilds:
  dsh-greet-plugin:
true

教程明确警告:

Treat that allowance as what it is: permission to execute the package's code on your machine at install time, outside any sandbox the agent runs under.

不想让用户处理这个?发布到 npm 或发 tarball:

sh 复制代码
# 发布到 npm(用户直接安装预构建代码)
npm publish

# 或发 tarball
pnpm pack
# 用户运行:dsh plugin --profile demo add ./greet-plugin-0.1.0.tgz

10.8 HMR 调试循环

开发时不需要每次改代码都重启。HMR 让你保存文件即热替换。

完整的开发循环:

步骤 命令/操作
1. 启动 dsh 带 patch pnpm dsh web --patch ./greet-tool/cordis.yml
2. 改代码 编辑 greet-tool/src/index.ts
3. 保存 HMR 自动卸载旧实例、加载新代码
4. 验证 浏览器里测试
5. 改配置 编辑 cordis.yml,HMR 也会检测
6. 诊断 PENDING 如果插件没加载,检查 inject 的服务是否可用

HMR 的前提条件(docs/cordis-tutorial/06-composition-and-hmr.md):

条件 说明
显式 id 没有显式 id 的条目每次读取生成新 id,被误判为删除+新增
依赖服务到位 HMR 自身 inject 了 timer 和 logger,缺少它们 HMR 会 PENDING
tsx 运行 node --import tsx 让 TypeScript 直接运行

10.9 完整插件检查清单

写一个 dsh 插件,检查这些项:

检查项 通过标准
导出 name 诊断用的显示名称
导出 inject 声明所有依赖的服务
导出 Config(如需配置) Schemastery schema,不是普通对象
apply(ctx, config) 第二参数接收已校验配置
注册走 effect API ctx.on / ctx.tools.register / ctx.plugin
资源用 ctx.effect 包裹 timer、connection、watcher 等非 Cordis 管理的资源
服务名加前缀 避免和 dsh 内置服务名冲突
声明合并加类型 declare module '@deepseek-ai/cordis'
不硬编码可调值 所有可调值都是 Config 字段
测试覆盖 按 docs/testing.md 的策略

金句:写插件不难,写对插件才难。检查清单不是形式,是血泪教训的浓缩。

本章小结

步骤 关键点
写工具 defineTool + inject + apply,注册走 ctx.tools.register
加配置 导出 Config schema(Schemastery),apply 第二参数
加监听器 ctx.on('tools/result', ...) + import 类型声明
打包 bundle package.json 声明 dsh.bundle + cordis.patch.yml
安装 profile dsh plugin --profile add
配置层顺序 bundle → profile → home → --patch,整行替换非深合并
GitHub 安装 需要 prepare 脚本 + allowBuilds 允许
HMR 调试 显式 id + 依赖服务到位 + tsx 运行

我是怕浪猫,第 10 章写完。从零到一个可安装的插件包,每一步都有代码和命令。照着做就能跑。

有问题评论区聊,有纠错欢迎指出。如果这篇对你有帮助,分享给也在写 dsh 插件的同事。

下一章我们深入 Cordis 的事件系统------五把钥匙怎么用,什么时候用哪种。

相关推荐
QCodingDev1 小时前
Spring AI Alibaba Graph实战:从ReAct Agent到Workflow,企业AI复杂流程该如何编排?
java·人工智能·spring·ai·ai编程·企业ai
微软技术分享2 小时前
LangChain 消息流输出与结构化处理
langchain
一航jason2 小时前
AIOS 岗位技能技术栈与开源工程全景
人工智能·ai·ai编程·ai-native
狂师2 小时前
UI自动化测试提效必备Skill!一套CI流水线编排 Skill 可以直接抄了...
人工智能·agent·测试
天空之城--2 小时前
Android Flutter行业最新动态与实用参考(2026年8月第3周)
android·人工智能·flutter·ai编程
撑伞的鱼99373 小时前
2026年前端AI编程工具评测:Figma 还原、组件复用、跨文件联动三项对比
前端·ai编程·figma·效率工具·ai编程工具
Cx330❀3 小时前
【LangChain】LangChain 核心技术全景指南:从基础入门到 LCEL 链式编程
大数据·elasticsearch·搜索引擎·性能优化·langchain·全文检索
Mininglamp_27186 小时前
明略科技携手海康机器人亮相世界机器人大会,以“Agent+具身“联合进入商业机器人场景
人工智能·科技·机器人·开源·agent·ai agent
chunmiao303211 小时前
GPT-5.6上线Kiro平台:AI编程降价潮下模型怎么选
gpt·ai编程