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 是服务的容器

Registry 服务仓库 + 激活图 · 根共享 EventService 事件总线 · 根共享 root Scope new Context() 插件 A Scope ctx.Plugin(A) 插件 B Scope ctx.Plugin(B)
不对称是刻意的:共享的 Registry/Events 让插件能相遇,独立的 EffectScope 让插件能被单独摘除(LIFO 回收)。

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 APIawait?顺序返回值
emitEmitAsync(name, args)按序执行,忽略返回注册顺序无
waterfallWaterfallAsync<T>(name, terminal, args)是环绕嵌套有(terminal 的)
parallelParallelAsync(name, args)全部并发后一起等待并发无
serialSerialAsync(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 保证逆序释放)。

下一步

在 GitHub 上编辑此页