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