假设我们正在构建一个 Agent。它需要模型服务、工具、日志、会话、权限校验等几十个插件,还要允许不同租户使用不同的模型配置。运行过程中,有些插件会被关闭,有些服务会被替换,开发时还希望修改代码后立即生效。
如果这些事情全部靠业务代码手工处理,很快就会遇到四类问题:
- 插件卸载了,定时器、监听器或连接却没有释放;
- 服务发生变化后,依赖它的插件仍拿着旧实例运行;
- 插件之间互相直接调用,最终形成难以拆解的依赖网;
- 改一处配置或代码,整个进程都要重启。
Cordis 就是为这类动态应用准备的运行底座。它并不直接提供 Agent 能力,而是规定插件如何进入系统、如何协作、如何退出,以及整个应用如何在运行中发生变化。
可以先把它概括成四个问题和四组机制:
| 问题 | Cordis 的回答 |
|---|---|
| 插件产生的资源怎么回收? | Effect与 disposer |
| 插件什么时候应该运行? | Service、inject与响应式依赖 |
| 插件之间怎么通信? | 五种事件分发模式 |
| 大量插件怎么组织和更新? | Loader、YAML、isolate、intercept与 HMR |
理解这四组机制后,DSH 为什么能把大量能力做成可插拔组件,也就不难理解了。
本文讨论的是
@deepseek-ai/cordis 4.0.1所体现的运行语义。它是 DSH 从 Cordis fork 并重新命名作用域后的版本,对应的上游核心 API 语义基本一致。
先建立一张运行时地图
Cordis 中最常见的入口是Context,插件则是一个可以被独立装卸的功能单元。最简单的插件只有一个apply(ctx):
importtype{Context}from'@deepseek-ai/cordis'exportfunctionapply(ctx:Context){console.log('plugin started')}Context可以理解为插件看到的共享运行环境。插件通过它挂载子插件、注册服务、订阅事件,也通过它访问自己声明过的依赖。
不过,真正负责管理一次插件运行的并不是插件源码,而是Fiber。每调用一次ctx.plugin(),框架都会为这次挂载创建一个 Fiber。即使同一个插件被挂载三次,也会产生三个独立的 Fiber 实例。
因此需要区分三层:
- Plugin 是功能定义,也就是“要运行什么”;
- Context 是插件操作环境的入口,也就是“能看到什么”;
- Fiber 是某次挂载的运行实例,也就是“当前运行得怎么样”。
这里的“Plugin”容易让人以为存在一个像 Context、Fiber 那样由 Cordis 创建的 new Plugin() 对象。这里指的是插件定义,在文中的例子里,就是导出 apply 的模块。
Fiber 会经历PENDING、LOADING、ACTIVE、UNLOADING、DISPOSED等状态,加载失败时还会进入FAILED。父插件挂载子插件后,Fiber 之间也形成父子关系,最终构成一棵插件树。
整个运行模型可以先简化成下面这样:
配置或代码 │ ▼ Context + Plugin │ ctx.plugin() ▼ Fiber 运行实例 ├── Effect:管理资源生命周期 ├── Service:提供具名能力 ├── inject:声明运行依赖 └── Event:完成插件间通信整个过程涉及“挂载插件”和“运行插件”:
- ctx.plugin(plugin, config) 是调用方使用的 API:把插件挂到插件树上,创建对应的 Fiber,并交给 Cordis 管理依赖、生命周期和卸载。
- apply(ctx, config) 是插件作者提供的入口:当依赖满足、插件可以启动时,由 Cordis 调用,执行插件的初始化逻辑。它不是 ctx 上的方法。
const worker = { inject: ['db'], apply(ctx, config) { // db 就绪后,Cordis 才会执行这里 }, } const fiber = ctx.plugin(worker, { name: 'example' }) // 之后可以通过 fiber 卸载这个运行实例补充:apply(ctx, config) 主要做的是启动这个插件的一次运行实例:读取配置,取得已声明的服务依赖,然后注册这个插件要提供的能力和要执行的工作,例如服务、事件监听器、定时器或子插件。
ctx.plugin(plugin, config) 挂载插件,交给 Cordis 管理 ↓ 依赖满足后 plugin.apply(ctx, config) 执行插件的初始化逻辑下面从一个 Fiber 的生与死开始。
插件能加载,更要能干净地卸载
插件很少只做一次计算。它通常会启动定时器、打开连接、注册监听器,或者挂载更多子插件。这些操作都会改变外部环境,也就是产生副作用。
Cordis 的基本要求是:产生副作用时,同时描述如何撤销它。
exportfunctionapply(ctx:Context){ctx.effect(()=>{consttimer=setInterval(()=>{console.log('tick')},1000)return()=>clearInterval(timer)})}这段代码里,setInterval()是向前执行的操作,返回的函数则是撤销路径。插件被卸载时,框架会主动调用这条路径。业务代码不需要在另一个角落记住定时器编号,也不需要另外维护一套停止流程。
这就是 Cordis 所说的“时间可组合性”:组件不仅可以加入系统,也可以把自己对环境造成的改变撤销掉。
很多 Cordis API 本身就是 Effect
实际开发中,并不需要把每个操作都手工包进ctx.effect()。Cordis 自己提供的注册型 API 已经带有 Effect 语义:
ctx.on()注册的监听器会在所属插件卸载时移除;ctx.plugin()挂载的子插件会随父插件卸载;ctx.provide()或Service注册的服务会在提供方退出时撤销;- 这些 API 返回的 disposer 都会归属到当前 Fiber。
只有数据库连接、文件句柄、原生定时器等 Cordis 无法自动认识的资源,才需要使用ctx.effect()明确提供释放逻辑。
在 Cordis 里,Effect 可以理解为“创建一项持续存在的影响,同时登记如何撤销它”。它通常不是一个需要 new Effect() 的对象,而是通过 ctx.effect() 体现的生命周期约定:
export function apply(ctx: Context) { ctx.effect(() => { const timer = setInterval(() => { console.log('tick') }, 1000) return () => clearInterval(timer) }) }这里 setInterval() 注册了一个定时器;即使 apply() 已经返回,它仍会持续运行。返回的函数叫 disposer,告诉 Cordis 如何停止它。所属 Fiber 卸载时,Cordis 会调用这个函数。
为什么叫 Effect 为“副作用”
看同一个函数里,除了返回值还发生了什么:
let count = 0 function add(a: number, b: number) { count++ // 改变了函数外面的变量 return a + b // 返回值 }调用 add(1, 2) 后,有两件事发生:拿到返回值 3,同时外部的 count 增加了。count 的变化就是这次调用的副作用。去掉 count++,返回值仍是 3,但副作用没了。
再放回 Cordis:apply(ctx) 注册监听器时,改变的是 ctx 所管理的监听器列表;即使 apply() 返回了,监听器仍在。这就是它产生的副作用。Cordis 的 Effect 机制负责记录“卸载时怎样移除这个监听器”。“副”只是相对于函数返回值而言,不代表它在插件里不重要。
所以 Cordis 插件的 apply() 常常正是为了产生这类变化:注册监听器、启动定时器、提供服务。Cordis 称它们为 Effect,是为了把变化和清理配成一对:装插件时增加了什么,卸插件时就移除什么。例如注册定时器是副作用,clearInterval() 是对应的清理。
卸载并不是简单地逐行反向执行
调用fiber.dispose()后,框架大致会走过这条链路:
fiber.dispose() │ ▼ _unload() │ ▼ DisposableList.clear() │ 按注册顺序逆序取出 ▼ Promise.all(...) 并发启动各个 disposer │ ▼ 单个 disposer 内部再逆序执行自己的清理步骤这里有一个容易误解的细节:多个 disposer 会逆序启动,但外层使用了Promise.all()。如果清理函数包含异步操作,框架并不保证它们按逆序完成。
假设卸载时必须先停止轮询,再关闭连接,最后释放文件,那么这三步不应该拆成互相独立的 disposer。更稳妥的写法是放在同一条清理链中:
ctx.effect(()=>{constfile=openFile()constconnection=connect()constpolling=startPolling(connection)returnasync()=>{awaitpolling.stop()awaitconnection.close()awaitfile.close()}})Cordis 负责兑现清理函数,却不能替开发者推断资源之间的业务顺序。它提供的是可靠的生命周期边界,而不是自动发现所有 JavaScript 资源。
插件不再等待“正确的启动顺序”
资源能够干净回收,只解决了单个插件的生命周期。真实系统里,一个插件往往依赖另一个插件提供的能力。
例如聊天插件需要一个模型服务。模型服务可能尚未加载,也可能在运行中被替换。传统做法通常是先确定启动顺序,再写大量“服务是否就绪”的判断。Cordis 换了一个思路:插件只声明依赖,框架负责决定它何时运行。
Service 负责提供能力
在 Cordis 中,Service 是注册在 Context 上的具名能力:
import{Service,typeContext}from'@deepseek-ai/cordis'declaremodule'@deepseek-ai/cordis'{interfaceContext{greeter:GreeterService}}exportclassGreeterServiceextendsService{constructor(ctx:Context){super(ctx,'greeter')}greet(name:string){return`Hello,${name}`}}super(ctx, 'greeter')才是运行时注册服务的动作。TypeScript 的声明合并只负责让ctx.greeter获得类型提示,并不会产生运行时代码。
服务注册本身也是 Effect。因此,当提供服务的 Fiber 被卸载时,服务会自动从 Context 中消失。
inject 负责声明硬依赖
消费方只需要声明自己需要什么:
exportconstinject=['greeter']exportfunctionapply(ctx:Context){console.log(ctx.greeter.greet('world'))}如果greeter尚未出现,这个插件不会贸然执行apply(),而是停在PENDING。服务就绪后,它才会被激活。
更重要的是,inject不是启动时只检查一次。Cordis 会持续追踪依赖:
greeter v1上线,消费插件开始运行;greeter v1被撤销,消费插件自动卸载自己的 Effect;greeter v2上线,消费插件带着新实例重新执行apply()。
整个过程中,消费方没有编写启动、停止或重试代码。依赖关系本身就是编排规则。
响应式依赖的核心是一枚“依赖指纹”
当服务被提供或撤销时,Cordis 会通知所有声明了相关依赖的 Fiber。核心链路可以概括为:
provide / revoke │ ▼ notify │ 找到声明了该依赖的 Fiber ▼ _checkImpl │ 重新查询服务实例 ▼ _refresh │ 用服务实例 uid 计算依赖指纹 epoch ▼ _setEpoch ├── 依赖失效:_unload() └── 依赖恢复或更换:_reload()每个 Fiber 都维护自己当前依赖的服务实例。只要缺少一个硬依赖,指纹就会变成不活跃状态;依赖重新满足后,框架会重新执行插件的初始化逻辑。
这里的“重新加载”不是把冻结的旧对象唤醒,而是基于新依赖重新执行一次apply()。所以插件内部的运行状态要么来自配置和服务,要么需要由更高层显式保存,不能假设重启后还能继续使用旧的局部变量。
- 为什么需要 _checkImpl?
以消费插件声明 inject: [‘greeter’] 为例:_checkImpl 会从消费插件当前的 Context 重新查询“现在可见的 greeter 是谁”。它可能是原来的 v1、新提供的 v2,也可能已经不存在。Cordis 再把查询结果与这个 Fiber 之前记录的依赖实例比较,决定是否卸载或重新执行 apply()。
所以可以记成:查的是“我现在依赖谁”,记录和响应变化的是“我自己”。 查询还会受 isolate 影响,并不一定是全局唯一的同名服务。
- uid 计算依赖指纹 epoch 怎么理解?
可以把 epoch 理解成:这个 Fiber 当前拿到的整组依赖的“版本标记”,不是时间戳。
假设插件声明 inject: [‘greeter’, ‘db’]:
当前依赖:greeter(uid=12) + db(uid=7) 依赖指纹:由 (12, 7) 得到的 epochCordis 重新查询服务后,会用查到的实例 uid 再算一次:
- greeter 换成新实例,uid 变为 19:指纹变化,插件需要用新依赖重新运行 apply()。
- greeter 消失:硬依赖不满足,插件需要卸载。
- 只是同一个 greeter 实例内部的数据变了:uid 没变,不会因此重启插件。
- 别的作用域换了服务,但当前插件看到的实例没变:指纹也不变。
所以,uid 标识的是服务实例的身份;epoch 概括的是我这一组依赖目前绑定到了哪些实例。它用来判断是否需要重新运行插件,而不是记录服务内部每次状态变化。
并非所有依赖都应该写进 inject
如果某项能力只是锦上添花,缺少它时插件仍然可以运行,就不应该把它声明为硬依赖,而应在使用处探测:
constgreeter=ctx.get('greeter')if(greeter){greeter.greet('world')}else{console.log('greeter unavailable, skip')}可以用一句话区分:
inject表示“没有它,我就不应该运行”;ctx.get()表示“有它就增强,没有它也能继续”。
这套持续重新评估依赖的模型,是 Cordis 与许多传统 IoC 容器最明显的区别。传统容器通常擅长启动阶段的一次性装配,而 Cordis 从一开始就把依赖图当成会在运行中变化的对象。
依赖之外,插件还需要“说话”
依赖关系表达的是“我需要谁的能力”,事件表达的则是“我想告诉别人发生了什么”。
日志插件和统计插件都想知道用户发出了消息,但消息发送方不需要直接依赖这两个插件。这正适合事件系统。
Cordis 支持通过 TypeScript 声明合并定义事件签名:
declaremodule'@deepseek-ai/cordis'{interfaceEvents{'chat/message'(text:string):void}}ctx.on('chat/message',(text)=>{console.log('message:',text)})ctx.emit('chat/message','hello')所有事件共享一个扁平命名空间,所以通常使用namespace/action的命名方式。类型声明让事件名和参数在编译期得到检查;ctx.on()又具有 Effect 语义,因此监听器会随插件自动卸载。
真正需要做选择的地方,是用哪一种分发模式。可以沿着三个问题判断。
是否需要监听器的处理结果? ├── 不需要 │ ├── 不等待完成:emit │ └── 等待全部完成:parallel └── 需要 ├── 谁能处理谁接手:同步用 bail,异步用 serial └── 层层加工或拦截:waterfall| 场景 | 模式 | 执行语义 |
|---|---|---|
| 只广播通知 | emit | 按注册顺序同步调用,不等待 Promise,不收集结果 |
| 并发完成多个任务 | parallel | 并发调用并等待全部结束,错误汇总为AggregateError |
| 异步寻找第一个处理者 | serial | 按顺序await,遇到有效返回值就短路 |
| 同步寻找第一个处理者 | bail | serial的同步版本 |
| 拦截、包装或转换结果 | waterfall | 监听器通过next()形成洋葱式调用链 |
两种短路不能混为一谈
serial和bail的短路取决于返回值。只要监听器返回的值不是null、false或undefined,框架就认为这件事已经被处理,后面的监听器不再执行。
waterfall的短路则取决于有没有调用next():
ctx.on('chat/reply',(text,next)=>{constresult=next()return`[bot]${result}`})ctx.on('chat/reply',(text,next)=>{if(containsSensitiveContent(text)){return'content blocked'}returnnext()})第一层调用next(),拿到下游结果后再包装;第二层在特定条件下不调用next(),因此直接截断后续处理。一个只想记录日志的 waterfall 监听器如果忘记调用next(),也会意外吞掉整条链路。
此外,ctx.once()适合只等待下一次事件,prepend可以把监听器插到队首。不过执行顺序最好主要依靠清晰的注册关系,只有确实需要抢占顺序时才使用prepend。
从源码角度看,五种事件并不是五套系统。它们都先通过dispatch()取得同一份监听器列表,差别只在如何遍历:同步map、并发等待、顺序短路,或者递归调用next()。复杂的使用语义,建立在一个很小的统一内核之上。
从手写挂载走向声明式插件树
Effect、依赖和事件解决了插件的运行问题。但当应用包含几十上百个插件时,继续在入口文件中逐行调用ctx.plugin(),仍会产生大量机械代码。
Cordis 的 Loader 把装配过程转移到 YAML。这里需要同时理解两棵树:
- 配置树是
cordis.yml中的声明,是应用的图纸; - 插件树是运行时产生的 Fiber 层级,是图纸对应的实物。
一个最小条目可能是:
-id:loggername:./plugins/logger.tsconfig:level:info它的运行链路如下:
创建根 Context │ ▼ 挂载 Loader │ ▼ include cordis.yml │ ▼ 生成 Entry 配置节点 │ ▼ registry.plugin() │ ▼ 创建 Fiber,校验配置,执行 apply()这条链路可以分成三段:准备运行环境 → 把 YAML 变成配置节点 → 把配置节点变成正在运行的插件。
| 层 | 在做什么 |
|---|---|
| 创建根 Context | 建立整个应用的根运行环境。后续插件都从这棵 Context 插件树中获得服务、事件和生命周期管理能力。 |
| 挂载 Loader | 把 Loader 作为一个插件装上去。它负责读取配置,并根据配置管理其他插件。 |
includecordis.yml | 告诉 Loader 把这份 YAML 纳入管理;Loader 读取、解析其中的插件声明。 |
| 生成 Entry | 为每个配置条目建立内部节点,记录id、插件name、config、父子关系等。Entry 是配置树中的节点,还不是运行中的插件。 |
registry.plugin() | 根据 Entry 找到对应的插件模块,并发起挂载。这一步是从“配置声明”走向“运行实例”的桥梁。 |
| 创建 Fiber | 为这次挂载建立运行实例,管理状态、依赖和卸载时的清理。配置通过校验、硬依赖满足后,Cordis 才调用插件的apply(ctx, config)。 |
例如 YAML 里写了 name: ./logger.ts,Entry 表示“配置要求装一个 logger”;Fiber 表示“这个 logger 此次挂载的实际运行状态”。同一模块挂载两次,可以对应两个 Entry 和两个 Fiber。
图中最后的“创建 Fiber,校验配置,执行 apply()”是简写,不保证挂载后立刻执行 apply():条目被禁用时可能不挂载;配置校验失败会进入失败状态;声明的 inject 尚未满足时,Fiber 会等待依赖。
Loader 还会把 Entry 记录到 Fiber 上,使配置节点与运行实例能够持续对账。这一点不仅方便诊断,也是精准热更新的基础。
配置字段不是清单,而是运行规则
一份完整配置里,常见字段分别解决不同问题:
| 字段或能力 | 作用 |
|---|---|
id | 条目的稳定身份,用于生成全名、配置对账和精准更新 |
name | 要加载的插件模块,可以是相对路径或包名 |
config | 传给插件apply()的配置对象 |
disabled | 保留配置但暂不挂载,父级停用时子条目也受影响 |
group | 把子条目组织成树干节点;组本身也是一个真实插件 |
!!js | 在config或disabled中惰性计算动态值 |
inject | 从配置层为插件追加硬依赖 |
插件还可以导出运行时 schema。Loader 会先校验和补齐默认值,再调用apply():
importzfrom'@deepseek-ai/schemastery'exportconstConfig=z.object({level:z.string().default('info'),targets:z.array(String).default(['world']),})这样一来,业务代码收到的是一份完整且经过验证的配置。字段类型错误时,Fiber 会进入FAILED,并给出具体的ValidationError,而不是带着错误配置继续运行。
这里有两条很实用的原则:经常随环境变化的值放进 YAML,不要硬编码;需要严格约束的配置应在加载阶段响亮地失败,不要等到业务运行中再暴露问题。
同名服务如何服务不同租户
这一节可以用一个具体问题来理解:A、B 两个租户都运行同一份chat.ts,但 A 要用 mock 模型,B 要用真实模型。代码都写ctx.shell,怎么保证它们拿到的不是同一个服务?
先看一份完整的配置。为便于说明,shell.ts会注册名为shell的服务,chat.ts则声明inject: ['shell']并调用ctx.shell:
# cordis.yml-id:tenant-aname:'@deepseek-ai/cordis-plugin-group'group:trueisolate:shell:tenant-aconfig:-id:a-shellname:./plugins/shell.tsconfig:provider:mockmodel:mock-chat-id:a-chatname:./plugins/chat.ts-id:tenant-bname:'@deepseek-ai/cordis-plugin-group'group:trueisolate:shell:tenant-bconfig:-id:b-shellname:./plugins/shell.tsconfig:provider:openaimodel:gpt-4o-id:b-chatname:./plugins/chat.tschat.ts可以非常简单:
exportconstinject=['shell']exportfunctionapply(ctx){// 这里的 ctx.shell 由当前租户的 isolate 作用域决定ctx.shell.chat('你好')}运行时可以把它想成两间互相隔开的房间:
租户 A 的房间 租户 B 的房间 ┌────────────────────┐ ┌────────────────────┐ │ a-shell │ │ b-shell │ │ mock / mock-chat │ │ openai / gpt-4o │ │ │ │ │ │ a-chat │ │ b-chat │ │ ctx.shell ──────────┘ │ ctx.shell ──────────┘ └────────────────────┘ └────────────────────┘这里发生了四件事:
- 两个
group分别创建租户 A、B 的插件作用域。 - A 的
isolate.shell: tenant-a和 B 的isolate.shell: tenant-b,把同名的shell放进两个不同的服务“世界”。 a-shell和b-shell虽然使用同一个插件文件,但因为配置不同,产生两个服务实例。a-chat、b-chat都只声明“我要shell”,不需要知道具体供应商;解析依赖时,A 只能看到 A 的shell,B 只能看到 B 的shell。
因此,isolate不会自动创建服务,也不会改变chat.ts的代码;它只决定“同名服务在哪个作用域内可见”。每个租户仍然要各自挂载自己的shell.ts。
intercept又解决什么问题
假设 B 仍然使用b-shell这个实例,但只想让 B 下面的某个子插件临时看到另一份模型配置,可以在该作用域加上intercept:
-id:tenant-bname:'@deepseek-ai/cordis-plugin-group'group:trueisolate:shell:tenant-bintercept:shell:model:gpt-4o-miniconfig:-id:b-shellname:./plugins/shell.tsconfig:provider:openaimodel:gpt-4o-id:b-chatname:./plugins/chat.ts这时要区分两件事:b-shell服务实例仍然是原来的那个,原始配置仍是gpt-4o;但 B 这个作用域中的消费者读取配置时,会看到被覆盖的gpt-4o-mini。因此:
isolate = 把服务实例分到不同作用域(换一个“房间”) intercept = 只改变当前作用域看到的配置(在房间里换一张“配置便签”)简单说:要让两个租户拥有不同的服务对象,用isolate;已经是同一个服务对象,只想让某个范围内的插件使用不同配置视图,才用intercept。
HMR 为什么能只替换发生变化的部分
插件树一旦具备明确的生命周期、稳定的身份和响应式依赖,就已经拥有局部更新所需的基础。
Cordis 的 HMR 有两条路径。
修改配置:按 Entry 更新
cordis.yml变化后,include 会根据稳定id对比新旧配置,只重启发生变化的条目:
配置变化 │ ▼ 根据 id 找到 Entry │ ▼ fiber.restart() │ ├── 卸载旧实例并回收 Effect └── 使用新配置重新执行 apply()如果条目没有显式id,Loader 会生成随机身份。下次读取配置时,它就可能被识别成“删除旧条目,再新增一个条目”,相关子树也会被重新挂载。因此,稳定id是精准配置热更新的前提,而不只是为了让 YAML 更易读。
修改代码:按模块更新
插件源文件变化后,HMR 会清理模块缓存、重新import新代码,再使用原配置替换插件。共用同一个模块的多个实例会一起换装,而没有引用这个模块的其他插件不受影响。
所以两条路径的更新粒度不同:
- 改配置以 Entry 为单位,只处理发生变化的条目;
- 改代码以模块为单位,使用该模块的实例一起更新。
HMR 本身还依赖timer服务进行防抖。没有挂载 timer 时,HMR 会因为依赖不满足而停在PENDING。此外,它需要 Node 的内部模块加载能力,因此要使用--expose-internals,并注意不同 Node 版本的内部 API 差异。
HMR 看似是一项独立功能,实际仍在复用前面的机制:旧实例依靠 Effect 干净卸载,新实例依靠 Service 和inject重新就绪,Fiber 则提供重启与状态管理。Cordis 并没有为热更新重新发明一套生命周期。
把四套机制还原为一条运行链路
现在可以把整个 Cordis 运行过程收拢到一张图中:
YAML / ctx.plugin() │ ▼ Fiber │ ▼ apply(ctx) ┌────┼────────┐ ▼ ▼ ▼ Effect Service Event │ │ │ ▼ ▼ ▼ dispose notify dispatch │ ▼ unload / reload │ ▼ 插件树持续演化这条链路揭示了 Cordis 最重要的设计取向:它不是把插件当成启动一次就永远存在的模块,而是把插件当成随时可能出现、消失、重新执行和改变依赖的运行单元。
因此:
Effect回答“这个插件离开时,怎样把环境恢复干净”;Service / inject回答“依赖变化时,这个插件是否应该运行”;- 事件系统回答“没有直接依赖的插件,怎样按合适的语义通信”;
- Loader、
isolate、intercept和 HMR 回答“整棵插件树怎样被描述、分区和局部替换”。
写一个 Cordis 插件时,也可以用这四个问题自检:
- 我创建的每项外部资源是否都有 disposer?
- 必需能力是否声明为
inject,可选能力是否使用ctx.get()? - 插件通信究竟是广播、竞争,还是洋葱式加工?
- 配置是否有 schema,条目是否有稳定
id,多租户是否需要isolate?
DSH 在 Cordis 之上构建模型、工具、会话等 Agent 能力。Cordis 自己并不理解模型推理,也不负责回答用户问题;它提供的是一套更底层的秩序:让能力能够以插件形式加入系统,让依赖和通信可以被声明,让资源可以被回收,并让运行中的应用能够安全地改变形态。
从这个角度看,Cordis 的核心并不是“插件很多”,而是插件无论来、去、协作还是替换,都处在同一套可推导的运行规则里。