插件系统最容易实现的部分是加载。
给定一个模块:
function plugin(ctx) {
ctx.registerTool(...)
ctx.on(...)
}
调用它以后,新的能力进入系统。
当组件只在进程启动时加载一次,并在进程结束时整体销毁,这种模型通常足够。动态 Harness 的约束更强:插件可能被禁用、替换、热更新;某些插件只有在依赖存在时才应该运行;依赖消失以后,它们还需要退出,并在依赖恢复后重新激活。
Cordis 将这类问题概括为 Spatiotemporal Composability。2026 年 8 月 13 日发布的预印本《A Programming Paradigm for Spatiotemporal Composability》把问题拆成两个相互独立的维度:[1]
- Temporal Composability:组件离开系统时,其产生的副作用可以完整撤销。
- Spatial Composability:组件可以声明对环境的要求,并随着环境变化动态调整自己的有效性。
论文进一步使用 Effect、Coeffect 和统一 Context 描述这两个维度。
这篇文章不按论文目录复述形式化定义,而是先建立工程问题,再解释这些抽象为什么成立。
动态插件首先面临的是“撤销”
一个插件运行后通常会修改多个外部结构:
function plugin(ctx) {
ctx.tools.register(tool)
ctx.events.on('request', listener)
const timer = setInterval(task, 1000)
}
加载以后,运行环境发生了三个变化:
Tools + tool
Listeners + listener
Timers + timer
如果卸载只是删除 plugin 对象,这些变化仍然存在。
所以动态插件生命周期不能只记录:
plugin = loaded
还需要记录插件对环境做过哪些修改。
最直接的方式是让每个操作返回对应的撤销函数:
const disposeTool = registerTool(tool)
const disposeListener = on(event, listener)
return () => {
disposeListener()
disposeTool()
}
这已经非常接近论文所说的 revertible effect。
可以把一次注册写成状态变换:
Context C
│
│ apply effect E
▼
Context C'
如果组件要被安全移除,就需要存在相应逆操作:
Context C'
│
│ revert E
▼
Context C
关键点不在于必须严格恢复内存的每一个字节,而在于组件对共享运行环境产生的可观察影响能够被生命周期系统撤销。
这就是 Temporal Composability 的工程含义。
Effect 需要由 Runtime 持有
如果每个插件作者都手动维护一组 cleanup callback,理论上也可以工作。但随着插件内部出现异步初始化、嵌套资源、多个注册和错误回滚,生命周期很容易失去一致性。
更稳定的模型是:
Plugin Runtime
│
└── owns Effects
├── Tool registration
├── Listener
├── Service
└── Timer
插件执行期间产生的 Effect 被当前运行单元收集。
卸载时:
dispose runtime unit
↓
revert owned effects
插件生命周期于是从“调用一个函数”变成“创建一个拥有资源的运行单元”。
Cordis 将这个运行单元称为 Fiber。[2][3]
需要注意,它和 JavaScript coroutine 或 Effect-TS 中的 Fiber 不是同一个抽象。Cordis 的 Fiber 更接近一个 Plugin Runtime Instance:它保存插件配置、依赖快照、生命周期状态和当前拥有的 Effect。
在 DeepSeek Harness vendored Cordis 当前源码中,Fiber 状态包括:
PENDING
LOADING
ACTIVE
FAILED
UNLOADING
DISPOSED
这已经表明插件不是一个布尔值 loaded=true/false,而是一个可经历依赖等待、加载、卸载和失败的状态机。[3]
仅有可逆副作用仍然无法处理依赖
考虑两个插件:
Plugin A
└── provides FileSystem
Plugin B
└── requires FileSystem
如果 B 在 A 之前启动,会发生什么?
传统 Plugin Manager 可能依赖加载顺序:
A
↓
B
规模扩大后,手工排序会逐渐变成依赖图管理问题。
更重要的是,动态系统里的依赖会在运行中变化。
A 可以在 B 已经运行以后被卸载:
time ───────────────────────────>
A: ACTIVE ─────────── DISPOSED
B: ?
如果 B 继续运行,它持有的依赖已经失效;如果 Runtime 只在启动时检查依赖,同样无法解决问题。
因此组件除了描述“我会修改什么”,还需要描述“我需要环境提供什么”。
论文使用 Coeffect 表示后一类约束。[1]
Effect 描述组件对环境产生的作用:
component → environment
Coeffect 描述环境必须向组件提供的条件:
environment → component
例如:
requires FileSystem
requires ToolRegistry
requires SessionStore
这些要求共同决定组件当前是否具备运行条件。
Reactive Coeffect 解决的是依赖变化
普通依赖注入通常发生在对象创建时:
construct B(FileSystem)
对象创建以后,依赖关系基本固定。
动态 Harness 的要求更接近:
FileSystem appears
↓
B becomes activatable
↓
B starts
FileSystem disappears
↓
B becomes invalid
↓
B unloads
FileSystem appears again
↓
B starts again
论文将这种随着 Context 变化重新判断组件有效性的机制称为 reactive coeffect。[1]
这里的“reactive”非常重要。
它意味着依赖检查不是一次性的 constructor validation,而是 Runtime 持续维护的一条关系:
Context changed
↓
which coeffects are affected?
↓
re-evaluate components
工程实现不必不断轮询整个系统。更合理的做法是,在 Service 注册或移除时触发依赖更新。
Cordis 当前实现正是事件触发的:服务变化后通知依赖该服务的 Fiber,Fiber 重新计算自己的依赖 epoch,再决定保持 ACTIVE、进入 UNLOADING,或者重新 LOADING。[3]
因此所谓“重新计算”不是一个永久运行的 while (true):
while (true) {
recalculateAllPlugins()
}
它发生在环境发生结构变化时。
为什么叫 Spatial Composability
“空间”容易让人联想到物理位置,这里的含义更接近同一时刻组件之间的组合关系。
给定:
A provides X
B requires X, provides Y
C requires Y
某一时刻的有效系统并不等于所有已安装插件的集合,而是由当前 Context 中可满足的依赖关系决定:
Installed:
A B C D E
Current Context:
A ACTIVE
B ACTIVE
C ACTIVE
D PENDING
E PENDING
当 X 消失后:
A removed
↓
X disappears
↓
B loses requirement
↓
B unloads
↓
Y disappears
↓
C unloads
空间可组合性关注的就是这一刻“哪些组件能够共同存在”。
它和 Temporal Composability 连接以后,形成完整的动态变化:
依赖消失
↓
组件失效
↓
撤销组件 Effects
↓
组件提供的 Service 消失
↓
影响下一层依赖
这里会产生连锁反应,但它并不要求 Runtime 做全局递归推演。每一次 Service 的撤销都会产生下一次局部依赖变化,直到系统达到新的稳定状态。
Context 为什么需要统一 Effect 与 Coeffect
如果 Effect 和依赖各自维护独立世界,会出现一个难题:组件修改的是哪一个环境,依赖观察的又是哪一个环境?
论文提出将 effect context 和 coeffect context 统一成一个 Context type。[1]
从工程角度,可以把它理解为:
Context
│
┌───────────┴───────────┐
│ │
component reads component writes
│ │
Coeffect Effect
│ │
└───────────┬───────────┘
▼
shared runtime
Context 同时承担:
- 查找当前可用 Service;
- 注册新的 Service;
- 注册监听器和其他 Effect;
- 记录这些 Effect 属于哪个运行单元;
- 形成子 Scope / Isolation;
- 在结构变化后通知相关依赖。
这样,一个 Plugin 不需要额外拿到全局 Application、ServiceRegistry、LifecycleManager、EventBus 等多个对象。它通过同一个 Context 与运行环境发生交互,而 Runtime 可以统一跟踪交互产生的生命周期关系。
从单组件到组件系统
论文最终关心的不只是一个插件能否安全卸载,而是这些性质能否在多个组件交错变化时继续成立。
例如:
A provides X
B requires X -> provides Y
C requires X + Y
A 卸载以后:
X removed
↓
B invalid
C invalid
↓
B effects reverted
C effects reverted
↓
Y removed
如果 A 之后重新出现:
X available
↓
B activatable
↓
B provides Y
↓
C activatable
整个系统并没有依赖固定启动顺序。顺序是依赖关系在当前 Context 下自然产生的结果。
这也是“Composable”真正有价值的部分:每个组件只声明自己的贡献和要求,系统组合后的生命周期由 Runtime 推导。
这套模型为什么适合 Agent Harness
Agent Harness 的能力天然具有高度动态性。
LLM Provider 可以替换,Tool Provider 可以按 Session 隔离,Sandbox 可以按执行环境变化,MCP Server 可能断开,SubAgent 只在某段执行中存在,UI Contribution 也可能随着插件启停出现和消失。
如果所有这些关系都通过手工启动顺序和手工 cleanup 维护,Harness 会很快形成大量跨模块生命周期代码。
时空可组合模型提供了另一种组织方式:
Plugin
├── declares dependencies
└── produces reversible effects
Context
├── resolves dependencies
└── owns shared capabilities
Fiber
├── tracks current dependency epoch
├── owns effects
└── drives load / unload
这三个对象基本构成了 Cordis 的核心。
下一篇会进入实际源码,回答几个更具体的问题:
- 一个依赖 Service 被卸载时,依赖它的 Fiber 如何发现变化;
- 为什么下层 Service 会继续产生连锁失效;
- 卸载过程中如果依赖重新出现,Runtime 如何避免生命周期竞争;
- DeepSeek Harness 如何在这套机制上实现 Everything is a Plugin。
参考资料
[1] A Programming Paradigm for Spatiotemporal Composability: https://github.com/cordiverse/paper
[2] Cordis repository: https://github.com/cordiverse/cordis
[3] DeepSeek Harness vendored Cordis: https://github.com/deepseek-ai/deepseek-harness/tree/master/vendor/cordis
[4] DeepSeek Harness Cordis Primer: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-primer.md