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.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user