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:message | IncomingMessage | 平台收到用户消息 |
inbound:message:archived | { sessionId, incoming, archivedMessage } | 入站消息已完成落库 |
outbound:message | OutgoingMessage | AI 回复即将发送 |
outbound:stream | StreamChunkMessage | 流式输出增量 |
tool:execute | ToolExecuteMessage | 工具调用开始/结束 |
session:created | sessionId | 会话创建 |
session:updated | sessionId | 会话更新 |
session:switched | sessionId | 会话切换 |
session:deleted | sessionId | 会话删除 |
session:completed | sessionId | 子任务会话完成 |
todo:updated | { sessionId, items } | 待办事项变化 |
scheduler:job:start | jobId | 定时任务开始 |
scheduler:job:done | jobId | 定时任务完成 |
scheduler:job:error | jobId, error | 定时任务出错 |
service:registered | name, capabilities[] | 服务注册 |
service:unregistered | name | 服务移除 |
plugin:loaded | name | 插件加载 |
plugin:unloaded | name | 插件卸载 |
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() 都能获得精确类型。