Files
pheby-hermes-platform-adapter/plugin/pheby/adapter.py
T
Pheby 53ffdb8aa2 feat: Pheby platform adapter/plugin for Hermes (protocol v1)
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS
protocol for the native Android client, behind Caddy:

- Platform adapter (BasePlatformAdapter subclass) registered via the
  documented plugin system (plugins.platforms pattern, kind: platform)
- Multiple conversations mapped to Hermes sessions (authoritative state)
- Streaming chat via GatewayStreamConsumer edit path (message.delta)
- Structured tool events (never fake tool text in chat), incl.
  post_tool_call hook relay with Hermes tool_call_ids
- Native approval + clarification round-trips via tools.approval /
  tools.clarify_gateway primitives
- Attachment delivery: adapter-owned copies, opaque IDs, persistent
  metadata, 7-day retention + safe cleanup, authenticated HTTPS download
- Model + reasoning-effort query/change via Hermes picker data and
  session/global overrides
- Shared-secret auth (constant-time, lockout), size limits, path-
  traversal-proof attachment resolution, minimal health endpoint
- Protocol v1 spec with JSON examples (docs/PROTOCOL.md)
- Install/config/Caddy/security docs + discovered Hermes limitations
- 37 passing tests (auth, conversations, protocol, attachments incl.
  expiry/traversal, tool events, approvals, clarifications, cancel,
  reconnect re-sync, live WS smoke tests) — gateway-free fakes

No Hermes core modifications required.
2026-09-02 19:31:17 +00:00

486 lines
21 KiB
Python

