Dsh Cordis教程
Cordis 入门
English | 中文
Cordis 是 DeepSeek Harness 底层以 vendor 方式引入的插件框架。本文介绍 harness 插件作者在阅读子系统页面上生成的服务/事件参考之前需要了解的 Cordis 核心概念;Cordis 教程则通过实践逐一讲解这些概念。vendor 源码与同步流程见 vendor/README.md。
五个核心概念
-
插件是实现 Service 的对象。 它可以是一个带有可选
inject和apply(ctx)字段的函数,也可以是一个Service子类,其生命周期由 Cordis 挂载到当前上下文中。 -
上下文是服务的容器。 一个服务占据一个稳定的
ctx.<key>(如ctx.tools、ctx.llm、ctx.sessions);其他插件通过 key 查找服务,而非导入具体实现。 -
通过
inject声明服务依赖。 插件声明所需的服务后,会等待这些服务就绪才启动;加载顺序通过服务依赖表达,而非手动编排启动序列。 -
类型化事件用于通信。 服务通过 TypeScript 声明合并注册事件名,然后以
emit、waterfall(瀑布式事件)、parallel、serial或bail方式分发,分别对应监听者观察、包装、并行扇出、按序执行或停在首个 bail 值。 -
注册是可逆的副作用。 提示词片段、工具 schema、适配器、提供方和监听器通过
ctx.effect()或ctx.on()安装,reload 和 teardown 时会按预期撤销。
分发模式
每个事件具有以下分发模式之一,且只能通过对应方法分发。
| 模式 | 是否 await? | 分发顺序 | 是否有返回值? |
|---|---|---|---|
emit |
否 | 监听器按注册顺序观察 | 否 |
waterfall |
否 | 监听器按注册顺序观察 | 是 |
parallel |
是 | 所有监听器并行观察事件 | 否 |
serial |
是 | 监听器按注册顺序观察 | 是 |
bail |
否 | 监听器按注册顺序观察,直到某个监听器返回 bail 值 | 是 |
分发模式是事件公开约定的一部分。新的 harness 事件通过 @mode 标签记录模式,以便生成的目录可以将声明与分发调用点做交叉校验。
Cordis Waterfall 语义
ctx.waterfall 是环绕中间件。监听器接收 (...args, next)。调用 next() 会执行下游监听器;下游返回值通过 next() 返回当前包装层,可由该层包装后继续向外返回。不调用 next() 直接返回则短路。
协作式监听器通常修改一个共享的请求或决策对象,然后委托。监听器也可以选择完全替换结果,下游监听器将只看到替换后的结果。仅当监听器必须在普通注册之前运行时才使用 prepend: true。
对于单决策事件,短路是设计意图。策略监听器在拥有决策权时可以不调用 next() 直接返回,而仅做标注或观察的监听器则必须委托。
Loader 配置
@deepseek-ai/cordis-plugin-include 将 !!js 解析为表达式节点。Loader 在声明的注入激活后,基于该插件上下文(ctx.serviceName)插值条目的 config,并在每次挂载决策时基于 loader 上下文插值其 disabled 字段;Include 会保留嵌套行表达式,直到目标行激活。其余条目元数据保持字面值。由环境选择插件时,请使用 overlay。
实践规则
将行为封装为插件:工具流水线事件属于 ctx.tools,模型流式输出属于 ctx.llm,实时 agent(智能体)协调属于 ctx.agents。拦截和策略优先使用事件;直接能力调用优先使用服务方法。
每个注册都应有对应的 disposer(资源释放函数):要么从 ctx.effect() 返回一个,要么使用 Cordis 提供的辅助方法自动处理。如果 teardown 顺序有要求,请将相关工作放在同一个 effect 中,以确保资源按预期顺序释放。
Cordis 教程
English | 中文
Cordis 是 DeepSeek Harness 底层的插件框架:它是一个小型运行时,其中的每项能力,包括工具、LLM(大语言模型)适配器、文件访问乃至 agent loop(智能体循环)本身,都是挂载到共享上下文中的插件。本教程通过动手实践讲解 Cordis:每一章都是一个可以运行的示例,你将在本仓库内的临时目录中逐步构建它,最后把一个插件接入真实的 harness 服务。
本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 TypeScript 说明会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。
如果你想阅读精简的概念参考,而不是逐步实践,请参阅 Cordis 入门。详尽的 API 参考见子系统页面上生成的 cordis-surface 区块,以及 Cordis 核心 API 页面。
如果你要为 harness 本身编写插件——由 cordis.yml 加载、在 Web UI 中驱动,而不是下面这个启动器——请从第一个 Harness 插件开始。
准备工作
你需要克隆本仓库并安装依赖;开发指南列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
创建各章使用的临时目录。tmp/ 已被 git 忽略,因此你在其中写入的任何内容都不会进入版本控制:
mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial
每一章都从该目录运行同一条命令:
node --import tsx ../../vendor/cordis/bin.js
这个单文件启动器(见 vendor/cordis/bin.js)会创建根 Context、挂载 Loader 插件,并让它从当前目录加载 ./cordis.yml。其余所有内容,包括有哪些插件以及如何配置它们,都来自你稍后将编写的 YAML 文件。--import tsx 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。
章节
- 你的第一个插件:插件是函数,由 loader 挂载。
- 生命周期与 effect:由 Cordis 管理的注册会在所属插件卸载时撤销。
-
服务:在
ctx上公开一项能力,并通过inject依赖它。 - 事件:类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。
-
配置:读取
cordis.yml中经过校验的配置,并在输入错误时明确报错。 - 组合与 HMR(热模块替换):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。
- 进入 harness:基于真实的 harness 服务注册一个可由模型调用的工具。
TypeScript 说明
这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能:
-
类型注解描述值,但不会改变运行时行为:
ctx: Context表示ctx具备 Cordis 上下文 API,who: string接受文本,而string[]表示字符串数组。 -
import type { Context } from '@deepseek-ai/cordis'只导入类型信息。它在运行时会消失,因此仅为类型注解使用Context的插件文件不会增加运行时依赖。 -
声明合并(
declare module '@deepseek-ai/cordis' { ... })会为 Cordis 已经声明的接口添加你的条目,例如新ctx.greeter属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。
第 5 章还会使用 interface 描述配置对象的字段,并使用 Schema<Config> 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。
1. 编写第一个插件
English | 中文
在本教程使用的 loader 配置中,Cordis 插件模块通过命名导出提供 apply 函数。Cordis 加载模块时,会用一个 上下文 调用 apply;该上下文就是 ctx 对象,插件通过它注册自己贡献的所有内容。
编写插件
在 tmp/cordis-tutorial 目录中(参见环境设置)创建 hello.ts:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello'
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
name 导出项是可选的显示元数据;它用于在诊断信息中标识插件。
组合应用
本教程的启动器通过配置组装应用。创建 cordis.yml:
- name: './hello.ts'
该文件是一组 Cordis 配置项的列表。name 是模块指定符,可以是相对路径或 NPM 包名;loader 会挂载每个配置项。各项会并发启动,因此它们在列表中的位置不保证插件的加载先后;顺序由服务依赖(inject,参见第 3 章)决定,而非文件中的位置。
运行
node --import tsx ../../vendor/cordis/bin.js
预期输出:
hello from my first plugin
当没有任何内容继续运行时,进程会自行退出。具体过程如下:
- 启动器创建根
Context,并挂载 Loader 插件。 - Loader 读取
cordis.yml,解析./hello.ts,然后将其作为子插件挂载。 - Cordis 调用你的
apply(ctx)。
你的文件中没有框架启动代码:插件描述自己的贡献,cordis.yml 则组合应用。例如,dsh base 就是一份更长的插件组合,由部署 overlay 对它进行修补。
其他两种插件形态
函数是最常见的形式,但 Cordis 接受三种形式:
import { Service, type Context } from '@deepseek-ai/cordis'
// 1. Function plugin (what you just wrote).
export function apply(ctx: Context) {}
// 2. Object plugin: an object with an `apply` method.
export const objectPlugin = {
name: 'object-plugin',
apply(ctx: Context) {},
}
// 3. Class plugin: a Service subclass (covered in chapter 3).
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myTutorialService')
}
}
在你需要公开服务之前,请一直使用函数形态;第 3 章介绍了何时应当使用类形态。
尝试制造错误
让 apply 抛出异常:
export function apply(ctx: Context) {
throw new Error('apply exploded')
}
再次运行:进程会因该错误而终止。插件加载失败会明确报错,不会仅跳过该配置项。
还需要尽早了解一个例外:如果某个配置项的模块无法被 解析,例如路径或包名拼写错误,Cordis 会通过 logger 服务报告错误,而不会使进程崩溃。在启动阶段,这条报告可能在 console 导出器开始观察之前丢失。如果新增配置项似乎没有任何效果,请先检查拼写。
下一章:生命周期与 effect:插件卸载时会发生什么。
2. 生命周期与 effect
English | 中文
Cordis 插件可能因修改配置、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 ctx.effect() 中。
Effect
对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 ctx.effect() 中并返回 disposer(资源释放函数):
创建 lifecycle.ts,将它放在 tmp/cordis-tutorial 中:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'lifecycle-demo'
function heartbeat(ctx: Context) {
console.log('heartbeat plugin loading')
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => {
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}
export function apply(ctx: Context) {
// Mount a child plugin and keep its fiber to dispose it later.
const fiber = ctx.plugin(heartbeat)
// The demo timer is itself an effect: if THIS plugin is unloaded first,
// the pending callback is cancelled instead of firing on a dead app.
ctx.effect(() => {
const timer = setTimeout(async () => {
await fiber.dispose()
console.log('disposed')
process.exit(0)
}, 700)
return () => clearTimeout(timer)
})
}
让 cordis.yml 指向该文件:
- name: './lifecycle.ts'
运行(node --import tsx ../../vendor/cordis/bin.js)后会得到:
heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed
请留意三点:
-
ctx.plugin(heartbeat)会把一个来自代码的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要apply方法:Cordis 会直接调用该函数,其名称只用于诊断。只有对象形态才要求apply方法,例如ctx.plugin({ apply(ctx) { /* ... */ } })。调用会返回一个 fiber,即一个已加载插件实例的运行时句柄。 - effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。
-
fiber.dispose()会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。
Fiber 状态机
每个已加载插件实例都拥有一个 fiber,并在以下状态之间转换:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
- PENDING:已经声明,但所需服务(第 3 章)尚不可用。
-
LOADING / ACTIVE:
apply正在运行/已经完成。 -
FAILED:
apply或配置校验抛出异常。 - UNLOADING / DISPOSED:disposer 正在运行/一切均已拆除。
你会在第 6 章再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。
已经属于 effect 的操作
你很少需要亲自编写 ctx.effect(),因为内置注册 API 本身已经是 effect:
-
ctx.on(event, listener):监听器会在卸载时移除(第 4 章)。 -
ctx.plugin(child):子插件会随父插件一同 dispose(资源释放)。 - 服务注册属于 effect。
ctx.tools.register(...)等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动撤销(第 7 章)。
对于 Cordis 不管理的资源,应在 ctx.effect() 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。
有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个异步 disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。
下一章:服务:插件如何共享功能。
3. 服务
English | 中文
服务是一个插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,ctx.tools、ctx.llm 和 ctx.agents 都是服务。消费方只指定 'tools' 之类的能力,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。
提供服务
创建 greeter.ts,将它放在 tmp/cordis-tutorial 中:
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter')
}
greet(who: string) {
return `Hello, ${who}!`
}
}
export const name = 'greeter'
export function apply(ctx: Context) {
ctx.plugin(GreeterService)
}
两部分协同工作:
-
运行时:
super(ctx, 'greeter')以名称greeter注册该实例。此后,任何插件都可以通过ctx.greeter访问它。注册属于 effect,卸载提供方时会移除该服务。 -
编译时:
declare module '@deepseek-ai/cordis'块使用 TypeScript 声明合并,把greeter加入Context接口,使ctx.greeter在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。
Service 子类本身就是插件(第 1 章介绍的类形态),因此 ctx.plugin(GreeterService) 会像挂载其他插件一样挂载它。
使用 inject 消费服务
创建 consumer.ts:
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 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 apply 内可以保证 ctx.greeter 已经就绪。cordis.yml 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。
组合并运行:
- name: './greeter.ts'
- name: './consumer.ts'
Hello, world!
交换 cordis.yml 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 ./greeter.ts:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。第 6 章介绍如何诊断这种状态。
加载后仍会跟踪依赖关系
inject 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect(第 2 章),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会撤销。
这也是配置中可以替换服务的原因:卸载 Cordis 配置项 dsh-bash-local,挂载另一个 shell 提供方,所有注入 'shell' 的插件都会重新启动并使用新实现。
可选依赖
inject 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 inject,并在使用处探测:
export function apply(ctx: Context) {
// undefined when no provider is loaded; the plugin still runs.
const greeter = ctx.get('greeter')
console.log(greeter?.greet('maybe') ?? 'no greeter available')
}
命名
每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 tools 和 llm 等普通名称);子系统页面上生成的 cordis-surface 区块列出 harness 注册的每个名称。
下一章:事件:无需共享服务即可通信。
4. 事件
English | 中文
服务支持直接调用;事件让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。
声明、发出与监听
创建 stats.ts,将它放在 tmp/cordis-tutorial 中。它是一项负责计数并在每次变化时发出通知的服务:
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
stats: StatsService
}
interface Events {
'stats/report'(name: string, count: number): void
}
}
export class StatsService extends Service {
private counts = new Map<string, number>()
constructor(ctx: Context) {
super(ctx, 'stats')
}
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
this.ctx.emit('stats/report', name, next)
}
}
export const name = 'stats'
export function apply(ctx: Context) {
ctx.plugin(StatsService)
}
interface Events 合并与第 3 章的 interface Context 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 ctx.emit 和 ctx.on 都具有完整类型。namespace/action 命名约定让扁平的事件命名空间保持易读。
创建 reporter.ts:
import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts'
export const name = 'reporter'
export const inject = ['stats']
export function apply(ctx: Context) {
ctx.on('stats/report', (name, count) => {
console.log(`[stats] ${name} -> ${count}`)
})
ctx.stats.bump('tool_call')
ctx.stats.bump('tool_call')
ctx.stats.bump('prompt')
}
import type {} from './stats.ts' 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。组合并运行:
- name: './stats.ts'
- name: './reporter.ts'
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1
因为 ctx.on() 属于 effect,监听器会随插件一同消失,绝不需要手动维护 removeListener。
分发模式
emit 是 5 种分发模式之一。事件采用哪种模式是其约定的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路:
| 模式 | 调用 | 语义 |
|---|---|---|
| emit | ctx.emit(name, ...args) |
同步广播;不会等待或收集返回的 promise 与值。 |
| parallel | await ctx.parallel(name, ...args) |
所有监听器并发运行,并一同等待。 |
| serial | await ctx.serial(name, ...args) |
监听器按顺序运行并等待;第一个非 null/false/undefined 返回值胜出,并停止后续监听器。 |
| bail | ctx.bail(name, ...args) |
serial 的同步版本。 |
| waterfall(瀑布式事件) | ctx.waterfall(name, ...args, next) |
环绕中间件,见下文。 |
每个 harness 事件都会在其所属子系统页面自动生成的参考文档中记录其模式。
waterfall:转换或短路
waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 next() continuation;它可以转换 next() 的返回值,也可以不调用 next() 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 waterfall-demo.ts:
import type { Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Events {
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
export const name = 'waterfall-demo'
export function apply(ctx: Context) {
// Listener 1: wrap the downstream result.
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// Listener 2: short-circuit when it owns the decision.
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
void (async () => {
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
})()
}
让 cordis.yml 只指向该文件并运行:
HELLO
** BLOCKED **
按顺序看第二行如何产生:监听器 1 先运行并调用 next(),从而调用监听器 2;监听器 2 看到 blocked 后直接返回而不调用 next(),因此最内层默认逻辑(传给 ctx.waterfall 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。
由此得到一项纪律:只负责观察或标注的 waterfall 监听器必须调用 next();不调用就直接返回代表有意短路。如果日志监听器忘记调用 next(),会悄无声息地吞掉所有下游的默认行为。这是本仓库的常设规则(waterfall 语义)。
harness 使用 waterfall 处理协作插件可以包装或回答的决策:agent/request 允许插件替换模型调用配置,approval/request 允许策略代替用户作答。
下一章:配置:来自 cordis.yml 的插件选项。
5. 配置
English | 中文
cordis.yml 中的每个 Cordis 配置项都可以携带 config 块,插件则声明一个 schema,在运行 apply 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。
可配置插件
创建 config-demo.ts,并将其放在 tmp/cordis-tutorial 中:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'config-demo'
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) {
for (const target of config.targets) {
console.log(`${config.greeting}, ${target}!`)
}
}
导出的 Config 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 Schemastery 定义 schema;Cordis 本身接受任意 Standard Schema 验证器,因此将普通对象导出为 Config 无法工作。
对其进行配置:
- name: './config-demo.ts'
config:
targets: ['alpha', 'beta']
运行:
Hello, alpha!
Hello, beta!
未提供 greeting,因此 schema 默认值会将其补齐:apply 始终会收到完整且经过验证的配置。
明确报错
现在向它传入无效内容:
- name: './config-demo.ts'
config:
targets: 'not-an-array'
ValidationError: invalid config:
- $.targets expected array but got not-an-array (at targets)
插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。
计算得到的配置值
本仓库使用的 loader 支持 !!js 标签,用于必须在加载时计算的配置值:
- name: './config-demo.ts'
config:
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
!!js 仅在 config 与条目 disabled 字段内有效。disabled: !!js ... 在每次挂载决策时基于 loader 上下文求值(本仓库的扩展),可以按平台或环境门控一行;其余元数据(name、id、inject 等)保持静态,其中的表达式是普通真值数据。详见 loader 配置。
下一章:组合与 HMR(热模块替换):将 cordis.yml 视为应用。
6. 组合与 HMR(热模块替换)
English | 中文
到目前为止构建的每项能力都是插件,cordis.yml 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。
Cordis 配置项不只有名称
Cordis 配置项除了 name 和 config,还接受其他元数据:
- id: greeter # stable identity for this entry
name: './greeter.ts'
- id: consumer
name: './consumer.ts'
disabled: true # keep the entry, skip mounting it
id 为 Cordis 配置项提供稳定标识,使 loader 能区分修改现有 Cordis 配置项与先删除再添加。disabled: true 会卸载插件而不删除其 Cordis 配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。
组可以嵌套一份 Cordis 配置项子列表,并将其作为一个单元加载和卸载;isolate 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 shell 提供方,互不影响。Cordis 入门和服务隔离示例介绍了详细内容。
热模块替换
卸载会释放 effect(第 2 章),加载则遵循依赖关系(第 3 章),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。@deepseek-ai/cordis-plugin-hmr 插件会监视文件,并在保存时执行这一过程。
在 tmp/cordis-tutorial 中编写 cordis.yml:
- id: logger
name: '@deepseek-ai/cordis-plugin-logger-console'
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
- id: hmr
name: '@deepseek-ai/cordis-plugin-hmr'
config:
root: ['.']
- id: hello
name: './hello.ts'
列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 inject timer 服务来实现去抖,如果没有 @deepseek-ai/cordis-plugin-timer,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。
HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis:
node --import tsx ../../vendor/cordis/bin.js
现在编辑 hello.ts,修改日志消息并保存:
hello from my first plugin
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
hello from my EDITED plugin
旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,apply 再次运行。按 Ctrl-C 停止进程。编辑 cordis.yml 本身也会触发更新:loader 按 id 比较 Cordis 配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述 Cordis 配置项显式携带 id 的原因:不带该字段的 Cordis 配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。
诊断始终无法加载的插件
依赖驱动加载也有另一面:如果插件的 inject 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。
你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 diagnose.ts:
import { FiberState, type Context } from '@deepseek-ai/cordis'
export const name = 'diagnose'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log(`${fiber.name} is PENDING — a required service is missing`)
}
}
}
}, 500)
}
再创建一个依赖无法满足的插件 needs-timer.ts:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'needs-timer'
export const inject = ['timer']
export function apply(ctx: Context) {
console.log('needs-timer loaded')
}
- name: './needs-timer.ts'
- name: './diagnose.ts'
运行它(直接执行 node --import tsx ../../vendor/cordis/bin.js,按 Ctrl-C 停止):
needs-timer is PENDING — a required service is missing
inject: ['timer'] 没有提供方。向列表添加 - name: '@deepseek-ai/cordis-plugin-timer' 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。
下一章:进入 harness:把相同模式用于真实的 harness 服务。
7. 进入 harness
English | 中文
本章会向 harness 的 tools 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。
工具插件
创建 greet-tool.ts,将它放在 tmp/cordis-tutorial 中:
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallId } 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}!`
},
}))
// Drive one call through the real execution pipeline, standing in for
// the model. ToolCallId brands the correlation id a provider would issue.
void (async () => {
const result = await ctx.tools.execute({
callId: brandString<ToolCallId>('demo-1'),
name: 'greet',
arguments: { name: 'Cordis' },
signal: new AbortController().signal,
})
console.log('tool replied:', JSON.stringify(result.content))
})()
}
这里的每个模式都来自前几章:inject: ['tools'](第 3 章)会让插件等待工具注册表就绪;ctx.tools.register(...) 会把注册 disposer 附着到插件(第 2 章),因此卸载时会注销工具。defineTool 将 parameters 规约转换为向模型展示的 JSON Schema,推导 args 的类型,并在 execute 运行前校验模型提供的参数。工具返回由 output.schema 声明的规范值;output.render 则作为 Native renderer(原生渲染器),另行生成可持久化的结果内容。
观察插件
创建 tool-logger.ts。这是一个独立插件,通过 harness 的 tools/result 事件观察应用中的每次工具调用:
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' 行会引入该包的声明合并,使 'tools/result' 及其 payload 具有类型。这与第 4 章导入 stats.ts 的做法相同,只是扩展到了包级别。
组合并运行
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'
@deepseek-ai/dsh-tools 会注入 systemPrompt 服务,因为工具需要向系统提示词贡献 schema,所以组合中也要列出该服务的提供方。缺少提供方时,工具插件会像第 6 章所述那样保持 PENDING。
node --import tsx ../../vendor/cordis/bin.js
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
logger 会先触发:tools/result 在结果物化过程中发出,发生在 execute 向调用方返回的 promise 兑现之前。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。
从这里走向完整 agent(智能体)
真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和应用入口。对照 base profile 层与 headless 层,你现在已经可以读懂其中各项。通过一个小型 --patch overlay 加入 greet-tool.ts 即可。
后续可以阅读: