Skip to content

EventBus 与 HookRegistry — 事件与中间件

Aalis 提供两种互补的扩展机制:事件(单向通知)和中间件钩子(可拦截的管道)。

EventBus — 事件总线

源码: packages/core/src/events.ts

类型安全的全局发布/订阅事件总线,用于松耦合的异步通知。事件只通知、不干预流程。

API

typescript
// 监听事件(返回 dispose 函数)
const off = ctx.on('inbound:message', async (msg) => { ... });

// 一次性监听
ctx.once('ready', () => { ... });

// 发出事件(按注册顺序依次 await 每个 handler)
await ctx.emit('outbound:message', outMsg);

实现特性

  • 异步串行:emit() 会 await 每个 handler 完成后再执行下一个
  • 按注册顺序调用
  • on() 返回 dispose 函数,可随时移除监听
  • Context 销毁时自动移除该 Context 注册的所有监听

内置事件

事件参数说明
inbound:messageIncomingMessage平台收到用户消息
inbound:message:archived{ sessionId, incoming, archivedMessage }入站消息已完成落库
outbound:messageOutgoingMessageAI 回复即将发送
outbound:streamStreamChunkMessage流式输出增量
tool:executeToolExecuteMessage工具调用开始/结束
session:createdsessionId会话创建
session:updatedsessionId会话更新
session:switchedsessionId会话切换
session:deletedsessionId会话删除
session:completedsessionId子任务会话完成
todo:updated{ sessionId, items }待办事项变化
scheduler:job:startjobId定时任务开始
scheduler:job:donejobId定时任务完成
scheduler:job:errorjobId, error定时任务出错
service:registeredname, capabilities[]服务注册
service:unregisteredname服务移除
plugin:loadedname插件加载
plugin:unloadedname插件卸载
plugins:changed插件状态变更
app:starting应用启动中
ready应用启动完成
app:started应用启动完成后,适合 CLI/TUI 接管终端
app:stopping应用停止中
dispose应用关闭
restarting应用即将重启

扩展自定义事件

第三方插件通过 TypeScript declaration merging 即可为事件系统新增类型安全的自定义事件:

typescript
declare module '@aalis/core' {
  interface AalisEvents {
    'scheduler:tick': [jobId: string];
    'scheduler:error': [jobId: string, error: Error];
  }
}

// 之后可以类型安全地使用
ctx.on('scheduler:tick', async (jobId) => { ... });
ctx.emit('scheduler:tick', 'job-1');

AalisEvents类型封闭的(没有 [key: string] 兜底):对扩展开放、对拼写错误封闭—— 未声明的事件名在 ctx.on / ctx.emit 处直接编译报错,契约始终可枚举、依赖边在包图中可见。

事件名需要运行时动态生成(如按频道/任务 ID 派生)时,官方出路是在自己的命名空间内 合并一条模板字面量签名(TS 4.4+):

typescript
declare module '@aalis/core' {
  interface AalisEvents {
    // 动态事件名族:myplugin:channel: 前缀下的任意后缀都合法,payload 类型统一
    [k: `myplugin:channel:${string}`]: [payload: ChannelMessage];
  }
}

ctx.on(`myplugin:channel:${channelId}`, async (msg) => { ... }); // msg: ChannelMessage

同前缀下更具体的字面量 key 仍可逐条声明(TS 优先匹配字面量)。前缀必须用自己插件的 命名空间,避免与他人模板签名相互吞并。


HookRegistry — 中间件钩子管道

源码: packages/core/src/hooks.ts

中间件钩子是 Aalis 最强大的扩展机制。与事件不同,钩子是有序管道,插件可以修改管道中的数据、也可以完全中断流程。

核心概念

中间件采用 (data, next) 签名。调用 next() 将控制权传递给下一个中间件(或最终的 defaultAction)。不调用 next() 即中断整个管道——这是拦截消息的标准做法。

ctx.runHook(hookName, data, defaultAction)


中间件 A(先注册) ───── await fn(data, next)
  │ next()                     │ 不调用 next() → 中断
  ▼                             ▼
中间件 B(后注册)        管道终止,defaultAction 不执行
  │ next()

defaultAction() ← 所有中间件都 next() 后执行

API

typescript
// 注册中间件(同一钩子内按注册顺序执行)
const dispose = ctx.middleware('agent:reply:before', async (data, next) => {
  data.content = processContent(data.content);
  await next();
});

// 执行管道(由 Agent 或其他插件调用)
await ctx.runHook('agent:reply:before', { content: '...' }, async () => {
  // defaultAction: 所有中间件通过后才执行
});

// dispose() 可手动解除;插件卸载时本 ctx 注册的中间件自动清扫

内置钩子

钩子数据类型用途
agent:input:before{ message: IncomingMessage, metadata: Record<string, unknown> }修改/拦截收到的消息
agent:turn:after{ message: IncomingMessage, reply: string, sessionId: string, metadata: Record<string, unknown> }agent 回复周期完成后
agent:llm:before{ messages: Message[], tools: ToolDefinition[] }修改发给 LLM 的消息列表和工具
agent:llm:after{ response: ChatResponse, messages: Message[] }处理 LLM 返回的响应
agent:tool:before{ name: string, args: Record<string, unknown>, toolCallContext: ToolCallContext }修改工具调用参数
agent:tool:after{ name: string, result: string, toolCallContext: ToolCallContext }处理工具返回结果
agent:reply:before{ content: string, sessionId: string }修改最终回复内容
memory:clear{ scope, types?, sessionId?, results, rollbacks }统一记忆清理编排,供 /clear 与各记忆插件协作

中间件特性

  • 注册顺序执行: 同一钩子内按注册顺序串行执行(无优先级数字;相位间次序由调度方显式表达)
  • 数据修改: data 通过引用传递,修改 data 对象即影响后续中间件和 defaultAction
  • 流程控制: 调用 next() 继续管道;不调用则中止后续中间件和 defaultAction
  • 上下文绑定: 每个中间件关联 contextId,插件卸载时自动清理(通过 unregisterByContext

典型用法

typescript
// 1. 拦截消息(不调用 next = 中断管道)
ctx.middleware('agent:input:before', async (data, next) => {
  if (shouldBlock(data.message)) return; // 不调用 next,整个管道终止
  await next();
});

// 2. 注入上下文到 LLM 调用
ctx.middleware('agent:llm:before', async (data, next) => {
  data.messages.unshift({ role: 'system', content: '额外上下文...' });
  await next();
});

// 3. 后处理回复内容
ctx.middleware('agent:reply:before', async (data, next) => {
  await next();
  data.content = transform(data.content);
});

// 4. 替换工具列表(如工具搜索层)
ctx.middleware('agent:llm:before', async (data, next) => {
  data.tools = await searchRelevantTools(data.messages);
  await next();
});

扩展自定义钩子

第三方插件可以定义自己的钩子,并让其他插件注入中间件:

typescript
// 声明类型(可选但推荐)
declare module '@aalis/core' {
  interface HookContextMap {
    'schedule:before': { jobId: string; cron: string };
  }
}

// 定义钩子的插件:在关键路径上调用 ctx.runHook
await ctx.runHook('schedule:before', { jobId, cron }, async () => {
  // defaultAction: 执行调度任务
  await executeJob(jobId);
});

// 拦截钩子的第三方插件
ctx.middleware('schedule:before', async (data, next) => {
  logger.info(`即将执行: ${data.jobId}`);
  data.cron = modifyCron(data.cron);
  await next();
});

自定义 hook 需要通过 declaration merging 扩展 HookContextMap,这样 ctx.middleware()ctx.runHook() 都能获得精确类型。