Cordis 入门
Cordis 是 Tether 底层的插件框架,C# 移植自 dsh vendored 的
Cordis(TS 版)。
本文介绍插件作者在深入各子系统页面前需要了解的核心概念,**每个概念都对应到
src/Cordis/ 里的具体代码**。语义对照基准是 dsh 的
Cordis 入门。
五个核心概念
1. 插件是挂载到 Context 上的行为单元
TS 版里插件是”带可选 inject 和 apply(ctx) 字段的函数,或 Service 子类”。C# 版的对应物是 src/Cordis/Plugin.cs:
public interface IPlugin
{
/// 依赖的服务键;全部出现才激活,任一消失即回收
IReadOnlyList<Type> Inject => Array.Empty<Type>();
/// 在 fork 出的子 Context 上应用本插件
ValueTask ApplyAsync(Context context);
}
public abstract class Plugin : IPlugin
{
// Inject 默认从类型上的 [Inject(typeof(...))] attribute 读取
public virtual IReadOnlyList<Type> Inject => /* InjectAttribute 聚合 */;
protected abstract void Apply(Context context);
}
一次性逻辑可以不写类:Plugin.From(Action<Context> apply, params Type[] inject) 把委托包成插件——
headless 宿主里 Plugin.From(ctx => ctx.Provide<ILlmClient>(llm)) 就是这种用法。
TS 版的 Service 子类对应 src/Cordis/Service.cs 的 CordisService:构造函数把 StartAsync 挂到
LifecycleEvents.Ready("ready")事件上、把 StopAsync 挂到 Context.OnDispose 上——
启动等待应用就绪,停止随 scope 回收。CordisService<TSelf> 变体还会把自己按具体类型注册进服务仓库。
2. Context 是服务的容器
TS 版用字符串键(ctx.tools、ctx.llm)。C# 版用 Type 键,API 在 src/Cordis/Context.cs:
public class Context
{
public Registry Registry { get; } // 服务仓库 + inject 激活图(根共享)
public EventService Events { get; } // 事件总线(根共享)
public EffectScope Scope { get; } // effect 作用域(按 fork 独立)
public T? Get<T>() where T : class; // 找不到返回 null
public T Require<T>() where T : class; // 找不到抛异常
public bool Has<T>();
public ServiceRegistration Provide<T>(T instance); // 注册服务,scope-bound
public ValueTask SetAsync<T>(T instance); // 更新,等依赖方收敛
public ValueTask<bool> RemoveAsync<T>(); // 移除,等依赖方清理
}
Provide 返回的 ServiceRegistration(src/Cordis/Registry.cs)是作用域绑定的:
只有提供它的 exact scope 能 SetAsync / RemoveAsync,其他 scope 调用立即失败且不改变权威值。
提供者卸载时服务随之移除,并等待依赖它的插件完成清理后才结束 scope disposal——
这是 SetOwnedAsync / RemoveOwnedAsync 里 activation 图的职责。
3. 通过 Inject 声明服务依赖
public sealed class WebFetchPlugin : Plugin
{
// Inject 声明激活前置条件:IWebService 出现才 apply,消失则回收回 pending
public override IReadOnlyList<Type> Inject { get; } = [typeof(IWebService)];
protected override void Apply(Context context)
{
var web = context.Require<IWebService>();
context.Effect(web.RegisterFetchProvider(provider).Dispose);
}
}
激活图住在 Registry(src/Cordis/Registry.cs):插件依赖的服务出现时激活、消失时回收 scope 并回到 pending。
同一提供者 SetAsync 更新服务实例时,依赖它的活跃插件重启以绑定新实例。
委托版快捷方式 ctx.Inject([typeof(A)], c => ...)(Context.cs)等价于挂一个 Plugin.From。
4. 类型化事件用于通信
事件 API 在 src/Cordis/Events.cs(EventService),Context 上是一组泛型转发。
TS 版靠声明合并扩展事件名;C# 版事件名是字符串常量(如 LlmEvents.Stream = "llm/stream"),
类型安全靠 On<T1, TResult> 这类泛型重载在调用点落地。
5. 注册是可逆的 effect
src/Cordis/EffectScope.cs 的 EffectScope 是”注册即回收”的实现:
// Context 上的三组注册入口,全部落到当前 scope:
context.OnDispose(disposer); // = Effect(disposer)
context.Effect(disposer); // 注册一个 disposer
context.On(name, listener); // 事件订阅;返回的 IDisposable 只是"提前摘除"
scope 回收时按 LIFO 逆序执行全部 disposer。ctx.On 返回的 IDisposable 只是提前摘除的手段,
scope disposal 必须兜底——不允许存在”注册后无法回收”的 API。这也是热重载能卸载旧程序集的唯一保障
(任何活引用——事件处理器、静态缓存、timer 回调——都会阻止 ALC 卸载)。
派发模式
dsh 的四种派发模式逐一对应(bail 判定:非 null 且非 false):
| 模式 | Tether API | await? | 顺序 | 返回值 |
|---|---|---|---|---|
emit | EmitAsync(name, args) | 按序执行,忽略返回 | 注册顺序 | 无 |
waterfall | WaterfallAsync<T>(name, terminal, args) | 是 | 环绕嵌套 | 有(terminal 的) |
parallel | ParallelAsync(name, args) | 全部并发后一起等待 | 并发 | 无 |
serial | SerialAsync(name, args) | 按序,遇首个 bail 值中断 | 注册顺序 | 有(首个命中) |
(bail) | BailAsync(name, args) / BailAsync<T> | 是 | 注册顺序 | 首个命中值 |
普通派发中的监听器异常直接使 dispatch 失败:顺序模式在失败处停止,ParallelAsync 等待全部
settled 后聚合失败。只有显式的 contained 通知——EventService.EmitContained——把监听器失败路由到
internal/error(LifecycleEvents.Error)而不影响触发方;这是 CordisService 启动失败、
后台通知等”绝不能打断主流程”场景使用的形式。
Waterfall 语义
WaterfallAsync(Events.cs)把 listeners 嵌套在 terminal delegate 外层——around 中间件:
// 监听器收到 (args..., next);只有显式调 next() 才进入后续 listener 和 terminal
context.OnWaterfall<int, string>("tools/pre-execute", (call, next) => Wrap(call, next));
var result = await context.WaterfallAsync(
"tools/pre-execute",
terminal: () => DispatchCore(call), // 所有 listener 都调 next() 才会走到这里
call);
监听器不调用续延(next)就直接返回 = 短路整条链路。策略监听器在拥有决策权时短路是设计意图;
只做标注或观察的监听器必须委托。prepend: true 让监听器排在普通注册之前,只应在必须先运行时使用。
挂载、fork 与生命周期事件
var child = context.Fork(); // 独立 EffectScope,共享根 Registry/Events
var handle = context.Plugin(myPlugin, config); // = fork + apply + 纳入激活图
Context.Plugin(Context.cs)把挂载动作本身也注册为父 scope 的 effect——卸载父级会连带卸载插件。
PluginHandle(src/Cordis/PluginHandle.cs)暴露 Completion:apply 失败会回收其 scope 并使
Completion 以 PluginApplyException 失败——不允许静默吞掉。
内置生命周期事件(src/Cordis/LifecycleEvents.cs):ready、dispose、fork、service、
internal/error。
Loader 配置
TS 版用 !!js 表达式插值 config;Tether 有意不做 !!js,改用环境变量替换 +
stable-id 有序 patch(YamlDotNet)。见 YAML 组合与加载。
实践规则
将行为封装为插件:工具流水线事件属于 IToolRegistry,模型流式输出属于 ILlmClient,
实时 Agent 协调属于 IAgentRegistry。拦截和策略优先用事件;直接能力调用优先用服务方法。
每个注册都应有对应 disposer:ctx.Effect / ctx.OnDispose 注册,或 ctx.On 返回的
IDisposable 提前摘除。teardown 顺序有要求时,把相关工作放在同一个 effect 里(LIFO 保证逆序释放)。