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`. - **`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`. - **`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. - **`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. - **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code.
- **`routers/*.py`** — ein Router je Bereich: - **`routers/*.py`** — ein Router je Bereich:
- `models.py` — status/download/register/update_model/unload/chat - `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) - `integration.py` — test (Engine-Verbindungstest)
- `news.py` — RSS-Aggregation via stdlib - `news.py` — RSS-Aggregation via stdlib
- `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien - `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/`): **Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich. - **`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_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle | | `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis | | `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis |
| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks | | `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks | | `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_HOST` | leer | Windows-PC IP für SSH |
| `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username | | `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username |
| `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes | | `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). - **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). - **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`). - **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). - **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). - **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.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.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. **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): Offene v8-Einrichtungsschritte (kein Code, nur Setup):
- Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ 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_UPDATE_CMD` | leer | Engine-Update-Befehl |
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle | | `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank | | `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank |
| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks | | `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks | | `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff | | `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff |
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary | | `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme | | `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme |
+2 -2
View File
@@ -20,8 +20,8 @@
**Phasen:** **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 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. - [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). - [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.
- [ ] **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 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). - [ ] **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. **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")) DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!) TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
# Hermes Agent # Hermes Agent (Nous-Framework, eigener Dienst auf :8642 — siehe ROADMAP v9).
HERMES_SIMPLE_MODEL = os.environ.get("HERMES_SIMPLE_MODEL", "scout") # Mission Control proxyt nur noch dorthin; Modell-Routing macht Hermes selbst.
HERMES_COMPLEX_MODEL = os.environ.get("HERMES_COMPLEX_MODEL", "coder") 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_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC") 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"))) 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"})
+76 -9
View File
@@ -18,6 +18,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import httpx
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import Response, JSONResponse from fastapi.responses import Response, JSONResponse
@@ -25,8 +26,8 @@ from auth import auth
from config import ( from config import (
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE, PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY, 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") router = APIRouter(prefix="/api")
@@ -100,7 +101,12 @@ def _tts(text: str) -> Optional[bytes]:
@router.websocket("/hermes/chat") @router.websocket("/hermes/chat")
async def hermes_chat(websocket: WebSocket): 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) # Manuelle Token-Auth (WS kann keine HTTP-Header senden)
from auth import TOKEN from auth import TOKEN
token_param = websocket.query_params.get("token", "") token_param = websocket.query_params.get("token", "")
@@ -121,20 +127,73 @@ async def hermes_chat(websocket: WebSocket):
if not message: if not message:
continue continue
async def send(obj: dict) -> None: await websocket.send_json({"type": "thinking"})
await websocket.send_json(obj) try:
await _proxy_chat(message, websocket)
await run_agent(message, send) except Exception as exc:
await websocket.send_json(
{"type": "error", "content": f"Hermes nicht erreichbar: {exc}"}
)
await websocket.send_json({"type": "done"})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except Exception as exc:
try:
await websocket.send_json({"type": "error", "content": str(exc)})
except Exception: except Exception:
pass 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)]) @router.post("/hermes/transcribe", dependencies=[Depends(auth)])
async def transcribe_audio(audio: UploadFile = File(...)): async def transcribe_audio(audio: UploadFile = File(...)):
"""Audio-Datei (webm/ogg/wav) per Whisper transkribieren.""" """Audio-Datei (webm/ogg/wav) per Whisper transkribieren."""
@@ -206,7 +265,15 @@ def hermes_status():
except Exception: except Exception:
ssh_ok = False 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 { return {
"hermes": hermes,
"whisper": whisper, "whisper": whisper,
"piper": piper, "piper": piper,
"ssh_configured": ssh_configured, "ssh_configured": ssh_configured,