中继 ↔ 连接器契约(v1,实验性){#relay--connector-contract-v1-experimental}
状态: 实验性。在至少两个真实的 Class-1 平台(Discord + Telegram)验证通过之前,本契约可能不经弃用周期即发生变更。实验阶段的演进只做增量,由
contract_version控制。破坏性变更需要两个仓库同步更新。
本文档是 Hermes 网关(Python,gateway/relay/)与连接器(Node/TypeScript,NousResearch/gateway-gateway)之间的正式接口。连接器实现者的第一件事就是阅读本文件。
网关运行一个通用的 RelayAdapter,它主动外连连接器,在握手时收到一个 CapabilityDescriptor,随后在按会话建立的双向 WebSocket 上交换归一化的 MessageEvent(入站)和动作(出站)。网关永远不知道它背后具体是哪个平台;连接器拥有所有平台专属的 socket/身份逻辑。
1. 握手 {#1-handshake}
- 网关打开传输(
connect)。 - 网关调用
handshake();连接器返回一个CapabilityDescriptor(第 2 节),描述本适配器实例所代理的平台。 - 网关根据该描述配置适配器(字符上限、长度单位、草稿/编辑/线程/markdown 能力),并注册一个入站处理器。
- 连接器随后开始流式推送入站事件,并接受出站动作。
contract_version(当前为 1)携带在描述符中。网关忽略未知的描述符字段(向前兼容),并为缺失的可选字段填充默认值。
2. CapabilityDescriptor(握手载荷){#2-capabilitydescriptor-handshake-payload}
JSON 对象。事实来源:gateway/relay/descriptor.py。
| 字段 | 类型 | 必填 | 含义 |
|---|---|---|---|
contract_version | int | 是 | 契约版本(同一版本内只做增量)。 |
platform | string | 是 | 平台名(如 "discord"、"telegram")。 |
label | string | 是 | 人类可读标签。 |
max_message_length | int | 是 | 字符上限;网关暴露为 MAX_MESSAGE_LENGTH。0 → 按 4096 处理。 |
supports_draft_streaming | bool | 是 | 是否支持原生的草稿流式预览。 |
supports_edit | bool | 是 | 是否可做基于编辑的流式;若为 false,消费方退化为每段一条消息。 |
supports_threads | bool | 是 | create_handoff_thread 能力。 |
markdown_dialect | string | 是 | "plain"、"markdown_v2"、"discord"……(驱动 supports_code_blocks)。 |
len_unit | string | 是 | "chars"(内建 len)或 "utf16"(Telegram UTF-16 码元)。 |
emoji | string | 否 | 展示用 emoji(默认 🔌)。 |
platform_hint | string | 否 | 系统提示词中的平台提示。 |
pii_safe | bool | 否 | 在会话描述中脱敏 PII。 |
supports_context | bool | 否 | 连接器能否为本平台上被点名的轮次提供周边频道/群组上下文(模型 A:按需拉取历史——Discord/Slack/Matrix;模型 B:被动缓冲——Telegram/Signal/WhatsApp)。默认 false ⇒ 入站事件不附带 context。见 §3。 |
supports_inchannel_continuable | bool | 否 | 平台能否承载扁平的可续跑 cron 表面(Slack 原生的 cron_continuable_surface: in_channel):简报在频道/私信顶层发送,一条普通回复即通过扁平的 (platform, chat_id, None) 会话续跑该作业。默认 false ⇒ 网关调度器安全回退到线程模式(D6 门槛),因此旧连接器保持今天的线程行为。 |
supports_block_formatting | bool | 否 | 本平台的发送方能否在网关于 send/edit 帧上盖戳 metadata.format_hints 时,从原始 markdown 渲染块级格式(Slack:用于表格/列表/代码的原生 markdown 块,mrkdwn 文本作为回退保留)。默认 false ⇒ 网关永不盖戳,因此旧连接器永远收不到该元数据。 |
supported_ops | string[] | 否 | 动作级能力发现:该连接器在本平台的发送方实际实现了哪些出站 op 名(如 ["send", "edit", "typing", "follow_up", "get_chat_info"])。缺失/为空 ⇒ 连接器早于本字段,网关按旧版 op 集(send/edit/typing/follow_up)处理;新 op 仅在被显式宣告时才使用。 |
大多数字段是网关既有 PlatformEntry 的投影;仅运行时字段(len_unit、supports_*、markdown_dialect)来自实时平台适配器的能力方法。
3. 入站:MessageEvent 信封 {#3-inbound-messageevent-envelope}
连接器把每个平台线上事件归一化为一个 MessageEvent(gateway/platforms/base.py),再投递给网关。入站经由网关的出站 /relay WebSocket 投递(见下文传输说明)——连接器顺着网关已经拨出的那条 socket 向下推送一个 inbound 帧。网关通过内嵌的 SessionSource,用 build_session_key() 生成会话键——因此填好正确的判别字段,是连接器正确性上最关键的责任。
入站传输(WS 反向通道,而非 HTTP){#inbound-transport-ws-back-channel-not-http}
网关主动外连到连接器的 /relay WebSocket,用于握手 + 出站动作(§4)+ 自己的 /stop 出口(§5)。入站沿同一条 socket 反方向流动:连接器顺着网关的出站 WS 向下推送一个 inbound 帧(以及 §5 的 interrupt_inbound)。网关侧没有入站 HTTP 端点——网关无需(在被托管时也不能)暴露任何入站端口;一切都经由它主动发起的连接流动。
多实例路由。 拥有某平台 socket 的连接器实例(因而产出入站事件)通常不是网关把出站 WS 拨入的那个实例。因此产出方实例把事件发布到连接器内部的中继总线(Redis 发布/订阅;src/core/relayBus.ts 中的 RelayBus),按租户做键。每个连接器实例都订阅,并把每条消息路由到它自己那里、该租户的本地会话(RelayServer.routeBusMessage);真正持有网关 socket 的那个实例负责投递,没有该租户本地会话的实例则空转。跨实例投递因此是一次集群内的 Redis 跳转,而非公开 HTTP 调用。
帧(连接器 → 网关,经 WS):
{"type":"inbound", "event": <MessageEvent>, "bufferId"?}{"type":"interrupt_inbound", "session_key", "chat_id"}(§5){"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}(§5.1)
入站上的频道上下文(设计 relay-channel-context)。 当来源平台的描述符宣告了 supports_context(§2),且该聊天是多方的(chat_type ∈ group/channel/thread/forum,绝不为 dm)时,连接器可以给入站 MessageEvent 附加两个可选的增量字段:
context:一组只读的周边消息数组(同一频道,从旧到新)——连接器拉取(模型 A)或缓冲(模型 B)到的、与本次被点名消息相邻的其他闲聊。仅作参考:它绝不触发智能体(触发决策已在连接器侧仅凭被点名事件做出)。网关把它渲染进MessageEvent.channel_context(与历史回填所用的同一条只读注入路径)。context_error:bool;当平台具备上下文能力但拉取/缓冲失败、连接器"失败即开放"地回退到空context时为 true(可观测性标记;在连接器侧通过投递 span 呈现)。
两者都缺失 ⇒ 与今天逐字节一致。从不发送这两个字段的连接器、dm、或不支持上下文的平台,都不会产生 channel_context。
PassthroughForward 是转发式直通平面请求(Class-2/3 webhook——Discord interaction、Twilio)的线上形态:{platform, botId, method, path, headers: [[k,v],…], bodyB64, profile?}。profile 可选——当 NAS 为某个 Team-Gateway interaction 解析出目标 profile 时,连接器给它盖戳;省略它(单 profile 网关)则保留旧路由到默认的 agent:main 会话命名空间,与 inbound 帧的 SessionSource 已携带的 profile 字段一致(#60586)。正文做 base64 编码,以便任意字节都能在换行分隔 JSON 传输中存活;网关再 base64 解码回连接器转发的精确字节(连接器已在边缘验证提供商签名并剥离任何共享身份凭据——§6——因此网关处理的是一份已净化、无 token 的正文,并经由无 token 的 follow_up 路径行动)。见 §3.1。
信任。 WS 升级用网关的"按网关"密钥认证(§6.1),因此这条通道是端到端可信的——入站帧不再单独做 HMAC 签名(已认证的 socket 涵盖了旧 HTTP 路径所需的"每次投递来源证明")。中继总线跳转位于连接器信任域之内(与租约/缓冲/能力存储相同)。
本契约的更早草稿曾把入站经由签名的 HTTP POST 投递到某个
gatewayEndpoint(HttpGatewayDelivery+ 网关侧的inbound_receiver),用按租户的投递密钥做 HMAC 签名。那要求每个网关暴露一个可达的入站 URL——对没有公网 IP 的托管网关来说不可能。上面的 WS 反向通道取代了它;按租户的投递密钥仍在开通时保留以备向前兼容,但不再用于入站。直通平面(Class-2/3 webhook,如 Discord interaction / Twilio)历史上仍用gatewayEndpoint做 ACK 后的转发;第 5 阶段 §5.1 把该转发也搬到 WS 上(即上面的passthrough_forward帧),因此托管网关不需要任何公开入站表面,切换完成后gatewayEndpoint即退役。
3.1 直通平面转发(§5.1){#31-passthrough-plane-forward-51}
直通平面在连接器边缘应答提供商对延迟敏感的 ACK(如 Discord 要求在约 3 秒内给出的延迟 interaction 响应),然后把真实请求发后即忘地转发给网关。该转发不需要回应(提供商已被满足),因此它经 passthrough_forward 帧、沿与 inbound 相同的出站 WS 传输,而非 HTTP POST。网关通过其正常智能体路径处理解码后的请求(Discord interaction 被解码为 MessageEvent,像普通消息一样处理;回复经出站 / follow_up 路径出口)。当转发被缓冲(第 5 阶段 §5.3 的"仅缓冲"翻转)时会带 bufferId,网关在持久化交接后对其 ACK。
SessionSource 字段(线上表面){#sessionsource-fields-the-wire-surface}
事实来源:gateway/session.py 中的 SessionSource.to_dict()。这些是网关在线上接受的全部键。platform、chat_id、chat_type、user_id、user_name、thread_id、chat_name、chat_topic 总是存在(可为 null);其余仅在被设置时出现。
| 字段 | 类型 | 总是发送 | 含义 |
|---|---|---|---|
platform | string | 是 | 平台名(与描述符的 platform 一致)。 |
chat_id | string | 是 | 主会话 id(频道/聊天)。会话键判别字段。 |
chat_type | string | 是 | dm / group / channel / thread / forum。 |
chat_name | string|null | 是 | 人类可读的聊天名。 |
user_id | string|null | 是 | 消息作者 id。会话键判别字段。 |
user_name | string|null | 是 | 作者展示名。 |
thread_id | string|null | 是 | 位于线程中时,为线程/论坛话题 id。会话键判别字段。 |
chat_topic | string|null | 是 | 频道话题/描述(Discord、Slack)。 |
user_id_alt | string | 否 | 平台专属的稳定 alt id(Signal UUID、飞书 union_id)。 |
chat_id_alt | string | 否 | 备用聊天 id(如 Signal 群组内部 id)。 |
scope_id | string | 否 | 平台中立的范围判别字段:Discord guild / Slack workspace / Matrix server。Discord/Slack 范围隔离所必需。 会话键判别字段。(自 D-Q2.5 线上迁移起为规范名。) |
guild_id | string | 否 | 旧别名,连接器不再读取。 自 D-Q2.5c 起,连接器只读写 scope_id;网关全智能体的 SessionSource.to_dict() 仍为非中继会话持久化而发出 guild_id(镜像到 scope_id),因此它可能仍出现在线上,但连接器会忽略。不要依赖它。 |
parent_chat_id | string | 否 | 当 chat_id 指向线程时的父频道。 |
message_id | string | 否 | 触发消息的 id(用于置顶/回复/表情回应)。 |
is_bot(作者是否为 bot/webhook 的分类)存在于网关侧数据类上,但在 v1 中刻意不上线——它不属于to_dict()。在它先被加到这里和to_dict()之前,不要把它加进连接器的SessionSource(增量升级)。
各平台的 SessionSource 判别字段 {#sessionsource-discriminators-per-platform}
| 平台 | chat_id | chat_type | user_id | thread_id | scope_id |
|---|---|---|---|---|---|
| Discord | 频道 id | dm/group/thread | 作者 id | 线程频道 id(线程时) | guild id(服务器隔离所必需) |
| Telegram | 聊天 id | dm/group/forum | from id | 论坛话题 id(论坛时) | — |
Discord 的 guild_id 搞错,两个服务器就会撞进同一个会话。 这是头号高危风险。网关的 build_session_key() 是一致性基准:对给定的 SessionSource,连接器的归一化必须产出与 Python 适配器相同的键。(第 1 阶段 stub 测试断言已知输入 → 已知键。)
Bot 身份 vs 租户(单 bot 合并,附录 A){#bot-identity-vs-tenant-single-bot-consolidation-appendix-a}
信封携带发起 bot 身份,作为一个独立于租户的字段。租户从事件自身的判别字段解析(Discord guild_id、Telegram chat_id、webhook 路径/子域)——绝不由是哪个 token/socket/进程投递来决定。这让一个共享 bot 能代理多个租户(第 6 阶段),而不必挤占某个既有字段。
作者优先解析 + 账号绑定(DM)路径(第 7 阶段){#author-first-resolution--the-account-link-dm-path-phase-7}
第 7 阶段新增面向共享 bot 的、用户自助的上手引导,这改变了对一条被路由的入站消息由哪个判别字段解析出实例——并增加了一条让用户绑定自己账号的管理路径。
作者优先解析(多租户 guild 规则,D-7.2)。 单个 Discord guild 可能容纳多个租户——不同成员各自绑定到自己的智能体。因此在投递时,连接器从已认证的作者绑定(user_instance_binding,以 (tenant, platform, platform_user_id) 为键,经 resolveByUser)解析目标实例,而非按 guild→实例路由。具体而言:
- 由已绑定用户撰写、被路由的消息,只会到达该用户自己的实例——即使同一 guild 里第二个已绑定用户由另一个实例服务(各自只到达自己的实例)。
- 由未绑定用户撰写的消息解析为没有实例,被丢弃(失败即关闭——绝不广播给 guild 的其他租户)。
- 所用作者 id 是从观察到的事件上取到的真实
user_id,即上文记录的同一个SessionSource.user_id——绝不使用由网关断言的值或管理帧携带的值。
这就是连接器在 WsGatewayDelivery 中强制执行的、按 user_id 的"仅属主"路由(网关侧的多租户 guild E2E 驱动 gateway_multitenant_guild_driver.py 是跨仓库基准)。
账号绑定(DM)路径。 用户用一次性码把自己的账号绑定到某个实例,通过给共享 bot 发 DM 来兑换:
- 属主从 Portal(或自托管 CLI)触发绑定。连接器为该已认证实例签发一个短期绑定码(
POST /manage/link;instanceId 来自调用方的主体——一个 NAS 签名的aud=agent:{instanceId}token 或实例自己的按网关密钥——绝不来自请求体)。 - 用户从想绑定的账号,以私信形式给共享 bot 发送
/link <code>。 - 连接器的入站观察者消费这条 DM(它不被路由给任何智能体),并使用从观察到的 DM 事件上取到的真实
user_id写入user_instance_binding。此后,作者优先解析会把该用户的消息路由到绑定的实例。
退出绑定以连接器为准。 去开通一个实例(POST /manage/deprovision)会丢弃它的作者绑定(使其用户不再解析到它),并吊销它的按网关密钥(使其 socket 无法再认证——下一次 WS 升级会被以 4401 关闭)。网关若在"此前握手成功之后"看到 4401 关闭,即视为终局吊销:停止重连,并把该中继平台报告为已禁用(不是可重试错误)。在任何成功握手之前的 4401 仍可重试(冷启动/尚未开通的竞态,而非吊销)。
3.2 进入空闲 / 缓冲翻转原语(§5.3){#32-going-idle--buffered-flip-primitive-53}
一个缩容到零的原语(不是行为——这里没有任何东西决定去睡眠或挂起机器;后续工作流会消费这些帧)。它让网关进入排空/空闲过渡,而不丢失它离开期间到达的入站:做法是让连接器为该实例缓冲,并在重连时回放。
三个帧(全部以连接的已认证按实例 id 为键——在 WS 升级时从存储的密钥记录读取,绝不从帧中断言):
{"type":"going_idle"}(网关 → 连接器)——作为网关既有排空过渡的一部分发出(适配器在拆毁 socket 之前发送它)。请求连接器把本实例翻转为仅缓冲。{"type":"going_idle_ack"}(连接器 → 网关)——连接器已翻转:实时投递停止,本实例后续入站被持久缓冲。网关保持服务直到收到这个 ack(因此落在翻转窗口里的事件会被实时投递,而非丢失——与总线相同的"先订阅再服务"顺序纪律)。只有收到 ack 后关闭才安全。{"type":"inbound_ack", "bufferId"}(网关 → 连接器)——对重连时回放的某条已缓冲inbound投递(携带其bufferId)的持久确认。连接器仅在此之后才 ACK 该缓冲条目,从而在投递段做到排空不重复:在排空中途死亡的实例只会重投未被 ack 的尾部;已 ack 的条目永不重投。
缓冲 + 排空。 翻转期间,连接器把入站追加到一个持久的按实例投递段缓冲(delivery:<instanceId>),而非实时推送。在网关重连时(一次 NET-NEW 重连循环,在意外关闭后重新拨号 + 重新握手),新握手触发连接器顺着新 socket 按顺序、以 ack 为门槛把积压排空,然后清除翻转,恢复实时投递。这复用了 Discord→连接器摄入段所用的同一套 drainWithoutDup 机制,只是施加在连接器→网关投递段上。全程以连接器为准:网关只能翻转/排空自己的实例。
不在范围内(延后行为):决定去排空的自主空闲计时器、真正的机器挂起,以及 NAS 挂起健康模型。本原语是"当网关排空时,中继翻转为缓冲 + 重连时回放,不丢不重";什么触发排空不在范围内。
3.3 唤醒触发(§5.2){#33-wake-poke-52}
睡眠/唤醒循环的另一半:一个已挂起的网关如何得知自己有缓冲好的工作在等待。这是一个原语——这里没有任何东西挂起机器;它把唤醒信号接好线,以便未来的缩容到零行为层可以依赖"已缓冲 ⇒ 已唤醒触发"。
- 注册。 网关在开通/供给时注册一个唤醒 URL——任意连接器可以 GET 到来唤醒它的可达 URL(一个 Fly autostart 主机名、一个 dashboard 主机)。自托管:
hermes gateway enroll --wake-url <url>(或GATEWAY_RELAY_WAKE_URL/gateway.relay_wake_url)。托管/NAS:盖戳进容器环境,放在GATEWAY_RELAY_URL旁。在/relay/provision正文中以wakeUrl转发,并按实例存储在连接器的密钥记录上(由网关断言但安全受限——与instanceId同姿态;组织/租户仍由 token 校验,因此网关只能为自己的实例注册唤醒目标)。它与已退役的gatewayEndpoint不同:是一个触发目标,不是投递目标。 - 触发。 当一个仅缓冲(进入空闲)的目的地收到它第一条缓冲事件时,连接器向该实例注册的
wakeUrl发起一次无载荷、未签名的 GET,直接地(不经 NAS 中转——中继保持 NAS 无关)。它不携带租户数据,也不携带入站:它只说"你有缓冲好的工作,重连吧"。租户权威在网关重新拨号(已认证的 WS 升级)时以正常方式重建,因此一个泄漏/被猜中的唤醒 URL,最坏情况只是造成它自己那个实例的一次虚假重连。按实例做限流(每个冷却窗口一次触发,而非每个事件一次),且尽力而为——失败的触发被吞掉;网关下次自行重连时仍会排空。不新增帧:唤醒是带外 HTTP GET,不是中继 WS 消息(socket 已断——这正是关键所在)。
不在范围内(延后行为):真正的机器挂起(Fly
autostop:"suspend")以及决定去睡眠的自主空闲计时器。本原语是"某个睡眠实例有了缓冲事件 ⇒ 触发其 wakeUrl";什么让实例睡眠(以及唤醒后服务)属于行为层。
3.4 对未来缩容到零行为层的义务 {#34-obligations-on-a-future-scale-to-zero-behaviour-layer}
§3.2 和 §3.3 交付的是原语;本节是一个独立的缩容到零行为工作流在安全消费这些原语时必须遵守的契约。它负责挂起的决策、真正的机器挂起以及平台/健康模型——这些都不在这里——但它必须守住原语所假设的以下保证:
- 在实例可能被挂起之前先注册
wakeUrl。 一个没有注册wakeUrl的挂起实例是黑洞——缓冲的入站永远不会触发唤醒,于是它会在自己的流量中一直睡下去,直到别的什么把它重连。行为层必须确保在允许挂起之前,已注册一个可达的唤醒目标(自托管:--wake-url;托管:盖戳)。机器挂起期间不可达的唤醒 URL(例如指向被挂起的机器本身、前面没有平台 autostart)等同于没有。 - 经
going_idle排空 → 在拆毁 socket 或挂起之前等待going_idle_ack。 绝不带着一个在飞、未 ack 的翻转去挂起。ack 是连接器确认"本实例的投递现在是仅缓冲";一台机器在发出going_idle之后、ack 之前挂起,可能丢掉与翻转竞态的入站。网关已经把 socket 拆毁门槛设在 ack 上(Q-5.3c);挂起步骤必须位于干净排空完成之后,而非与之竞态。 - 保持 NET-NEW 重连循环存活,作为挂起的前置条件。 唤醒→排空契约是"触发 ⇒ 网关重新拨号 ⇒ 连接器在重连握手时排空"。若重连循环被禁用,一次触发会落在一台永不重新拨号的机器上,缓冲就卡住了。行为层不得挂起一个其中继传输在唤醒后不会重连的实例。
- 在健康模型中把"挂起"当作"≠ 宕机"(Q-5.3b)。 挂起的实例是健康地睡着,而非失败。健康/监控层必须区分二者(例如通过平台机器状态),从而挂起实例不会被重启、告警或当作不健康回收——那会抵消挂起,并可能与唤醒/排空竞态。
- 唤醒触发是尽力而为且限流的——不要假设恰好一次或立即唤醒。 每个实例每个冷却窗口至多一次触发,失败的触发被吞掉。行为层不得把触发当作保证/即时信号来依赖;正确性仍在于"网关下次重连时就会排空"。双保险唤醒(例如另起一个也会重连的计划作业)是行为层的决定,不是原语的。
- 仅在真正空闲时才挂起——而空闲是连接器可观测的,不是网关猜的。 什么算空闲(无在飞轮次 + N 分钟无入站)是行为层的策略,但它必须与既有排空机制(
gateway_staterunning→draining)组合,而非引入一条并行的、仅中继的空闲路径——即 §3.2 对going_idle施加的同一条集成约束。 - 仅在每条消息连接都由中继代理时才挂起——并在挂起时再次核查,而不仅是启动时。 一个直连平台(Photon iMessage 的 gRPC 流、BlueBubbles、从网关自身拨出的 bot token)持有一条连接器无法缓冲或触发的 socket,因此带着一条存活连接去挂起会丢入站且永不唤醒。该门槛计入已启用的启动 profile 平台以及每个被服务 profile 的存活适配器(含
gateway.multiplex_profiles的次 profile),空闲监视器在每次休眠序列前再次询问它,以便启动后才起来的直连适配器(profile 对账)能让实例保持清醒。
这些是行为层欠原语的保证;原语欠行为层的只有 §3.2/§3.3 已规定的内容(going_idle 时翻转、持久的按实例缓冲 + 以 ack 为门槛的重连排空,以及对翻转实例的首个缓冲事件做一次触发)。
4. 出站:动作集 {#4-outbound-action-set}
网关用动作字典调用传输。事实来源:gateway/relay/transport.py + gateway/relay/adapter.py。
op | 字段 | 结果 |
|---|---|---|
send | chat_id、content、reply_to?、metadata? | {success: bool, message_id?, error?} |
edit | chat_id、message_id、content、metadata? | {success: bool, error?} |
typing | chat_id、content?、metadata? | {success: bool} |
follow_up | session_key、kind、content、metadata? | {success: bool, message_id?, error?} |
send_media | chat_id、media_kind、source_url、content?(caption)、filename?、reply_to?、metadata? | {success: bool, message_id?, error?} |
prompt | chat_id、prompt_kind、prompt_id、content(问题)、options[]{id,label,style?}、timeout_s?、reply_to?、metadata? | {success: bool, message_id?, error?} |
react | chat_id、message_id、emoji、remove?、metadata? | {success: bool, error?} |
thread_create | chat_id(父)、thread_name、message_id?(锚点)、metadata? | {success: bool, thread_id?, error?} |
thread_rename | chat_id(父)、message_id(线程 id)、thread_name、only_if_current_name?、metadata? | {success: bool, error?} |
get_chat_info(chat_id) 是一个单独的代理调用,至少返回 {name, type}。
metadata.profile(多路复用往返)。 每个按聊天寻址的出站帧,其 metadata 都携带网关从入站捕获的租户判别字段(scope_id、user_id),以及在多路复用网关上携带连接器把该聊天入站路由到的那个 Hermes profile;follow_up 帧携带编码在其 session_key 命名空间里的 profile。连接器必须在该聊天或 interaction 的下一个 passthrough_forward / inbound 上盖戳相同的 profile,以便斜杠命令之后的一次 Discord 按钮按下落在同一 profile 的会话里(gateway/relay/adapter.py::_with_scope、send_follow_up)。单 profile 网关从不发出该键——帧保持逐字节一致。
send_media(第 2 阶段媒体出口)。 媒体以引用方式过线:source_url 要么是 (a) 一个连接器重新托管地址——网关此前通过 POST {connector}/relay/media 上传过(原始字节正文,Content-Type + 可选 X-Media-Filename 头,按网关 HMAC bearer——与 WS 升级同一 token 方案;响应 {id, size} → 引用 {connector}/relay/media/{id});要么是 (b) 一个公网 http(s) URL(如 fal.media 生成的图),由连接器直接下载。media_kind 取 image / voice / audio / video / document 之一,选择平台原生上传通道(Telegram sendPhoto/sendVoice/……、Discord multipart 附件、Slack external upload、WhatsApp 媒体上传 + 媒体消息)。caption 走 content,经平台正常 markdown 通道渲染;没有原生 caption 的平台由连接器侧补发一条 follow_up 文本。两条路由和该 op 都以 supported_ops 宣告 send_media 为门槛——旧连接器永远看不到该 op(网关的媒体发送退化为其媒体之前的文本回退)。大小上限 25 MB(连接器 mediaStore.ts 的 MEDIA_MAX_BYTES;超过的上传被以 413 拒绝)。
入站媒体(第 2 阶段媒体摄入)。 入站事件的 media_urls 携带可取引用:平台公开 URL 直接透传(Discord CDN);有鉴权/会过期的平台 URL(Telegram file API、Slack url_private、WhatsApp Graph media)由连接器侧用平台凭据下载并重新托管为 {connector}/relay/media/{id}——平台凭据永不过线。重新托管引用可被任何已认证网关读取(能力 URL 语义:id 是 128 位随机数,且已送达每个被准入的接收方);网关用自己的按网关 bearer 下载每个引用,并向智能体呈现本地文件路径,与原生适配器一致。重新托管会过期(TTL 约 1 小时)——收到即下载,不要懒加载。一个平行的 media 数组(同序)补充 kind、mime、size、filename、caption 元数据;message_type 反映首个附件的种类(image/audio/document)。
prompt(第 3 阶段交互)。 一个平台抽象 op,用原生控件渲染网关最关键的交互(执行审批、斜杠确认、澄清选择器):Discord 按钮组件、Telegram inline keyboard、Slack Block Kit actions、WhatsApp 按钮消息(≤3 项选项)/列表消息(4–10 项;>10 退化为编号文本回退)。prompt_kind(approval/clarify/choice)只是样式提示。prompt_id 由网关签发,对连接器不透明;每个选项的回调载荷携带 token hp1:<prompt_id>:<option_id>(≤64 字节——Telegram 的 callback_data 上限约束了每条通道;选项 id 取 [A-Za-z0-9_.-],≤32 字符)。网关在同一字符集和预算内把 prompt id 签为 <per-process nonce>.<8 hex>:连接器会把一次直通转发(一次 Discord 按下)扇出到该租户的每一个存活网关会话,不像消息那样收窄到被准入实例集,因此 nonce 就是网关区分"自己的 prompt"与"兄弟网关的 prompt"的手段。style 按平台映射(primary/success/danger/secondary)。timeout_s 在线上只是建议——过期由网关侧强制执行(待决 prompt 注册表丢弃过期条目;属主网关随后回一句简短的"不再等待"通知)。
prompt_response(第 3 阶段入站)。 用户的按下以普通入站 MessageEvent 返回,携带 prompt_response: {prompt_id, option_id, label?, prompt_message_id?}——绝不是一个裸的平台 custom_id。事件的 text 镜像 /{option_id} 并带 message_type: "command",以便早于本字段的网关把这次按下当作文本回复路由,而非丢弃它。确实理解本字段的网关则总是消费这次按下:一个它没有签发的 prompt id 属于兄弟网关(同一次扇出也到达了它),若放任 /{option_id} 文本到达聊天通道,会让每个兄弟在属主网关的单次 ack 下都回答"Unknown command"。来源是真实点击的用户(连接器观察到的:Telegram callback_query.from、Slack block_actions.user、WhatsApp messages[].from、Discord interaction member/user),因此网关侧授权门槛对按钮按下的适用方式与对手打 /approve 完全一致。摄入通道:Telegram callback_query(轮询,放宽 allowed_updates;尽力 answerCallbackQuery 停止转圈)、Slack POST /slack/interactions(原始字节 HMAC + 重放窗口,与 /slack/commands 同姿态)、WhatsApp 交互式 button_reply/list_reply(webhook 归一化分支)、Discord type-3 组件 interaction(直通 §5.1 的净化转发;type-3 边缘 ack 为 DEFERRED_UPDATE,因此没有可见的"思考中……"回复)。外来回调载荷(另一个集成的按钮)绝不会变成 prompt 事件:Telegram/Slack/WhatsApp 在连接器处丢弃;Discord type-3 转发保留旧的"custom_id 作文本"形态。
react(第 3 阶段 ack 生命周期)。 在 message_id 上添加/移除 bot 自己的 emoji 表情回应——在中继上恢复原生适配器的 👀→✅/❌ 处理生命周期 ack。线上用 Unicode emoji;Slack 发送方映射到 Slack 的名称词表(eyes、white_check_mark……),并把 already_reacted/no_reaction 当成功(幂等)。Telegram 用 setMessageReaction(空集合 = 移除;Telegram 的精选 emoji 限制可能拒绝字形——失败是结构化的,网关把表情回应当作装饰性的)。WhatsApp 发送一条 reaction 消息(空 emoji = 移除)。按契约,表情回应是尽力而为:一次 react 失败绝不能让一轮次失败。
thread_create / thread_rename(第 4 阶段线程生命周期)。 一对平台抽象 op 覆盖交接线程、Telegram DM/论坛话题以及 LLM 标题语义重命名。thread_create:Discord 在设置 message_id 时发布一个频道线程(type 11)或消息锚定线程;Telegram createForumTopic(返回话题 id);Slack 发布一条具名种子根消息并返回其 ts(那里的线程是消息锚定的——显式的 message_id 锚点被原样回显)。创建出的 id 走 SendResult.thread_id。thread_rename:Discord PATCH 该线程频道;Telegram editForumTopic。only_if_current_name 的防覆盖守卫是原生适配器的"人工改名优先"语义,在连接器侧强制执行:Discord 先读当前名,不匹配时 no-op(结构化 success:false);Telegram 没有话题名读取,因此带守卫的改名无法满足,安全失败(不带守卫的改名照常进行)。Slack 不宣告 thread_rename(根消息的文本是内容,不是名称)。WhatsApp 两者都不宣告(没有线程)。
自动线程标记 + 网关声明的命令清单(第 4 阶段入站/握手)。 当连接器的自动线程出口策略创建了一个 Discord 线程时,此后来自该线程的入站事件携带 source.auto_thread_created: true + source.auto_thread_initial_name——这是连接器观察到的证据,点亮网关的语义重命名通道(LLM 会话标题通过一次带守卫的 thread_rename 重命名线程;按实例记忆,因此在 N>1 的机群中,一次未命中就只是不点亮该通道)。网关还可以在 Discord hello 帧上声明其斜杠命令集(command_manifest: [{name, description, options?}]);连接器据此对账 Discord 的全局应用命令注册(GET → diff → bulk PUT 覆盖;幂等、去抖、尽力而为——注册失败绝不影响握手)。命令仍像以前一样经直通平面分发;该清单只是让 Discord 的注册表与网关分发器实际处理的内容保持同步。
入站 reply_to 富化(第 4 阶段)。 平台回复可在 reply_to_message_id 之外携带 reply_to: {text?, author?, is_own?}——即用户引用的内容,仅从连接器手头已有的数据填充(Discord 的内联 referenced_message、Telegram 的内联 reply_to_message、WhatsApp context.from + 一个有界的按实例入站文本缓存用于文本段)。缺失字段意味着平台没有携带该数据——绝不触发额外的平台 API 调用。is_own = 被引用消息由被代理的 bot 撰写(与 is_reply_to_bot 相关性标记同一证据)。网关把这些映射到原生适配器所填充的同一批 MessageEvent 回复上下文字段。
typing 的 content?(Slack 状态清除)。 typing 帧通常省略 content——连接器渲染其平台的活跃指示(Slack 上的"is typing…"Assistant 状态,其他地方的一次性 typing)。一个空字符串 content 是显式的清除请求:在 Slack 上,连接器把 Assistant 线程状态设为 "",将其关闭。网关仅对 Slack 发出清除(持久状态);一次性平台永不收到它。在 contract_version 1 内为增量,但注意部署顺序:早于 gateway-gateway #154 的连接器会忽略 content,并在清除帧上设置"is typing…"——先部署连接器。
follow_up(A2 能力动作)。 某些入站载荷携带一个作用于共享 bot 身份的凭据(如 Discord interaction follow_up token)。按 §6,连接器在边缘剥离它,并在其能力保险库中以会话为键保存;它永不到达网关。要用它,网关发出 follow_up,指名它已经身处其中的会话(session_key)加上能力 kind(如 discord.interaction_token)——绝不带 token。连接器从其保险库解析真实值,强制执行租户匹配(租户 B 绝不能动用租户 A 的能力),再出口。当能力缺失/过期或租户不匹配时 success: false——按设计,网关没有可重试的东西(一个泄漏的网关持有零能力材料)。事实来源:gateway/relay/transport.py(send_follow_up)+ gateway/relay/adapter.py。
5. 中断(/stop)路由 {#5-interrupt-stop-routing}
- 网关 → 连接器:
send_interrupt(session_key, reason?)经出站 WS 出口一个轮次中途的/stop。连接器必须把它转发给运行该session_key的网关实例(路由不变量)。 - 连接器 → 网关: 对某
session_key的入站中断,作为一个interrupt_inbound帧顺着网关的出站 WS(§3 传输说明)投递——经中继总线跨实例路由到持有该 socket 的那个实例——再由适配器的on_interrupt(session_key, chat_id)桥接到既有的按会话中断机制,精确取消那一轮次(兄弟轮次不动)。
两个方向都走网关的出站 WS:网关→连接器的 /stop 经它出口,连接器→网关的中断作为归一化事件沿同一条 inbound 反向通道返回。
6. 信任边界与签名正文处理(A2){#6-trust-boundary--signed-body-handling-a2}
连接器是唯一的密码学/身份边界。网关不再校验任何东西。
Webhook 签名(Discord ed25519、Twilio HMAC、企业微信 BizMsgCrypt)是在精确原始字节上计算的,某些载荷还用共享密钥加密。连接器为多个租户代理一个共享 bot,并持有每个租户的平台密钥,因此它:
- 在边缘验证/解密(密钥唯一存在之处),
- 把载荷归一化成一个租户范围的
MessageEvent(§3), - 把任何共享身份能力从载荷中剥离,绑定进其能力保险库,以会话为键(见 §4
follow_up), - 只转发净化后的
MessageEvent——绝不转发原始签名正文。
因此网关在中继路径上不做任何平台签名/密码学校验;它信任归一化后的事件。这是网关侧一条被强制的不变量(tests/gateway/relay/test_relay_sheds_crypto.py:中继包不导入/调用任何平台密码学)。
为什么不"逐字节转发签名正文,让网关重新校验"? 那个更早的模型在一个不可信、可丢弃的租户网关下是不自洽的:
- 重新校验 Twilio HMAC / 企业微信密码学,需要把共享签名密钥交给网关——那本身就是泄漏,而在共享 bot 上它是跨租户泄漏。
- 企业微信载荷用共享密钥加密;连接器必须在边缘解密才能路由,因此转发密文又得把密钥交给网关。
- Discord interaction token 就活在签名 JSON 正文内部——你无法既保留字节又剥离凭据;它们是同一批字节。
因此逐字节保留被刻意放弃:连接器重新序列化净化后的事件,网关信任它。这也统一了直通平面与中继平面——两者都是"在边缘验证 → 发出归一化事件",仅传输不同。完整的 A2 理由和连接器侧保险库见 docs/capability-trust-boundary.md(连接器仓库:gateway-gateway)。
6.1 通道认证(连接器⇄网关链路本身){#61-channel-authentication-the-connectorgateway-link-itself}
A2 让连接器成为平台密钥的唯一持有者,而网关可能是客户自管、暴露在公网的,因此连接器⇄网关通道本身也要认证。网关持有一个由开通或供给签发的按网关密钥(hermes gateway enroll → 连接器 /relay/enroll,或托管自供给 → /relay/provision),用它认证自己的出站 WS 升级。这是一个 HMAC-SHA256 方案,带多密钥轮换校验列表(网关侧:gateway/relay/auth.py;连接器侧:src/core/relayAuthToken.ts)。
| 段 | 凭据 | 机制 |
|---|---|---|
| 网关 → 连接器 WS 升级 | 按网关密钥 | /relay 升级上的一个 Authorization bearer 头。token 为 base64url(payload:exp:sig),其中 payload = gatewayId,sig = HMAC(payload:exp, secret)。连接器在校验不匹配/缺失/吊销时拒绝升级(关闭 4401)。已认证的租户来自连接器的存储,绝不来自 hello 帧。 |
连接器 → 网关入站(inbound / interrupt_inbound 帧) | —(搭已认证 WS) | 入站顺着网关已认证的出站 socket 推送(§3),因此无需逐消息签名。一个按租户投递密钥仍在开通/供给时签发并保留以备向前兼容,但不再用于给入站签名。 |
这是通道认证器——与平台密码学不同,后者仍被中继路径完全剥离(§6)。网关持有零平台密钥;按网关密钥只认证连接器链路。完整威胁模型 + 开通/轮换/急停开关设计见 docs/connector-gateway-auth-design.md(连接器仓库)。
7. 按实例投递与管理平面(第 6 阶段){#7-per-instance-delivery--the-management-plane-phase-6}
第 1–5 阶段把连接器当作单租户前台:某租户的入站事件扇出到该租户的网关 socket。第 6 阶段让投递按实例进行——一个共享 bot 可在一个租户内(一个 Discord guild、一个 Telegram bot)代理多个用户/智能体而不交叉投递——并新增一个小型管理平面,供智能体(或托管 Portal)声明谁看到什么、什么才相关。这一切都在连接器侧;网关唯一的新责任是在启动时声明自己的相关性策略(§7.3)。
7.1 投递门槛(连接器侧,信息性){#71-the-delivery-gate-connector-side-informational}
对每条入站事件,连接器用三个相与(AND)的过滤器组合来决定哪些实例收到它。网关不实现这些——它们在连接器里运行——但它们定义了网关所依赖的投递语义:
| 层 | 问题 | 事实来源 |
|---|---|---|
| 属主 / 范围 ∧ 主体 | 这个实例能否在这里看到这位作者? | 按用户的 user_id → instance 绑定(属主下限)+ 按实例的 (guild, channel) 范围授权 + 一个 owner-only / allow-list / any 主体策略。 |
| 可见性下限 | 该实例绑定的属主在 Discord 里真的 VIEW_CHANNEL 得了它吗? | 实时 Discord ACL(有效权限),失败即关闭。把过宽的范围授权收窄。 |
| 相关性 | 既然允许看到,智能体是否应当介入? | §7.3 声明的相关性策略(点名门槛 / 自由回复 / 允许 bot)。 |
该组合只会收窄投递(deliver ⇔ authorized ∧ visible ∧ relevant);属主下限绕过相关性层(作者自己的消息总是到达自己的实例——你不会 @ 自己的智能体)。由未绑定用户撰写的消息不到达任何实例(失败即关闭)。完整设计 + 不变量在连接器仓库(NousResearch/gateway-gateway);本节是面向网关的摘要。
7.2 管理路由(连接器侧,已认证){#72-management-routes-connector-side-authenticated}
连接器挂载已认证的管理路由。它们与 WS 升级共享同一套双重认证:要么是一个托管的、NAS 签名的 aud=agent:{instanceId} RS256 JWT,要么网关自己的按网关密钥 bearer(§6.1 的 make_upgrade_token)。两种情况下,连接器都从其存储记录解析权威的 {tenant, instanceId}——绝不从请求体(体中断言的 instanceId 被忽略)。
| 路由 | 用途 |
|---|---|
POST /manage/link | 签发一个短期码,把一个平台账号绑定到已认证实例(/link <code> 流程;连接器从入站事件读取真实 user_id)。 |
POST /manage/scope、/manage/scope/release | 为已认证实例认领/释放一个 (guild, channel) 范围。一个频道至多归一个实例所有(不重叠是主键约束)。 |
POST /manage/principal | 设置实例的主体策略(owner-only | allow-list | any)。 |
POST /manage/dm-default | 设置用户的 DM 默认实例(当用户绑定了多个时,DM 的平局打破)。 |
POST /relay/policy | 声明实例的相关性策略(§7.3)。 |
这些归连接器所有(管理平面不属于网关的智能体路径);网关只调用 POST /relay/policy(§7.3)。其余由托管 Portal / hermes CLI 驱动。
7.3 相关性策略声明(网关的责任){#73-relevance-policy-declaration-the-gateways-responsibility}
相关性层(§7.1)是网关自身行为开关(require_mention、free_response_channels、{PLATFORM}_ALLOW_BOTS)的按租户对等物。为了让同一套行为同时约束中继投递,网关把这些开关投影成一个平台中立的策略,并在启动时(解析出按网关密钥之后)POST 到 POST /relay/policy。
正文(gateway/relay/__init__.py 的 relay_relevance_policy() → send_relay_policy()):
| 字段 | 类型 | 投影自 | 含义 |
|---|---|---|---|
platform | string | 被代理平台(relay_platform_identity) | 本策略适用于哪个平台。 |
requireAddress | bool | require_mention | 非属主消息必须 @ 提及/回复 bot 才算相关。 |
freeResponseScopes | string[] | free_response_channels | 豁免 requireAddress 的范围(频道)id。与 §7.1 范围授权使用同一范围词表。 |
allowOtherBots | bool | {PLATFORM}_ALLOW_BOTS ∈ {mentions, all} | 是否接纳 bot 撰写的消息(默认关)。 |
认证用的是按网关升级 token(§6.1),因此连接器把策略附到已认证实例上。网关是事实来源,并在每次启动重新声明(全量替换,镜像供给时的 routeKeys upsert——自愈)。当投影出的策略全是默认值时,网关什么都不发(连接器缺行默认值已匹配)。该 POST 是软失败:失败只记日志、启动继续——相关性是叠加在授权门槛(§7.1)之上的优化层,绝不是启动依赖。没有新的网关入站表面,也没有新凭据——它复用按网关密钥和与 /relay/provision 相同的主机。
相关性丢弃发生在连接器唤醒一个缩容到零的智能体(第 5 阶段)之前,因此被排除的闲聊绝不会拉起一个智能体——相关性既是正确性过滤器,也是主要的缩容到零杠杆。
8. 网关侧平台行为控制(企业){#8-gateway-side-platform-behavior-controls-enterprise}
企业部署在网关侧配置被代理平台的行为,位于 platforms.relay.extra.<platform>——该平台原生选项的一个受支持子集。原生平台块(如 platforms.slack)不在中继通道上读取;连接器接收这些控制的结果作为帧元数据(§4)并机械执行——它自己不持有任何平台行为策略。
platforms:
relay:
extra:
slack:
reply_in_thread: true # 默认
解析:嵌套的 extra.<platform> 对象优先 → extra 上旧的扁平键作为回退被尊重 → 默认值。事实来源:RelayAdapter._effective_reply_in_thread(gateway/relay/adapter.py)。值的强制转换与原生 Slack 适配器完全一致——1/true/yes/on(不区分大小写、去空白)为开,其余为关——因此 YAML 引号括起的 "false" 会真正关闭开关,而不会被当作真值字符串读入。
当前控制项(Slack):
| 键 | 默认 | 效果 |
|---|---|---|
reply_in_thread | true | true:每条消息一个线程——每条顶层 DM 消息锚定自己的线程(状态、进度、提示、最终回复都携带该 metadata.thread_id)。false:扁平滚动 DM——发送通道帧不带线程锚点(被剥离,而非省略),每个 DM 一个共享会话。 |
dm_top_level_threads_as_sessions | true | 原生对等逃生舱(镜像 platforms.slack.extra.dm_top_level_threads_as_sessions)。true:在每条消息一个线程模式下,每条顶层 DM 消息以自己的会话为键,因此并发消息并行运行。false:保留线程内回复位置,但跳过会话盖戳——一个滚动 DM 会话(旧的 steer/queue 姿态)。在扁平模式下无效果,后者始终保持单一滚动会话。 |
typing/状态帧在已知触发 ts 时总是携带它作为锚点(活跃性是无条件的,两种模式皆然):Slack 的状态行是线程范围的,而在扁平模式下,发送侧的锚点剥离保证状态锚点绝不会泄漏进回复位置。原生键的语义见 Slack。
线程锚点解析适用于每一条发送通道——文本(send)和媒体(send_media)一视同仁——经同一个瓶颈(RelayAdapter._apply_slack_thread_anchor)。媒体帧经由同一连接器侧 Slack 发送方出口,后者只按 metadata.thread_id 串线程,因此附件解析其锚点的方式与文本回复完全相同:在每条消息一个线程模式下提升进 metadata,在扁平模式下剥离。
变更在网关重启后生效;无需连接器介入。
9. 版本策略 {#9-versioning-policy}
contract_version是 int;实验阶段仅在增量变更时升级(新增可选字段、新增op)。- 破坏性变更(重命名/删除字段、改变语义)需要两个仓库协同更新并升级版本号。
- 连接器的首个 PR 引用它所实现的本文件的 commit SHA。