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

194 lines
8.5 KiB
Python

"""Pheby protocol v1 — message types, error codes, and (de)serialization helpers.
The protocol is JSON-over-WebSocket. Every message (both directions) has a
``type`` field. Client → server requests may carry a ``request_id`` (any
string) which is echoed on the direct reply so the client can correlate
RPC-style calls. Server → client events are broadcast to all authenticated
connections and carry the IDs needed to associate them with a conversation,
message, run, tool call, approval, clarification, or attachment.
This module is intentionally dependency-free (stdlib only) so it can be unit
tested without Hermes/aiohttp installed.
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
# ── Protocol version ─────────────────────────────────────────────────────────
PROTOCOL_VERSION = 1
# Bump when a wire-incompatible change lands. Clients negotiate via the
# ``hello`` handshake; the server refuses mismatches with a ``version_mismatch``
# error instead of guessing.
# ── Limits ───────────────────────────────────────────────────────────────────
MAX_TEXT_CHARS = 64_000 # client chat message body limit
MAX_WS_MESSAGE_BYTES = 2 * 1024 * 1024 # inbound WebSocket frame cap (aiohttp)
MAX_HISTORY_MESSAGES = 500 # per conversation.open fetch cap
AUTH_TIMEOUT_SECONDS = 10.0 # hello must arrive within this window
AUTH_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source
AUTH_FAILURE_THRESHOLD = 5 # failures before lockout
# ── Error codes (machine-readable) ───────────────────────────────────────────
ERR_UNAUTHORIZED = "unauthorized"
ERR_AUTH_TIMEOUT = "auth_timeout"
ERR_VERSION_MISMATCH = "version_mismatch"
ERR_BAD_REQUEST = "bad_request"
ERR_INVALID_JSON = "invalid_json"
ERR_UNKNOWN_TYPE = "unknown_type"
ERR_NOT_FOUND = "not_found"
ERR_CONVERSATION_NOT_FOUND = "conversation_not_found"
ERR_APPROVAL_NOT_FOUND = "approval_not_found"
ERR_CLARIFY_NOT_FOUND = "clarify_not_found"
ERR_TOO_LARGE = "too_large"
ERR_RATE_LIMITED = "rate_limited"
ERR_INTERNAL = "internal_error"
ERR_NOT_IMPLEMENTED = "not_implemented"
# ── Client → server message types ────────────────────────────────────────────
C_HELLO = "hello"
C_PING = "ping"
C_CONVERSATION_LIST = "conversation.list"
C_CONVERSATION_OPEN = "conversation.open"
C_CONVERSATION_CREATE = "conversation.create"
C_CONVERSATION_RENAME = "conversation.rename"
C_CONVERSATION_DELETE = "conversation.delete"
C_CHAT_SEND = "chat.send"
C_RUN_CANCEL = "run.cancel"
C_APPROVAL_RESPOND = "approval.respond"
C_CLARIFY_RESPOND = "clarify.respond"
C_MODELS_LIST = "models.list"
C_MODEL_SET = "model.set"
C_MODEL_CURRENT = "models.current"
C_REASONING_SET = "reasoning.set"
C_REASONING_CURRENT = "reasoning.current"
# ── Server → client message types ────────────────────────────────────────────
S_READY = "ready"
S_PONG = "pong"
S_ERROR = "error"
S_CONVERSATION_SNAPSHOT = "conversation.snapshot" # reply to conversation.list
S_CONVERSATION_CREATED = "conversation.created"
S_CONVERSATION_RENAMED = "conversation.renamed"
S_CONVERSATION_UPDATED = "conversation.updated" # auto-title etc.
S_CONVERSATION_DELETED = "conversation.deleted"
S_CONVERSATION_HISTORY = "conversation.history" # reply to conversation.open
S_RUN_ACCEPTED = "run.accepted"
S_RUN_FINISHED = "run.finished"
S_MESSAGE_START = "message.start" # streaming draft opened
S_MESSAGE_DELTA = "message.delta" # cumulative streamed draft text
S_MESSAGE_COMPLETE = "message.complete" # final assistant message
S_TOOL_EVENT = "tool.event" # structured tool activity
S_APPROVAL_REQUEST = "approval.request"
S_APPROVAL_RESOLVED = "approval.resolved"
S_CLARIFY_REQUEST = "clarify.request"
S_CLARIFY_RESOLVED = "clarify.resolved"
S_ATTACHMENT_ADDED = "attachment.added"
S_MODELS_SNAPSHOT = "models.snapshot" # reply to models.list
S_MODEL_CURRENT_SNAPSHOT = "model.current" # reply to models.current
S_MODEL_CHANGED = "model.changed" # after model.set accepted
S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current
S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted
# Reasoning effort levels supported by Hermes (hermes_constants).
REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
_ATTACHMENT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
def now_iso() -> str:
"""UTC timestamp in ISO-8601 format."""
return datetime.now(timezone.utc).isoformat()
def new_id() -> str:
"""Generate an opaque 32-hex identifier (also the attachment ID shape)."""
return uuid.uuid4().hex
def is_valid_attachment_id(value: str) -> bool:
"""True when *value* looks like one of our opaque attachment IDs."""
return bool(isinstance(value, str) and _ATTACHMENT_ID_RE.match(value))
def encode_message(payload: Dict[str, Any]) -> str:
"""Serialize a protocol message to a JSON string (compact, UTF-8)."""
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def decode_message(raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Parse one inbound WebSocket text frame.
Returns ``(message, None)`` on success or ``(None, error_code)`` when the
frame is not a valid JSON object. Never raises.
"""
try:
data = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError):
return None, ERR_INVALID_JSON
if not isinstance(data, dict):
return None, ERR_INVALID_JSON
if not isinstance(data.get("type"), str) or not data["type"]:
return None, ERR_INVALID_JSON
return data, None
def error_event(code: str, message: str, request_id: Optional[str] = None,
**extra: Any) -> Dict[str, Any]:
"""Build a server → client error event with a machine-readable code."""
event: Dict[str, Any] = {
"type": S_ERROR,
"error": {"code": code, "message": str(message)[:500]},
"ts": now_iso(),
}
if request_id is not None:
event["request_id"] = request_id
if extra:
event.update(extra)
return event
def safe_str(value: Any, max_len: int = 500) -> str:
"""Coerce *value* to a bounded string for logs and summaries."""
if value is None:
return ""
text = value if isinstance(value, str) else json.dumps(
value, ensure_ascii=False, default=str)
return text[:max_len]
__all__ = [
"PROTOCOL_VERSION", "MAX_TEXT_CHARS", "MAX_WS_MESSAGE_BYTES",
"MAX_HISTORY_MESSAGES", "AUTH_TIMEOUT_SECONDS",
"AUTH_FAILURE_LOCKOUT_SECONDS", "AUTH_FAILURE_THRESHOLD",
"ERR_UNAUTHORIZED", "ERR_AUTH_TIMEOUT", "ERR_VERSION_MISMATCH",
"ERR_BAD_REQUEST", "ERR_INVALID_JSON", "ERR_UNKNOWN_TYPE", "ERR_NOT_FOUND",
"ERR_CONVERSATION_NOT_FOUND", "ERR_APPROVAL_NOT_FOUND",
"ERR_CLARIFY_NOT_FOUND", "ERR_TOO_LARGE", "ERR_RATE_LIMITED",
"ERR_INTERNAL", "ERR_NOT_IMPLEMENTED",
"C_HELLO", "C_PING", "C_CONVERSATION_LIST", "C_CONVERSATION_OPEN",
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
"C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND",
"C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET",
"C_REASONING_CURRENT",
"S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT",
"S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED",
"S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED",
"S_RUN_FINISHED", "S_MESSAGE_START", "S_MESSAGE_DELTA",
"S_MESSAGE_COMPLETE", "S_TOOL_EVENT", "S_APPROVAL_REQUEST",
"S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED",
"S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT",
"S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED",
"REASONING_EFFORTS",
"now_iso", "new_id", "is_valid_attachment_id", "encode_message",
"decode_message", "error_event", "safe_str",
]