|
Download website/docs/developer-guide/multiplexing-gateway.md from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/website/docs/developer-guide/multiplexing-gateway.md
- Command line
-
hf download hf://SaylorTwift/hermes-agent/website/docs/developer-guide/multiplexing-gateway.md
-
curl -L -o multiplexing-gateway.md https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/website/docs/developer-guide/multiplexing-gateway.md
10.9 kB
| title: "Multiplexing Gateway Internals" | |
| description: "Design of the one-gateway-for-all-profiles mode: scope composition, secret scope, inbound routing, persistence" | |
| # Multiplexing Gateway | |
| One gateway process can serve every profile in the install. The mode is opt-in | |
| (`gateway.multiplex_profiles`, default `false`), and everything it changes | |
| reverts the moment the flag is off. This document is the design rationale | |
| referenced from `agent/secret_scope.py` ("Workstream A"): what is isolated per | |
| profile, the mechanism that isolates it, and what deliberately stays | |
| process-global. | |
| ## Overview | |
| Without multiplexing, one gateway process serves exactly one profile β its | |
| `.env`, sessions, skills, and platform adapters β and multi-profile installs | |
| run one process per profile. Multiplexing collapses that into a single | |
| process: the default profile plus every served named profile get their own | |
| adapters, secrets, sessions, and cron ticks, while sharing one event loop, one | |
| HTTP listener, one process lock, and one status surface. | |
| The design constraint that shapes everything below: **profile A's turns must | |
| never observe profile B's state**. Secrets, homes, sessions, and adapter lanes | |
| are isolated per profile; anything that cannot yet be isolated fails closed or | |
| is documented as a known limitation at the end of this document. | |
| ## The mode flag | |
| - Config: `gateway.multiplex_profiles: true` (also accepted at top level). | |
| Parsed in `gateway/config.py` with precedence env > config > default. | |
| - Env override: `GATEWAY_MULTIPLEX_PROFILES` accepts explicit truthy/falsy | |
| tokens only; a blank or unrecognized value returns "no override" so an empty | |
| deployment secret cannot shadow a config opt-in. | |
| - At startup, `GatewayRunner.__init__` calls | |
| `agent.secret_scope.set_multiplex_active(...)` once. `_MULTIPLEX_ACTIVE` is | |
| a plain module global, not a contextvar: it describes the deployment mode, | |
| not a per-task value. Its only job is to arm the fail-closed behavior in | |
| `get_secret()`. | |
| ## Scope composition | |
| Every inbound event composes the same two context-local scopes before any | |
| profile-owned code runs: | |
| ``` | |
| 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` wraps every seam where profile-owned code executes: | |
| secondary adapter startup, connect and reconnect, the primary platform event | |
| handler, inbound preprocessing, `/model` and session-info resolution, | |
| background tasks, and the agent turn itself. Config reloads run under the | |
| default profile's scope so global gateway settings (`#64674`) resolve | |
| consistently. | |
| Both scopes are `contextvars`, so they propagate into executor worker threads | |
| via `copy_context()` and unwind deterministically β nothing is written to | |
| `os.environ`, ever. | |
| ## Workstream A: context-local secret scope | |
| `agent/secret_scope.py` exists because the obvious implementation β union all | |
| profile `.env` files into `os.environ` β leaks profile A's keys into profile | |
| B's turns and into every subprocess spawned with `env=dict(os.environ)`. | |
| - `build_profile_secret_scope(home)` merges the profile's `.env` with its | |
| configured secret sources, skipping globals. | |
| - `set_secret_scope(mapping)` installs it for the current task. | |
| - `get_secret(name)` resolves: global allowlist β active scope β fallback. | |
| The fallback is the load-bearing part: | |
| - multiplexing **off**: reads `os.environ`, so single-profile gateways and | |
| every non-gateway caller behave exactly as before; | |
| - multiplexing **on**, no scope installed: **raises `UnscopedSecretError`** | |
| rather than silently reading the process environment. An un-migrated call | |
| site fails loud at that exact line instead of leaking another profile's | |
| value. | |
| - A small allowlist (`HERMES_HOME`, `HERMES_PROFILE`, proxy settings, | |
| `API_SERVER_*` listener settings β but deliberately not `API_SERVER_KEY`) | |
| stays global because those describe the process, not a profile. | |
| Because the per-turn `.env` reload is a no-op under multiplexing, rotated | |
| credentials are picked up through the profile scope on the next turn β never | |
| via `os.environ`. This holds at the loader boundary, not just the gateway's | |
| reload helper: `hermes_cli.env_loader.load_hermes_dotenv` skips the | |
| process-global load whenever multiplexing is active *and* a profile-home | |
| override is installed (import-time and cron callers hit it mid-turn), while | |
| still hydrating the profile's external secret sources into its private | |
| snapshot (`#77562`). The unscoped startup load is unchanged. | |
| The same scope-authoritative rule covers the other `os.environ` seams a | |
| routed turn can reach: `${VAR}` / `${env:VAR}` references in a profile's | |
| `config.yaml` resolve through `get_secret` when a scope is installed | |
| (`#84079`), and `.env` writes made under a scope (`save_env_value`, e.g. a | |
| `/pair` grant mirror) update the installed scope mapping instead of the | |
| process environment (`#88441`). | |
| ## The HERMES_HOME override | |
| `hermes_constants.py` holds a context-local override consulted by | |
| `get_hermes_home()` before the `HERMES_HOME` env var. Everything that resolves | |
| paths through it β config, `state.db`, skills, memory, SOUL, sessions, kanban, | |
| goals, plugin discovery, MCP startup β follows the active profile | |
| automatically. `get_process_hermes_home()` exists for the few machine-level | |
| assets that must not follow the override. `hermes_home_key()` gives | |
| per-home registries a stable scope key. A one-shot warning (`#18594`) fires if | |
| profile-scoped code runs without the override where one is expected. | |
| ## Inbound routing | |
| `gateway.profile_routes` maps `(platform, guild_id, chat_id, thread_id)` to a | |
| profile; matching is conjunctive, most-specific-first, with parent-chain chat | |
| matching for threads. Routing only runs when multiplexing is active, and a | |
| matched route whose target is outside the served set is rejected (the event is | |
| dropped, not misdelivered). Full schema and matching rules: | |
| [Routing shared-bot chats to profiles](../user-guide/multi-profile-gateways.md#routing-shared-bot-chats-to-profiles-profile_routes). | |
| ## Serving selected profiles | |
| `profiles_to_serve(multiplex, profile_allowlist)` in `hermes_cli/profiles.py` | |
| is the single chokepoint for which profiles a multiplexer serves: default plus | |
| every valid profile directory, optionally filtered by allowlist. A malformed | |
| allowlist fails safe to default-only. The served set gates adapter startup, | |
| cron ticking (`#69377`), `/p/<profile>/` HTTP admission, route eligibility, | |
| and the runtime status surface. An excluded profile stays installed and can | |
| still run its own standalone gateway. | |
| ## Per-profile persistence | |
| `SessionStore` binds no database handle at construction (`#88532`). Session | |
| DB handles are resolved at call time through the active HERMES_HOME override β | |
| one cached handle per resolved `profiles/<name>/state.db` β so sessions land | |
| in the owning profile's store even when the store object itself is shared. | |
| Pairing stores are constructed per served profile. | |
| ## Per-bot session lanes | |
| Session keys are namespaced by profile (`agent:main` for default, | |
| `agent:<name>` for named profiles). Adapters carry `_owner_profile` | |
| (installed at adapter configuration time, before any inbound event) because | |
| adapter ingress runs before `SessionSource.profile` is stamped; | |
| `_session_key_profile` resolves source stamp β owner profile β store | |
| resolver. Text/media batching, active-session tracking, and the busy-session | |
| guard are all keyed per lane, so two bots sharing a chat do not share a | |
| session lane. | |
| ## Control plane | |
| Desktop plugins reach the gateway only through the ws JSON-RPC door, so | |
| profile enumeration and configuration live in | |
| `tui_gateway/methods_profiles.py`: `profiles.list`, `profiles.create`, | |
| `profiles.describe`, `profiles.configure`, `profiles.set_asset`, | |
| `profiles.get_asset`. Reads and writes run under the target profile's | |
| HERMES_HOME override. Asset writes are atomic, type- and size-capped. | |
| ## Failure modes | |
| - Fatal at startup: multiplex config errors and a secondary profile enabling a | |
| port-binding platform (`MultiplexConfigError`, | |
| `SecondaryPortBindingConfigError`) β one shared HTTP listener is owned by | |
| the default profile. | |
| - Skipped, not fatal: a single misconfigured secondary adapter is skipped with | |
| a warning rather than taking down the multiplexer. | |
| - Fail-closed: unscoped `get_secret()` under multiplexing raises; a routed | |
| event targeting an unserved profile is dropped; an unscoped `/p/` request | |
| enters the default profile's scope (`#61276`) rather than an undefined one. | |
| - Fallback: an external `cron.provider` does not support multiplexing and | |
| falls back to the built-in ticker with a warning. | |
| ## Known limitations | |
| Process-global state that is not yet profile-scoped: | |
| | Surface | State at time of writing | | |
| | --- | --- | | |
| | MCP discovery and tool registration | Process-global; the first profile to build an agent wins the discovery slot. Full per-profile MCP registries are tracked in `#67605`. | | |
| | Terminal / sandbox env (`TERMINAL_*`) | Global by allowlist; tools read it from the process environment. | | |
| | Built-in tool registry | Built-ins are process-global; plugin-registered tools are overlaid per profile via `hermes_home_key()`. | | |
| | Provider/capability registries | Same hybrid overlay pattern (browser, image-gen, TTS, transcription, video-gen, web-search, secret sources). | | |
| | HTTP listener, relay ingress, process lock | One per process, owned by the default/active profile. Per-profile `runtime_status.json` is still written. | | |
| ## Non-goals | |
| Multiplexing isolates *profiles*; it does not authenticate or authorize *end | |
| users*. A profile is a configuration, not a person: the gateway trusts its | |
| transport and its routing table to decide which profile an event belongs to. | |
| Request-level identity and per-user authorization above the profile layer are | |
| out of scope for this document. | |
| ## Related | |
| - [Multi-profile gateways](../user-guide/multi-profile-gateways.md) β user-facing guide, including `profile_routes` | |
| and the standalone one-gateway-per-profile alternative. | |
| - `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`. | |