feat(hermes): plugin captain-webhook (stable session_chat_id)
Plugin pro Hermes Agent que SUBSTITUI o WebhookAdapter built-in pra
suportar session_chat_id estável derivado de campo no payload.
Por que existe
--------------
O WebhookAdapter built-in monta a chave de sessão como:
session_chat_id = f"webhook:{route}:{delivery_id}"
delivery_id é único por POST → cada msg cria sessão nova no Hermes. OK pra
webhooks one-shot, ERRADO pra integração de chat onde múltiplas mensagens
da mesma conversa precisam compartilhar memória de sessão.
Como funciona
-------------
Quando o caller (Captain) inclui `conversation_id` ou `hermes_session_id`
no payload, o plugin reescreve chat_id pra:
session_chat_id = f"webhook:{route}:session:{conversation_id}"
Mesma conversation_id em múltiplas POSTs → mesma sessão Hermes →
contexto e memória preservados. Sem o campo, fallback ao comportamento
default (session nova por POST). 100% backward-compatible.
Implementação
-------------
- kind: platform — registra com name="webhook" pra substituir built-in
(Hermes prioriza platform_registry sobre código built-in em
gateway/run.py:_create_adapter)
- Herda WebhookAdapter — só override `handle_message` (rewrite chat_id)
e `connect` (recupera gateway_runner via _gateway_runner_ref pq o
plugin path não seta isso explicitamente)
- Outros adapters (HMAC, rate limit, idempotency, parsing, deliver
dispatch) — herdados sem cópia
Validado end-to-end na VPS (profile valentina):
- POST com conversation_id=99999 (msg 1) → session:99999 criada
- POST com conversation_id=99999 (msg 2) → MESMA session reutilizada
- Hermes responde via Codex em ~10s (2 turnos cumulativos)
- http_callback faz POST de volta no Captain (HTTP 200)
- Logs mostram: [captain-webhook] Stable session: ... -> session:99999
Combinado com captain-http-callback, completa o ciclo Captain ↔ Hermes:
Captain manda webhook com conversation_id → Hermes processa em sessão
estável → http_callback POSTa resposta de volta → Captain envia ao
WhatsApp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
35de8b7fde
commit
d781f4a048
77
hermes-plugins/captain-webhook/README.md
Normal file
77
hermes-plugins/captain-webhook/README.md
Normal file
@ -0,0 +1,77 @@
|
||||
# captain-webhook
|
||||
|
||||
Hermes Agent platform plugin que **substitui** o `WebhookAdapter` built-in pra suportar **session_chat_id estável** derivado de campo no payload.
|
||||
|
||||
## Por que existe
|
||||
|
||||
O webhook adapter built-in do Hermes monta a chave de sessão como:
|
||||
|
||||
```
|
||||
session_chat_id = f"webhook:{route_name}:{delivery_id}"
|
||||
```
|
||||
|
||||
`delivery_id` é único por POST → cada mensagem cria sessão nova no Hermes. Isso funciona pra webhooks one-shot (alertas, GitHub events), mas é **errado pra integração de chat** onde múltiplas mensagens da mesma conversa precisam compartilhar memória de sessão.
|
||||
|
||||
Esse plugin permite que o caller (ex: Captain) inclua um identificador estável no payload — `conversation_id` (preferido) ou `hermes_session_id` — e o adapter reescreve a chave pra:
|
||||
|
||||
```
|
||||
session_chat_id = f"webhook:{route_name}:session:{conversation_id}"
|
||||
```
|
||||
|
||||
Mesmo `conversation_id` em múltiplas POSTs → mesma sessão Hermes → memória da conversa preservada.
|
||||
|
||||
## Como funciona
|
||||
|
||||
`CaptainWebhookAdapter` herda de `WebhookAdapter` built-in e faz **uma única override**: o método `handle_message()`. Ele:
|
||||
|
||||
1. Recebe o `event` já montado pelo built-in
|
||||
2. Lê `event.raw_message` (o payload JSON do webhook)
|
||||
3. Se houver `hermes_session_id` ou `conversation_id`, monta novo `chat_id`
|
||||
4. Mirror o `_delivery_info` pra nova chave (pra o `send()` posterior achar config)
|
||||
5. Modifica `event.source.chat_id`
|
||||
6. Chama `super().handle_message(event)`
|
||||
|
||||
Toda outra lógica (HMAC, rate limit, idempotency, parsing JSON, signature validation, deliver dispatch) é herdada **sem cópia**.
|
||||
|
||||
## Como o Hermes substitui o built-in
|
||||
|
||||
`gateway/run.py`:
|
||||
```python
|
||||
# Plugin-registered platforms (checked first)
|
||||
if platform_registry.is_registered(platform.value):
|
||||
adapter = platform_registry.create_adapter(platform.value, config)
|
||||
if adapter is not None:
|
||||
return adapter
|
||||
# Fall through to built-in adapters below
|
||||
```
|
||||
|
||||
Se este plugin se registrar com `name="webhook"` (mesmo nome do built-in), `is_registered("webhook")` retorna `True` e o `CaptainWebhookAdapter` é usado em vez do built-in `WebhookAdapter`.
|
||||
|
||||
## Instalação no profile do Hermes
|
||||
|
||||
```bash
|
||||
# Copia plugin pro profile
|
||||
cp -r hermes-plugins/captain-webhook /root/.hermes/profiles/<profile>/plugins/
|
||||
|
||||
# Ativa
|
||||
HERMES_HOME=/root/.hermes/profiles/<profile> hermes plugins enable captain-webhook
|
||||
|
||||
# Reinicia gateway pra carregar
|
||||
pkill -f "HERMES_HOME=/root/.hermes/profiles/<profile>"
|
||||
HERMES_HOME=/root/.hermes/profiles/<profile> nohup hermes gateway run --replace > /var/log/hermes-<profile>.log 2>&1 &
|
||||
```
|
||||
|
||||
Verifica:
|
||||
```bash
|
||||
HERMES_HOME=/root/.hermes/profiles/<profile> hermes plugins list | grep captain-webhook
|
||||
```
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Quando o payload **NÃO traz** `hermes_session_id` nem `conversation_id`, o adapter **não modifica nada** — comportamento idêntico ao built-in. Webhooks one-shot continuam funcionando normalmente.
|
||||
|
||||
## Limitações
|
||||
|
||||
- O plugin estende a versão do `WebhookAdapter` instalada no Hermes. Quando o Hermes for atualizado, é prudente revisar se a interface base mudou (signature do `handle_message`, formato do `chat_id`, etc).
|
||||
- Não modifica o ciclo de idempotency: cada POST ainda precisa de `delivery_id` único (auto-gerado pelo Hermes ou via header `X-Request-ID`).
|
||||
- Não persiste sessions entre restarts do Hermes — isso é responsabilidade do session store do próprio Hermes (SQLite por profile).
|
||||
3
hermes-plugins/captain-webhook/__init__.py
Normal file
3
hermes-plugins/captain-webhook/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
198
hermes-plugins/captain-webhook/adapter.py
Normal file
198
hermes-plugins/captain-webhook/adapter.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Captain Webhook Adapter — extends Hermes built-in WebhookAdapter to
|
||||
support stable session_chat_id derived from a payload field.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The default ``WebhookAdapter`` builds the session key as::
|
||||
|
||||
session_chat_id = f"webhook:{route_name}:{delivery_id}"
|
||||
|
||||
Since ``delivery_id`` is unique per HTTP POST, every webhook hit creates a
|
||||
fresh Hermes session. That's correct behavior for one-shot webhook events
|
||||
(GitHub PRs, monitoring alerts) but wrong for backend integrations where
|
||||
many messages of the same logical conversation must share session memory.
|
||||
|
||||
This plugin lets the caller include a stable identifier in the JSON
|
||||
payload — typically ``conversation_id`` (preferred) or
|
||||
``hermes_session_id`` — and the adapter rewrites the session key to::
|
||||
|
||||
session_chat_id = f"webhook:{route_name}:session:{conversation_id}"
|
||||
|
||||
Same conversation_id across multiple POSTs → same session in Hermes →
|
||||
session memory preserved. Different conversation_id (or none) → fresh
|
||||
session, identical to the built-in.
|
||||
|
||||
Idempotency (``delivery_id`` based) is unchanged: each POST still gets a
|
||||
unique delivery_id, so retries are still de-duplicated.
|
||||
|
||||
Implementation
|
||||
--------------
|
||||
We register this plugin under the SAME platform name as the built-in
|
||||
(``webhook``). Hermes' ``_create_adapter`` checks ``platform_registry``
|
||||
first and uses the plugin if registered, falling back to the built-in
|
||||
only if nothing is registered. So registering ``name="webhook"`` here
|
||||
substitutes the built-in completely.
|
||||
|
||||
Behavior is inherited unchanged from ``WebhookAdapter`` (HMAC, rate
|
||||
limiting, idempotency, signature validation, prompt rendering, deliver
|
||||
dispatch). The only override is ``handle_message()``, which mutates
|
||||
``event.source.chat_id`` before delegating to the parent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.platforms.webhook import WebhookAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CaptainWebhookAdapter(WebhookAdapter):
|
||||
"""WebhookAdapter with stable session_chat_id derived from payload."""
|
||||
|
||||
SESSION_FIELD_PRIMARY = "hermes_session_id"
|
||||
SESSION_FIELD_FALLBACK = "conversation_id"
|
||||
|
||||
async def connect(self) -> bool:
|
||||
# Hermes' built-in webhook adapter is the only platform that gets
|
||||
# `gateway_runner` set explicitly in `_create_adapter()` (see
|
||||
# gateway/run.py around `Platform.WEBHOOK`). When a plugin replaces
|
||||
# the built-in via `platform_registry`, that explicit assignment is
|
||||
# skipped — leaving `self.gateway_runner` unset and breaking
|
||||
# cross-platform delivery (e.g. forwarding the response to a sibling
|
||||
# plugin like `http_callback`).
|
||||
#
|
||||
# Recover by pulling the live runner from the module-level
|
||||
# `_gateway_runner_ref` weakref that GatewayRunner.__init__ populates.
|
||||
if not getattr(self, "gateway_runner", None):
|
||||
try:
|
||||
from gateway.run import _gateway_runner_ref
|
||||
runner = _gateway_runner_ref()
|
||||
if runner is not None:
|
||||
self.gateway_runner = runner
|
||||
logger.info(
|
||||
"[captain-webhook] gateway_runner linked via _gateway_runner_ref"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[captain-webhook] _gateway_runner_ref() returned None — "
|
||||
"cross-platform delivery (http_callback etc) will fail"
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"[captain-webhook] Could not link gateway_runner: %s", exc
|
||||
)
|
||||
return await super().connect()
|
||||
|
||||
async def handle_message(self, event: MessageEvent) -> None:
|
||||
custom_session = self._extract_custom_session_id(event)
|
||||
if custom_session:
|
||||
self._rewrite_chat_id(event, custom_session)
|
||||
return await super().handle_message(event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_custom_session_id(self, event: MessageEvent) -> Optional[str]:
|
||||
payload = event.raw_message
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
value = payload.get(self.SESSION_FIELD_PRIMARY) or payload.get(
|
||||
self.SESSION_FIELD_FALLBACK
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).strip() or None
|
||||
|
||||
def _rewrite_chat_id(self, event: MessageEvent, custom_session: str) -> None:
|
||||
old_chat_id = event.source.chat_id
|
||||
# Default chat_id format from base WebhookAdapter:
|
||||
# "webhook:<route_name>:<delivery_id>"
|
||||
# Extract route_name to keep the new key consistent with that scheme.
|
||||
try:
|
||||
prefix, route_name, _ = old_chat_id.split(":", 2)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"[captain-webhook] Unexpected chat_id format %r — skipping rewrite",
|
||||
old_chat_id,
|
||||
)
|
||||
return
|
||||
|
||||
new_chat_id = f"{prefix}:{route_name}:session:{custom_session}"
|
||||
if new_chat_id == old_chat_id:
|
||||
return
|
||||
|
||||
# The base adapter stores delivery info under the original chat_id so
|
||||
# that send() can locate the deliver target/payload when the agent
|
||||
# finishes. Mirror the entry under the new chat_id so the rewrite
|
||||
# doesn't break delivery routing.
|
||||
delivery_config = self._delivery_info.get(old_chat_id)
|
||||
if delivery_config is not None:
|
||||
self._delivery_info[new_chat_id] = delivery_config
|
||||
self._delivery_info_created[new_chat_id] = (
|
||||
self._delivery_info_created.get(old_chat_id, 0)
|
||||
)
|
||||
|
||||
# SessionSource and MessageEvent are non-frozen dataclasses — safe to mutate.
|
||||
event.source.chat_id = new_chat_id
|
||||
|
||||
logger.info(
|
||||
"[captain-webhook] Stable session: %s -> %s (custom=%s)",
|
||||
old_chat_id,
|
||||
new_chat_id,
|
||||
custom_session,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registry hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_requirements() -> bool:
|
||||
"""Built-in WebhookAdapter must be importable (it always is in Hermes)."""
|
||||
try:
|
||||
from gateway.platforms.webhook import WebhookAdapter # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_config(config) -> bool: # pragma: no cover - trivial passthrough
|
||||
"""All built-in webhook config is valid for this adapter as well."""
|
||||
return True
|
||||
|
||||
|
||||
def is_connected(config) -> bool: # pragma: no cover - trivial passthrough
|
||||
"""Always considered ready when the platform block is enabled."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
return bool(extra.get("enabled", True))
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point.
|
||||
|
||||
Registers under name="webhook" — same as the built-in — so Hermes'
|
||||
``_create_adapter`` picks this adapter instead of the built-in
|
||||
(registry is checked before the if/elif chain).
|
||||
"""
|
||||
ctx.register_platform(
|
||||
name="webhook",
|
||||
label="Captain Webhook (stable sessions)",
|
||||
adapter_factory=lambda cfg: CaptainWebhookAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=[],
|
||||
install_hint="Inherits from built-in WebhookAdapter — no extra deps.",
|
||||
emoji="🪝",
|
||||
pii_safe=True,
|
||||
allow_update_command=False,
|
||||
platform_hint=(
|
||||
"Webhook platform with stable session_chat_id when payload "
|
||||
"carries 'conversation_id' or 'hermes_session_id'. Keeps the "
|
||||
"same Hermes session across multiple HTTP POSTs of the same "
|
||||
"logical conversation."
|
||||
),
|
||||
)
|
||||
28
hermes-plugins/captain-webhook/plugin.yaml
Normal file
28
hermes-plugins/captain-webhook/plugin.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
name: captain-webhook
|
||||
kind: platform
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Drop-in replacement for the built-in `webhook` platform adapter that
|
||||
supports stable session_chat_id via payload field.
|
||||
|
||||
The default Hermes WebhookAdapter creates a fresh session per POST
|
||||
(session_chat_id = "webhook:<route>:<delivery_id>" — and delivery_id is
|
||||
unique per request). For backend integrations like Captain ↔ Hermes
|
||||
where multiple messages of the same conversation must share session
|
||||
memory, the caller can include `conversation_id` (or `hermes_session_id`)
|
||||
in the payload — this adapter constructs:
|
||||
|
||||
session_chat_id = "webhook:<route>:session:<conversation_id>"
|
||||
|
||||
keeping the Hermes session continuous across messages of the same
|
||||
conversation. Idempotency (delivery_id) remains unchanged.
|
||||
|
||||
When the payload has neither `conversation_id` nor `hermes_session_id`,
|
||||
behavior is identical to the built-in adapter (every msg is fresh
|
||||
session). 100% backward-compatible.
|
||||
|
||||
Inheritance-only override: this plugin extends WebhookAdapter and only
|
||||
overrides handle_message() to rewrite event.source.chat_id when the
|
||||
payload carries a stable session id. All other behavior (HMAC, rate
|
||||
limiting, idempotency, parsing, deliver) is inherited unchanged.
|
||||
author: Captain (fazer.ai)
|
||||
Loading…
Reference in New Issue
Block a user