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:
@@ -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 0–2 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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user