Skip to content

第一步 · 迷你 Cordis:Context 与事件总线

目标:实现插件宿主与事件总线——mini-harness 的地基。对应文件:final-project/packages/mini-harness/src/context.tsevents.tsplugin.tsservice.ts。对照原理篇 02

1.1 先写事件总线(约 90 行)

src/events.ts 的核心是四组分发实现。瀑布的实现与 Cordis 源码同构:

ts
waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]> {
  const hooks = [...this.resolve(name as string)]
  const inner = args.pop() as () => any       // 最后一个参数:内置行为
  const next = (): any => {
    const hook = hooks.shift()
    if (hook) return hook.listener(...args, next)   // 监听器接收 (…args, next)
    return inner()                                  // 链条耗尽:执行内置行为
  }
  return next() as ReturnType<Events[K]>
}

三个值得注意的决策:

  1. 监听器签名包含 nextEvents 接口里的事件类型就带 next 参数)——与真实 dsh 的声明方式一致,TypeScript 才能同时约束"派发方必须传内置行为"与"监听器可以调用它"。
  2. hooks.shift() 而非 index 游标:因为监听器可能递归派发同事件(少见但合法),拷贝数组 + shift 保证每次派发有独立游标。
  3. emit 同步、serial 按序 bail、parallel 聚合错误——语义与 Demo 2 观察到的完全一致。

1.2 类型化事件:声明合并

与 dsh 相同的写法——Events 是空接口,各模块用 declare module 追加:

ts
// events.ts
export interface Events {}

// agent.ts 里
declare module '@mini/harness/events' {
  interface Events {
    'agent/pre-step'(agent: Agent, messages: Message[], turn: number, step: number,
      next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
  }
}

为什么内部导入用包名?

真实 dsh 的包互相用包名导入(@deepseek-ai/dsh-llm),声明合并才有一个稳定的目标模块名。mini-harness 照做:package.jsonexports@mini/harness/context@mini/harness/events 等子路径映射到 src/*.ts,所有内部导入统一走包名——声明合并能工作的前提是"模块名稳定",相对路径 ../../events 会随文件位置漂移。

1.3 Context:服务容器 + 插件宿主(约 170 行)

src/context.ts 的三个职责:

① 服务注册表

ts
provide<T>(name: string, impl: T): Disposable {
  if (this.services.has(name)) {
    throw new Error(`service "${name}" 已经被注册(每个服务只有一个提供者)`)
  }
  this.services.set(name, impl)
  this.events.emit('service/provided', name)
  this.wakePlugins()          // 服务就绪 → 唤醒等待中的插件
  return () => {
    this.services.delete(name)
    this.events.emit('service/removed', name)
    this.unloadDependents(name)   // 提供者卸载 → 级联卸载依赖者
  }
}

② 插件加载plugin() 创建实例,依赖(inject)满足立即启动,否则 pending;wakePlugins() 在每次服务变化时重查。

③ 可逆副作用

ts
effect(body: () => Disposable | void): Disposable {
  const disposer = body() ?? (() => {})
  if (this.current) {
    this.current.disposers.push(disposer)   // 记到当前启动中的插件账上
  }
  return disposer
}

一个真实的 bug:provide 触发重复启动

初版 wakePlugins 只检查 state === 'pending',结果插件 A 在 start() 里 provide 服务时,wakePlugins还在启动中的 A 自己又拉起来一次 → 服务重复注册崩溃。修复是在 start() 开头先置 state = 'starting'

ts
async start() {
  this.state = 'starting'   // 先标记,避免 provide 触发的 wakePlugins 重复启动自己
  ctx.activate(this)
  // …执行 apply…
  this.state = 'active'
}

这正是真实 Cordis fiber 状态机存在的原因之一:LOADING 状态不仅为了观察,更为了排除重入。教程里这个 bug 是真实发生的(不是摆拍),修复代码就留在 plugin.ts 里。

1.4 Service:构造即注册

ts
export abstract class Service {
  constructor(ctx: Context, name: string) {
    this.ctx = ctx
    this.name = name
    ctx.effect(() => ctx.provide(name, this))   // 注册是 effect
  }
}

与 Cordis 的 Service 同构:super(ctx, 'llm') 一行完成注册,提供者卸载时服务自动消失。

1.5 我们砍了什么、为什么

Cordis 有mini 砍法影响
Proxy 属性代理直接 ctx.get('llm')少了 ctx.llm 的糖,语义无损
isolate/intercept服务名全局唯一单 agent 场景够用
fiber 状态机 + HMRpending/starting/active/disposed 四态无热重载

小练习:给 mini-harness 加一个服务

写一个 ClockService extends Service,每秒 emit 一个 clock/tick 事件,卸载时清理定时器。要求:1)在 events.ts 里声明事件类型;2)用一个插件挂载它;3)用 ctx.effect 让定时器随插件卸载清理。参考答案见 examples/hello-agent.ts 里 observer 插件的写法。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写