多路复用 Gateway
一个 gateway 进程可以服务安装中的每个 profile。该模式默认开启(gateway.multiplex_profiles,默认 true),它改变的一切在标志关闭的瞬间回退。未设置的标志在启动时由 hermes_cli/gateway_multiplex_mode.py::resolve_multiplex_mode 定夺,它运行 hermes gateway migrate 预检,并当某二级 profile 仍跑自己的 gateway、存在阻塞或宿主机无法迁移时保持 gateway 独立(见"模式标志")。本文是 agent/secret_scope.py("工作流 A")引用的设计理由:每个 profile 隔离什么、隔离它的机制是什么,以及什么刻意保持进程全局。
概览
不做多路复用时,一个 gateway 进程恰好服务一个 profile——它的 .env、会话、skill 与平台适配器——多 profile 安装每个 profile 跑一个进程。多路复用量把它们折叠成单进程:默认 profile 加每个被服务的具名 profile 各得自己的适配器、机密、会话与 cron tick,同时共享一个事件循环、一个 HTTP 监听器、一个进程锁与一个状态面。
塑造下面一切的设计约束:profile A 的轮次绝不能观察到 profile B 的状态。机密、home、会话与适配器车道按 profile 隔离;任何尚不能隔离的东西要么 fail-closed,要么作为已知限制记录在本文末尾。
模式标志
- 配置:
gateway.multiplex_profiles(也接受顶层)。在gateway/config.py中解析,优先级 env > config > 未设置。GatewayConfig把未设置标志保持为None(读取者测试真值,因此读作关);load_gateway_config_for_runner随后调resolve_multiplex_mode,后者写入启动裁决——在安静的多 profile 默认安装上为True,否则为False并记录原因。显式值原样通过;注入GatewayRunner(config=...)的 config 不被解析。 - 其他进程先读 LIVE gateway 的
served_profiles记录、再读显式标志(gateway_multiplex_mode.default_gateway_multiplexes/explicit_multiplex_flag),绝不读合并默认:named_profile_served_by_running_multiplexer、enroll 告警、dashboard 的监听器守护、cron-fire 端口解析器、容器启动与迁移计划(_read_multiplex_flag,因此未设置默认读作"尚未多路复用",fold 继续)。 - env 覆盖:
GATEWAY_MULTIPLEX_PROFILES只接受显式真/假词元;空白或不可识别值返回"无覆盖",以便空的部署机密不会遮蔽 config 的 opt-in。 - 启动时,
GatewayRunner.__init__调一次agent.secret_scope.set_multiplex_active(...)。_MULTIPLEX_ACTIVE是普通模块全局,不是 contextvar:它描述部署模式,不是按任务的值。它唯一的活是在get_secret()中武装 fail-closed 行为。 - dashboard/Desktop 后端(
hermes serve)没有这种标志,因此hermes_cli/web_server.py::start_server把tui_gateway.launch_profile_policy.activate_multi_profile_hosting_eagerly()作为其最后一个启动步骤:当机器有多于一个可服务 profile home 时,宿主机武装守护,而不是等第一个?profile=<other>请求。激活是单向的,后端到那时已做的一切(空闲回收器转录刷写、托管房间、cron)从不重新 scope。它最后跑,是因为激活还把os.environ冻结为启动 profile 的凭据,而那个快照是无从重建.env的启动 key 的唯一来源(systemdEnvironment=、op run、Compose)——冻结后注入或轮换的 key 在进程生命周期内不可见。真正单 profile 的宿主机从不激活;gateway.multiplex_profiles: false已退役且在此刻意不被参考( honoring 它会用启动 profile 的凭据服务第二个 profile);不可读的profiles/目录 fail-closed(它激活)并记一条 WARNING。 - 守护武装后,启动 profile 也是一个租户:无路由 profile 的请求体绑定
launch_profile_scope_if_multiplexed(),而非在 ambientos.environ上无 scope 运行。注意该 scope 内的优先级:启动 home 的.env胜过冻结 env,因此激活对启动租户并非纯粹更严——对一个在os.environ与<launch home>/.env都设了的 key,未 scope 读取在激活前返回 ambient 值、激活后返回.env值。仅 env 的 key 不受影响。 - 路由 profile 名已不再解析的请求体(运行中删除或改名)什么都不绑定:它的机密读取抛
UnscopedSecretError,而非回退到启动 profile。"这是谁的?"无答案是 fail-closed 条件,绝不借用。
Scope 组合
每个入站事件在任何 profile 自有代码运行之前,组合同样两个 context-local scope:
platform event
│
▼
profile_routes match ──► served-set check ──► SessionSource.profile stamped
│ (gateway/profile_routing.py)
▼
_profile_runtime_scope(profile_home) (gateway/run.py)
├── set_hermes_home_override(home) config / state.db / skills /
│ memory / sessions resolve here
└── set_secret_scope(profile .env + secret sources)
│ provider keys, platform tokens
▼
agent turn (worker thread via copy_context())
│
▼
scope unwound in finally
_profile_runtime_scope 包裹每一处执行 profile 自有代码的接缝:二级适配器启动、connect 与 reconnect、主平台事件处理器、入站预处理、/model 与会话信息解析、后台任务,以及 agent 轮次本身。配置重载在默认 profile 的 scope 下运行,让全局 gateway 设置(#64674)一致解析。
两个 scope 都是 contextvars,因此经 copy_context() 传播进 executor worker 线程并确定性展开——绝不写 os.environ。
工作流 A:context-local 机密 scope
agent/secret_scope.py 的存在是因为显而易见的实现——把所有 profile .env 联合进 os.environ——会把 profile A 的 key 泄漏进 profile B 的轮次,以及每个以 env=dict(os.environ) 派生的子进程。
build_profile_secret_scope(home)把 profile 的.env与其配置的机密源合并,跳过全局。set_secret_scope(mapping)为当前任务安装它。get_secret(name)解析:全局白名单 → 活动 scope → 回退。回退是承重部分:- 多路复用关:读
os.environ,因此单 profile gateway 与每个非 gateway 调用方行为与之前完全一致; - 多路复用开、未装 scope:抛
UnscopedSecretError,而非静默读进程环境。一个未迁移的调用点在那一行 fail-loud,而不是泄漏另一个 profile 的值。
- 多路复用关:读
- 一个小白名单(
HERMES_HOME、HERMES_PROFILE、代理设置、API_SERVER_*监听器设置——但刻意不含API_SERVER_KEY)保持全局,因为那些描述进程而非 profile。 - 云 SDK 默认凭据链按构造是 ambient 的(
google.auth.default()、DefaultAzureCredential、无 key 的boto3.Session()):它们走过的每个源——进程 env、CLI 缓存、实例元数据——都是启动上下文的身份。多路复用上,一个没有自己完整凭据的被服务 profile 会被 Vertex、Entra ID 与 Bedrock 适配器拒绝,而不是针对它自己的base_url铸造那个身份;独立运行保留该链。
因为每轮 .env 重载在多路复用上是 no-op,轮换的凭据在下一轮经 profile scope 被拾取——绝不经 os.environ。这在加载器边界成立,不只是 gateway 的重载辅助:hermes_cli.env_loader.load_hermes_dotenv 在多路复用激活且装了 profile-home 覆盖时跳过进程全局加载(导入时与 cron 调用方在轮次中途命中它),同时仍把 profile 的外部机密源水合进其私有快照(#77562)。未 scope 的启动加载不变。
同一条 scope 权威规则覆盖路由轮次可到达的其他 os.environ 接缝:profile config.yaml 中的 ${VAR} / ${env:VAR} 引用在装了 scope 时经 get_secret 解析(#84079),而 scope 下做的 .env 写(save_env_value,如 /pair 授权镜像)更新已装 scope 映射而非进程环境(#88441)。
HERMES_HOME 覆盖
hermes_constants.py 持有一个 context-local 覆盖,由 get_hermes_home() 在 HERMES_HOME env 变量之前查阅。一切经它解析路径的东西——config、state.db、skill、记忆、SOUL、会话、看板、目标、插件发现、MCP 启动——自动跟随活动 profile。get_process_hermes_home() 为少数绝不能跟随覆盖的机器级资产存在。hermes_home_key() 给按 home 的注册表一个稳定 scope 键。若在预期有覆盖处跑了 profile scope 代码,会触发一次性告警(#18594)。
入站路由
gateway.profile_routes 把 (platform, user_id, guild_id, chat_id, thread_id) 映射到一个 profile;匹配是合取、最具体优先,线程带父链聊天匹配。路由只在多路复用激活时运行,且目标不在被服务集合内的匹配路由被拒(事件被丢弃,而非误投)。完整 schema 与匹配规则:把共享 bot 聊天路由到 profile。
服务选定的 profile
hermes_cli/profiles.py 中的 profiles_to_serve(multiplex, profile_allowlist) 是多路复用器服务哪些 profile 的唯一咽喉:默认加每个有效 profile 目录,可选按白名单过滤。畸形白名单安全回退到仅默认。被服务集合门控适配器启动、cron tick(#69377)、/p/<profile>/ HTTP 准入、路由资格与运行时状态面。被排除的 profile 保持安装,仍可跑自己的独立 gateway。
按 profile 持久化
SessionStore 构造时不绑定数据库句柄(#88532)。会话 DB 句柄在调用时经活动 HERMES_HOME 覆盖解析——每个解析的 profiles/<name>/state.db 一个缓存句柄——因此即使存储对象本身被共享,会话也落进所属 profile 的存储。配对存储按被服务 profile 构造。
按 bot 的会话车道
会话键按 profile 加命名空间(默认 agent:main,具名 profile agent:<name>)。每个入站事件携带一个冻结的 RoutingIdentity(gateway/session_identity.py),由 runner 入口处理器的 resolve_identity() 解析,并作为线上不可见属性钉在 source 上:transport_profile(收到它的 bot——凭据、白名单、authorization_home)、runtime_profile(执行的路由 profile——runtime_home、键 namespace、store_path),以及指向接收适配器的弱 transport 引用。"default" 被拼写出来;None 绝不意味默认。多路复用上,指向未服务 profile 的路由抛 IdentityUnresolved,事件被丢弃。
适配器还携带 _owner_profile(在适配器配置时、任何入站事件之前安装)。每条入口路径先规范化身份——BasePlatformAdapter._canonicalize 在 handle_message、文本/照片/相册批处理、繁忙路径与每个适配器派生会话键时运行;runner 的按 profile 与默认处理器、auth-check 回调与共享 _handle_message 门做同样的事——因此在收到 bot 已知之前,没有车道被键定。文本/媒体批处理、活跃会话跟踪、繁忙会话守护、/stop /new /reset 与澄清回复都按车道键定,因此共享一个聊天的两个 bot 不共享会话车道,一个 bot 上的控制命令到不了另一个的运行。指向未服务 profile 的路由在它到达的第一个接缝以一条 WARNING 丢弃,绝不键进 agent:main。用 session_identity.replace_source 复制 source,而非 dataclasses.replace,否则副本丢失其 transport 与身份。
摄入 vs 投递:哪个 bot 对事件动手
两个 runner 接缝回答多路复用 gateway 一直混淆的两个问题(gateway/authz_mixin.py):
_intake_adapter_for(source)——收到事件的 bot。仅活来源:build_source钉的 transport 引用、中转投递事件的进程级中继适配器,或 reconnect 后当前为该身份transport_profile注册的适配器。它门控摄入策略(Slack 忽略频道、中继前置、排队活事件的重派发),对无活来源的 source 返回None——没有东西可以凭猜的 bot 重新接纳恢复的行。_delivery_adapter_for(source)——应答的 bot:发送、编辑、typing、进度、选择器、待处理消息槽。接收 bot 已知时用它;否则是(platform, runtime_profile)的唯一拥有者——二级自己的适配器、共享 bot 卫星的主,以及其 bot 断开的二级的None(它绝不借用默认 bot)。
| 拓扑 | 运行时(runtime_profile、键命名空间、home) | 摄入 | 投递 |
|---|---|---|---|
| 每凭据 bot,无路由 | bot 自己的 profile | 拥有者适配器 | 拥有者适配器 |
共享凭据 → 经 profile_routes 的卫星 | 路由的 profile | 接收(共享)适配器 | 接收适配器;重启后卫星仍经主排空 |
| 共享 bot → 一个拥有自己 bot 的 profile | 路由的 profile | 接收适配器 | 接收适配器——对话留在用户写信的那个 bot |
二级拥有的 bot → default(bot_profile: <secondary>) | default(agent:main,默认 home) | 接收(二级)适配器 | 接收适配器 |
| 恢复/合成 source,无活来源 | 存储的 source.profile | None(fail closed) | (platform, runtime) 的唯一拥有者,否则 None |
多路复用之外每平台一个适配器,因此两个接缝都返回它。tests/gateway/test_multiplex_transport_matrix.py 断言每一行。
恢复、中继、回调与线程跳转
路由条目把 transport_profile 持久化在键旁(以及 state.db 中的 sessions.transport_profile 列),因此重启后复活的车道仍知道哪个 bot 收到它:_restored_source(entry) 重新钉一个无活适配器的 RoutingIdentity,_delivery_adapter_for 经那个 bot 的适配器投递或 fail closed——经默认 bot 路由的卫星继续从默认 bot 应答,二级拥有的车道绝不回退到默认 bot 的凭据。该列存在之前写入的条目带 null,保留共享 bot 启发式。经中继,每个出站帧的 metadata.profile(以及 follow_up 的键命名空间)告诉连接器在下一个 passthrough_forward 上盖哪个 profile,因此路由斜杠命令后的按钮按压留在同一 profile。延迟回调(/model 选择器)在命令时捕获路由 home,gateway 的 executor 跳转复制 ContextVar scope。
控制面
Desktop 插件只经 ws JSON-RPC 门到达 gateway,因此 profile 枚举与配置住在 tui_gateway/methods_profiles.py:profiles.list、profiles.create、profiles.describe、profiles.configure、profiles.set_asset、profiles.get_asset。读写在目标 profile 的 HERMES_HOME 覆盖下运行。资产写入是原子的,按类型与大小封顶。
失败模式
- 启动即致命:多路复用配置错误,以及二级 profile 启用端口绑定平台(
MultiplexConfigError、SecondaryPortBindingConfigError)——一个共享 HTTP 监听器由默认 profile 拥有。 - 跳过、不致命:单个配置错的二级适配器带一条 warning 被跳过,而非拖垮多路复用器。
- fail-closed:多路复用上未 scope 的
get_secret()抛错;指向未服务 profile 的路由事件被丢弃;未 scope 的/p/请求进入默认 profile 的 scope(#61276)而非一个未定义 scope。 - fail-closed,按 profile:一个远程 MCP server 其
url/headers在所属 profile scope 下渲染后仍带字面${VAR},则不连接(MCP server 'x': ${VAR} in url/headers is not set in this profile's .env or secret source);该引用在每次 connect 与 reconnect 时在属主新 scope 下重新渲染,因此一旦该 profile 的.env或机密源提供了值即自愈。启动 profile 的映射仍含其冻结启动 env(systemdEnvironment=/op run凭据解析);二级只从自己的文件解析。 - scope 化平台门:A2A(
A2A_PORT)与 Buzz 启用经 profile 自己的 scope /platforms.<name>段读取,因此一个启动 profile env 变量不再在每个二级里启用入站监听器。 - 回退:外部
cron.provider不支持多路复用,带一条 warning 回退到内置 ticker。
已知限制
尚未按 profile scope 的进程全局状态:
| 面 | 撰写本文时的状态 |
|---|---|
| MCP 发现与工具注册 | 进程全局;第一个构建 agent 的 profile 赢得发现槽。完整按 profile 的 MCP 注册表跟踪于 #67605。 |
终端 / 沙箱 env(TERMINAL_*) | 按白名单全局;工具从进程环境读它。 |
| 内置工具注册表 | 内置是进程全局;插件注册的工具经 hermes_home_key() 按 profile 叠加。 |
| Provider/能力注册表 | 同一混合叠加模式(browser、image-gen、TTS、转录、video-gen、web-search、机密源)。 |
| HTTP 监听器、中继入口、进程锁 | 每进程一个,由默认/活动 profile 拥有。按 profile 的 runtime_status.json 仍写入。 |
非目标
多路复用隔离profile;它不对终端用户做认证或授权。profile 是一份配置,不是一个人:gateway 信任其 transport 与其路由表来决定事件属于哪个 profile。profile 层之上的请求级身份与按用户授权不在本文范围。
另见
- 多 profile gateway——面向用户的指南,含
profile_routes与每 profile 一个 gateway 的独立替代方案。 agent/secret_scope.py、hermes_constants.py、gateway/profile_routing.py、gateway/run.py(_profile_runtime_scope)、hermes_cli/profiles.py(profiles_to_serve)、gateway/session.py、tui_gateway/methods_profiles.py。