|
Download website/docs/developer-guide/gateway-internals.md from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/website/docs/developer-guide/gateway-internals.md
- Command line
-
hf download hf://SaylorTwift/hermes-agent/website/docs/developer-guide/gateway-internals.md
-
curl -L -o gateway-internals.md https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/website/docs/developer-guide/gateway-internals.md
16.7 kB
| sidebar_position: 7 | |
| title: "Gateway Internals" | |
| description: "How the messaging gateway boots, authorizes users, routes sessions, and delivers messages" | |
| # Gateway Internals | |
| The messaging gateway is the long-running process that connects Hermes to 20+ external messaging platforms through a unified architecture. | |
| ## Key Files | |
| | File | Purpose | | |
| |------|---------| | |
| | `gateway/run.py` | `GatewayRunner` facade β composes the `gateway/run_*.py` sibling mixins (startup, adapters, inbound, turn, busy, goals, notifications, shutdown, β¦) and `gateway/slash_commands_*.py` handlers | | |
| | `gateway/session.py` | `SessionStore` β conversation persistence and session key construction | | |
| | `gateway/delivery.py` | Outbound message delivery to target platforms/channels | | |
| | `gateway/pairing.py` | DM pairing flow for user authorization | | |
| | `gateway/channel_directory.py` | Maps chat IDs to human-readable names for cron delivery | | |
| | `gateway/hooks.py` | Hook discovery, loading, and lifecycle event dispatch | | |
| | `gateway/mirror.py` | Cross-session message mirroring for `send_message` | | |
| | `gateway/status.py` | Token lock management for profile-scoped gateway instances | | |
| | `gateway/builtin_hooks/` | Extension point for always-registered hooks (none shipped) | | |
| | `gateway/platform_registry.py` | Adapter registry, factories, and deferred (lazy) loaders for bundled platform plugins | | |
| | `plugins/platforms/<name>/` | Bundled messaging adapters (most platforms: `adapter.py` + `plugin.yaml`) | | |
| | `gateway/platforms/` | Shared `base.py` plus legacy/direct adapters (Signal, API server, webhooks, β¦) | | |
| ## Architecture Overview | |
| ```text | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β GatewayRunner β | |
| β β | |
| β ββββββββββββ ββββββββββββ ββββββββββββ β | |
| β β Telegram β β Discord β β Slack β β | |
| β β Adapter β β Adapter β β Adapter β β | |
| β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β | |
| β β β β β | |
| β βββββββββββββββΌββββββββββββββ β | |
| β βΌ β | |
| β _handle_message() β | |
| β β β | |
| β βββββββββββββΌββββββββββββ β | |
| β βΌ βΌ βΌ β | |
| β Slash command AIAgent Queue/BG β | |
| β dispatch creation sessions β | |
| β β β | |
| β βΌ β | |
| β SessionStore β | |
| β (SQLite persistence) β | |
| βββββββββ΄ββββββββββββββ΄ββββββββββββββ΄ββββββββββββββ | |
| ``` | |
| ## Message Flow | |
| When a message arrives from any platform: | |
| 1. **Platform adapter** receives raw event, normalizes it into a `MessageEvent` | |
| 2. **Base adapter** checks active session guard: | |
| - If agent is running for this session β queue message, set interrupt event | |
| - If `/approve`, `/deny`, `/stop` β bypass guard (dispatched inline) | |
| 3. **GatewayRunner._handle_message()** receives the event: | |
| - Resolve session key via `_session_key_for_source()` (format: `agent:main:{platform}:{chat_type}:{chat_id}`) | |
| - Check authorization (see Authorization below) | |
| - Check if it's a slash command β dispatch to command handler | |
| - Check if agent is already running β intercept commands like `/stop`, `/status` | |
| - Otherwise β create `AIAgent` instance and run conversation | |
| 4. **Response** is sent back through the platform adapter | |
| ### Session Key Format | |
| Session keys encode the full routing context: | |
| ``` | |
| agent:main:{platform}:{chat_type}:{chat_id} | |
| ``` | |
| For example: `agent:main:telegram:private:123456789` | |
| Thread-aware platforms (Telegram forum topics, Discord threads, Slack threads) may include thread IDs in the chat_id portion. **Never construct session keys manually** β always use `build_session_key()` from `gateway/session.py`. | |
| ### Two-Level Message Guard | |
| When an agent is actively running, incoming messages pass through two sequential guards: | |
| 1. **Level 1 β Base adapter** (`gateway/platforms/base.py`): Checks `_active_sessions`. If the session is active, queues the message in `_pending_messages` and sets an interrupt event. This catches messages *before* they reach the gateway runner. | |
| 2. **Level 2 β Gateway runner** (`gateway/run_inbound.py`): Checks `_running_agents`. Intercepts specific commands (`/stop`, `/new`, `/queue`, `/status`, `/approve`, `/deny`) and routes them appropriately. Everything else triggers `running_agent.interrupt()`. | |
| Commands that must reach the runner while the agent is blocked (like `/approve`) are dispatched **inline** via `await self._message_handler(event)` β they bypass the background task system to avoid race conditions. | |
| ## Authorization | |
| The gateway uses a multi-layer authorization check, evaluated in order: | |
| 1. **Per-platform allow-all flag** (e.g., `TELEGRAM_ALLOW_ALL_USERS`) β if set, all users on that platform are authorized | |
| 2. **Platform allowlist** (e.g., `TELEGRAM_ALLOWED_USERS`) β comma-separated user IDs | |
| 3. **DM pairing** β authenticated users can pair new users via a pairing code | |
| 4. **Global allow-all** (`GATEWAY_ALLOW_ALL_USERS`) β if set, all users across all platforms are authorized | |
| 5. **Default: deny** β unauthorized users are rejected | |
| ### DM Pairing Flow | |
| ```text | |
| Admin: /pair | |
| Gateway: "Pairing code: ABC123. Share with the user." | |
| New user: ABC123 | |
| Gateway: "Paired! You're now authorized." | |
| ``` | |
| Pairing state is persisted in `gateway/pairing.py` and survives restarts. | |
| ## Slash Command Dispatch | |
| All slash commands in the gateway flow through the same resolution pipeline: | |
| 1. `resolve_command()` from `hermes_cli/commands.py` maps input to canonical name (handles aliases, prefix matching) | |
| 2. The canonical name is checked against `GATEWAY_KNOWN_COMMANDS` | |
| 3. `_handle_message()` (`gateway/run_inbound.py`) looks the handler up by name β `_handle_<name>_command` on the `gateway/slash_commands_*.py` mixins β via `_command_handler_table` over `_IDLE_COMMANDS` / `_PLAIN_COMMANDS` in `gateway/run_busy.py`; there is no `if canonical == ...` chain | |
| 4. Some commands are gated on config (`gateway_config_gate` on `CommandDef`) | |
| ### Running-Agent Guard | |
| Commands that must NOT execute while the agent is processing are rejected early: | |
| While `_quick_key in self._running_agents`, `_dispatch_busy_slash_command()` in `gateway/run_busy.py` routes each recognized command by its `CommandDef.busy_policy` / `busy_handler`: a mid-run variant (`_busy_<key>_command`) if one exists, otherwise the normal handler when `busy_policy` allows it, otherwise a reject message ("β³ Agent is running β `/model` can't run mid-turnβ¦"). | |
| Bypass commands (`/stop`, `/new`, `/approve`, `/deny`, `/queue`, `/status`) have mid-run handlers and are dispatched inline. | |
| ## Config Sources | |
| The gateway reads configuration from multiple sources: | |
| | Source | What it provides | | |
| |--------|-----------------| | |
| | `~/.hermes/.env` | API keys, bot tokens, platform credentials | | |
| | `~/.hermes/config.yaml` | Model settings, tool configuration, display options | | |
| | Environment variables | Override any of the above | | |
| Unlike the CLI (which uses `load_cli_config()` with hardcoded defaults), the gateway reads `config.yaml` directly via YAML loader. This means config keys that exist in the CLI's defaults dict but not in the user's config file may behave differently between CLI and gateway. | |
| ## Platform Adapters | |
| Most messaging platforms ship as plugin adapters under `plugins/platforms/<name>/adapter.py`; a few legacy adapters still live directly in `gateway/platforms/`. All extend `BasePlatformAdapter` from `gateway/platforms/base.py`: | |
| ```text | |
| plugins/platforms/ # plugin-packaged adapters (one dir each) | |
| βββ telegram/adapter.py # Telegram Bot API (long polling or webhook) | |
| βββ discord/adapter.py # Discord bot via discord.py | |
| βββ slack/adapter.py # Slack Socket Mode | |
| βββ whatsapp/adapter.py # WhatsApp Business Cloud API | |
| βββ matrix/adapter.py # Matrix via mautrix (optional E2EE) | |
| βββ mattermost/adapter.py # Mattermost WebSocket API | |
| βββ email/adapter.py # Email via IMAP/SMTP | |
| βββ sms/adapter.py # SMS via Twilio | |
| βββ dingtalk/adapter.py # DingTalk WebSocket | |
| βββ feishu/adapter.py # Feishu/Lark WebSocket or webhook | |
| βββ wecom/adapter.py # WeCom (WeChat Work) callback | |
| βββ line/adapter.py # LINE Messaging API | |
| βββ teams/adapter.py # Microsoft Teams | |
| βββ irc/adapter.py # IRC (canonical scoped-lock example) | |
| βββ homeassistant/adapter.py # Home Assistant conversation integration | |
| βββ β¦ # google_chat, ntfy, photon, raft, simplex, β¦ | |
| gateway/platforms/ # core base + legacy direct adapters | |
| βββ base.py # BasePlatformAdapter β shared logic for all platforms | |
| βββ signal.py # Signal via signal-cli REST API | |
| βββ weixin.py # Weixin (personal WeChat) via iLink Bot API | |
| βββ bluebubbles.py # Apple iMessage via BlueBubbles macOS server | |
| βββ qqbot/ # QQ Bot (Tencent QQ) via Official API v2 (sub-package) | |
| βββ yuanbao.py # Yuanbao (Tencent) DM/group adapter | |
| βββ msgraph_webhook.py # Microsoft Graph change-notification webhook (Teams, Outlook, etc.) | |
| βββ webhook.py # Inbound/outbound webhook adapter | |
| βββ api_server.py # REST API server adapter | |
| ``` | |
| **Deferred loading:** Bundled `kind: platform` plugins register cheap `register_deferred` loaders in `gateway/platform_registry.py` (via `hermes_cli/plugins.py`) so platform SDKs import only when the gateway starts, delivers, or runs setup/status β not on plain `hermes chat`. Resolution loads one adapter on lookup; full enumeration runs pending loaders only on paths that need every platform. | |
| Experimental connector-backed platforms use the generic relay adapter in `gateway/relay/` instead of a direct platform module. When `GATEWAY_RELAY_URL` or `gateway.relay_url` is configured, the gateway registers the `relay` platform, dials the connector over an outbound WebSocket, and receives `descriptor`, `inbound`, and `interrupt_inbound` frames on that same socket. The connector advertises a `CapabilityDescriptor`; Hermes can send normal outbound replies, token-less `follow_up` operations, and interrupt frames back through the relay. The source-grounded wire contract lives in [Relay β Connector contract](relay-connector-contract.md). | |
| Adapters implement a common interface: | |
| - `connect()` / `disconnect()` β lifecycle management | |
| - `send()` β outbound message delivery | |
| - inbound events are normalized into a `MessageEvent` and forwarded via `handle_message()` | |
| Internal push wakes use `gateway.wake.admit_internal_event`: the public | |
| `handle_message()` still returns `None`, but the event's process-local | |
| `_gateway_accepted` receipt is set only after scheduling or queue insertion. | |
| A missing handler, mismatched explicit session key, or queue-cap drop is not | |
| acceptance. Custom adapters overriding ingress should delegate internal events to | |
| `BasePlatformAdapter.handle_message()` (or explicitly record actual admission), | |
| not equate a consumed/dropped callback with acceptance. This receipt is separate | |
| from heartbeat execution accounting and does not bypass authorization, emergency | |
| stop, or later turn-preparation gates. | |
| ### Token Locks | |
| Adapters that connect with unique credentials call `acquire_scoped_lock()` in `connect()` and `release_scoped_lock()` in `disconnect()`. This prevents two profiles from using the same bot token simultaneously. | |
| A lock conflict is emitted as `{scope}_lock` with `retryable=True` so a **mid-run** reconnect can recover once the other holder exits. At **startup**, though, a live foreign holder is a configuration conflict: `gateway/restart.py::is_global_startup_conflict()` recognizes the `*_lock` / `lock_conflict` code families and the startup router parks the platform `fatal` instead of retry-queueing it. With nothing else connected the gateway exits `78` (`EX_CONFIG`, `gateway_state=startup_failed`) so the supervisor stops restarting it; alongside a genuinely transient peer failure the gateway stays alive and only the peer retries. | |
| ## Delivery Path | |
| Outgoing deliveries (`gateway/delivery.py`) handle: | |
| - **Direct reply** β send response back to the originating chat | |
| - **Home channel delivery** β route cron job outputs and background results to a configured home channel | |
| - **Explicit target delivery** β the send engine specifying `telegram:-1001234567890`, exposed via the [`hermes send` CLI](/guides/pipe-script-output) for shell scripts and via cron `deliver:` targets | |
| - **Cross-platform delivery** β deliver to a different platform than the originating message | |
| Cron job deliveries are NOT mirrored into gateway session history β they live in their own cron session only. This is a deliberate design choice to avoid message alternation violations. | |
| ## Hooks | |
| Gateway hooks are Python modules that respond to lifecycle events: | |
| ### Gateway Hook Events | |
| | Event | When fired | | |
| |-------|-----------| | |
| | `gateway:startup` | Gateway process starts | | |
| | `session:start` | New conversation session begins | | |
| | `session:end` | Session completes or times out | | |
| | `session:reset` | User resets session with `/new` | | |
| | `agent:start` | Agent begins processing a message | | |
| | `agent:step` | Agent completes one tool-calling iteration | | |
| | `agent:end` | Agent finishes and returns response | | |
| | `command:*` | Any slash command is executed | | |
| Hooks are discovered from `gateway/builtin_hooks/` (an extension point β currently empty in the shipped distribution; `_register_builtin_hooks()` is a no-op stub) and `~/.hermes/hooks/` (user-installed). Each hook is a directory with a `HOOK.yaml` manifest and `handler.py`. | |
| ## Memory Provider Integration | |
| When a memory provider plugin (e.g., Honcho) is enabled: | |
| 1. Gateway creates an `AIAgent` per message with the session ID | |
| 2. The `MemoryManager` initializes the provider with the session context | |
| 3. Provider tools (e.g., `honcho_profile`, `viking_search`) are routed through: | |
| ```text | |
| AIAgent._invoke_tool() | |
| β self._memory_manager.handle_tool_call(name, args) | |
| β provider.handle_tool_call(name, args) | |
| ``` | |
| 4. On session end/reset, `on_session_end()` fires for cleanup and final data flush | |
| ### Memory Flush Lifecycle | |
| Explicit conversation boundaries (such as `/new`, `/reset`, or `/resume`) flush and finalize the outgoing session. Idle time and daily boundaries never finalize it. | |
| Resource-only TTL, LRU, and memory-pressure eviction commits the cached transcript to configured memory providers before releasing the agent's clients. It does not close the durable conversation: the next turn reloads the same transcript and identity. | |
| ## Background Maintenance | |
| The gateway runs periodic maintenance alongside message handling: | |
| - **Cron ticking** β checks job schedules and fires due jobs | |
| - **Session housekeeping** β reclaims cached resources without ending transcripts | |
| - **Memory flush** β commits memory before soft cache eviction | |
| - **Cache refresh** β refreshes model lists and provider status | |
| ## Process Management | |
| The gateway runs as a long-lived process, managed via: | |
| - `hermes gateway start` / `hermes gateway stop` β manual control | |
| - `systemctl` (Linux) or `launchctl` (macOS) β service management | |
| - PID file at `~/.hermes/gateway.pid` β profile-scoped process tracking | |
| **Profile-scoped vs global**: `start_gateway()` uses profile-scoped PID files. `hermes gateway stop` stops only the current profile's gateway. `hermes gateway stop --all` uses global `ps aux` scanning to kill all gateway processes (used during updates). | |
| ## Related Docs | |
| - [Session Storage](./session-storage.md) | |
| - [Cron Internals](./cron-internals.md) | |
| - [ACP Internals](./acp-internals.md) | |
| - [Agent Loop Internals](./agent-loop.md) | |
| - [Messaging Gateway (User Guide)](/user-guide/messaging) | |