Files
pheby-hermes-platform-adapter/plugin/pheby/ws_client.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

73 lines
2.7 KiB
Python

"""Per-client WebSocket connection state and inbound dispatch."""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Dict, Optional
from aiohttp import web, WSMsgType
from . import protocol as proto
logger = logging.getLogger(__name__)
class ClientConnection:
"""One authenticated WebSocket client (single-user app: at most a few)."""
def __init__(self, ws: "web.WebSocketResponse", conn_id: str):
self.ws = ws
self.conn_id = conn_id
self.authenticated = False
self.protocol_version: Optional[int] = None
self.connected_at = time.time()
# Serialized outbound writes — aiohttp WS frames are not safe to
# compose concurrently from multiple tasks.
self._send_lock = asyncio.Lock()
self.closed = False
async def send_json(self, payload: Dict[str, Any]) -> bool:
"""Thread-safe send; returns False when the socket is going away."""
if self.closed:
return False
try:
async with self._send_lock:
await self.ws.send_str(proto.encode_message(payload))
return True
except (ConnectionError, RuntimeError, asyncio.CancelledError):
self.closed = True
return False
# ------------------------------------------------------------------
async def read_loop(self, server: Any) -> None:
"""Receive/dispatch loop; exits on close, error, or auth timeout."""
try:
async for msg in self.ws:
if msg.type == WSMsgType.TEXT:
if len(msg.data) > proto.MAX_WS_MESSAGE_BYTES:
await self.send_json(proto.error_event(
proto.ERR_TOO_LARGE,
"WebSocket message exceeds server limit"))
continue
await server.handle_client_message(self, msg.data)
elif msg.type == WSMsgType.BINARY:
await self.send_json(proto.error_event(
proto.ERR_BAD_REQUEST,
"Binary frames are not part of the Pheby protocol"))
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING,
WSMsgType.CLOSED):
break
elif msg.type == WSMsgType.ERROR:
logger.warning("[pheby] ws error on %s: %s",
self.conn_id, msg.data)
break
except asyncio.CancelledError:
raise
except Exception:
logger.warning("[pheby] client %s read loop crashed",
self.conn_id, exc_info=True)
finally:
self.closed = True