diff --git a/hermes-plugins/captain-webhook/README.md b/hermes-plugins/captain-webhook/README.md new file mode 100644 index 000000000..fd5c96795 --- /dev/null +++ b/hermes-plugins/captain-webhook/README.md @@ -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//plugins/ + +# Ativa +HERMES_HOME=/root/.hermes/profiles/ hermes plugins enable captain-webhook + +# Reinicia gateway pra carregar +pkill -f "HERMES_HOME=/root/.hermes/profiles/" +HERMES_HOME=/root/.hermes/profiles/ nohup hermes gateway run --replace > /var/log/hermes-.log 2>&1 & +``` + +Verifica: +```bash +HERMES_HOME=/root/.hermes/profiles/ 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). diff --git a/hermes-plugins/captain-webhook/__init__.py b/hermes-plugins/captain-webhook/__init__.py new file mode 100644 index 000000000..d4f1d7bf0 --- /dev/null +++ b/hermes-plugins/captain-webhook/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/hermes-plugins/captain-webhook/adapter.py b/hermes-plugins/captain-webhook/adapter.py new file mode 100644 index 000000000..ab7f712e4 --- /dev/null +++ b/hermes-plugins/captain-webhook/adapter.py @@ -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::" + # 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." + ), + ) diff --git a/hermes-plugins/captain-webhook/plugin.yaml b/hermes-plugins/captain-webhook/plugin.yaml new file mode 100644 index 000000000..03a4a0a35 --- /dev/null +++ b/hermes-plugins/captain-webhook/plugin.yaml @@ -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::" — 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::session:" + + 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)