"""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: 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"]