在很多 Agent 框架里,开发功能几乎等同于"注册一个 Tool"。
DeepSeek Harness 不是这样。
Tool 只是模型侧的 Consumer。一个完整功能还可能涉及 Service、Provider、事件、持久化、权限和 UI。如果一上来就把所有逻辑塞进 Tool,最后得到的仍然是一个无法替换的单体模块。
这篇从一个最小 Tool 开始,逐步讲清功能应该怎么落在 Harness 里。
先做功能归属判断
写代码前,先确认需求属于哪一层:
| 需求 | 扩展位置 |
|---|---|
| 增加模型 | ctx.llm Adapter |
| 增加模型工具 | ctx.tools |
| 增加用户命令 | ctx.commands |
| 增加后台任务 | ctx.jobs |
| 替换文件系统 | ctx.fs Provider |
| 替换进程执行 | ctx.subprocess Provider |
| 增加执行策略 | tools/* 或 fs/* 事件 |
| 注入模型上下文 | agent.inject() |
| 保存会话事实 | SessionEventMap |
| 增加聊天 UI 节点 | Conversation Node 与 Renderer |
如果功能已经有明确扩展点,就不要修改 Agent Loop。
从最小插件开始
ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export function apply(ctx: Context) {
console.log('my plugin loaded')
}
这个模块已经是合法插件。
本地调试时,用 Patch 加载:
yaml
- insert:
- id: my-plugin
name: '/absolute/path/to/my-plugin.ts'
bash
pnpm dsh web --patch ./scratch-plugin/cordis.yml
不要为了验证一个想法就先创建完整 npm 包。Patch 更适合原型阶段,确认扩展点正确后再打包。
依赖不要靠加载顺序
插件需要 Tool Registry 时,应声明 inject:
ts
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(/* ... */)
}
Cordis 会在 tools 服务就绪后调用 apply。
下面这种方式不可靠:
ts
import { globalToolRegistry } from 'some-provider'
它让插件依赖具体 Provider,也绕过了 Profile 的组合能力。换一个 Tool Provider 或隔离 Realm 后,代码很可能直接失效。
写副作用时顺手写清理逻辑
插件可能被卸载,也可能因为配置变更而热替换。
定时器、连接和外部进程必须可清理:
ts
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(runTask, 5000)
return () => {
clearInterval(timer)
}
})
}
如果插件注册了一个能力,还应该补一个生命周期测试:
text
挂载插件
→ 断言注册存在
→ Dispose Fiber
→ 断言注册消失
这类测试比单纯验证"注册成功"更重要,因为残留注册通常只会在热更新或多次测试后暴露。
配置不要写死在常量里
不同环境可能变化的参数,都应该进入配置 Schema:
ts
import Schema from '@deepseek-ai/schemastery'
export interface Config {
endpoint: string
timeoutMs: number
}
export const Config: Schema<Config> = Schema.object({
endpoint: Schema.string().required(),
timeoutMs: Schema.number().default(30_000),
})
export function apply(ctx: Context, config: Config) {
// config 已经过运行时校验
}
一个简单判断方法是:
修改这个值时,运维或用户是否应该重新编译代码?
如果答案是否定的,它就不该是硬编码常量。
Schema 同时承担默认值和加载期校验。配置错误应该让插件明确加载失败,而不是悄悄回退到一个意外值。
一个合格的 Tool 至少有三层
下面是官方教程中的问候 Tool:
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}!`
},
}))
}
这里故意分开了三件事:
text
parameters 定义并校验模型输入
execute 返回规范业务值
output.render 转换成模型可见内容
不要让 execute 直接返回一段带 UI 含义的拼接文本。业务值与展示分离后,同一个结果才能被日志、测试、模型和 Web UI 分别消费。
什么时候需要 Capability Seam
假设要增加代码搜索能力。
如果永远只调用一个固定 SDK,可以先做单插件。
但如果未来需要本地搜索、远程搜索和沙箱搜索,就应该拆分:
text
code-search Service Definition
code-search-local Local Provider
code-search-remote Remote Provider
tool-code-search Model Consumer
Tool 只依赖 code-search 接口,不关心搜索发生在哪里。
建立 Seam 前应确认至少满足一项:
- 存在多种 Provider
- 多个 Consumer 共享同一能力
- 需要独立安全边界
- 需要替换运行环境
- 需要单独测试或发布
否则拆包成本可能高于收益。
模型能看到的内容必须进入日志
假设插件要把项目规则注入下一次模型请求。
直接监听 agent/request 并修改消息,看起来能工作,但恢复会话时无法重建这段内容。
Harness 要求模型可见内容能够从 Session Event 日志重放。
正确设计至少要回答:
- 这是什么持久事实?
- 对应哪一种 Session Event?
- 如何从日志投影成模型消息?
- Fork 时是否继承?
- UI 是否需要展示?
- Snapshot 如何固定行为?
这一步会增加代码量,但能避免"当前运行正常,恢复后行为改变"的问题。
拦截事件时不要忘记 next()
Waterfall 事件允许插件包装后续行为:
ts
ctx.on('tools/pre-execute', async (request, next) => {
checkPermission(request)
return next()
})
如果策略决定拒绝,可以显式返回错误或拒绝结果。
如果允许继续,就必须调用 next()。漏掉它会让后续执行链直接停止,这是 Harness 插件开发中非常典型的问题。
测试要覆盖真实组合
只写一个 ctx.plugin() 单元测试,不足以证明插件能在产品中运行。
Harness 官方测试大致分为:
bash
pnpm run test # Unit
pnpm run test:coverage # 逐文件覆盖率门禁
pnpm run test:snapshot # Keyless 行为与日志快照
pnpm run test:web # 浏览器快照
pnpm run test:e2e # 真实模型 API
pnpm run build # 构建发布产物
pnpm run typecheck
pnpm run lint
产品可见插件至少需要一次真实 Loader 组合测试。测试应从 cordis.yml 启动插件,而不是只手工组装 Context。
如果功能修改了模型、协议或用户可见输出,还需要增加 Keyless Snapshot。
真实 API E2E 不应该检查 Agent 回答中是否包含"成功"两个字。文件任务要重新读取文件,命令任务要重新执行验证。
验证世界是否改变,不要验证 Agent 是否声称自己改变了世界。
把插件发布成 Bundle
Bundle 是带有 Cordis Patch 的 npm 包:
json
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
cordis.patch.yml:
yaml
- insert:
- id: hello
name: dsh-hello-plugin
本地安装:
bash
dsh plugin --profile demo add ./hello-plugin
dsh --profile demo --dump-config
dsh --profile demo
没有 dsh.bundle 的普通 npm 包也能成为 Profile 依赖,但不会自动贡献配置层。
从 GitHub 安装插件有执行风险
可以直接安装 Git 仓库:
bash
dsh plugin --profile demo add \
github:you/hello-plugin#<commit-sha>
但 Git 依赖拿到的是源码,不是预构建产物。TypeScript 插件需要提供 prepare 脚本,在安装后构建 lib/。
pnpm 10 之后,安装脚本默认需要通过 allowBuilds 显式授权。
这不是一个普通配置项。授权意味着允许插件代码在安装阶段直接运行在宿主机上,而且不受 Agent Sandbox 保护。
因此至少要做到:
- 审查源码
- 锁定 Commit SHA
- 只授权确切包名
- 避免跟随可变分支
- 能发 npm 包时优先发布预构建产物
- 或通过
pnpm pack交付 Tarball
我的开发顺序
实际开发一个 Harness 功能时,我会按下面的顺序推进:
text
确认功能归属
→ 判断是否需要 Capability Seam
→ 定义 Service 或事件
→ 实现 Provider
→ 实现 Tool 或其他 Consumer
→ 接入 Patch
→ 测试卸载与错误路径
→ 测试真实 Loader 组合
→ 增加 Snapshot
→ 构建并验证 lib 发布入口
→ 打包成 Bundle
这个顺序刻意把 Tool 放在 Provider 之后。
因为 Tool 只是模型看到的入口,真正决定系统是否可替换、可测试和可恢复的,是它背后的能力边界。