深色模式
第一步 · 迷你 Cordis:Context 与事件总线
目标:实现插件宿主与事件总线——mini-harness 的地基。对应文件:
final-project/packages/mini-harness/src/context.ts、events.ts、plugin.ts、service.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]>
}三个值得注意的决策:
- 监听器签名包含
next(Events接口里的事件类型就带 next 参数)——与真实 dsh 的声明方式一致,TypeScript 才能同时约束"派发方必须传内置行为"与"监听器可以调用它"。 hooks.shift()而非 index 游标:因为监听器可能递归派发同事件(少见但合法),拷贝数组 + shift 保证每次派发有独立游标。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.json 的 exports 把 @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 状态机 + HMR | pending/starting/active/disposed 四态 | 无热重载 |
小练习:给 mini-harness 加一个服务
写一个 ClockService extends Service,每秒 emit 一个 clock/tick 事件,卸载时清理定时器。要求:1)在 events.ts 里声明事件类型;2)用一个插件挂载它;3)用 ctx.effect 让定时器随插件卸载清理。参考答案见 examples/hello-agent.ts 里 observer 插件的写法。