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