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
+6 -6
View File
@@ -13,7 +13,6 @@ FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC oh
- **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`.
- **`hw_math.py`** — Odysseus-Fit-Mathe: VRAM/RAM-Bedarf, tps-Schätzung, `max_ctx_for`, `extract_params_b`.
- **`recipes.py`** — kuratierte Use-Case-Stacks fürs Cookbook 2.0.
- **`hermes_agent.py`** — Kern des Hermes-Agenten: Tool-Definitionen, Tool-Dispatcher, LLM-Loop (ReAct), Modell-Routing. **⚠️ DEPRECATED ab v9** — wird durch das Nous Hermes-Agent-Framework ersetzt (siehe ROADMAP v9). Bleibt bis Phase 3 bestehen, dann löschen.
- **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code.
- **`routers/*.py`** — ein Router je Bereich:
- `models.py` — status/download/register/update_model/unload/chat
@@ -24,7 +23,7 @@ FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC oh
- `integration.py` — test (Engine-Verbindungstest)
- `news.py` — RSS-Aggregation via stdlib
- `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien
- `hermes.py` — Chat-WebSocket/transcribe(Whisper)/tts(Piper)/status/pubkey
- `hermes.py` — Chat-WS **proxyt zum Hermes-Agent-Server (:8642)** + transcribe(Whisper)/tts(Piper)/status/pubkey
**Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich.
@@ -85,8 +84,8 @@ Oder über den Self-Update-Button in Mission Control (empfohlen).
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis |
| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks |
| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks |
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH |
| `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username |
| `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes |
@@ -113,7 +112,8 @@ Oder über den Self-Update-Button in Mission Control (empfohlen).
- **Piper TTS**: Binary + Stimm-Modell müssen separat installiert werden (nicht via pip).
- **Whisper**: lädt beim ersten Aufruf ~1.4 GB Modell herunter (einmalig, dann gecacht).
- **Hermes SSH-Key**: muss auf dem Bosgame generiert werden (`~/.ssh/id_ed25519_hermes_agent`).
- **system_status-Keys**: `/api/system/status` liefert verschachtelt (`cpu.percent`, `ram.used` in Bytes) — nicht flach. `hermes_agent.py` muss entsprechend lesen.
- **system_status-Keys**: `/api/system/status` liefert verschachtelt (`cpu.percent`, `ram.used` in Bytes) — nicht flach. Konsumenten müssen entsprechend lesen.
- **Hermes-Server-Key**: `routers/hermes.py` proxyt zu `:8642` und liest den Key bei Bedarf aus `~/.hermes/.env` (`API_SERVER_KEY`) — MC muss als User `hitonabi` laufen, sonst kein Lesezugriff.
- **OpenCode Config**: Datei heißt `opencode.jsonc` (nicht `.json`), Key ist `"providers"` (Plural).
- **ConnectPanel hinter NPM-Proxy**: `location.hostname` zeigt Proxy-Domain → LAN-IP Override im Verbinden-Tab setzen (localStorage).
@@ -124,7 +124,7 @@ Oder über den Self-Update-Button in Mission Control (empfohlen).
**v8.1 (Bug-Fix)**: system_status-Keys, Model-Routing, asyncio, sudo-PW für Engine-Update, Chat-History in sessionStorage.
**v8.2 (UX)**: Guide-Tabs + Begriffe oben, Mobile Bottom-Nav (≤520px), Download-Links, ConnectPanel-Fixes.
**v8.3 (Memory Import)**: Import aus Cloud-KIs (Claude/Gemini/ChatGPT) — zeilenweiser Batch-POST.
**v9 (Hermes-Agent-Adoption, IN ARBEIT, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. 5 Phasen, siehe ROADMAP v9. Aktuell: Phase 0 (Proof auf Bosgame).
**v9 (Hermes-Agent-Adoption, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. Phasen 02 live + Phase 3 (harter Cutover): `hermes_agent.py` entfernt, `routers/hermes.py` proxyt zu `:8642`, Modell-Routing macht Hermes selbst. Offen: Self-Update auch für `hermes-gateway`, Status-/Scheduler-/Skills-Panels (Phase 4). Siehe ROADMAP v9.
Offene v8-Einrichtungsschritte (kein Code, nur Setup):
- Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ Setup)
+2 -2
View File
@@ -78,8 +78,8 @@ Alle Einstellungen über Umgebungsvariablen in der systemd-Unit:
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank |
| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks |
| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks |
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff |
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme |
+2 -2
View File
@@ -20,8 +20,8 @@
**Phasen:**
- [x] **Phase 0 — Proof auf echter HW (✅ 2026-06-23):** Hermes Agent **v0.17.0** installiert (`~/.hermes/`, Code in `~/.hermes/hermes-agent`). `coder` (Qwen3-Coder-30B-A3B) in llama-swap um `--jinja` ergänzt (Backup: `/etc/llama-swap/config.yaml.bak-pre-jinja`). `hermes config`: provider=custom, base_url=`http://127.0.0.1:8080/v1`, ctx 65536. Proof via `hermes -z … --yolo`: echter Tool-Call (`free -h` → „21 GiB frei", exakt deckungsgleich mit Live-Wert). Befunde: ripgrep fehlt (grep-Fallback, später `apt install ripgrep`); Hermes hat **eigenes lokales STT** (faster-whisper); terminal.backend=local; Version nicht sauber pinbar → v0.17.0 in `~/.hermes/PINNED_VERSION`, **kein** `hermes update`.
- [x] **Phase 1 — Memory-Bridge (✅ 2026-06-23):** `mcp_memory.py` via `hermes mcp add mission-control-memory --command /opt/mission-control/.venv/bin/python --env MC_URL=http://127.0.0.1:9000 --args …/mcp_memory.py` registriert (5/5 Tools, Test ✓). Erkenntnis: `mcp_memory.py` ist stdio-MCP→HTTP-Wrapper auf MC `/api/memory`**MC :9000 muss laufen**. Proof: Hermes liest beide Bestands-Memories *und* schrieb neuen Eintrag (verifiziert in SQLite, 2→3). v7/v8.3-Gedächtnis ohne Migration im Agenten. Hinweis: Hermes' **eigenes** Session-Memory läuft zusätzlich → zwei Schichten im Blick behalten.
- [ ] **Phase 2 — Control-Plane:** systemd-Dienst `hermes-agent.service` + MC-Panels (Agent-Status, Chat→:8642, Voice-Wrap).
- [ ] **Phase 3 — Aufräumen:** `hermes_agent.py` + tote Tools löschen, `routers/hermes.py` zu Proxy verschlanken, Self-Update auf beide Dienste erweitern.
- [x] **Phase 2 — Control-Plane (✅ 2026-06-23):** Hermes als **systemd-User-Service** `hermes-gateway` (`hermes gateway install`, Linger aktiv → boot-/logout-fest, kein sudo). API-Server `:8642` aktiviert (`~/.hermes/.env`: `API_SERVER_ENABLED/KEY/PORT/HOST`). Produktionspfad bewiesen: Tool-Call via `curl :8642/v1/chat/completions`. mcp_memory.py läuft als Kindprozess im Dienst. **UI-Hälfte:** `routers/hermes.py`-Chat-WS proxyt jetzt streamend zu `:8642` (Key aus `~/.hermes/.env`); WS-Contract (`thinking/token/done/error`) beibehalten → **HermesPanel unverändert, kein Frontend-Build nötig**. Status-Endpoint meldet zusätzlich Hermes-Erreichbarkeit.
- [x] **Phase 3 — Aufräumen (✅ 2026-06-23, harter Cutover):** `hermes_agent.py` gelöscht (ReAct-Loop, tote Tools, Modell-Routing weg); `config.py` um `HERMES_SIMPLE/COMPLEX_MODEL` bereinigt, `HERMES_API_URL/KEY` ergänzt; `routers/hermes.py` zu reinem Proxy verschlankt. **Rest-offen:** Self-Update auch für `hermes-gateway` (aktuell nur `mission-control`); HermesPanel-Altlast (Windows-SSH-Setup-Sektion) ist nur noch kosmetisch.
- [ ] **Phase 4 — Auszahlung:** Cron-Scheduler + Skills im Dashboard sichtbar machen (das geschenkte v9).
**Entscheidungen (gesetzt):** Memory via MCP einbinden (nicht migrieren) · HermesPanel behalten (nicht hermes-webui, das ist an Interna gekoppelt) · erst Qwen3-coder behalten, dann Hermes-4.3-36B A/B-testen · WebUI = Lockstep-Falle, stattdessen stabiler OpenAI-API-Server.
+25 -3
View File
@@ -44,9 +44,31 @@ UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
# Hermes Agent
HERMES_SIMPLE_MODEL = os.environ.get("HERMES_SIMPLE_MODEL", "scout")
HERMES_COMPLEX_MODEL = os.environ.get("HERMES_COMPLEX_MODEL", "coder")
# Hermes Agent (Nous-Framework, eigener Dienst auf :8642 — siehe ROADMAP v9).
# Mission Control proxyt nur noch dorthin; Modell-Routing macht Hermes selbst.
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642/v1").rstrip("/")
def _read_hermes_api_key() -> str:
"""API-Key des Hermes-Agent-Servers: bevorzugt Env HERMES_API_KEY,
sonst aus ~/.hermes/.env (Schluessel API_SERVER_KEY) — so muss das
Secret nicht dupliziert werden."""
k = os.environ.get("HERMES_API_KEY", "")
if k:
return k
envf = Path(os.path.expanduser("~/.hermes/.env"))
if envf.exists():
try:
for line in envf.read_text(errors="replace").splitlines():
line = line.strip()
if line.startswith("API_SERVER_KEY="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return ""
HERMES_API_KEY = _read_hermes_api_key()
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
-463
View File
@@ -1,463 +0,0 @@
"""
Hermes Agent — lokaler KI-Assistent fuer das Agentic OS.
Empfaengt Aufgaben per Text (und Voice), nutzt llama-swap als LLM-Backend,
kann per Tools auf das Bosgame und spaeter per SSH auf den Windows-PC zugreifen.
"""
import asyncio
import json
import subprocess
from pathlib import Path
from typing import Callable, Awaitable
import httpx
from config import LLAMA_SWAP_URL, HERMES_SIMPLE_MODEL, HERMES_COMPLEX_MODEL
# ---------------------------------------------------------------------------
# Tool-Definitionen (OpenAI Function Calling Format)
# ---------------------------------------------------------------------------
TOOL_DEFS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Liest eine Textdatei auf dem Bosgame.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absoluter Pfad zur Datei"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "list_directory",
"description": "Listet Dateien und Ordner eines Verzeichnisses auf dem Bosgame.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absoluter Pfad zum Verzeichnis"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": (
"Fuehrt einen Lese-Befehl auf dem Bosgame aus (kein sudo, kein rm). "
"Geeignet fuer: ls, cat, ps, df, free, journalctl, systemctl status, ip, curl, etc."
),
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell-Befehl"}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "get_system_status",
"description": "Gibt aktuellen System-Status zurueck: CPU, RAM, GPU, laufende Modelle.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "get_memories",
"description": "Laedt alle gespeicherten Fakten und Entscheidungen.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Speichert eine neue Information dauerhaft ins Gedaechtnis.",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Der zu speichernde Fakt"},
"category": {
"type": "string",
"enum": ["user", "instruction", "stable", "versioned", "ephemeral"],
"description": (
"user=Fakten ueber den User (Wer ist er? Wie arbeitet er?), "
"instruction=Verhaltensregeln fuer alle KI-Tools, "
"stable=Projektfakten, "
"versioned=Tech-Versionen (bei Updates ueberschreiben), "
"ephemeral=temporaer (7 Tage)"
),
},
},
"required": ["content"],
},
},
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Sucht im Internet nach aktuellen Informationen.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff"}
},
"required": ["query"],
},
},
},
]
# ---------------------------------------------------------------------------
# Tool-Implementierungen
# ---------------------------------------------------------------------------
_BLOCKED_CMDS = {
"rm", "rmdir", "mv", "cp", "dd", "mkfs", "fdisk",
"parted", "shutdown", "reboot", "halt", "poweroff",
"chmod", "chown", "passwd", "userdel", "useradd",
}
def _exec_read_file(path: str) -> str:
try:
p = Path(path)
if not p.exists():
return f"Datei nicht gefunden: {path}"
content = p.read_text(errors="replace")
if len(content) > 8000:
return content[:8000] + f"\n\n... (gekuerzt, gesamt {len(content):,} Zeichen)"
return content
except Exception as e:
return f"Fehler beim Lesen: {e}"
def _exec_list_directory(path: str) -> str:
try:
p = Path(path)
if not p.exists():
return f"Pfad nicht gefunden: {path}"
items = sorted(p.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
lines = []
for item in items[:80]:
if item.is_dir():
lines.append(f"📁 {item.name}/")
else:
size = item.stat().st_size
sz = f"{size/1024/1024:.1f} MB" if size > 1024*1024 else f"{size/1024:.1f} KB" if size > 1024 else f"{size} B"
lines.append(f"📄 {item.name} ({sz})")
total = sum(1 for _ in p.iterdir())
if total > 80:
lines.append(f"... und {total - 80} weitere")
return "\n".join(lines) if lines else "(leer)"
except Exception as e:
return f"Fehler: {e}"
def _exec_run_command(command: str) -> str:
first = command.strip().split()[0] if command.strip() else ""
if first in _BLOCKED_CMDS or "sudo" in command:
return f"Befehl '{first}' blockiert. Nutze Mission Control fuer Systemoperationen."
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=30
)
out = (result.stdout + result.stderr).strip()
if len(out) > 4000:
out = out[:4000] + "\n... (Ausgabe gekuerzt)"
return out or "(kein Output)"
except subprocess.TimeoutExpired:
return "Timeout nach 30 Sekunden."
except Exception as e:
return f"Fehler: {e}"
def _exec_get_system_status() -> str:
base = "http://127.0.0.1:9000"
try:
s = httpx.get(f"{base}/api/status", timeout=5).json()
models = s.get("models", [])
running = [m for m in models if m.get("state") in ("running", "ready", "loading")]
model_str = ", ".join(m["name"] for m in running) if running else "keines"
except Exception:
model_str = "unbekannt"
try:
d = httpx.get(f"{base}/api/system/status", timeout=5).json()
cpu = d.get("cpu", {})
ram = d.get("ram", {})
gtt = d.get("gpu", {}).get("gtt", {})
cpu_pct = round(cpu.get("percent", 0), 1)
cpu_temp = cpu.get("temp", "?")
ram_used = round(ram.get("used", 0) / (1024 ** 3), 1)
ram_total = round(ram.get("total", 0) / (1024 ** 3), 1)
gtt_used = round(gtt.get("used", 0) / (1024 ** 3), 1)
gtt_total = round(gtt.get("total", 0) / (1024 ** 3), 1)
return (
f"Aktives Modell: {model_str}\n"
f"CPU: {cpu_pct}% | "
f"RAM: {ram_used} / {ram_total} GB | "
f"GPU: {gtt_used} / {gtt_total} GB GTT\n"
f"Temp: CPU {cpu_temp}°C"
)
except Exception as e:
return f"Aktives Modell: {model_str}\nSystem-Metriken nicht verfuegbar: {e}"
def _exec_get_memories() -> str:
try:
items = httpx.get("http://127.0.0.1:9000/api/memory", timeout=5).json()
if not items:
return "Kein Gedaechtnis vorhanden."
return "\n".join(f"[{m['category']}] {m['content']}" for m in items)
except Exception as e:
return f"Fehler: {e}"
def _exec_add_memory(content: str, category: str = "stable") -> str:
try:
r = httpx.post(
"http://127.0.0.1:9000/api/memory",
json={"content": content, "category": category, "source": "hermes"},
timeout=5,
)
r.raise_for_status()
return f"Gespeichert: {content}"
except Exception as e:
return f"Fehler: {e}"
def _exec_web_search(query: str) -> str:
try:
r = httpx.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_html": 1, "skip_disambig": 1},
timeout=10,
headers={"User-Agent": "HermesAgent/1.0"},
follow_redirects=True,
)
data = r.json()
results = []
if data.get("AbstractText"):
results.append(data["AbstractText"])
for rt in data.get("RelatedTopics", [])[:4]:
if isinstance(rt, dict) and rt.get("Text"):
results.append(rt["Text"])
return "\n\n".join(results) if results else "Keine direkten Ergebnisse. Versuche eine genauere Suchanfrage."
except Exception as e:
return f"Websuche fehlgeschlagen: {e}"
def execute_tool(name: str, args: dict) -> str:
if name == "read_file":
return _exec_read_file(args.get("path", ""))
if name == "list_directory":
return _exec_list_directory(args.get("path", ""))
if name == "run_command":
return _exec_run_command(args.get("command", ""))
if name == "get_system_status":
return _exec_get_system_status()
if name == "get_memories":
return _exec_get_memories()
if name == "add_memory":
return _exec_add_memory(args.get("content", ""), args.get("category", "stable"))
if name == "web_search":
return _exec_web_search(args.get("query", ""))
return f"Unbekanntes Tool: {name}"
# ---------------------------------------------------------------------------
# Modell-Auswahl
# ---------------------------------------------------------------------------
_COMPLEX_KW = {
# Erstellen / Schreiben
"schreib", "schreibe", "erstell", "erstelle", "generier", "generiere",
"baue", "bau",
# Planen / Analysieren / Recherchieren
"plan", "analysier", "analysiere", "recherchier", "recherchiere",
"vergleich", "vergleiche", "bewert", "bewerte", "untersuche", "untersuchen",
# Entwicklung / Code
"refactor", "debug", "implementier", "implementiere", "entwickl", "entwickle",
"code", "programm", "programmier", "programmiere",
# Web-Suche / Recherche
"such im internet", "suche im internet", "such online", "suche online",
"such nach aktuell", "suche nach aktuell",
# Erklären (ausführlich)
"erklaer ausfuehrlich", "erklaere ausfuehrlich", "erklaer detailliert",
"erklaere detailliert", "erklaer mir genau", "was ist der unterschied",
# Optimieren / Verbessern
"optimier", "optimiere", "verbessere", "verbessern", "reparier", "repariere",
# Multi-Step / Komplex
"konfiguriere", "konfigurieren", "installier", "installiere",
"wie kann ich", "schritt fuer schritt", "schritt für schritt",
}
def choose_model(message: str) -> str:
"""Waehlt simple/complex Modell — upgraded wenn noetig, degradiert nie."""
lower = message.lower()
wants_complex = any(kw in lower for kw in _COMPLEX_KW)
# Nicht downgraden wenn komplexes Modell bereits geladen ist
try:
s = httpx.get("http://127.0.0.1:9000/api/status", timeout=3).json()
loaded = {m["name"] for m in s.get("models", []) if m.get("state") in ("running", "ready")}
if HERMES_COMPLEX_MODEL in loaded:
return HERMES_COMPLEX_MODEL
except Exception:
pass
return HERMES_COMPLEX_MODEL if wants_complex else HERMES_SIMPLE_MODEL
# ---------------------------------------------------------------------------
# System-Prompt
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """\
Du bist Hermes, ein lokaler KI-Assistent der dauerhaft auf dem Bosgame M5 laeuft.
Dein Zuhause (Bosgame M5):
- AMD Strix Halo, ~124 GB GTT-Speicher, Ubuntu 26.04, Kernel 7.0
- LAN-IP: 192.168.178.151
- Dienste: llama-swap :8080 (Inference), Mission Control :9000 (Dashboard)
- Modelle: coder (Qwen3-30B-A3B), scout (Qwen3-8B), vision (Qwen3-VL)
- Du laeuft als Teil von Mission Control
Gespeichertes Wissen (nach Kategorie):
{memories}
Verhaltensregeln (fest):
- Antworte praegnant auf Deutsch (max 2-3 Saetze wenn nicht ausdruecklich mehr gewuenscht)
- Kuendige an was du tust bevor du es tust
- Frag nach bevor du Dateien ueberschreibst oder Dienste neustartest
- Nutze get_system_status() wenn du nicht sicher bist was gerade laeuft
- Bei einfachen Fragen: keine Tools, direkt antworten
Gedaechtnis-Kategorien (fuer add_memory):
- user → Fakten ueber den User: Wer er ist, wie er arbeitet, was er bevorzugt
- instruction → Verhaltensregeln die fuer ALLE KI-Tools gelten sollen
- stable → Technische Projektfakten (IPs, Ports, Architektur)
- versioned → Tech-Versionen (bei Updates ueberschreiben, nicht anhaengen)
- ephemeral → Temporaerer Kontext (7 Tage, dann weg)
Speichere wichtige Erkenntnisse aktiv per add_memory() — besonders wenn der User
etwas ueber sich selbst erwaehnt (→ user) oder Verhaltensregeln nennt (→ instruction).
"""
# ---------------------------------------------------------------------------
# LLM-Aufruf (synchron, in Executor ausfuehren)
# ---------------------------------------------------------------------------
def _call_llm(model: str, messages: list) -> dict:
r = httpx.post(
f"{LLAMA_SWAP_URL}/v1/chat/completions",
json={
"model": model,
"messages": messages,
"tools": TOOL_DEFS,
"tool_choice": "auto",
"temperature": 0.3,
},
timeout=120,
)
r.raise_for_status()
return r.json()
# ---------------------------------------------------------------------------
# Haupt-Agent-Loop
# ---------------------------------------------------------------------------
SendFn = Callable[[dict], Awaitable[None]]
async def run_agent(message: str, send: SendFn) -> None:
"""
Fuehrt den Agenten-Loop aus und streamt Ergebnisse via send()-Callback.
Nachrichten-Typen:
{"type": "info", "content": str} — Status-Meldung
{"type": "thinking"} — LLM denkt nach
{"type": "token", "content": str} — Wort der Antwort
{"type": "tool_call", "name": str, "args": dict}
{"type": "tool_result", "name": str, "content": str}
{"type": "error", "content": str}
{"type": "done"}
"""
memories = _exec_get_memories()
model = choose_model(message)
await send({"type": "info", "content": f"Modell: {model}"})
messages = [
{"role": "system", "content": SYSTEM_PROMPT.format(memories=memories)},
{"role": "user", "content": message},
]
for _round in range(8):
await send({"type": "thinking"})
try:
result = await asyncio.get_running_loop().run_in_executor(
None, lambda: _call_llm(model, messages)
)
except Exception as exc:
await send({"type": "error", "content": f"LLM nicht erreichbar: {exc}"})
return
choice = result["choices"][0]
msg = choice["message"]
finish = choice.get("finish_reason", "")
tool_calls = msg.get("tool_calls") or []
# Text-Antwort: Wort fuer Wort streamen
if msg.get("content"):
words = msg["content"].split(" ")
for i, word in enumerate(words):
token = word + (" " if i < len(words) - 1 else "")
await send({"type": "token", "content": token})
await asyncio.sleep(0.012)
if not tool_calls or finish == "stop":
break
# Tool-Calls ausfuehren
messages.append(msg)
for tc in tool_calls:
fn = tc["function"]
name = fn["name"]
try:
args = json.loads(fn.get("arguments", "{}"))
except Exception:
args = {}
await send({"type": "tool_call", "name": name, "args": args})
tool_out = await asyncio.get_running_loop().run_in_executor(
None, lambda n=name, a=args: execute_tool(n, a)
)
preview = tool_out[:300] + "..." if len(tool_out) > 300 else tool_out
await send({"type": "tool_result", "name": name, "content": preview})
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": tool_out,
})
await send({"type": "done"})
+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,