CHAPTER 04 · STAGE 01 · 建立项目地图
Cordis:插件、Context、Service、事件和 Effect
假设我们想给系统增加一个问候能力:插件 A 提供 ctx.greeter.greet(name),插件 B 在启动时调用它,插件 C 监听问候事件并记录次数。从一个最小需求开始
假设我们想给系统增加一个问候能力:插件 A 提供 ctx.greeter.greet(name),插件 B 在启动时调用它,插件 C 监听问候事件并记录次数。
这个小例子包含 Cordis 的核心:插件负责贡献行为;Context 保存可发现的 Service;inject 声明依赖;事件连接生产者和观察者;effect 让注册能随插件卸载。
Plugin:可被挂载和卸载的功能单元
插件可以是带 apply(ctx, config) 的对象或函数,也可以是 Service 子类。Loader 根据配置把插件挂到树上,Cordis 为它建立一个生命周期作用域。
apply 不是普通的应用启动函数。它运行在一个属于当前插件的 Context 中。插件通过这个 Context 注册 Service、事件监听器、子插件和清理逻辑。
当插件被卸载时,它拥有的 effect 会被撤销。热重载本质上也是旧生命周期结束、新生命周期开始。
Context:服务容器加生命周期作用域
把 Context 仅仅理解为“全局对象”是不准确的。它既提供 ctx.tools、ctx.llm 等 Service 的发现入口,也携带插件树位置、作用域和 effect 所有权。
父子 Context 可以共享 Service Store,但某些注册又能按 Agent 或 scope 生效。一个插件拿到哪个 Context,会影响它注册的贡献由谁拥有、对谁可见、何时释放。
阅读 ctx.plugin(child, config) 时,可以理解为“把 child 挂成当前插件生命周期拥有的子节点”。父插件卸载时,子节点也会随之释放。
Service:稳定名字后面的可替换能力
Service 通过稳定的 ctx key 暴露 API。消费者依赖 ctx.shell,而不是直接导入 bash-local。这样 Provider 可以从本地 Bash 换成沙箱 Bash,而消费者仍使用相同接口。
并非所有 Service 都只有一个 Provider。Shell 通常是单一当前执行器;LLM 和 Subagent 则是注册表,可以同时注册多个具名 Provider,再按 provider id 选择。
判断一个包是 Service Definition 还是 Service Provider,可以看它是否主要声明抽象 API,还是把一个实现注册到已有 Service。
inject:运行时服务依赖
JavaScript import 表示模块依赖,inject 表示运行时组合依赖。
一个插件可以导入 Service 的类型,却只有在 ctx.tools 实际存在时才能工作。通过 inject = ['tools'],Cordis 会等待依赖出现再激活插件;依赖消失时,相关生命周期也能正确处理。
因此加载顺序通常不靠“把 YAML 第 3 行放在第 2 行后面”维持,而是由 Service 依赖关系表达。配置仍有层次和覆盖顺序,但 Service 激活不是简单的逐行脚本。
事件:不拥有对方也能通信
事件适合“发生了什么”或“允许其他插件拦截什么”。Cordis 支持四种常见分发模式:
| 模式 | 直观理解 | 典型用途 |
|---|---|---|
emit |
同步通知观察者,不等待结果 | 注册变化、状态通知 |
parallel |
等待所有监听器并行完成 | 相互独立的异步观察 |
serial |
按顺序等待监听器 | 有顺序要求的检查点 |
waterfall |
中间件链,可改写或短路结果 | 请求、策略和执行管线 |
事件模式是公开行为的一部分。看到事件名后,要同时确认它的 mode;只知道 payload 不足以安全编写监听器。
Waterfall:最容易写错的事件模式
Waterfall 监听器通常收到 next()。调用 next() 表示把控制交给下一个监听器,并取得下游结果;不调用则表示当前监听器完全拥有结果,后续监听器不会运行。
例如权限插件在拒绝工具调用时可以直接返回 deny,这是有意短路。只做日志记录的监听器如果忘记调用 next(),却会意外阻断整个工具执行链。
阅读 waterfall 时画成嵌套函数比画成广播更准确:
listener A before
listener B before
terminal operation
listener B after
listener A after
Effect:注册与撤销是一对操作
工具注册、事件监听、Provider 注册和提示词 section 都必须能撤销。Cordis 的 ctx.on() 等辅助方法会返回 disposer;ctx.effect() 可以把一组建立/释放逻辑绑定到当前生命周期。
这条规则解决三类问题:热重载不残留旧行为;测试能够卸载插件并验证清理;进程退出或 Provider 被替换时资源能按所有权释放。
看到直接写全局状态、启动计时器或创建进程时,要主动寻找它的释放路径。
插件树与普通依赖图的区别
依赖图回答“谁需要谁”。插件树还回答“谁拥有谁的生命周期”。一个组合包把多个子插件挂在自己下面,表示它负责这组组件的创建和释放;Service Store 又让同级插件能够通过注入发现这些服务。
因此“父子”不一定表示业务调用关系。“谁调用谁”看 Service 和事件,“谁随谁卸载”看插件树。
Scope:为什么同一个 Agent 能看到不同工具
系统可以给每个 Agent 建立 scoped Context。全局注册对所有 Agent 可见,Agent scope 内的同名贡献可以覆盖或补充全局贡献。
这让一个子 Agent 获得受限工具集、不同系统提示词或不同模型配置,而不用复制整个进程。理解 scope 的前提仍是 Context 和 effect:scope 只改变贡献的可见范围,不取消生命周期所有权。
最小实践路径
仓库自带的 Cordis 教程是本篇的动手部分,按顺序完成七章:插件、生命周期、Service、事件、配置、组合/HMR、进入 Harness。
入口:docs/cordis-tutorial/index.zh.md。教程使用 tmp/cordis-tutorial,不会污染版本控制,也不需要 API Key。
常见误解
“声明了 Context 类型,Service 就存在”
错误。声明合并只影响编译器,运行时仍需要 Provider 注册 Service。
“YAML 在前面的插件一定先完全启动”
错误。实际激活还依赖 inject 和插件生命周期。
“事件监听器只是旁观者”
错误。Waterfall 监听器可以改写结果或短路整个操作。
“插件卸载就是从数组里删掉名字”
错误。卸载还需要停止计时器、进程、异步工作并撤销所有注册。
本篇练习
- 完成 Cordis 教程前四章。
- 画两张图:一张是 Service 依赖图,一张是插件生命周期树。
- 写一个 waterfall 监听器:当名字为
blocked时短路,否则调用next()。 - 卸载监听器所属插件,验证监听器不再收到事件。
验收标准:能解释 import、inject、Context declaration merging 和运行时 Service 注册之间的区别。