插件内核与事件系统

插件内核与事件系统

一句话总结

官方用 Cordis 运行时承载"一切皆插件":插件导出 apply、通过 ctx 注册能力、用事件彼此松耦合通信;dsh-go 没有照搬 Cordis,而是把同一套契约拆成三个 Go 原语——registry.Freezable 管能力的注册与冻结、eventbus.Bus 管四种同步分发、waterfall.Chain 管洋葱式委托。

官方插件的三种形态

在 TypeScript 版里,一个插件就是一个被框架加载时调用的模块,框架传入 ctx,插件通过它注册事件监听、工具、LLM 适配器。它有三种写法:

形态适用场景关键特征
函数形式绝大多数插件直接导出 (ctx) => { ... }
对象形式需要 name/inject 分离{ name, inject, apply(ctx) }
类形式向其他插件提供命名服务继承 Service,super(ctx, '服务名')

Go 没有运行时动态加载模块这一层,“插件"在 dsh-go 里退化为编译期的装配:你在组合根(main / wire 函数)里把一个个构造好的能力注册进注册中心。理解这一点很重要——我们复刻的是契约与生命周期语义,不是 Cordis 的模块加载器。

注册中心:能力的唯一入口

官方插件通过 ctx.tools、ctx.llm、ctx.agents 这些命名服务查找能力,消费方按 key 查找而非导入具体实现。dsh-go 对应的存储原语是 pkg/registry.Freezable:

1reg := registry.NewFreezable[string, *tools.Tool]()
2_ = reg.Put("bash", bashTool)          // 启动期热注册
3tool, ok := reg.Get("bash")            // 冻结前:读锁
4reg.Freeze()                           // 装配完成,构建只读快照
5_ = reg.Put("x", xTool)                // 冻结后写入 → ErrFrozen
6tool, ok = reg.Get("bash")             // 冻结后:无锁快照直读

它的两段式生命周期正好对应 Agent 的装配阶段与运行阶段:

  • 冻结前:读写都走 sync.RWMutex,允许启动期热注册、动态装载;
  • Freeze():一次性构建只读快照(不可逆),之后读路径完全无锁,写路径返回 ErrFrozen。

这是一个性能与安全兼得的设计:高频读在运行期不再竞争锁,而"装配完成后禁止再改"这一约束让能力集合在运行时保持稳定、可审计。

事件系统:四种同步分发

插件之间如何松耦合通信?事件是核心。Cordis 为不同交互契约提供不同分发方法,每个事件有且只有一种分发模式。dsh-go 用 pkg/eventbus.Bus[T,R] 的三个方法把契约固化下来:

模式语义dsh-go 方法典型用途
emit广播,所有监听器执行,返回值忽略Emit(payload)状态变更通知、打点
bail顺序执行,首个命中的监听器短路Bail(payload, shouldShort)能力查找、策略命中
serial顺序执行,聚合返回值为切片Serial(payload)收集多来源候选
waterfall洋葱式 next 委托pkg/waterfall.Chain工具/Agent 多级拦截
1bus := eventbus.New[int, error]()
2bus.On(func(n int) error { return nil })          // 未命中
3bus.On(func(n int) error { return errHit })       // 命中即短路
4res, ok := bus.Bail(7, func(e error) bool { return e != nil })

注意 waterfall 与前三者的本质区别:emit/bail/serial 的监听器彼此独立、不知道对方存在;而 waterfall 的每个中间件都持有 next(),主动决定是否把决定权委托下去,因此能形成"进入—下沉—返回"的洋葱双层拦截。工具流水线与 Agent pre-step 用的正是 waterfall。

自动清理:资源随插件生命周期回收

Cordis 的一条关键规则是:通过 ctx.on 注册的监听器,在插件卸载时自动移除,所有注册皆可逆。dsh-go 里 On 返回一个 dispose 闭包,你必须在自己的清理路径里保留并调用它:

1dispose := bus.On(handler)
2defer dispose()          // 组件销毁时摘除,幂等可重复调用

这种"注册即返回逆操作"的模式贯穿整个项目:注册工具返回注销、订阅事件返回退订。它让每一项资源都有明确的归属与回收点,避免监听器泄漏导致的"幽灵回调”。

作用域的父链

分层作用域除了"层栈叠加",还能显式声明自己的外层(父)作用域,解析时沿祖先链向上回溯。scope.ParentTree 承载这层关系:

  • Bind(key, parent) 一次性绑定父,重复绑定返回 ErrAlreadyBound;
  • 每次绑定做环检测:沿父链若能走回 key 自己(会成环),返回 ErrCycle;
  • ChainOf(key) 返回 nearest-first 的祖先链 [key, parent, …],并带 visited 防御异常数据。

父关系因此是一张有向无环图:无论作用域怎么嵌套,“沿父走到根"的遍历永远不会陷入循环。

为什么这样取舍?

照搬 Cordis 的动态插件容器到 Go 会引入大量运行时反射与全局可变状态,违背 Go"显式优于隐式"的习惯。dsh-go 的选择是:

  • 能力可替换(seam)保留——通过接口 + 注册中心按 key 查找;
  • 生命周期可逆保留——每个注册动作返回 dispose;
  • 动态模块加载放弃——改为编译期显式装配,错误提前到编译期。

结果是:你失去了运行时热插拔模块的灵活性,换来了更强的类型安全、更清晰的装配链路,以及零反射的性能。

源码对照

概念Go 实现官方 TypeScript
命名服务存储pkg/registry/registry.go — FreezableCordis ctx.service / ctx.set
只读冻结pkg/registry/registry.go — Freeze()(官方无对应,dsh-go 性能增强)
广播/保释/串行pkg/eventbus/eventbus.go — Emit/Bail/SerialCordis emit/bail/serial
洋葱委托pkg/waterfall/waterfall.go — Chain.RunCordis waterfall
自动清理pkg/eventbus/eventbus.go — On 返回 disposeCordis 插件卸载自动 dispose
父作用域链pkg/scope/parent.go — ParentTreepackages/core/scope parent binding

下一步