从 0 写一个 dsh 插件,让模型能调用你的工具
本篇实战级:用一个真实可跑的最小插件,把
apply / inject / register三步走串起来,再对照 MCP 桥接说明「不写代码也能让模型多一个工具」的另一条路。
教程都让你「装插件」,但没人说插件到底长什么样、装完之后怎么被模型看见。这篇从 0 写一个,跑给你看,再对照「不写代码也能让模型多一个工具」的 MCP 路径。
0. 地图:插件三步走
Cordis 插件契约就三件事,全部来自真实仓库 export:
- apply:导出一个
apply(ctx)函数,Cordis 加载时调它。这是插件的入口。 - inject:声明
inject: ['tools'],Cordis 等服务就绪才调 apply。 - register:在 apply 里调
ctx.tools.register(defineTool(...)),把你的工具暴露给模型。
二篇讲过这五要素「是什么」(Plugin/Context/Service/Events/Effects 的源码级拆解),本篇讲「怎么用它们写一个」。二篇已经明确:ctx.tools 不是 Cordis 内置键,是 dsh 上层包用 ctx.provide('tools', ...) 注入的。所以本篇写 tool plugin 必须 inject:['tools'] 等待 dsh 上层包加载完,这是物理约束,不是风格选择。
1. 第一步:apply 写插件骨架
最小插件长这样(docs/cordis-tutorial/01-first-plugin.md:L11-19):
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello'
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
它对应的 cordis.yml 是一个 YAML 列表(01-first-plugin.md:L27-29):
- name: './hello.ts'
name 是模块说明符,相对路径或 npm 包名都行。Loader 的工作机制(docs/cordis-tutorial/index.md:L36)是:root Context 挂载 Loader 插件,Loader 读 cordis.yml,解析每个 entry 的 name,把它 mount 为子插件,最后调每个插件的 apply(ctx)。无 framework bootstrap 代码:你的文件只描述「贡献什么」,cordis.yml 组合应用。
两条关键纪律来自官方教程原文:
- 纪律 1:list position 不保证加载顺序。
01-first-plugin.md:L31原文「Entries start concurrently, so list position guarantees nothing about which plugin loads first; ordering comes from service dependencies」。顺序靠inject依赖声明,不靠 yml 里的位置。 - 纪律 2:apply 抛错是 loud failure。
01-first-plugin.md:L89原文「A plugin that fails to load is a loud failure, not a skipped entry」。插件加载失败直接杀进程,不是静默跳过。
三种 plugin shape 都被接受(01-first-plugin.md:L57-77):函数式、对象式 { name, apply(ctx) {} }、类式 class extends Service。官方建议:用函数式直到你需要暴露 service,class 形态第三章才讲。本篇用函数式,够用。
骨架就位了,但模型还看不见它。下一步让它看见。
2. 第二步:inject 拿到 dsh 服务
消费端长这样(docs/cordis-tutorial/03-services.md):
import type { Context } from '@deepseek-ai/cordis'
export const name = 'consumer'
export const inject = ['greeter']
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world'))
}
inject: ['greeter'] 列出本插件依赖的服务名。Cordis 把插件挂起 PENDING,等到 ctx.greeter 存在才调 apply(03-services.md:L59 原文「Cordis holds the plugin in PENDING until every listed service exists, so inside apply, ctx.greeter is guaranteed ready」)。在 apply 里,ctx.greeter 保证就绪。
关键纪律:inject 不是一次性 boot 检查。03-services.md:L74-78 原文「If a required service disappears while the app runs, every dependent plugin is unloaded too, and loads again when the service returns」。这是 dsh 能热替换 shell provider 的物理基础:unload dsh-bash-local,mount 另一个 shell provider,所有 inject:['shell'] 的插件干净重启。
与二篇衔接:二篇讲过 ctx.tools 是 dsh 上层包注入的;本篇消费端 inject:['tools'] 等它就绪。二篇 registry.ts:300 是 inject 的源码实现,二篇 reflect.ts:277-278 是 ctx.provide 的源码实现。
能调 ctx.tools 了,但模型还调不到你的逻辑。下一步把它暴露给模型。
3. 第三步:register 让模型能调用
完整可跑的 tool plugin 长这样(docs/cordis-tutorial/07-into-the-harness.md):
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet the named person.',
parameters: {
name: { type: 'string', required: true, description: 'Who to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
// 教程里还有一段手动调 ctx.tools.execute 验证管道工作
void (async () => {
const result = await ctx.tools.execute({
callId: CallId('demo-1'),
name: 'greet',
arguments: { name: 'Cordis' },
signal: new AbortController().signal,
})
console.log('tool replied:', JSON.stringify(result.content))
})()
}
这段代码 keyless 可跑。docs/cordis-tutorial/index.md:L15 原文承诺「No API key is needed for this tutorial; every example runs keylessly」。本机未实测 pnpm install + node --import tsx,引用官方此承诺,不编造「我跑了」的截图或输出。若你要实测,clone 仓库 + pnpm install 即可。
四个关键事实:
defineTool做了三件事:把parametersspec 转 JSON Schema 给模型、推断args类型、在execute前校验模型提供的参数。ctx.tools.register自带 disposer:注册是 effect,插件卸载自动撤销工具。这就是二篇讲的「effect 可逆性」在 tool 注册上的落地。ctx.tools.execute是真实调用路径(参数是{ callId, name, arguments, signal }。CallId是个 brand 类型,标 correlation id。tools/result事件先于 execute promise resolve(原文「The logger fired first:tools/resultis emitted as part of result materialization, beforeexecute's promise resolves to the caller」)。
配一个 observer plugin 监听每次工具调用(07-into-the-harness.md):
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@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 type {} from '@deepseek-ai/dsh-tools' 这行拉进 declaration merges,让 'tools/result' 和它的 payload 有类型(07:L72)。与二篇 events.ts:288 assertActive + register 衔接:这是二篇讲的 5 种 dispatch 里 emit 的应用。
最后一条关键纪律:cordis.yml 必须同时列 @deepseek-ai/dsh-system-prompt 和 @deepseek-ai/dsh-tools(原文「@deepseek-ai/dsh-tools injects the systemPrompt service because tools contribute schemas to the system prompt」)。少了 system-prompt,tools 服务不就绪,greet-tool 永远 PENDING。
闭环了。模型现在能调用你的工具。
4. 对照:MCP 桥接是另一种注册路径
不写一行 TS,写一段 yml 也能让模型多一个工具。真实配置(examples/mcp-memory/engram.cordis.yml):
- insert:
- id: memory-engram
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: engram
transport: stdio
command: engram
args: [mcp]
cwd: !!js process.cwd()
这段 yml 让 dsh 启动一个 engram MCP server 子进程,discover 它的工具,以 mcp__engram__<rawName> 命名注册到 ctx.tools。命名规则(packages/mcp/mcp-client/README.md:L5, L53-60):公开名是 mcp__<serverName>__<rawName>,与 Claude Code / Codex 同形状(README.md:L32 原文「the same server-qualified shape Claude Code and Codex use」)。
三个关键事实:
- MCP 桥接只接 tools(原文「Tools are the only bridged MCP capability. Resources and Prompts have no harness consumer and are deferred」)。Resources 和 Prompts 暂无 dsh 消费者。
- stdio 桥接移除 ambient 凭据变量(原文「The stdio bridge deliberately removes ambient variables whose names usually identify credentials and all
DSH_*variables before launching a child」)。需要的密钥走config.env显式声明,不靠 ambient 继承。 - 命名是纯函数(原文「Names are pure functions of
(serverName, rawName)」)。连接顺序、re-sync、其他 server 都不会改名。重复serverName会让后挂的实例加载失败。
所以 MCP 桥接不是「外部工具接进来」,是「按同一份 ctx.tools 契约暴露」。模型看到的 mcp__engram__* 和你自写的 greet 是同一种东西,都从 ctx.tools 拿 schema、调 execute。两条生产路径,同一种东西。
5. 决策:什么场景自写 vs 用现成生态
基于机制推论(不依赖个例数字):
| 场景 | 自写 plugin | MCP 桥接 | 机制依据 |
|---|---|---|---|
| 工具是私有业务逻辑 | 推荐 | 不推荐 | MCP 要起独立 server 进程,私有逻辑直接 ctx.tools.register 更轻(07:L20) |
| 工具已有 MCP server | 不推荐 | 推荐 | 复用生态,命名与 Claude Code/Codex 一致(mcp-client/README.md:L32) |
| 工具需要 dsh 内部状态 | 推荐 | 不推荐 | MCP server 跨进程拿不到 ctx.sessions/ctx.agents;自写 plugin 直接 inject |
| 工具要被多框架复用 | 不推荐 | 推荐 | MCP 是跨框架标准;自写 plugin 绑死 dsh |
| 工具要持久化附件 | 推荐 | 看情况 | MCP image 桥接需 ctx.attachments + 精确路由证明(mcp-client/README.md:L68, L116),自写直接用 ctx.attachments 更直接 |
一句话收口:私有逻辑 + 需要 dsh 内部状态,自写;公开生态 + 跨框架复用,MCP。 两者不互斥,同一份 cordis.yml 可以同时挂自写 plugin 和 MCP overlay。
收尾
三步走不是教程编排,是 Cordis 插件契约的物理形状。apply 来自 Cordis 的 plugin shape(01-first-plugin.md:L57),inject 是 Service 消费端(03-services.md:L52),register 是 dsh 上层包 ctx.tools 服务的注册接口(07-into-the-harness.md:L20)。读完这篇,你应该能对着官方 7 篇 cordis-tutorial 写出第一个让模型能调用的 tool plugin。
你最想接哪个工具进 dsh?评论区说一说。