插件与 Context:插件拿到的到底是什么
Plugins & Context
上一篇说,DeepSeek Harness 里一切都是插件。这一篇从最底层开始:一个插件长什么样,
ctx.plugin()那一瞬间发生了什么,插件拿到的ctx又是什么。对应 NamiDSH 的
packages/cordis/src/context.ts和registry.ts,对应 DSH 的vendor/cordis/src里的同名文件。
01插件的三种写法
在 Cordis 里,插件可以写成三种形式,框架都认:
// 1. 函数
function hello(ctx: Context, config: Config) { /* ... */ }
// 2. 带 apply 方法的对象(或者一个模块)
const hello = { name: 'hello', inject: ['llm'], apply(ctx, config) { /* ... */ } }
// 3. class,通常继承 Service
class AgentLoop extends Service {
static inject = ['llm', 'tools']
constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') }
}
DSH 里三种都有,最常见的是后两种:服务写成 class(DSH 里有 62 个),小插件写成一个模块,用 export const name、export const inject、export function apply 导出,挂载时直接 ctx.plugin(模块)。
框架怎么认出这三种写法?只看一件事:能不能拿到一个「可调用的东西」。
// packages/cordis/src/registry.ts
resolve(plugin: Plugin): Function | undefined {
if (typeof plugin === 'function') return plugin
if (plugin && typeof plugin === 'object' && typeof plugin.apply === 'function') return plugin.apply
}
函数和 class 本身就是函数;对象就取它的 apply。这个函数同时也是插件的「身份」:同一个插件挂多少次,都共享一个身份,框架用它来记录这个插件有几个实例。
02ctx.plugin() 做了什么
挂载一个插件,只有一行:
app.plugin(hello, { name: 'nami' })
框架内部做了三件事:
// packages/cordis/src/registry.ts(简化)
plugin(ctx, plugin, config) {
const callback = this.resolve(plugin)
let runtime = this._internal.get(callback) // 1. 找到或创建这个插件的登记项
if (!runtime) { /* 新建,记下名字和配置校验规则 */ }
const fiber = new Fiber(ctx, config, Inject.resolve(plugin.inject), runtime) // 2. 创建一个插件实例
return /* fiber,外面包一层,让它可以被 await */ // 3. 返回
}
这里出现了一个新名字:Fiber。一个插件挂一次,就产生一个 Fiber,它代表「这个插件的这一次运行」:它现在是什么状态、依赖到齐没有、做过哪些注册。下一篇整篇都讲它。
注意第 3 步:返回值可以 await。为什么要 await?因为插件不是在 ctx.plugin() 里同步执行的。
兼容性测试的第一个场景就验证了这一点,迷你版和真 Cordis 输出完全一样:
=== 01 插件是异步加载的
[status] hello: PENDING -> LOADING
ctx.plugin() 已返回
apply 执行
[status] hello: LOADING -> ACTIVE
ctx.plugin() 已经返回了,apply 才执行。这样设计有两个好处:一是插件的 apply 可以是异步的(连数据库、读配置);二是一个插件出错,异常不会冒到挂载它的地方,把后面的插件都拖垮。第 3 篇讲错误隔离时会再看到它。
如果你确实想等插件加载完:
await app.plugin(hello) // 加载失败时,这里会抛出插件的错误
03每个插件拿到一个自己的 ctx
插件的 apply(ctx) 拿到的 ctx,不是全局那个根 ctx,而是专门为它造的子 ctx:
// packages/cordis/src/fiber.ts(Fiber 的构造函数里)
this.ctx = parent.extend({ fiber: this })
// packages/cordis/src/context.ts
extend(meta: object = {}): this {
const self = Object.create(this)
for (const prop of Reflect.ownKeys(meta)) {
Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)!)
}
return self
}
Object.create(this) 让子 ctx 原型继承父 ctx:父 ctx 上有的方法(plugin、on、provide……)子 ctx 都能用。子 ctx 只多了一个自己的属性:fiber,指向这个插件实例。
为什么要多此一举?看 ctx.on 是怎么实现的:
// packages/cordis/src/context.ts
on(name: string, listener: (...args: any[]) => any, options?: boolean | EventOptions) {
return this.events.on(this, name, listener, options)
}
它把 this(也就是调用它的那个 ctx)传给了事件服务。事件服务再通过 ctx.fiber 知道:这个监听是哪个插件注册的,于是把「撤销这个监听」记在那个插件名下。
插件卸载时,框架只要把这个插件名下记的东西全部撤销,就清理干净了。插件自己不用写一行清理代码。
这就是子 ctx 的意义:让每一次注册都知道自己属于谁。这是 Cordis 能做到干净热插拔的根基。
04根 ctx:一切的起点
整个应用从一个根 ctx 开始:
// packages/cordis/src/context.ts
constructor() {
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
this.logger = createLogger()
this.fiber = new Fiber(self, {}, Object.create(null), null)
this.reflect = new ReflectService(self)
this.registry = new RegistryService()
this.events = new EventsService()
return self
}
构造函数做了三件事:
- 包一层 Proxy。以后读
ctx.llm、ctx.tools这种属性,都会经过这个 Proxy 去服务表里找。第 4 篇专门讲它。 - 创建根 Fiber。根 Fiber 一出生就是运行状态,所有插件都挂在它下面,形成一棵树。
- 装上四个内置服务:
reflect(服务表)、registry(插件登记)、events(事件总线)、logger(日志)。
真 Cordis 的构造函数几乎一样,也是这四个服务。区别在于,真 Cordis 用 mixin 把 ctx.on、ctx.plugin 这些方法从各个服务上「借」到 ctx 上;迷你版直接把它们写成 Context 的方法。效果相同,读起来更直接。
05插件树
有了子 ctx,插件里还可以再挂插件:
app.plugin({
name: 'parent',
apply(ctx) {
ctx.plugin({ name: 'child', apply: () => () => console.log('child 清理') })
return () => console.log('parent 清理')
},
})
子插件挂在父插件的 ctx 上。父插件卸载时,子插件跟着一起卸载:
=== 11 嵌套插件:父插件卸载,子插件一起卸载
[status] parent: ACTIVE -> UNLOADING
parent 清理
[status] child: ACTIVE -> UNLOADING
child 清理
[status] child: UNLOADING -> DISPOSED
[status] parent: UNLOADING -> DISPOSED
原因很简单:「挂载一个子插件」本身也是父插件做的一次注册,记在父插件名下。父插件清理时,顺手就把子插件卸了。
DSH 正是靠这一点组装出整个 Agent 的:它有一个叫 agent-spine 的「套件插件」,在自己的 apply 里一口气挂上二十多个核心插件。整个 Agent 就是一棵插件树,根在 new Context()。第 6 篇会用迷你版搭一棵这样的树。
小结
- 插件有三种写法:函数、带
apply的对象、class。框架只认「可调用的东西」。 ctx.plugin()为插件创建一个 Fiber,插件的apply是异步执行的。- 每个插件拿到自己的子 ctx,所以它做的每一次注册都知道属于谁,卸载时能清理干净。
- 插件里可以再挂插件,整个应用是一棵插件树。
下一篇讲 Fiber:一个插件从挂载到卸载,中间会经历哪几种状态,框架又是怎么替它记账的。