Dsh Cordis教程 Cordis 入门 English | 中文 Cordis 是 DeepSeek Harness 底层以 vendor 方式引入的插件框架。本文介绍 harness 插件作者在阅读 子系统页面 上生成的服务/事件参考之前需要了解的 Cordis 核心概念; Cordis 教程 则通过实践逐一讲解这些概念。vendor 源码与同步流程见 vendor/README.md 。 五个核心概念 插件是实现 Service 的对象。 它可以是一个带有可选 inject 和 apply(ctx) 字段的函数,也可以是一个 Service 子类,其生命周期由 Cordis 挂载到当前上下文中。 上下文是服务的容器。 一个服务占据一个稳定的 ctx. (如 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 这类泛型表示 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() 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): Promise } } 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 = 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('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 即可。 后续可以阅读: 构建工具 :深入了解 defineTool ,包括呈现和更丰富的 schema。 三层能力设计 :harness 如何组织可替换能力。 子系统页面 上生成的 cordis-surface 区块:可以注入和监听的所有内容,各在其所属页面上。 架构 :这些插件所处的系统地图。