gateway 服务
1. 定位
gateway 是 Aalis 的消息流编排中枢:把平台适配器(OneBot / WebUI / CLI 等)和 agent 之间的入站 / 出站消息路由统一收口到一条带相位的管道里。
- 服务注册名:
'gateway'(描述符gateway,消费方经gateway.use调用)。 - 契约包:
@aalis/api-gateway(packages/api-gateway/src/index.ts)。 - 默认实现包:
@aalis/plugin-gateway(packages/plugin-gateway/src/index.ts,provides: [gateway])。
它做两件事:
- 入站:监听
inbound:message事件,按INBOUND_PHASE_ORDER顺序串行运行inbound:command → inbound:trigger → inbound:flow → inbound:dispatch四个命名相位;终相dispatch的默认动作是调用agent.handleMessage。前三相位任一被 swallow(handler 未调用next())即停止后续调度,消息不触达 agent;房间类别为owner-only的会话(owner 自己的会话)跳过 trigger 与 flow 两个相位,见插件页「结构性放行」(packages/plugin-gateway/src/index.ts)。 - 出站:提供
dispatchOutbound(),运行outbound:dispatch钩子链,默认动作是向outbound:message事件总线广播,由平台适配器接收并发送(packages/plugin-gateway/src/index.ts)。
core 自身不再绑定任何路由实现。
packages/core/src/orchestration/app.ts的注释明确:「消息路由由网关插件承担,不在 core。」最小应用若不加载 gateway,则没有人监听inbound:message,消息不会被处理 —— gateway 是路由的必要部件,不存在 core 兜底。
2. 契约
来自 packages/api-gateway/src/index.ts。
2.1 服务接口
// packages/api-gateway/src/index.ts
export interface GatewayService {
/**
* 主动注入一条入站消息(idle-trigger / webui 直发 / 内部自检等)。与 emit('inbound:message') 一样走入站相位链,返回时这条输入
* 已落定:在相位里被挡下,或交给 agent 之后它所在的一轮结束、它被放掉
*/
ingressMessage(message: IncomingMessage): Promise<void>;
/** 派发一条出站消息,经过 outbound:dispatch 钩子链(脱敏 / 限速 / 审计),默认动作 emit('outbound:message')。 */
dispatchOutbound(message: OutgoingMessage): Promise<void>;
}契约包导出服务描述符 gateway = defineService<GatewayService>('gateway')(packages/api-gateway/src/index.ts),服务类型随描述符携带,不经 declaration merging。插件在 uses 里声明 gateway 后,gateway.use 推断为 GatewayService;按字符串名动态查询(services.use.get('gateway'))不带类型。
2.2 入站相位常量与相位数据
// packages/api-gateway/src/index.ts
export const INBOUND_PHASE = {
COMMAND: 'inbound:command', // 指令解析与执行,以及指令意图确认的回复(plugin-commands)
TRIGGER: 'inbound:trigger', // 要不要开口:禁言关键词/点名识别/判定(生效的触发插件,如 plugin-trigger-policy)
FLOW: 'inbound:flow', // 节流硬闸:禁言/冷却/限速(plugin-flow-control)
DISPATCH: 'inbound:dispatch', // 默认派发到 agent.handleMessage(plugin-gateway 提供默认动作)
} as const;
export const INBOUND_PHASE_ORDER = [
INBOUND_PHASE.COMMAND, INBOUND_PHASE.TRIGGER, INBOUND_PHASE.FLOW, INBOUND_PHASE.DISPATCH,
] as const;
export type InboundPhase = (typeof INBOUND_PHASE_ORDER)[number];每个相位对应一个命名 hook 键,相位间数据由同一对象引用传递:
// packages/api-gateway/src/index.ts
export interface InboundPhaseData {
message: IncomingMessage;
metadata: Record<string, unknown>;
/** 当前可用的 agent 服务;plugin-gateway 在调度前已注入(可能为 undefined)。 */
agent: AgentService | undefined;
}相位 hook 与出站 hook 经 declaration merging 注入 @aalis/api-hooks 的 HookContextMap(packages/api-gateway/src/index.ts):四个 inbound:* 相位载荷均为 InboundPhaseData;outbound:dispatch 载荷为 { message: OutgoingMessage; metadata: Record<string, unknown> }。
2.3 遥测事件
// packages/api-gateway/src/index.ts —— 注入 AalisEvents
'gateway:phase:done': [data: {
phase: string;
reachedEnd: boolean; // true=链走到底(未被 swallow);false=某 handler 未调用 next() 终止了链
durationMs: number;
sessionId: string;
platform: string;
}];对主流程零侵入:observer 异常不影响入站处理;遥测插件可订阅它统计各相位耗时 / swallow 率 / 消息流转路径。
2.4 消息载体类型(来自 @aalis/schema-message)
gateway 不持有消息类型,只搬运。IncomingMessage / OutgoingMessage 与相关事件签名(inbound:message / outbound:message 等)都定义在 packages/schema-message/src/index.ts。适配器需要填充的就是这两个结构,详见下文「写一个适配器」与 concepts/message-llm-pipeline.md。
3. 谁提供 / 谁消费
提供方(唯一):@aalis/plugin-gateway(packages/plugin-gateway/src/index.ts,provide.use(gateway, service))。required hooks(由 @aalis/plugin-hooks 提供,缺少时本插件停在 pending);uses optional = ['agent', 'session-manager', 'authority'] —— 没有 agent 时仍可处理出站、运行钩子链,dispatch 兜底给一条系统提示;会话管理用来在相位之前收录入站、交给 agent 之前比代号(见 §4),也与 authority 一起判房间类别做结构性放行;会话管理不在时全部照常走,authority 不在时没有人是 owner。
消费方分两类:
- 直接调服务(
gateway.use) —— 主要是 agent 自己回话,外加主动注入消息的系统侧触发器:packages/plugin-agent/src/index.ts:主出站流 —— agent 生成回复后先看gateway.present,再gateway.use.dispatchOutbound(message)把出站消息交给 gateway 运行出站钩子链(缺失时回退events.use.emit('outbound:message', message),中间件链被跳过)。packages/plugin-trigger-policy/src/idle-scheduler.ts:idle 触发,gateway.use.ingressMessage(msg)(gateway 是 trigger-policy 的必需依赖,缺席的瞬间放弃这次注入)。packages/plugin-commands/src/index.ts:指令结果与意图确认的提示经gateway.use.dispatchOutbound回到会话。
- 注册到相位 hook(不经服务接口;hook 键后期绑定,注册与 gateway 的加载先后无关) —— 各中间件占据一个语义相位:
plugin-commands→INBOUND_PHASE.COMMAND(packages/plugin-commands/src/index.ts)- 触发插件(
plugin-trigger-policy等,只有trigger服务选中的那一家判定)→INBOUND_PHASE.TRIGGER plugin-flow-control→INBOUND_PHASE.FLOW
平台适配器既不直接调服务、也不注册相位:它只往事件总线发 inbound:message、监听 outbound:message。例如 @aalis/plugin-adapter-onebot(provides: [platform],packages/plugin-adapter-onebot/src/index.ts)在多处 events.use.emit('inbound:message', {...}),并 events.use.on('outbound:message', ...) 发送。这种「适配器只与事件总线交互,gateway 接管编排」是有意的解耦:适配器不需要把 gateway 写进 uses,加载顺序也无所谓(事件是后期绑定的)。
4. 写一个 provider(替换 gateway 实现 —— 少见)
通常你不需要重写 gateway;默认实现已覆盖四个入站相位、结构性放行与出站钩子链。仅当你要替换整套编排策略时才自己 provide gateway。
最小必须实现:ingressMessage 与 dispatchOutbound 两个方法 + 监听 inbound:message 把消息送入 ingressMessage。可选但强烈建议:保留相位调度与 gateway:phase:done 遥测,否则现有 plugin-commands / plugin-flow-control 等相位插件会失效;保留结构性放行,否则 owner 自己的会话会被触发判定与流控挡住。
会话管理在场时还要做两件事。进入相位之前先等它收录这条入站(api-session-manager 的 recordInbound),否则新房间的第一条消息在相位里读不到会话记录。收录之后按 generationOf 记下这个会话的代号,交给 agent 之前再取一次比对,不一致的是删除开始之前已收录的旧一代(新旧两代见 api-session-manager 的 deleteSession),放掉,不交给 agent,也不回兜底的系统提示;代号记在收录之前的话,删除期间到、在收录里等删除做完的新一代会被当成旧一代放掉。
package.json 的 aalis.service 与源码 provides / uses 双源必须同步(参考默认实现 packages/plugin-gateway/package.json 的 aalis.service.provides: ['gateway'] + required: ['events', 'hooks', 'logger', 'provide'] + optional: ['agent', 'authority', 'session-manager']):
// package.json(与下方骨架的 uses 对应)
{
"keywords": ["aalis", "aalis-plugin"],
"aalis": {
"service": {
"provides": ["gateway"],
"required": ["events", "hooks", "provide"],
"optional": ["agent", "session-manager"]
}
}
}骨架用到的描述符来自 @aalis/api-agent、@aalis/api-gateway、@aalis/api-hooks 与 @aalis/api-session-manager,四者都须写进 dependencies。
可编译最小骨架(与默认实现同构,仅留主干):
import { agent } from '@aalis/api-agent';
import type { AgentService } from '@aalis/api-agent';
import { gateway, INBOUND_PHASE, INBOUND_PHASE_ORDER } from '@aalis/api-gateway';
import type { GatewayService, InboundPhaseData } from '@aalis/api-gateway';
import { hooks } from '@aalis/api-hooks';
import { sessionManager } from '@aalis/api-session-manager';
import { definePlugin, events, optional, provide } from '@aalis/core';
import type { IncomingMessage, OutgoingMessage } from '@aalis/schema-message';
export default definePlugin({
name: '@aalis/plugin-gateway',
provides: [gateway],
uses: { provide, events, hooks, agent: optional(agent), sessionManager: optional(sessionManager) },
apply({ provide, events, hooks, agent, sessionManager }) {
const generationOf = (sessionId: string) =>
sessionManager.present ? sessionManager.use.generationOf(sessionId) : undefined;
async function dispatchOutbound(message: OutgoingMessage): Promise<void> {
const data = { message, metadata: {} as Record<string, unknown> };
await hooks.use.run('outbound:dispatch', data, async () => {
await events.use.emit('outbound:message', data.message);
});
}
async function processInbound(message: IncomingMessage): Promise<void> {
if (sessionManager.present) await sessionManager.use.recordInbound(message);
const generation = generationOf(message.sessionId);
const agentSvc: AgentService | undefined = agent.present ? agent.use : undefined;
const data: InboundPhaseData = { message, metadata: {}, agent: agentSvc };
for (const phase of INBOUND_PHASE_ORDER.filter(p => p !== INBOUND_PHASE.DISPATCH)) {
const reachedEnd = await hooks.use.run(phase, data);
if (!reachedEnd) return;
}
await hooks.use.run(INBOUND_PHASE.DISPATCH, data, async () => {
if (generationOf(message.sessionId) !== generation) return;
if (data.agent) await data.agent.handleMessage(data.message);
});
}
events.use.on('inbound:message', msg => {
void processInbound(msg);
});
const service: GatewayService = {
ingressMessage: msg => processInbound(msg),
dispatchOutbound,
};
provide.use(gateway, service);
},
});
provide.use无需传 priority —— gateway 一般是单提供方。同名竞争时消费方选中哪一家按本插件偏好 > 全局偏好 > priority > 注册顺序(无能力匹配,0.5.0 已移除);细节见concepts/service-model.md。
5. 写一个平台适配器(最常见)
平台适配器是「适配器如何插入」的答案。它不实现 gateway,也不把 gateway 写进 uses,只 provides: [platform],与事件总线交互:
- 入站:把平台原始消息映射成
IncomingMessage,events.use.emit('inbound:message', msg)。gateway 会自动接管相位链;适配器不要自己做命令/流控/触发判定(这些是相位插件的职责,参考packages/plugin-adapter-onebot/src/index.ts注释「适配器不再做流控/触发判定」)。 - 出站:
events.use.on('outbound:message', msg),按msg.sessionId前缀(如'onebot:')认领属于自己平台的消息再发送(packages/plugin-adapter-onebot/src/index.ts)。
import { platform } from '@aalis/api-platform';
import { definePlugin, events, provide } from '@aalis/core';
export default definePlugin({
name: '@acme/plugin-example',
provides: [platform],
uses: { provide, events },
apply({ provide, events }) {
provide.use(platform, {
adapterName: 'MyPlat',
platform: 'myplat',
getConnections: () => [],
async sendMessage() {},
});
events.use.on('outbound:message', async msg => {
if (!msg.sessionId.startsWith('myplat:')) return;
});
},
});想从系统侧(非用户消息)主动喂入一条消息(idle / 定时 / 自检),先看
gateway.present再调gateway.use.ingressMessage(msg),或events.use.emit('inbound:message', msg)(plugin-scheduler 的写法)。两者都由 gateway 插件走完整入站相位链;gateway 缺席时两者都无人处理(见 §8),emit 不能当作 gateway 缺席时的回退。系统侧注入要设置source(见 §7)。
6. 标准消费方式
- 业务句柄是活的:
gateway.use的选择、缺席与固定同一家的规则见 选择层与业务层。 - gateway 视为可选依赖时给出回退:plugin-agent 把 gateway 声明为
optional,出站时gateway.present ? gateway.use.dispatchOutbound(msg) : events.use.emit('outbound:message', msg),缺席时记一条 warn 后走事件总线(packages/plugin-agent/src/index.ts)。若插件必须调用 gateway 才有意义,则写成uses required: ['gateway'](如 plugin-commands 靠它回送指令结果与确认提示,packages/plugin-commands/src/index.ts),gateway 缺席时插件停在 pending、不激活。只注册相位 hook 不需要依赖 gateway。 - 注册相位 = 用
hooks.use.middleware(INBOUND_PHASE.X, (data, next) => ...):洋葱模型,await next()放行进入后续相位;不调用next()即「我已处理」,整条入站管道立即停止、不触达 agent(packages/plugin-commands/src/index.ts)。相位内多个 handler 按注册顺序执行,无需优先级数字。hooks.use.middleware签名见packages/api-hooks/src/index.ts(Hooks)。 - 错误边界:默认实现把
processInbound/dispatchOutbound整体 try/catch 并降级为logger.use.warn(packages/plugin-gateway/src/index.ts)—— 单条消息出错不拖垮总线。你的相位 handler 也应自行兜底,别让异常冒泡出相位链。
7. 能力 / 风险 → 影响
- 出站统一收口:业务层不应再直接
events.use.emit('outbound:message', msg),而应dispatchOutbound(),以便所有出站消息都经过outbound:dispatch钩子链做脱敏 / 限速 / 审计(packages/api-gateway/src/index.ts)。直接 emit 会绕过这些守卫。适配器监听outbound:message仍是合法的(它是链尾的最终发送指令)。 - COMMAND 相位与在跑的一轮:
inbound:command排在最前。指令意图确认的回复(发出者本人在同一会话的下一条消息)在这里命中即被吞掉、不进入后续相位,不会被当成输入插进这个会话在跑的一轮(packages/plugin-commands/src/index.ts)。若你新增相位插在 COMMAND 之前并 swallow 消息,会破坏这一语义。人在回路确认机制本身见concepts/security-model.md与plugins/plugin-authority.md。 - 授权身份用
actor而非userId:系统侧触发器(scheduler / idle / workflow)投递的IncomingMessage应填actor: { platform, userId },表示「AI 代谁执行」;agent 算工具调用上下文的参与者时优先用actor、按它查权限等级,避免提权(packages/schema-message/src/index.ts)。actor不能由 LLM 在工具入参里自由指定。 source标识系统侧注入:闲置触发、定时任务、workflow 等触发器投递的消息必须设置IncomingMessage.source;平台适配器投递真人消息不得设置。带source的消息被视为内部注入:不经触发策略(不计数、不改写triggerType),不过回复后冷却,不过指令解析(声明了ownerApproved的除外,见 注入的文字不执行指令),禁言与限速照常生效。带source的输入在 agent 里各自成一轮,不与别的输入叠加,注入方的来源应当标识发送方(packages/schema-message/src/index.ts)。- 到达序号:每条入站在所有相位之前打一个这次运行内递增的到达序号(
IncomingMessage.arrival,网关重载后从头计),被指令相位吞掉的也有。它只用于日志关联:触发插件的判定行、流控与网关的吞噬行、agent 每条输入的去向与回合结束行都带#序号,不落库、不进模型。 ingressMessage在这条输入落定时返回:注入方(编排的 agent 节点、子任务的追问)据此知道它没开轮:返回时还没认到这一轮的结局,就是在相位里被挡下或被放掉。- gateway 自身不碰 storage / 网络出口;附件落盘、SSRF 守卫(
safeFetch)等是适配器与 media 层的事,见concepts/storage-uri-grammar.md/concepts/security-model.md。
8. 边界与注意事项
- 没有 core 兜底路由:core 的
start()不注册inbound:message监听,路由完全由 gateway 插件承担。不加载 gateway 时inbound:message无人消费,消息被静默丢弃,应把 gateway 视为必需件。 outbound:message直发仍被容忍但属旧路径:契约注释说 emit 出站「将逐步迁移」(packages/api-gateway/src/index.ts)。现状是两条路并存,新代码一律走dispatchOutbound()。- 相位顺序是单一真相,只在 gateway-api 改:默认实现用
INBOUND_PHASE_ORDER.filter(p => p !== DISPATCH)推导前置相位(packages/plugin-gateway/src/index.ts)。新增相位只改gateway-api的常量数组,调度方零改动;不要在自己插件里硬编码相位顺序。 ingressMessage走内部路径而非再 emit:默认实现里ingressMessage直接调processInbound,刻意避免「emit → 自己监听 → 再处理」的事件总线递归歧义(packages/plugin-gateway/src/index.ts)。你若重写 gateway 应保持这一点。
9. 交叉链接
- 服务模型 / 同名竞争 / DI 选择规则:
concepts/service-model.md、core/service.md - 业务句柄是活的、provider bounce:
concepts/lazy-service-access.md - 双源 manifest(
package.json aalis.servicevsprovides/uses):concepts/manifest-metadata.md - 消息载体类型与端到端流水线:
concepts/message-llm-pipeline.md(IncomingMessage/OutgoingMessage在@aalis/schema-message) - 确认 / 人在回路 / 授权:
concepts/security-model.md、plugins/plugin-authority.md - 相位 hook / 洋葱中间件机制:
api/api-hooks.md、packages/api-hooks/src/index.ts、packages/plugin-hooks/src/index.ts - 存储 URI 文法(适配器附件落盘相关):
concepts/storage-uri-grammar.md