feat(v9): Phase 2+3 — harter Cutover auf Hermes-Agent (:8642)

Mission Control proxyt jetzt zum Nous-Hermes-Agent-Server statt zum
Eigenbau-Agenten:

- routers/hermes.py: Chat-WS streamt als Proxy zu :8642 (OpenAI-API),
  WS-Contract (thinking/token/done/error) beibehalten -> HermesPanel
  unveraendert, kein Frontend-Build noetig. Status meldet Hermes-Health.
- config.py: HERMES_API_URL + HERMES_API_KEY (Key aus ~/.hermes/.env);
  HERMES_SIMPLE/COMPLEX_MODEL entfernt (Routing macht Hermes selbst).
- hermes_agent.py geloescht (ReAct-Loop, tote Tools, DuckDuckGo, Fake-Stream).
- CLAUDE/README/ROADMAP aktualisiert; Phasen 2+3 abgehakt.

Lokaler Smoke-Test: App importiert, /api/hermes/status liefert 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-23 19:24:09 +02:00
parent bc02779496
commit 8fd00d1887
6 changed files with 113 additions and 487 deletions
+78 -11
View File
@@ -18,6 +18,7 @@ import threading
from pathlib import Path
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import Response, JSONResponse
@@ -25,8 +26,8 @@ from auth import auth
from config import (
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
HERMES_API_URL, HERMES_API_KEY,
)
from hermes_agent import run_agent
router = APIRouter(prefix="/api")
@@ -100,7 +101,12 @@ def _tts(text: str) -> Optional[bytes]:
@router.websocket("/hermes/chat")
async def hermes_chat(websocket: WebSocket):
"""Streaming Chat mit dem Hermes Agent via WebSocket."""
"""Streaming-Chat: proxyt zum Hermes-Agent-Server (:8642, OpenAI-kompatibel).
Behaelt den WS-Contract des Frontends bei (thinking/token/done/error), damit
die HermesPanel unveraendert bleibt. Modell-Wahl, Tools, Gedaechtnis und
Mehr-Schritt-Logik macht der Hermes-Agent selbst.
"""
# Manuelle Token-Auth (WS kann keine HTTP-Header senden)
from auth import TOKEN
token_param = websocket.query_params.get("token", "")
@@ -121,18 +127,71 @@ async def hermes_chat(websocket: WebSocket):
if not message:
continue
async def send(obj: dict) -> None:
await websocket.send_json(obj)
await run_agent(message, send)
await websocket.send_json({"type": "thinking"})
try:
await _proxy_chat(message, websocket)
except Exception as exc:
await websocket.send_json(
{"type": "error", "content": f"Hermes nicht erreichbar: {exc}"}
)
await websocket.send_json({"type": "done"})
except WebSocketDisconnect:
pass
except Exception as exc:
try:
await websocket.send_json({"type": "error", "content": str(exc)})
except Exception:
pass
except Exception:
pass
async def _proxy_chat(message: str, websocket: WebSocket) -> None:
"""Eine Antwort vom Hermes-Agent-Server holen und Tokens ans WS relayen.
Unterstuetzt sowohl SSE-Streaming als auch eine nicht-streamende
JSON-Antwort — in beiden Faellen genau EIN Agent-Lauf (kein Doppel-Call).
"""
headers = {"Content-Type": "application/json"}
if HERMES_API_KEY:
headers["Authorization"] = f"Bearer {HERMES_API_KEY}"
body = {
"model": "hermes-agent",
"messages": [{"role": "user", "content": message}],
"stream": True,
}
timeout = httpx.Timeout(300.0, connect=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/chat/completions", headers=headers, json=body
) as resp:
if resp.status_code != 200:
detail = (await resp.aread()).decode(errors="replace")[:300]
await websocket.send_json(
{"type": "error", "content": f"HTTP {resp.status_code}: {detail}"}
)
return
if "text/event-stream" in resp.headers.get("content-type", ""):
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
delta = json.loads(data)["choices"][0].get("delta", {})
piece = delta.get("content") or ""
except Exception:
piece = ""
if piece:
await websocket.send_json({"type": "token", "content": piece})
else:
# Server streamt nicht → ganze JSON-Antwort als ein Token senden
raw = await resp.aread()
try:
d = json.loads(raw)
content = d["choices"][0]["message"].get("content", "") or ""
except Exception:
content = raw.decode(errors="replace")
if content:
await websocket.send_json({"type": "token", "content": content})
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
@@ -206,7 +265,15 @@ def hermes_status():
except Exception:
ssh_ok = False
# Hermes-Agent-Server (:8642) — erreichbar?
try:
httpx.get(HERMES_API_URL.rsplit("/v1", 1)[0] + "/", timeout=2)
hermes = "ready"
except Exception:
hermes = "offline"
return {
"hermes": hermes,
"whisper": whisper,
"piper": piper,
"ssh_configured": ssh_configured,