An agent working in an area gets root + that area's AGENTS.md as one project-context block, so the area files must leave room beside the root. hermes_cli (24.2k -> 18.0k): update-pipeline stage contract and free-tier long form -> cli-internals.md. gateway (20.9k -> 17.2k): background notifications and /login long form -> gateway-internals.md. apps/desktop (16.3k -> 9.4k): five judgment sections summarized with every invariant kept, the reasoning -> new apps/desktop/ENGINEERING.md, the onboarding rehearsal -> BUILDING.md.
382 lines
27 KiB
Markdown
382 lines
27 KiB
Markdown
---
|
|
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:{namespace}:{platform}:{chat_type}:{chat_id}`; the namespace is `main` for the default profile, `<profile>` under multiplexing — see [Multiplexed profiles](#multiplexed-profiles))
|
|
- 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:{namespace}:{platform}:{chat_type}:{chat_id}
|
|
```
|
|
|
|
For example: `agent:main:telegram:private:123456789` for the default profile, or
|
|
`agent:work:telegram:private:123456789` when the multiplexer routes that chat to profile `work`
|
|
(`gateway/session.py::_session_key_namespace`; a profile literally named `main` is marked `main~`).
|
|
|
|
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`, or `gateway.allow_all_users` in `config.yaml`, bridged to the env var by `gateway/config_loader.py::bridge_core_env_settings`) — 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)
|
|
└── … # 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: systemd via `RestartPreventExitStatus=78`, s6 via finish→125, launchd via `KeepAlive.SuccessfulExit=false` after the stderr wrapper maps 78→0. 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.md) 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 `<profile home>/hooks/` (user-installed; `~/.hermes/hooks/` for the default profile, one directory per served profile under multiplexing — paths resolve at call time, never at import). 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. Standalone (one gateway per profile), `hermes -p x gateway stop` stops only that profile's gateway. Under multiplexing there is ONE gateway process per host, owned by whichever profile launched it (`gateway/host_rendezvous.py` publishes its PID, home and served set; `gateway/host_attach.py` is the attach/rescan/refuse decision every lifecycle verb goes through): `hermes gateway stop` on the owner takes every served profile down, and `hermes -p x gateway stop` for a served secondary refuses with exit 78 (it has no gateway of its own). A second `gateway run` for a served profile attaches and exits 0 — under a service supervisor it exits 75 (EX_TEMPFAIL) instead, so the redundant unit is RETRIED rather than parked: "someone else serves me right now" is a runtime observation that ends when that process does, and 78 (which systemd, s6 and launchd all treat as permanent) would strand the profile. ATTACH requires a live `identify` answer from the owner; a rendezvous record with nothing answering behind it proves an owner exists but never that it serves you, so it yields a transient refusal (exit 75), never an attach. An owner that answers `multiplex: False` to the rescan is another profile's *standalone* gateway, not a multiplexer that excluded you: the verb starts this profile's own gateway beside it (the one-process-per-profile topology), it does not refuse — refusing there exited 78 and parked every launchd unit but the first to claim the host lock. `hermes gateway stop --all` uses global `ps aux` scanning to kill all gateway processes (used during updates). Liveness is decided by `gateway.status.live_gateway_pid_for_home` (PID + start-time fingerprint), never bare PID existence.
|
|
|
|
## Background process notifications
|
|
|
|
`terminal(background=true, notify_on_complete=true)` starts a gateway watcher that detects
|
|
completion and triggers a new agent turn. Verbosity: `display.background_process_notifications`
|
|
(or `HERMES_BACKGROUND_NOTIFICATIONS`): `concise` (default; one line, failures append an output
|
|
tail), `all` (running updates + final raw output), `result` (final raw output only), `error`
|
|
(final raw output only on non-zero exit), `off`.
|
|
|
|
The watcher is armed on the gateway loop at registration time (`terminal_tool_background.py::
|
|
_register_completion_watcher` → `run_notifications.py::arm_process_watcher`); `pending_watchers`
|
|
is only the fallback for processes registered before the gateway serves (checkpoint recovery) or
|
|
while it stops, drained at startup and post-turn. Agent-notify watchers send no user-facing
|
|
receipt (the agent's next turn is the report) unless the launching turn is still running at exit
|
|
— then the injection only queues a follow-up, so the concise receipt goes out immediately.
|
|
|
|
The idle completion watcher also drains `watch_match` / `watch_disabled`; no user follow-up is
|
|
required. Notify-off drains these without waking. Transport failures are retried; unavailable
|
|
durable completion owners/transports do not spend delivery attempts. Profile-namespaced process
|
|
events keep their owning adapter, including raw progress/final notices. Adapter acceptance is
|
|
at-least-once admission, not proof that a model turn or final outbound reply completed. API-server
|
|
async completions remain durable delivery rows, never autonomous new model turns. Require the
|
|
`admit_internal_event` receipt for completion/watch injection: a handler returning None is not
|
|
acceptance. Refused admission refunds every claimed batch sibling without spending an attempt;
|
|
actual delivery errors keep their bounded retry policy. Recognized raw API routes resolve after
|
|
persisted messaging origins and defer quietly when unavailable; malformed routes still warn.
|
|
|
|
Cron execution has its own session. Eligible continuable deliveries may mirror or seed the
|
|
reply-facing conversation: origin, origin-less home fallback, user-written bare-platform home,
|
|
or opted-in explicit targets. `all` expansions do not gain home mirror eligibility. Mirrored
|
|
briefs are labelled user turns appended at a turn boundary, preserving role alternation
|
|
(`cron/AGENTS.md`).
|
|
|
|
## `/login` (off-turn, paired DM only)
|
|
|
|
`/login` is registered in `hermes_cli/commands.py` with `busy_policy="dispatch"` and
|
|
`desktop="settings"`, listed in `run_busy.py::_PLAIN_COMMANDS`, and handled by
|
|
`GatewayLoginCommandsMixin` (`gateway/slash_commands_login.py`). It refuses outside a paired DM:
|
|
`chat_type in {"dm","private"}`, a truthy `chat_id`, and a platform whose `"dm"` really is a paired
|
|
conversation — ntfy, raft and a2a all report `chat_type="dm"` for a broadcast topic, a channel and
|
|
an agent peer, so posting a consent link there would publish it.
|
|
|
|
**It binds the whole install.** The sign-in writes the singleton `providers.nous`, so whoever
|
|
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
|
|
gating is opt-in (`gateway/slash_access.py`): with no `allow_admin_from` set, every user allowed to
|
|
DM the bot can run it. Operators of shared gateways must set it.
|
|
|
|
The handler returns its ack at once and drains `anon_auth.run_sign_in` on a **private single-worker
|
|
executor** (`_login_executor`), never the shared 10-thread gateway pool — a promotion wait can last
|
|
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
|
|
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
|
|
identity's second `/login` supersedes (the loser ends with the superseded copy pushed into its own
|
|
chat); a different identity is refused. `task.cancel()` cannot interrupt a blocking poll inside a
|
|
worker thread, so shutdown and supersede both work through `attempt.cancelled`, which
|
|
`wait_for_promotion` now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
|
|
task is dying and its adapters may already be gone — but if the server had already completed the
|
|
transfer it still persists, because that transfer is irreversible.
|
|
|
|
On completion the handler **evicts** every cached agent still on `nous/welcome` and clears any
|
|
session model override pinned to it. It does not switch agents in place and does not write a model
|
|
override: `settle_after_upgrade` already moved `model.default`/`model.base_url` in the config, every
|
|
turn re-resolves the config and the credentials, and `_agent_config_signature` already forces a
|
|
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
|
|
an expiring access token that nothing refreshes.
|
|
|
|
## Multiplexed profiles
|
|
|
|
With `gateway.multiplex_profiles: true` one process serves the default profile plus every live directory under `profiles/` (`hermes_cli/profiles.py::profiles_to_serve(multiplex=True)`). `os.environ` and module globals hold the **launch** profile's values, so every activity for a secondary binds its scope explicitly — a profile is home + secret scope + terminal scope together:
|
|
|
|
| Activity | Binding |
|
|
|---|---|
|
|
| Routed turn | `gateway/run.py::_profile_runtime_scope(home)` via `run_turn.py::_profile_scope_for_source` |
|
|
| Agent release / eviction (TTL, LRU, memory pressure) | `gateway/run_agent_cache.py::_run_release_in_profile_scope` |
|
|
| Shutdown | `gateway/run_shutdown.py::_finalize_session` |
|
|
| Post-turn media delivery | `gateway/platforms/base.py::_media_delivery_scope` |
|
|
| Cron tick | `cron/scheduler_provider.py::_profile_cron_scope(home)` (one ticker, profiles in sequence) |
|
|
| Child processes (`hermes -p X` workers, relay turns, browser drivers) | `tools/environments/local.py::served_profile_child_env` |
|
|
| Background threads | `agent/memory_provider.py::spawn_context_thread` |
|
|
|
|
Secret reads fail closed (`agent.secret_scope.get_secret` raises `UnscopedSecretError`) only after `set_multiplex_active(True)`, which the gateway, cron, `gateway migrate` and the Desktop/dashboard `serve` backend set. Adapter YAML never reaches `os.environ` under multiplex: `gateway/platforms/_shared.py::apply_yaml_bridge` seeds `PlatformConfig.extra` and skips the environ write under a secondary's scope; gates read through `platform_gate_env`. Shared-ingress platforms (WhatsApp bridge, Relay) run on the default profile only; a secondary that enables one is logged once and stamped into runtime status (`run_adapters.py::_note_unserved_secondary_platform`). Per-profile isolation as the user sees it: [Multi-profile gateways § What is isolated per profile](../user-guide/multi-profile-gateways.md#what-is-isolated-per-profile).
|
|
|
|
## Mid-run plugin loading
|
|
|
|
Plugins that load after the adapters connected (install/enable from the CLI, Desktop, dashboard or
|
|
`plugins.manage`; a tool-triggered force re-discovery) re-wire their platform handlers without a restart
|
|
(#87770). The pieces, all in `gateway/run_plugin_rewire.py`:
|
|
|
|
- **Discovery listener** — `_start_recover_previous_run` subscribes `PluginManager.on_plugin_loaded` for the
|
|
launch profile and `_load_secondary_profile_config` does so per served profile. The event fires from inside
|
|
`discover_and_load` (never from an RPC) for the newly loaded plugins; the callback hops onto the gateway
|
|
loop with `call_soon_threadsafe`.
|
|
- **Idempotent re-wire** — `BasePlatformAdapter.rewire_plugin_handlers()` re-reads
|
|
`get_platform_handler_factories(platform)` and runs only factories not yet wired on the live native
|
|
client (keyed `(plugin, qualname)` because a force reload hands back new function objects). Telegram
|
|
hoists the added handlers ahead of core's catch-alls; Slack also re-registers missing
|
|
`register_slack_action_handler` callbacks once per `AsyncApp`.
|
|
- **`reload-plugins` control verb** — other processes (`hermes plugins install`, `hermes serve`) ask the
|
|
running gateway to force-rescan the requested (served) home; the answer carries `plugins`, per-plugin
|
|
`activations` and `adapters_rewired`, so the caller can say "active now" truthfully.
|
|
- **Scope limit** — handlers only. Tools and system-prompt sections of a late plugin wait for the next
|
|
session (prompt-cache invariant); portable MCP servers wait for `mcp.reload`. Nothing un-wires on disable.
|
|
|
|
## 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/index.md)
|