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:
Rodribm10 2026-05-01 15:16:05 -03:00
parent 35de8b7fde
commit d781f4a048
4 changed files with 306 additions and 0 deletions

View 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).

View File

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View 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."
),
)

View 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)