"""Pheby platform adapter — the Hermes gateway ↔ Pheby protocol bridge.
Extends ``BasePlatformAdapter`` like every other platform (Telegram,
Discord, ntfy, …) so the full gateway pipeline — sessions, tool approval,
clarify, deliverables, streaming — works unchanged on the Pheby platform.
Outbound mapping:
* ``send`` / ``edit_message`` → chat draft events (S_MESSAGE_DELTA etc.)
* ``format_tool_event`` → structured S_TOOL_EVENT JSON (never fake text)
* ``send_clarify`` → structured S_CLARIFY_REQUEST
* ``send_document`` etc. → attachment registration + S_ATTACHMENT_ADDED
Inbound mapping (reversed): Pheby WS messages are turned into MessageEvents
delivered through ``handle_message`` so the gateway treats them identically
to any other platform's messages.
"""
from __future__ import annotations
import asyncio
import logging
import mimetypes
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
import aiohttp
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError: # pragma: no cover
AIOHTTP_AVAILABLE = False
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
from . import protocol as proto
from . import hermes_bridge
from .config import PhebyConfig, load_config
logger = logging.getLogger(__name__)
_MEDIA_TAG_RE = None # populated lazily from base module helpers
class PhebyAdapter(BasePlatformAdapter):
"""Serve the Pheby WebSocket/HTTP protocol and map it onto the gateway."""
# Async tools (background terminal tasks, delegate_task) may wake a later
# turn on this platform — the WS is a persistent push channel.
supports_async_delivery: bool = True
# Pheby clients re-render every delta; we accumulate full text and the
# client truncates nothing — no platform length limit.
MAX_MESSAGE_LENGTH = 0
def __init__(self, config: PlatformConfig):
# Platform("pheby") resolves via the enum's _missing_() hook once the
# plugin registry knows the name; in bare unit tests (registry not
# populated) fall back to a synthetic enum member so the adapter can
# still be constructed and tested.
try:
platform = Platform("pheby")
except ValueError:
platform = object.__new__(Platform)
platform._value_ = "pheby"
platform._name_ = "PHEBY"
super().__init__(config=config, platform=platform)
self._pcfg: PhebyConfig = load_config(config.extra or {})
self._server: Any = None
self._drafts: Dict[str, Dict[str, Any]] = {} # conv → draft state
self._typing: Dict[str, float] = {}
# ── connection lifecycle ─────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool:
if not AIOHTTP_AVAILABLE:
logger.warning("[pheby] aiohttp not installed — cannot serve")
return False
if not self._pcfg.enabled:
self._set_fatal_error(
"pheby_no_secret",
"PHEBY_SECRET is not set — refusing to start the Pheby "
"server without a credential. Set it in ~/.hermes/.env.",
retryable=False)
return False
from .server import PhebyServer
hermes_bridge.set_adapter(self)
self._server = PhebyServer(self._pcfg, adapter=self)
hermes_bridge.set_server(self._server)
ok = await self._server.start()
if not ok:
return False
self._mark_connected()
logger.info("[pheby] adapter connected (protocol v%d)",
proto.PROTOCOL_VERSION)
return True
async def disconnect(self) -> None:
self._running = False
if self._server is not None:
await self._server.stop()
self._server = None
self._mark_disconnected()
logger.info("[pheby] adapter disconnected")
# ── outbound: chat text ──────────────────────────────────────────────
def _conv_from_chat_id(self, chat_id: str) -> str:
return str(chat_id)
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Deliver assistant text (final response, commentary, or notices).
The stream consumer calls ``send`` for the first streamed chunk and
the gateway calls it for the final response; both land as
``S_MESSAGE_COMPLETE``. Streamed deltas ride ``edit_message``.
"""
if self._server is None:
return SendResult(success=False, error="server not running")
conversation_id = self._conv_from_chat_id(chat_id)
message_id = f"m-{proto.new_id()[:12]}"
# A draft exists while the turn streams; the final text supersedes
# the draft and closes it out.
draft = self._drafts.pop(conversation_id, None)
event = {
"type": proto.S_MESSAGE_COMPLETE,
"conversation_id": conversation_id,
"message_id": (draft or {}).get("message_id", message_id),
"text": content,
"ts": proto.now_iso(),
}
if metadata and metadata.get("non_conversational"):
# Gateway lifecycle/status notices — deliver as a system note so
# the client can render them differently (or ignore).
event["kind"] = "notice"
await self._server.broadcast(event)
hermes_bridge.note_run_finished(conversation_id, "completed")
return SendResult(success=True, message_id=message_id)
async def edit_message(
self,
chat_id: str,
message_id: str,
content: str,
finalize: bool = False,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Streaming path: GatewayStreamConsumer edits the in-place draft.
The stream-consumer contract requires concrete adapters to accept
``finalize=`` even when ignored (it's False during progressive
edits; the final content always arrives via ``send()``).
"""
if self._server is None:
return SendResult(success=False, error="server not running")
conversation_id = self._conv_from_chat_id(chat_id)
draft = self._drafts.setdefault(conversation_id, {
"message_id": message_id or f"draft-{proto.new_id()[:12]}",
"text": "",
})
draft["text"] = content # consumer sends cumulative text
await self._server.broadcast({
"type": proto.S_MESSAGE_DELTA,
"conversation_id": conversation_id,
"message_id": draft["message_id"],
"text": content,
"ts": proto.now_iso(),
})
return SendResult(success=True, message_id=draft["message_id"])
# ── structured stream events ─────────────────────────────────────────
def format_tool_event(self, event: Any, *, mode: str = "all",
preview_max_len: int = 40) -> Optional[str]:
"""Emit tool activity as structured JSON — never as fake chat text.
Returning a truthy marker would put prose in chat; instead we push an
S_TOOL_EVENT broadcast and return None so the gateway's text queue
stays clean. (The dispatcher treats None as "adapter ate the event".)
"""
try:
conversation_id = self._active_conversation_id()
if not conversation_id or self._server is None:
return None
if isinstance(event, ToolCallShim):
return None # never used at runtime; type-safety shim only
from gateway.stream_events import ToolCallChunk, ToolCallFinished
tool_event: Dict[str, Any]
if isinstance(event, ToolCallChunk):
tool_id = f"t-{proto.new_id()[:12]}"
args = event.args if isinstance(event.args, dict) else None
self._remember_tool(tool_id, conversation_id, event.tool_name)
tool_event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"tool_call_id": tool_id,
"tool_name": event.tool_name,
"status": "running",
"description": proto.safe_str(event.preview, 300)
if event.preview else None,
"args_redacted": _redact_args(args),
"ts": proto.now_iso(),
}
elif isinstance(event, ToolCallFinished):
tool_id = self._lookup_tool(event.tool_name, conversation_id)
tool_event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"tool_call_id": tool_id,
"tool_name": event.tool_name,
"status": "completed" if event.ok else "failed",
"duration_ms": int(event.duration * 1000)
if event.duration else None,
"ts": proto.now_iso(),
}
else:
return None
asyncio.ensure_future(self._server.broadcast(tool_event))
except Exception:
logger.debug("[pheby] tool event translation failed",
exc_info=True)
return None # never render tool chrome as chat text
def _tool_state(self) -> Dict[str, Any]:
if not hasattr(self, "_tool_calls"):
self._tool_calls: Dict[Tuple[str, str], str] = {}
self._tool_order: List[Tuple[str, str]] = []
return {"calls": self._tool_calls, "order": self._tool_order}
def _remember_tool(self, tool_id: str, conversation_id: str,
tool_name: str) -> None:
state = self._tool_state()
state["calls"][(tool_name, conversation_id)] = tool_id
state["order"].append((tool_name, conversation_id))
if len(state["order"]) > 200:
old = state["order"].pop(0)
state["calls"].pop(old, None)
def _lookup_tool(self, tool_name: str, conversation_id: str) -> str:
state = self._tool_state()
return state["calls"].get((tool_name, conversation_id),
f"t-{proto.new_id()[:12]}")
# -- Hermes plugin hooks (registered in __init__.py register()) --------
def on_post_tool_call(self, **kwargs: Any) -> None:
"""Observer for the ``post_tool_call`` plugin hook.
Hermes fires this after every tool execution with the authoritative
tool_call_id, status, duration, and result. We relay it as a
structured ``S_TOOL_EVENT`` so the client can settle the matching
"running" event emitted by ``format_tool_event``.
"""
try:
conversation_id = self._active_conversation_id()
if not conversation_id or self._server is None:
return
tool_name = str(kwargs.get("tool_name") or "tool")
status = str(kwargs.get("status") or "")
duration_ms = kwargs.get("duration_ms") or 0
event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"tool_call_id": str(kwargs.get("tool_call_id")
or self._lookup_tool(tool_name,
conversation_id)),
"tool_name": tool_name,
"status": "completed" if status in ("ok", "success", "")
else "failed" if status == "error" else status or "completed",
"duration_ms": int(duration_ms) if duration_ms else None,
# Result summaries are intentionally NOT included by default:
# tool results can embed file paths/host details. The client
# gets outcome status; verbose content stays in Hermes.
"ts": proto.now_iso(),
}
error_message = kwargs.get("error_message")
if error_message and event["status"] == "failed":
event["error"] = proto.safe_str(error_message, 200)
asyncio.ensure_future(self._server.broadcast(event))
except Exception:
logger.debug("[pheby] post_tool_call relay failed", exc_info=True)
def _active_conversation_id(self) -> Optional[str]:
"""Best-effort current conversation for adapter-level callbacks."""
if not self._active_sessions:
return None
# Most recent active session wins (single-user platform).
key = sorted(self._active_sessions.keys())[-1]
# Session keys end with :dm:<conversation_id>
return key.rsplit(":", 1)[-1] if ":" in key else None
# ── typing indicator → run activity ──────────────────────────────────
async def send_typing(self, chat_id: str, metadata=None) -> None:
# Pheby clients show their own activity UI from run/tool events.
return
# ── approvals ────────────────────────────────────────────────────────
async def send_approval_prompt(self, session_key: str,
approval_data: Dict[str, Any]) -> None:
"""Called from the approval notify callback (agent thread → here)."""
try:
await hermes_bridge.push_approval(approval_data, session_key)
except Exception:
logger.error("[pheby] approval push failed", exc_info=True)
def register_approval_notify(self, session_key: str) -> None:
"""Wire tools.approval's per-session notify callback to Pheby."""
from tools.approval import register_gateway_notify, \
unregister_gateway_notify
loop = asyncio.get_event_loop()
# The callback runs on the agent's worker thread; bridge to the loop.
def _notify(approval_data: Dict[str, Any]) -> None:
asyncio.run_coroutine_threadsafe(
self.send_approval_prompt(session_key, approval_data), loop)
register_gateway_notify(session_key, _notify)
self._approval_notify_sessions = getattr(
self, "_approval_notify_sessions", set())
self._approval_notify_sessions.add(session_key)
# ── clarification ────────────────────────────────────────────────────
async def send_clarify(
self,
chat_id: str,
question: str,
choices: Optional[list],
clarify_id: str,
session_key: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Native structured clarify prompt (buttons on the client)."""
if self._server is None:
return SendResult(success=False, error="server not running")
await hermes_bridge.push_clarify(clarify_id, session_key, question,
choices)
# Text capture is unnecessary: the client responds through
# clarify.respond, which resolves the entry directly.
return SendResult(success=True, message_id=clarify_id)
# ── deliverables (attachments) ───────────────────────────────────────
async def _register_and_broadcast(
self,
file_path: str,
conversation_id: str,
*,
kind_hint: Optional[str] = None,
filename: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if self._server is None:
return None
desc = await self._server.store.register_file(
file_path,
conversation_id=conversation_id,
message_id=None,
filename=filename,
kind_hint=kind_hint,
)
if desc is None:
return None
await self._server.broadcast({
"type": proto.S_ATTACHMENT_ADDED,
"conversation_id": conversation_id,
"attachment": desc,
"ts": proto.now_iso(),
})
return desc
async def send_document(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
file_path, conversation_id, filename=file_name)
if desc is None:
return SendResult(success=False, error="attachment failed")
if caption:
await self.send(chat_id, caption)
return SendResult(success=True, message_id=desc["attachment_id"])
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
image_path, conversation_id, kind_hint="image")
if desc is None:
return SendResult(success=False, error="attachment failed")
if caption:
await self.send(chat_id, caption)
return SendResult(success=True, message_id=desc["attachment_id"])
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
audio_path, conversation_id, kind_hint="voice")
if desc is None:
return SendResult(success=False, error="attachment failed")
return SendResult(success=True, message_id=desc["attachment_id"])
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
conversation_id = self._conv_from_chat_id(chat_id)
desc = await self._register_and_broadcast(
video_path, conversation_id, kind_hint="video")
if desc is None:
return SendResult(success=False, error="attachment failed")
return SendResult(success=True, message_id=desc["attachment_id"])
# ── misc contract ────────────────────────────────────────────────────
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": str(chat_id), "type": "dm"}
# Standalone cron/send_message delivery (out-of-process).
def standalone_send(self):
async def _send(pconfig, chat_id: str, message: str, **kwargs):
# Out-of-process there is no WS server; deliver via a transient
# connection to our own HTTP/WS endpoint is overkill — instead
# cron jobs targeting Pheby run in-process with the gateway.
return {"error": "pheby standalone delivery requires the gateway "
"(deliver within gateway-managed processes)"}
return _send
class ToolCallShim:
"""Marker type for internal typing only — never instantiated."""
def _redact_args(args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Strip likely-secret values from tool args before sending to client."""
if not isinstance(args, dict):
return None
sensitive = ("key", "token", "secret", "password", "credential", "auth")
out: Dict[str, Any] = {}
for k, v in args.items():
k_l = str(k).lower()
if any(s in k_l for s in sensitive):
out[str(k)] = "[redacted]"
elif isinstance(v, str) and len(v) > 500:
out[str(k)] = v[:500] + "…"
else:
out[str(k)] = v
return out
__all__ = ["PhebyAdapter", "AIOHTTP_AVAILABLE"]