hermes-bridge
by aidalinfo
README.md
# hermes-bridge
Relais MCP pour la communication synchrone et multi-tour entre agents Hermes
(framework Nous Research). Un bot Hermes peut déléguer une tâche ou poser une
question à un autre bot Hermes connu du relais et attendre sa réponse — sans
dépendre d'un service tiers (pas de Teams, pas de ntfy, pas de Raft).
## Architecture
```
bot A (adapter) relais (src/server/) bot B (adapter)
────────────── ──────────────────── ──────────────
tool ask_agent(to=B,...) ───HTTP/MCP──▶ handleAskAgent
│ registry.has(B)?
│ ConversationStore.createRequest ── wake JSON ──WS──▶ _on_wake()
│ (request_id, timer=ask_timeout_ms) │
│ │ tour d'inférence
│ ◀── heartbeat {request_id} ──WS── │ (post_tool_call/
│ extendRequest (ré-arme le timer) │ post_llm_call)
│ │
│ ◀── tool reply(request_id, answer) ───HTTP/MCP────────┘
ask_agent() se résout ◀── answer ────┘ resolveRequest
```
- **Le relais** (`src/server/`) expose trois tools MCP — `ask_agent`,
`reply`, `list_agents` — sur `mcp_servers` (transport HTTP), et un endpoint
WebSocket (`/bridge/connect`) que chaque bot rejoint en sortant (jamais
l'inverse : le relais n'a besoin d'aucun accès réseau vers les bots).
- **L'adapter** (`adapter/`) est un plugin "platform" Hermes installé dans
`/opt/data/plugins/hermes-bridge/` de chaque bot — il réveille
l'agent (déclenche un tour d'inférence) quand un message arrive, sans
toucher au core Hermes ni nécessiter un rebuild d'image.
- ⚠️ Le chemin compte : c'est `<HERMES_HOME>/plugins/<name>/`, **pas**
`<HERMES_HOME>/.hermes/plugins/<name>/`. `get_hermes_home()` (Hermes)
n'ajoute `.hermes` que quand `HERMES_HOME` est *absent* (défaut natif
`~/.hermes`) — l'image Docker des bots fixe `HERMES_HOME=/opt/data`
explicitement, donc le dossier de scan réel est `/opt/data/plugins`.
`npx @aidalinfo/hermes-bridge install` gère ça correctement depuis la
0.1.1 ; si un bot a été installé avant, relancer `install` pour corriger
l'emplacement, puis redémarrer le conteneur.
- **`ask_agent`** bloque jusqu'à ce que l'agent cible appelle `reply`, ou
jusqu'au timeout (défaut 120s, configurable via `ask_timeout_ms`). Réutiliser
le même `conversation_id` permet un échange multi-tour séquentiel ; Hermes
conserve l'historique automatiquement via son `chat_id` de session.
- **Timeout intelligent (heartbeat)** : `ask_timeout_ms` n'est qu'un filet de
sécurité contre un agent réellement bloqué/planté, pas une estimation à
deviner pour les réponses lentes (plusieurs tool calls, lookup mémoire…).
L'adapter de l'agent **cible** s'abonne aux hooks Hermes `pre_llm_call` /
`post_tool_call` / `post_llm_call` (les mêmes points d'extension que le
statut « busy » natif de Hermes, et le même pattern que l'adapter `raft`
bundlé). Tant que la session ouverte par le wake est active, l'adapter
envoie une frame `{"type":"heartbeat","request_id":"..."}` sur la **même
connexion WebSocket sortante** (pas un nouveau canal), throttlée à 1 toutes
les 5s par session. Le relais (`ConversationStore.extendRequest`) ré-arme
alors le timer de ce `request_id` pour une fenêtre complète. `on_session_end`
nettoie le suivi quand le tour se termine. Résultat : le délai ne compte
vraiment que si l'agent s'est *arrêté* de travailler, pas s'il est juste lent.
- ⚠️ **Le point piégeux** : les hooks Hermes exposent `session_id =
agent.session_id`, un identifiant généré à chaque run d'agent
(`f"{timestamp}_{uuid}"`) — **sans aucun rapport** avec la clé de session
que l'adapter calcule lui-même pour le routage
(`gateway.session.build_session_key`, utilisée pour la queue de wakes,
jamais exposée aux hooks). Impossible donc de précalculer la
correspondance `session_id → request_id` au moment du wake. La solution :
`wake.build_wake_text()` embarque déjà `request_id=<id>` en clair dans le
texte injecté ; le hook `pre_llm_call` (seul à fournir à la fois
`session_id` et `user_message`) relit cet identifiant dans le texte
(`wake.extract_request_id`) et fixe la correspondance à ce moment précis —
les `post_tool_call`/`post_llm_call` suivants du même run la réutilisent.
Autre piège du même hook : `platform` y est le membre d'enum
`gateway.config.Platform` (pas une chaîne) — `platform_value()` le
déballe avant toute comparaison, sans quoi le filtre `== "hermes-bridge"`
est toujours faux. Sans ces deux corrections, le heartbeat ne se déclenche
*jamais* (échec silencieux — aucune erreur, juste des frames qui ne
partent jamais), et `ask_timeout_ms` reste un mur fixe malgré un adapter
et un relais à jour.
- ⚠️ Ce mécanisme est **entièrement côté adapter + relais** — aucune action
requise de l'agent/LLM cible (il ne « sait » même pas que ça existe).
- ⚠️ **Le relais doit être redéployé** pour que le heartbeat fonctionne :
publier une nouvelle version npm de l'adapter ne suffit pas, le serveur
(`src/server/bridge-ws.ts` + `conversations.ts`) doit tourner avec le code
à jour pour comprendre les frames `heartbeat`.
Détails de conception complets : voir `manageai/docs/superpowers/specs/2026-06-30-hermes-bridge-design.md`.
## Déployer le relais
```bash
docker build -t hermes-bridge .
docker run -d -p 8787:8787 -v $(pwd)/config.yaml:/app/config.yaml:ro hermes-bridge
```
`config.yaml` (voir `config.example.yaml`) :
```yaml
agents:
- name: daniel-bot
token: <token-secret-par-bot>
- name: helpdesk-bot
token: <token-secret-par-bot>
ask_timeout_ms: 120000
```
## Installer l'adapter sur un bot
```bash
docker exec -it -u hermes <bot> npx @aidalinfo/hermes-bridge install \
--token=<token-du-bot> \
--relay-url=wss://<host-du-relais>/bridge/connect
```
Puis redémarrer le conteneur du bot pour charger le plugin.
## Ajouter le relais aux `mcp_servers` du bot
```yaml
mcp_servers:
hermes-bridge:
enabled: true
transport: http
url: https://<host-du-relais>/mcp
headers:
Authorization: Bearer ${HERMES_BRIDGE_TOKEN}
access_mode: read_write
```
## Persistance (mode db)
Par défaut, l'historique des échanges vit en mémoire (`maxHistory=200`,
`telemetry.ts`) et **disparaît à chaque redémarrage du relais** — y compris
un redeploy Coolify normal sur push. Pour une traçabilité durable (audit,
« qu'est-ce que daniel-bot a répondu à helpdesk-bot mardi dernier ? »),
configurez une base — seul postgres est implémenté, et c'est le driver par
défaut :
```yaml
db:
driver: postgres # défaut si omis
connection_string: postgresql://user:pass@host:5432/hermes_bridge
```
`connection_string` peut aussi venir de la variable d'env `DATABASE_URL`
(recommandé — évite de committer un secret dans `config.yaml` ; dans ce cas
le bloc `db:` peut être omis entièrement). Le mode db s'active dès que
`config.db.connection_string` **ou** `DATABASE_URL` est renseigné.
Ce que ça change concrètement :
- La table `hermes_bridge_exchanges` est créée automatiquement au démarrage
(`src/server/db.ts`, `CREATE TABLE IF NOT EXISTS`) — aucune migration
manuelle.
- Chaque `recordStart`/`recordEnd` écrit dans la base **en plus** de la
mémoire, en fire-and-forget (comme l'export Langfuse existant) : une
panne db ne bloque jamais un `ask_agent`/`reply`, juste un `console.warn`
(throttlé à une fois).
- **`/ui` et `/ui/api/state` lisent depuis la base** quand le mode db est
actif (pas depuis la mémoire) — c'est ce qui les rend durables : le flux
affiché après un redémarrage n'est plus vide, il reprend l'historique.
En cas d'échec de lecture db, repli silencieux sur la mémoire (mieux
vaut un historique tronqué qu'une page cassée).
- Sans `db` configuré, comportement strictement inchangé (mémoire
uniquement, comme avant cette fonctionnalité).
## Observabilité
Chaque échange `ask_agent` → `reply` (ou timeout/déconnexion) peut être
exporté vers une instance [Langfuse](https://langfuse.com/) existante
(cloud ou self-hosted), regroupé par `conversation_id` — les échanges
multi-tours d'une même conversation apparaissent comme plusieurs spans
d'une seule trace :
```yaml
langfuse:
public_key: pk-lf-...
secret_key: sk-lf-...
base_url: https://cloud.langfuse.com # optionnel, défaut cloud Langfuse
```
Sans cette section, le relais fonctionne normalement sans appel réseau vers
Langfuse. Langfuse et le mode db sont indépendants — Langfuse pour tracer
en externe, le mode db pour l'audit local/`/ui` durable — activables
séparément ou ensemble.
Le relais expose aussi une page `/ui` (ex: `http://<host-du-relais>:8787/ui`),
**« Conversations entre agents »** — layout et styles Forma importés du
projet Claude Design
[`Visualiser les conversations d'agents`](https://claude.ai/design/p/2463da63-90c9-4f82-9afd-d2011605f90c?file=Agent+Conversations.dc.html)
(voir `src/server/ui.ts`, réimplémenté en HTML/JS sans dépendance, branché sur
les vraies données au lieu des exemples du prototype) :
- Un badge par agent connu (en ligne / hors ligne, point de couleur), rangée
du haut.
- Une recherche texte (message + réponse/erreur) et un filtre par agent.
- Un flux des échanges les plus récents en premier, chacun avec `from → to`,
durée, badge de statut (`ok`, `timeout`, `agent hors ligne`,
`agent déconnecté`, `agent inconnu`, `conversation inconnue`, `en cours`),
message tronqué à 180 caractères avec un bouton **Voir plus/moins** qui
révèle la réponse (ou « En attente de réponse… » tant que c'est `pending`).
- Rafraîchissement automatique (`fetch('/ui/api/state')` toutes les 3s) sans
perdre la recherche/le filtre/les échanges dépliés en cours.
Cette page **n'est pas authentifiée** — elle affiche le contenu intégral des
messages/réponses. Si le relais est exposé au-delà d'un LAN de confiance,
mettez-la derrière un reverse-proxy protégé.
## Développement
```bash
npm install
npm test # tests TypeScript (vitest)
pytest adapter/test # tests Python (wake.py — logique pure, sans dépendance Hermes)
npm run dev # lance le relais localement (HERMES_BRIDGE_CONFIG, PORT)
```
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues