Skip to content

第 2 章 Cordis 插件基础

Cordis 是 DSH 底层的插件框架:每项能力——工具、LLM 适配器乃至 agent loop 本身——都是挂载到共享上下文中的插件。本章讲解编写 DSH 插件必须掌握的核心概念,并给出最小插件骨架。

本章目标

  • 理解插件、Context、inject 三个核心概念
  • 掌握类型化事件与五种分发模式,理解 waterfall 的 next() 与短路
  • 理解"注册是可逆副作用"与 fiber / loader 机制

插件是"实现 Service 的对象"

插件是导出 apply 的 TypeScript 模块(也可以是普通函数、带 apply 的对象、或 Service 子类)。框架加载时调用 apply 并传入 ctx,你通过 ctx 注册能力。最小骨架:

ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello-plugin'   // 可选的显示元数据,用于诊断信息

export const inject = ['tools']      // 声明依赖:tools 服务就绪后才会调用 apply

export function apply(ctx: Context) {
  // 在这里注册能力:事件监听、工具、定时器……
  console.log('[hello-plugin] plugin loaded!')
}

未公开服务前用函数形态;需要向其他插件提供服务时改用 Service 子类。

Context 是服务的容器

ctx 是服务的容器,插件在这里注册一切贡献。每个服务占据稳定的 ctx.<key> 挂载点——ctx.toolsctx.llmctx.agents 都是服务。插件按 key 查找服务而非导入具体实现,配置可替换提供方而无需改消费方。

inject 声明依赖

inject 列出插件需要的服务;Cordis 让插件保持 PENDING 直到服务就绪,apply 内可放心使用 ctx.tools。加载顺序因此无关紧要——决定启动时机的是依赖而非文件顺序。运行期间服务消失(提供方被卸载或热替换),依赖插件随之卸载,服务恢复后再加载。可选依赖跳过 inject,用 ctx.get('key') 探测。

类型化事件与声明合并

事件让插件无需知道监听者就能通信,DSH 用它处理工具结果、模型请求、审批决定。服务通过 TypeScript 声明合并注册事件名与监听器签名:

ts
declare module '@deepseek-ai/cordis' {
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

声明只提供类型、不生成运行时接线——插件必须另行 emit。消费方用 import type {} from './stats.ts' 引入合并,ctx.emit / ctx.on 便具备完整类型。事件名遵循 namespace/action 约定。

五种分发模式

每个事件都有一种分发模式,只能通过对应方法分发:

模式调用是否 await有返回值?语义
emitctx.emit(name, ...args)同步广播;监听器按注册顺序观察
parallelawait ctx.parallel(name, ...args)所有监听器并行运行并一同等待
serialawait ctx.serial(name, ...args)按序运行;第一个非 null/false/undefined 返回值胜出并停止后续
bailctx.bail(name, ...args)serial 的同步版本
waterfallctx.waterfall(name, ...args, next)环绕中间件,见下

waterfall 是拦截模式。监听器收到 (...args, next)next() 执行下游并把返回值带回本层,可包装后向外返回;不调用 next() 直接返回则短路——最内层默认逻辑不会运行。单决策事件的短路是设计意图:策略监听器有决策权时可不调 next(),只做观察的监听器必须委托。纪律:日志监听器忘记调用 next(),会悄悄吞掉所有下游默认行为。

ts
// 监听器 1:包装下游结果
ctx.on('demo/transform', async (input, next) => {
  const downstream = await next()        // 委托给下游
  return downstream.toUpperCase()        // 包装后向外返回
})

// 监听器 2:拥有决策权时短路
ctx.on('demo/transform', async (input, next) => {
  if (input.includes('blocked')) return '** blocked **'  // 不调 next():否决
  return next()
})

注册是可逆副作用

通过 ctx 注册的一切(事件监听、工具、定时器)卸载时都会自动清理,无需手动 removeListenerclearIntervalctx.on()ctx.plugin(child)ctx.tools.register() 等本身就是 effect;未管理的资源(网络连接、watcher)包进 ctx.effect() 并返回 disposer:

ts
ctx.effect(() => {
  const timer = setInterval(() => console.log('heartbeat'), 5000)
  return () => clearInterval(timer)   // 插件卸载时调用
})

配置修改、热重载(HMR)或显式 dispose 都触发卸载,effect 按注册逆序撤销(异步 disposer 并发;需按序拆除时,把步骤放进同一 disposer 依次 await)。

fiber 与 loader

每个已加载的插件实例都拥有一个 fiber,在 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 间转换(apply 或配置校验抛异常则进入 FAILED)。PENDING 表示插件已声明但所需服务尚不可用——"为什么我的插件没有输出"最常见的答案。loader 读取 cordis.yml(配置项列表,name 为模块指定符)并并发挂载每一项,与 ctx.plugin(child) 一致。

小结

  • 插件 = 带 apply(ctx) 的对象;ctx.<key> 是稳定服务挂载点;inject 以依赖而非顺序决定加载。
  • 事件经声明合并获得类型;五种分发模式中,waterfall 的 next() 委托与不调用即短路是拦截核心。
  • 一切注册可逆:effect 在卸载、HMR、dispose 时自动撤销。
  • fiber 状态机与 loader 解释了插件何时启动、为何静默。

下一步

掌握了 Cordis 基础,下一章 编写第一个工具 将定义第一个模型可调用的工具并用 --patch 挂进 Web UI,examples/greet-tool 是该章的完整示例。