theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

hermes_client.py (9191B)


      1 """Async HTTP client for the Hermes API."""
      2 
      3 import asyncio
      4 import logging
      5 from typing import Any
      6 
      7 import aiohttp
      8 
      9 logger = logging.getLogger(__name__)
     10 
     11 _DEFAULT_TIMEOUT = 30
     12 
     13 
     14 def _normalize_session(raw: dict[str, Any]) -> dict[str, Any]:
     15     """Map a Hermes session object to the frontend's Session shape.
     16 
     17     Hermes returns::
     18 
     19         {"id", "source", "title", "started_at", "ended_at", "end_reason",
     20          "message_count", "last_active", "preview", ...}
     21 
     22     Frontend expects::
     23 
     24         {"id", "name", "status", "created_at", "last_activity", "message_count", ...}
     25     """
     26     return {
     27         "id": raw.get("id", ""),
     28         "name": raw.get("title") or raw.get("name"),
     29         "status": raw.get("end_reason") or raw.get("status"),
     30         "created_at": raw.get("started_at") or raw.get("created_at"),
     31         "last_activity": raw.get("last_active") or raw.get("last_activity"),
     32         "message_count": raw.get("message_count"),
     33         "preview": raw.get("preview"),
     34         "model": raw.get("model"),
     35         "source": raw.get("source"),
     36         "archived": raw.get("archived", False),
     37         "parent_session_id": raw.get("parent_session_id"),
     38         "input_tokens": raw.get("input_tokens"),
     39         "output_tokens": raw.get("output_tokens"),
     40         "estimated_cost_usd": raw.get("estimated_cost_usd"),
     41     }
     42 
     43 
     44 def _normalize_message(raw: dict[str, Any]) -> dict[str, Any]:
     45     """Map a Hermes message object to the frontend's Message shape.
     46 
     47     Hermes returns::
     48 
     49         {"id", "session_id", "role", "content", "tool_calls", "timestamp", ...}
     50 
     51     Frontend expects::
     52 
     53         {"id", "role", "content", "timestamp", "toolCalls", ...}
     54     """
     55     result: dict[str, Any] = {
     56         "id": raw.get("id", ""),
     57         "role": raw.get("role", ""),
     58         "content": raw.get("content", ""),
     59         "timestamp": raw.get("timestamp"),
     60     }
     61     # Map tool_calls → toolCalls with camelCase sub-fields
     62     tool_calls = raw.get("tool_calls")
     63     if tool_calls:
     64         result["toolCalls"] = [
     65             {
     66                 "id": tc.get("id", ""),
     67                 "name": tc.get("name") or tc.get("function", {}).get("name", ""),
     68                 "args": tc.get("arguments") or tc.get("function", {}).get("arguments", ""),
     69                 "result": None,
     70                 "status": "complete",
     71             }
     72             for tc in tool_calls
     73         ]
     74     else:
     75         result["toolCalls"] = []
     76     return result
     77 
     78 
     79 import re
     80 
     81 _UNTRUSTED_TAG_RE = re.compile(
     82     r"</?untrusted_tool_result[^>]*>",
     83 )
     84 
     85 
     86 def _strip_untrusted_tags(content: str) -> str:
     87     """Remove <untrusted_tool_result> wrappers from tool content."""
     88     return _UNTRUSTED_TAG_RE.sub("", content).strip()
     89 
     90 
     91 def normalize_messages(raw_messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
     92     """Normalize a list of Hermes messages, linking tool results to tool calls.
     93 
     94     - Strips <untrusted_tool_result> XML tags from content
     95     - Links role=tool messages to their parent tool call
     96     - Filters out role=tool messages (results are in toolCalls[].result)
     97     """
     98     # First pass: normalize all messages
     99     normalized = [_normalize_message(m) for m in raw_messages]
    100 
    101     # Build a map of tool_call_id → result content from role=tool messages
    102     tool_results: dict[str, str] = {}
    103     for msg in normalized:
    104         if msg["role"] == "tool":
    105             call_id = ""
    106             # Hermes tool messages may have tool_call_id in raw data
    107             for raw in raw_messages:
    108                 if raw.get("id") == msg["id"]:
    109                     call_id = str(raw.get("tool_call_id", ""))
    110                     break
    111             if call_id:
    112                 tool_results[call_id] = _strip_untrusted_tags(msg["content"])
    113 
    114     # Second pass: link results to tool calls and filter tool messages
    115     output = []
    116     for msg in normalized:
    117         if msg["role"] == "tool":
    118             continue  # skip tool result messages
    119         # Link tool results
    120         if msg.get("toolCalls"):
    121             for tc in msg["toolCalls"]:
    122                 if tc["result"] is None and tc["id"] in tool_results:
    123                     tc["result"] = tool_results[tc["id"]]
    124         # Strip untrusted tags from assistant content too
    125         if msg["role"] == "assistant":
    126             msg["content"] = _strip_untrusted_tags(msg["content"])
    127         output.append(msg)
    128     return output
    129 
    130 
    131 def _normalize_model(raw: dict[str, Any]) -> dict[str, Any]:
    132     """Map a Hermes model object to the frontend's Model shape.
    133 
    134     Hermes returns::
    135 
    136         {"id", "object", "created", "owned_by", ...}
    137 
    138     Frontend expects::
    139 
    140         {"id", "name"}
    141     """
    142     model_id = raw.get("id", "")
    143     return {"id": model_id, "name": model_id}
    144 
    145 
    146 def _unwrap_hermes_response(data: Any, key: str = "data") -> Any:
    147     """Extract the payload from a Hermes envelope response.
    148 
    149     Hermes wraps list responses as ``{"object": "list", "data": [...]}``
    150     and single-object responses as ``{"object": "hermes.xxx", "xxx": {...}}``.
    151 
    152     This function extracts the inner payload so callers get the raw list
    153     or dict they expect.
    154     """
    155     if not isinstance(data, dict):
    156         return data
    157     # Try the specific key first (e.g. "session" for session objects)
    158     if key in data:
    159         return data[key]
    160     # Fall back to "data" for list envelopes
    161     if "data" in data and isinstance(data["data"], list):
    162         return data["data"]
    163     return data
    164 
    165 
    166 class HermesClient:
    167     """Async wrapper around the Hermes REST API."""
    168 
    169     def __init__(self, base_url: str, api_key: str, timeout: int = _DEFAULT_TIMEOUT) -> None:
    170         self.base_url = base_url.rstrip("/")
    171         self.api_key = api_key
    172         self.timeout = aiohttp.ClientTimeout(total=timeout)
    173 
    174     async def _request(
    175         self,
    176         method: str,
    177         path: str,
    178         **kwargs,
    179     ) -> Any:
    180         """Make an authenticated request to the Hermes API."""
    181         headers = {"Authorization": f"Bearer {self.api_key}"}
    182         url = f"{self.base_url}{path}"
    183         async with aiohttp.ClientSession(timeout=self.timeout) as session:
    184             async with session.request(method, url, headers=headers, **kwargs) as resp:
    185                 resp.raise_for_status()
    186                 if resp.status == 204:
    187                     return None
    188                 return await resp.json()
    189 
    190     async def list_sessions(self) -> Any:
    191         """List sessions, returning the raw Hermes response."""
    192         return await self._request("GET", "/api/sessions")
    193 
    194     async def get_session(self, session_id: str) -> Any:
    195         """Get a session, returning the raw Hermes response."""
    196         return await self._request("GET", f"/api/sessions/{session_id}")
    197 
    198     async def create_session(self, name: str | None = None) -> Any:
    199         """Create a session. Hermes accepts 'title' not 'name'."""
    200         payload: dict[str, Any] = {}
    201         if name:
    202             payload["title"] = name
    203         return await self._request("POST", "/api/sessions", json=payload)
    204 
    205     async def update_session(self, session_id: str, **fields) -> Any:
    206         """Update session fields. Maps frontend names to Hermes names."""
    207         mapped: dict[str, Any] = {}
    208         for k, v in fields.items():
    209             if k == "name":
    210                 mapped["title"] = v
    211             elif k == "status":
    212                 mapped["end_reason"] = v
    213             elif k in ("title", "end_reason"):
    214                 mapped[k] = v
    215         return await self._request("PATCH", f"/api/sessions/{session_id}", json=mapped)
    216 
    217     async def delete_session(self, session_id: str) -> Any:
    218         return await self._request("DELETE", f"/api/sessions/{session_id}")
    219 
    220     async def get_messages(self, session_id: str) -> Any:
    221         """Get messages, returning the raw Hermes response."""
    222         return await self._request("GET", f"/api/sessions/{session_id}/messages")
    223 
    224     async def fork_session(self, session_id: str) -> Any:
    225         """Fork a session, returning the raw Hermes response."""
    226         return await self._request("POST", f"/api/sessions/{session_id}/fork")
    227 
    228     async def list_models(self) -> Any:
    229         """List models, returning the raw Hermes response."""
    230         return await self._request("GET", "/v1/models")
    231 
    232     async def health_check(self) -> Any:
    233         return await self._request("GET", "/health")
    234 
    235     async def search_messages(self, query: str) -> Any:
    236         """Search messages. Note: Hermes API may not have this endpoint."""
    237         return await self._request("GET", f"/api/search?q={query}")
    238 
    239     def safe_call(self, coro):  # noqa: ANN001
    240         """Wrap a coroutine with error handling, returning None on failure."""
    241         async def _wrapper():
    242             try:
    243                 return await coro
    244             except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
    245                 logger.warning("Hermes API error: %s", exc)
    246                 return None
    247         return _wrapper()
    248 
    249     async def _safe_request(self, method: str, path: str, **kwargs) -> Any:
    250         """Request with graceful error handling."""
    251         try:
    252             return await self._request(method, path, **kwargs)
    253         except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
    254             logger.warning("Hermes API error: %s", exc)
    255             return None