Skip to content

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 服务接口 ​

ts
// 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 入站相位常量与相位数据 ​

ts
// 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 键,相位间数据由同一对象引用传递:

ts
// 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 遥测事件 ​

ts
// 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']):

jsonc
// 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。

可编译最小骨架(与默认实现同构,仅留主干):

ts
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)。
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.service vs provides/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

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布