199 lines
8.8 KiB
Python
199 lines
8.8 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_RUN_ACTIVE = "run_active"
|
|
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"
|
|
C_YOLO_SET = "yolo.set"
|
|
C_YOLO_CURRENT = "yolo.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
|
|
S_YOLO_SNAPSHOT = "yolo.snapshot" # reply to yolo.current
|
|
S_YOLO_CHANGED = "yolo.changed" # after yolo.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_RUN_ACTIVE", "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", "C_YOLO_SET", "C_YOLO_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",
|
|
"S_YOLO_SNAPSHOT", "S_YOLO_CHANGED", "REASONING_EFFORTS",
|
|
"now_iso", "new_id", "is_valid_attachment_id", "encode_message",
|
|
"decode_message", "error_event", "safe_str",
|
|
]
|