Feat: Hermes-Entfesselung — volle Stack-Control-Plane + PC-Executor-Autostart-Fix

mcp_mc.py: 7 -> 23 Tools (hf_search/quants, install/delete/load/unload_model,
set_model_role, list_jobs, cancel_job, list_services, backup_now, list_backups,
token_stats, check_updates, apply_update, service_logs). Hermes kann den Stack jetzt
vollständig steuern (alles was die MC2-UI kann).

executor.py: _ensure_streams() — unter pythonw (kein Konsolenfenster, für
Scheduled-Task-Autostart) sind sys.stdout/stderr=None und uvicorn-Logging crasht
beim Start. Jetzt Umleitung auf %LOCALAPPDATA%\HermesPCExecutor\executor.log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 12:28:14 +02:00
parent b1fa8bea43
commit 181db38b49
2 changed files with 212 additions and 24 deletions
+17
View File
@@ -126,7 +126,24 @@ def _get_local_ip() -> str:
return socket.gethostbyname(socket.gethostname()) return socket.gethostbyname(socket.gethostname())
def _ensure_streams() -> None:
"""Unter pythonw (kein Konsolenfenster) sind sys.stdout/stderr = None →
uvicorns Logging crasht beim Start. Dann auf eine Logdatei umbiegen."""
import sys
if sys.stdout is not None and sys.stderr is not None:
return
logdir = os.path.join(
os.environ.get("LOCALAPPDATA", os.path.dirname(os.path.abspath(__file__))),
"HermesPCExecutor",
)
os.makedirs(logdir, exist_ok=True)
f = open(os.path.join(logdir, "executor.log"), "a", buffering=1, encoding="utf-8")
sys.stdout = f
sys.stderr = f
if __name__ == "__main__": if __name__ == "__main__":
_ensure_streams()
my_ip = _get_local_ip() my_ip = _get_local_ip()
print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT}") print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT}")
uvicorn.run(app, host="0.0.0.0", port=MY_PORT) uvicorn.run(app, host="0.0.0.0", port=MY_PORT)
+194 -23
View File
@@ -3,12 +3,16 @@
Mission Control 2.0 — Stack-Management MCP Server (für Hermes). Mission Control 2.0 — Stack-Management MCP Server (für Hermes).
Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern: Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern:
Modelle ansehen/entdecken/eintragen, Routing setzen, Dienste neu starten, Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle
laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden,
System-Status lesen. Spricht die MC-2-REST-API (/api/*). System-Status lesen. Spricht die MC-2-REST-API (/api/*).
Damit ist Hermes echte Control-Plane des Stacks (Plan: „MC-Mgmt-MCP, damit Hermes Damit ist Hermes echte Control-Plane des Stacks (Plan: „MC-Mgmt-MCP, damit Hermes
den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes. den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes.
Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ),
damit kleine Modelle nicht in Aufruf-Schleifen laufen.
Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional). Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional).
Install: pip install mcp httpx Install: pip install mcp httpx
""" """
@@ -30,13 +34,13 @@ def _h() -> dict:
def _get(path: str, **params): def _get(path: str, **params):
r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=20) r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _post(path: str, data: dict): def _post(path: str, data: dict | None = None):
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data, timeout=60) r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -47,21 +51,34 @@ def _put(path: str, data: dict):
return r.json() return r.json()
def _delete(path: str):
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30)
r.raise_for_status()
return r.json()
# ── Modelle: ansehen / entdecken ────────────────────────────────────────────
@mcp.tool() @mcp.tool()
def list_models() -> str: def list_models() -> str:
"""Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten).""" """Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten)
und welche gerade geladen sind."""
data = _get("/api/models") data = _get("/api/models")
return "\n".join( running = set(data.get("running") or [])
f"- {m['name']} (Rolle: {m.get('role') or ''}, ctx: {m.get('ctx')}, " lines = []
for m in data.get("models", []):
warm = " [LÄUFT]" if m["name"] in running else ""
lines.append(
f"- {m['name']}{warm} (Rolle: {m.get('role') or ''}, ctx: {m.get('ctx')}, "
f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})" f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})"
for m in data.get("models", []) )
) or "Keine Modelle konfiguriert." return "\n".join(lines) or "Keine Modelle konfiguriert."
@mcp.tool() @mcp.tool()
def discover_models() -> str: def discover_models() -> str:
"""Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit). """Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit).
Nutze dies, bevor du ein neues Modell vorschlägst/einträgst.""" Nutze dies, bevor du ein neues Modell vorschlägst/installierst."""
data = _get("/api/discover") data = _get("/api/discover")
out = [f"System-RAM: {data.get('sys_ram_gb')} GB"] out = [f"System-RAM: {data.get('sys_ram_gb')} GB"]
for c in data.get("categories", []): for c in data.get("categories", []):
@@ -73,21 +90,94 @@ def discover_models() -> str:
@mcp.tool() @mcp.tool()
def system_status() -> str: def hf_search(q: str) -> str:
"""Live-Auslastung der Box (CPU/RAM/GPU/Disk).""" """Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes
s = _get("/api/system/status") Modell finden willst, das nicht in discover_models auftaucht."""
g = s.get("gpu") or {} res = _get("/api/hf/search", q=q).get("results", [])
return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · " if not res:
f"GPU {g.get('busy_percent', '')}% · Disk {(s.get('disk') or {}).get('percent', '')}%") return f"Keine HF-Treffer für '{q}'."
return json.dumps(res[:15], ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str: def hf_quants(repo: str) -> str:
"""Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias). """Listet die verfügbaren Quantisierungen (Q4_K_M, Q8_0, …) eines HF-Repos.
role z.B. fast/heavy/coder/vision/agent. jinja=True für Tool-Calling (Agent-Hirn).""" Nutze dies VOR install_model, um die passende Quant-Stufe zu wählen."""
res = _post("/api/models/register", d = _get("/api/hf/quants", repo=repo)
{"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja}) return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden."
return f"Eingetragen als '{res.get('model_id')}'."
# ── Modelle: installieren / löschen / laden ─────────────────────────────────
@mcp.tool()
def install_model(repo: str, role: str = "", quant: str = "Q4_K_M",
ctx: int = 0, jinja: bool = False) -> str:
"""Lädt ein GGUF-Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein. Akzeptiert volle HF-URL ODER org/repo. ctx=0 → setup-bewusst automatisch.
jinja=True für Tool-Calling-Modelle (Agent-Hirne). role z.B. fast/heavy/coder/vision.
Gibt eine job_id zurück — Fortschritt via list_jobs prüfen."""
body = {"repo": repo, "quant": quant, "jinja": jinja}
if role:
body["role"] = role
if ctx > 0:
body["ctx"] = ctx
r = _post("/api/models/install", body)
return (f"Installation gestartet: model_id={r.get('model_id')}, job_id={r.get('job_id')}, "
f"{r.get('files')} Datei(en), ~{round((r.get('total_bytes') or 0)/1e9, 1)} GB. "
f"Fortschritt mit list_jobs prüfen.")
@mcp.tool()
def list_jobs() -> str:
"""Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt."""
jobs = _get("/api/jobs").get("jobs", [])
if not jobs:
return "Keine Jobs."
out = []
for j in jobs:
prog = j.get("progress")
p = f" {round(prog*100)}%" if isinstance(prog, (int, float)) else ""
out.append(f"- [{j.get('id', '?')[:8]}] {j.get('label', '?')}{j.get('status', '?')}{p}")
return "\n".join(out)
@mcp.tool()
def cancel_job(job_id: str) -> str:
"""Bricht einen laufenden Job (z.B. Download) ab."""
ok = _post(f"/api/jobs/{job_id}/cancel").get("ok")
return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar."
@mcp.tool()
def delete_model(model_id: str) -> str:
"""Entfernt ein Modell aus llama-swap (Eintrag + Dateien). NUR wenn der Nutzer es
will oder es eindeutig veraltet/ersetzt ist — vorher list_models prüfen."""
_delete(f"/api/models/{model_id}")
return f"Modell '{model_id}' gelöscht."
@mcp.tool()
def load_model(model_id: str) -> str:
"""Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten
echten Request zu vermeiden."""
return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen."
@mcp.tool()
def unload_model(model_id: str = "") -> str:
"""Entlädt ein Modell aus dem Speicher (gibt RAM/GTT frei). Leer = ALLE entladen."""
path = f"/api/models/{model_id}/unload" if model_id else "/api/models/unload"
return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen."
# ── Rollen & Routing ────────────────────────────────────────────────────────
@mcp.tool()
def set_model_role(model_id: str, role: str = "") -> str:
"""Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…).
Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist."""
_post(f"/api/models/{model_id}/role", {"role": role or None})
return f"Rolle für '{model_id}': {role or '— (entfernt)'}"
@mcp.tool() @mcp.tool()
@@ -103,12 +193,93 @@ def routing_overview() -> str:
return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2) return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2)
@mcp.tool()
def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str:
"""Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias).
role z.B. fast/heavy/coder/vision/hermes. jinja=True für Tool-Calling (Agent-Hirn).
Für Download+Eintrag in einem Schritt nutze install_model."""
res = _post("/api/models/register",
{"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja})
return f"Eingetragen als '{res.get('model_id')}'."
# ── System / Status / Wartung ───────────────────────────────────────────────
@mcp.tool()
def system_status() -> str:
"""Live-Auslastung der Box (CPU/RAM/GPU/Disk)."""
s = _get("/api/system/status")
g = s.get("gpu") or {}
return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · "
f"GPU {g.get('busy_percent', '')}% · Disk {(s.get('disk') or {}).get('percent', '')}%")
@mcp.tool()
def list_services() -> str:
"""Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice)."""
data = _get("/api/system/services")
return "\n".join(
f"{'' if s['ok'] else ''} {s['name']} ({s['url']})"
for s in data.get("services", [])
) or "Keine Dienste."
@mcp.tool() @mcp.tool()
def restart_service(service: str) -> str: def restart_service(service: str) -> str:
"""Startet einen erlaubten Dienst neu (mission-control-2 | litellm-gateway | hermes-webui).""" """Startet einen erlaubten Dienst neu
(mission-control-2 | hermes-gateway | hermes-webui | mem0-service | voice-service)."""
res = _post("/api/system/restart", {"service": service}) res = _post("/api/system/restart", {"service": service})
return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}" return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}"
@mcp.tool()
def backup_now() -> str:
"""Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten
Änderungen oder wenn der Nutzer ein Backup will."""
r = _post("/api/system/backup")
return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}"
@mcp.tool()
def list_backups() -> str:
"""Listet vorhandene Backups (Datei, Zeit, Größe)."""
bks = _get("/api/system/backups").get("backups", [])
if not bks:
return "Keine Backups."
return json.dumps(bks, ensure_ascii=False, indent=2)
@mcp.tool()
def token_stats() -> str:
"""Token-Verbrauch + Cloud-Ersparnis (was die lokale Nutzung gegenüber Cloud-APIs spart)."""
return json.dumps(_get("/api/system/token-stats"), ensure_ascii=False, indent=2)
@mcp.tool()
def check_updates() -> str:
"""Prüft, ob Updates für OS / Engine (llama.cpp) / Router (llama-swap) / Hermes anstehen."""
return json.dumps(_get("/api/maintenance/updates"), ensure_ascii=False, indent=2)
@mcp.tool()
def apply_update(kind: str) -> str:
"""Wendet ein Update an. kind: os | engine | swap | hermes. NUR wenn der Nutzer es will
oder check_updates ein anstehendes Update zeigt. Kann den jeweiligen Dienst kurz neu starten."""
kind = kind.lower().strip()
routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update",
"swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"}
if kind not in routes:
return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes."
return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2)
@mcp.tool()
def service_logs(service: str, lines: int = 100) -> str:
"""Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway,
mission-control-2, voice-service."""
return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines),
ensure_ascii=False, indent=2)
if __name__ == "__main__": if __name__ == "__main__":
mcp.run() mcp.run()