Files
mission-control-v2/mcp/orchestrator_werkzeuge.py
T
HitonabiandClaude Opus 5.5 deaf35e953 lucy: Werkzeuge für den Orchestrator (MCP homelab-orchestrator)
User 25.09.: „Lucy ist hier der komplette Systemadmin + Helfer“ – „ja, bau das
mit Lucy und dem Orchestrator“. Lucy bekommt die Knöpfe der Oberfläche als
MCP-Werkzeuge: lesen (lage, updates, speicher, protokoll, radar) und handeln
(update_starten, alle_aktualisieren, nach_updates_suchen, dienst_neustarten,
kernel_aufraeumen, host_neustarten, radar_testen, radar_suchen) – über dieselben
MC2-Routen mit Snapshot, Prüfung, Rückweg und Sperren. Jede Aktion braucht einen
Grund (die Bitte des Commanders) und wird protokolliert; die Beschreibungen
verbieten Aktionen auf eigene Initiative (Reparatur-Neustart ausgenommen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:27:35 +02:00

336 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Die Knöpfe des Homelab Orchestrators für Lucy (seit 25.09.2026, User-Wunsch: „Lucy ist die Systemadministratorin
und Assistentin — bau das mit Lucy und dem Orchestrator“).
Reine Funktionen über die REST-API von MC2 auf der KI-Box (:9001); den Homelab-Teil erreicht sie über die
Durchreiche /api/partner/homelab/…. Sie tun genau, was die Knöpfe der Oberfläche tun, mit deren Leitplanken: Snapshot
oder Sicherung vorher, Prüfung danach, bei Rot zurück, Sperren gegen gleichzeitige Läufe, die Neustart-Allowlist.
Nichts hier geht an Proxmox oder die Gäste vorbei. mcp_orchestrator.py macht MCP-Werkzeuge daraus.
Antworten sind kurze deutsche Texte statt JSON — Lucy soll sie lesen und dem Commander berichten, nicht parsen.
Jede Aktion steht mit dem Grund (der Bitte des Commanders) in ~/.hermes/logs/orchestrator-werkzeuge.log.
"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import httpx
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
ORTSZEIT = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) # wie kern/zeit.py (AGENTS.md: Zeit)
HL = "/api/partner/homelab"
LOG = Path(os.environ.get("MC_ORCH_LOG", str(Path.home() / ".hermes" / "logs" / "orchestrator-werkzeuge.log")))
KI_BOX_ALLE = "/api/maintenance/update-all"
# Die Beschreibungen der MCP-Werkzeuge (mcp_orchestrator.py) — als Leitplanken formuliert. Hier, damit ein Test prüft,
# dass jede Aktion die Leitplanke trägt.
_HANDELN = (" Nur aufrufen, wenn der Commander es in diesem Gespräch ausdrücklich verlangt oder auf deine Rückfrage "
"bestätigt hat — nie auf eigene Initiative, auch nicht in geplanten Läufen. grund: seine Bitte in seinen "
"Worten (wird protokolliert).")
BESCHREIBUNG = {
"lage": ("Kurzbericht über KI-Box und Homelab: Hinweise beider Wächter, erreichbare Geräte, Zahl offener Updates, "
"Speicherpool, Systemplatte, NAS, Sicherungen und was gerade läuft. Nutze das, wenn der Commander wissen "
"will, wie es steht, oder bevor du etwas startest."),
"updates": ("Offene Updates je Gerät (KI-Box und Homelab) mit den Kennungen ziel und baustein für update_starten — "
"und bei denen ohne Knopf der Grund (etwa die 48-Stunden-Wartezeit nach einer Skriptänderung)."),
"speicher": ("Speicherpool, Systemplatte des Proxmox-Hosts, NAS und nächtliche Sicherungen, dazu Vorschläge, was "
"Platz bringt."),
"protokoll": "Was im Homelab zuletzt geschah — Updates, Meldungen, Hinweise —, neueste zuerst. anzahl: höchstens 60.",
"radar": "Kandidaten des Modell-Radars mit Status und ob „Jetzt testen“ gerade geht (sonst der Grund).",
"update_starten": ("Ein einzelnes Update starten, genau wie der Knopf auf der Update-Seite: vorher Snapshot oder "
"Sicherung, danach Prüfung, bei Rot automatisch zurück. ziel und baustein aus updates() (etwa "
"ziel=\"ct-104\", baustein=\"os\"; KI-Box: ziel=\"ki-box\", baustein=\"os\"/\"engine\"/\"swap\"/"
"\"hermes\" — bei \"hermes\" bist du kurz nicht erreichbar)." + _HANDELN),
"alle_aktualisieren": ("„Alle aktualisieren“ für bereich=\"homelab\" (Gäste nacheinander, Host-Pakete zuletzt, nie "
"Neustart, hält bei Rot an) oder bereich=\"ki-box\" (Motor, llama-swap, Hermes, "
"Betriebssystem; beim Hermes-Schritt bist du kurz weg)." + _HANDELN),
"nach_updates_suchen": "Die KI-Box nach neuen Updates suchen lassen (Paketlisten, Motor, llama-swap, Hermes)." + _HANDELN,
"dienst_neustarten": ("Einen Dienst der KI-Box neu starten (etwa \"llama-swap\", \"hermes-builtin-ui\", "
"\"mission-control-2\"). Erlaubt ist nur, was die Neustart-Liste von MC2 kennt. Einen "
"hängenden Dienst darfst du auch ohne Bitte neu starten (Reparatur) — sag es dem Commander "
"danach. grund: warum (wird protokolliert)."),
"kernel_aufraeumen": ("Alte Kernel vom Proxmox-Host entfernen — nur, was Proxmox selbst für entbehrlich hält; der "
"laufende und der für den nächsten Start bleiben. Die Gäste merken nichts." + _HANDELN),
"host_neustarten": ("Den Proxmox-Host neu starten. ALLE Container und die Arcane-VM sind dann ein paar Minuten weg, "
"auch AdGuard (die Namensauflösung im Heimnetz). Sag das dem Commander vorher und frag nach, auch "
"wenn er es verlangt hat." + _HANDELN),
"radar_testen": ("Einen Kandidaten des Modell-Radars jetzt testen (id aus radar()). Geht nur, wenn er samt Coder in "
"den Speicher passt und nicht zwischen 00:00 und 03:30; Lucy und NerdQuiz sind währenddessen etwas "
"langsamer." + _HANDELN),
"radar_suchen": "Das Modell-Radar jetzt nach neuen Modellen suchen lassen (Merkliste und Hugging Face)." + _HANDELN,
}
class Fehler(Exception):
"""MC2 hat abgelehnt oder war nicht erreichbar — der Text geht so an Lucy."""
def _get(pfad: str, **params: Any) -> Any:
try:
r = httpx.get(f"{MC_URL}{pfad}", params=params or None, timeout=30)
except httpx.HTTPError as exc:
raise Fehler(f"MC2 ist nicht erreichbar ({exc.__class__.__name__}).") from exc
if r.status_code >= 400:
raise Fehler(_grund(r))
return r.json()
def _post(pfad: str, daten: dict | None = None) -> dict:
"""POST wie ein Knopf der Oberfläche. MC2 antwortet mit ok/detail oder HTTP-Fehler samt detail."""
try:
r = httpx.post(f"{MC_URL}{pfad}", json=daten or {}, timeout=60)
except httpx.HTTPError as exc:
raise Fehler(f"MC2 ist nicht erreichbar ({exc.__class__.__name__}).") from exc
if r.status_code >= 400:
raise Fehler(_grund(r))
antwort = r.json() if r.content else {}
if isinstance(antwort, dict) and antwort.get("ok") is False:
raise Fehler(str(antwort.get("detail") or antwort.get("text") or "MC2 hat abgelehnt."))
return antwort if isinstance(antwort, dict) else {}
def _grund(r: httpx.Response) -> str:
try:
detail = r.json().get("detail")
except (ValueError, AttributeError):
detail = None
return str(detail or f"MC2 antwortete mit HTTP {r.status_code}.")
def _homelab_pfad(pfad: str) -> str:
"""Pfade aus den Knöpfen des Homelab-Teils (/api/homelab/…) laufen auf der Box über die Durchreiche."""
return HL + pfad[len("/api/homelab"):] if pfad.startswith("/api/homelab") else pfad
def _protokolliere(werkzeug: str, argumente: dict, grund: str, ergebnis: str) -> None:
try:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps({"zeit": datetime.now().isoformat(timespec="seconds"), "werkzeug": werkzeug,
"argumente": argumente, "grund": grund, "ergebnis": ergebnis},
ensure_ascii=False) + "\n")
except OSError:
pass
def _handeln(werkzeug: str, argumente: dict, grund: str, tun) -> str:
"""Eine Aktion ausführen und protokollieren. Ohne Grund keine Aktion — er ist der Nachweis, dass der Commander
darum gebeten hat."""
if not str(grund or "").strip():
text = ("Nicht ausgeführt: Es fehlt der Grund, also die Bitte des Commanders in seinen Worten. Aktionen nur, "
"wenn er sie verlangt oder bestätigt hat.")
else:
try:
text = tun()
except Fehler as exc:
text = f"Nicht ausgeführt: {exc}"
_protokolliere(werkzeug, argumente, str(grund or ""), text)
return text
# --- Lesen ----------------------------------------------------------------------------------------------------
def _ziele() -> list[dict]:
"""KI-Box und Homelab als Ziele mit Bausteinen — dasselbe, was die Update-Seiten zeigen."""
ziele = list(_get("/api/ziele").get("ziele") or [])
try:
ziele += list(_get(f"{HL}/ziele").get("ziele") or [])
except Fehler:
pass
return ziele
def _offen(z: dict) -> list[dict]:
return [b for b in z.get("bausteine") or [] if b.get("zustand") == "neu"]
def lage() -> str:
"""Kurzbericht: Hinweise beider Wächter, offene Updates, Speicher/NAS/Sicherungen, was gerade läuft."""
zeilen: list[str] = []
for wo, pfad in (("KI-Box", "/api/hinweise"), ("Homelab", f"{HL}/hinweise")):
try:
hinweise = _get(pfad).get("hinweise") or []
except Fehler as exc:
zeilen.append(f"{wo}: nicht erreichbar ({exc}).")
continue
if hinweise:
zeilen += [f"{wo}, {h.get('stufe')}: {h.get('titel')}. {h.get('text') or ''}".strip() for h in hinweise]
else:
zeilen.append(f"{wo}: keine Hinweise.")
ziele = _ziele()
offen = [(z, b) for z in ziele for b in _offen(z)]
zeilen.append(f"Geräte: {sum(1 for z in ziele if z.get('erreichbar') is not False)} von {len(ziele)} erreichbar; "
f"{len(offen)} Updates stehen an" + (" (Liste mit updates())." if offen else "."))
try:
zeilen += _speicher_zeilen(_get(f"{HL}/speicher"))
except Fehler:
pass
zeilen += _laeuft()
return "\n".join(zeilen)
def _speicher_zeilen(s: dict) -> list[str]:
zeilen = []
for p in s.get("pools") or []:
zeilen.append(f"Speicherpool {p['speicher']}: {round(p['anteil'] * 100)} % belegt ({p['stufe']}).")
if sp := s.get("systemplatte"):
zeilen.append(f"Systemplatte des Proxmox-Hosts: {round(sp['anteil'] * 100)} % ({sp['stufe']}).")
for n in s.get("nas") or []:
zeilen.append(f"NAS {n.get('server')}: " + (n.get("problem") or f"{round((n.get('anteil') or 0) * 100)} % "
f"belegt ({n.get('stufe')})."))
si = s.get("sicherungen") or {}
if si.get("stufe") not in (None, "keine"):
fehlend = ", ".join(f["name"] for f in si.get("fehlend") or [])
zeilen.append(f"Sicherungen: {si.get('stufe')}" + (f", fehlend: {fehlend}" if fehlend else "") + ".")
return zeilen
def _laeuft() -> list[str]:
zeilen = []
try:
for j in _get("/api/jobs").get("jobs") or []:
if j.get("state") in ("running", "queued"):
zeilen.append(f"Läuft auf der KI-Box: {j.get('label')}.")
except Fehler:
pass
try:
for lauf in _get(f"{HL}/laeufe").get("laeufe") or []:
if lauf.get("status") == "laeuft":
zeilen.append(f"Läuft im Homelab: {lauf.get('name')} ({lauf.get('baustein')}), "
f"zuletzt: {(lauf.get('schritte') or ['–'])[-1]}.")
sl = (_get(f"{HL}/sammellauf") or {}).get("sammellauf") or {}
if sl.get("status") == "laeuft":
zeilen.append("Im Homelab läuft „Alle aktualisieren“.")
except Fehler:
pass
return zeilen
def updates() -> str:
"""Offene Updates je Gerät mit der Kennung für update_starten — und warum eines keinen Knopf hat."""
zeilen = []
for z in _ziele():
for b in _offen(z):
was = b.get("kurz") or b.get("verfuegbar") or "neu"
if b.get("aktion"):
zeilen.append(f"{z['name']} (ziel={z['id']}, baustein={b['id']}): {b.get('name')} · {was} — startbar.")
else:
zeilen.append(f"{z['name']} ({z['id']}): {b.get('name')} · {was} — kein Knopf: "
f"{b.get('grund') or 'wartet'}.")
return "\n".join(zeilen) or "Keine offenen Updates, KI-Box und Homelab sind aktuell."
def speicher() -> str:
"""Speicherpool, Systemplatte, NAS und Sicherungen samt Vorschlägen, was Platz bringt."""
s = _get(f"{HL}/speicher")
zeilen = _speicher_zeilen(s)
for e in s.get("empfehlungen") or []:
platz = f" (~{round(e['platz'] / 1e9)} GB)" if e.get("platz") else ""
zeilen.append(f"Vorschlag: {e.get('titel')}{platz}. {e.get('text')}")
return "\n".join(zeilen)
def protokoll(anzahl: int = 15) -> str:
"""Was im Homelab zuletzt geschah (Läufe, Meldungen, Hinweise), neueste zuerst."""
eintraege = _get(f"{HL}/protokoll", grenze=max(1, min(int(anzahl), 60)), berichte=0).get("eintraege") or []
zeilen = []
for e in eintraege:
zeit = datetime.fromtimestamp(float(e.get("zeit") or 0), ORTSZEIT).strftime("%d.%m. %H:%M")
zeilen.append(f"{zeit} {e.get('titel')}" + (f" — {e.get('text')}" if e.get("text") else ""))
return "\n".join(zeilen) or "Das Protokoll ist leer."
def radar() -> str:
"""Kandidaten des Modell-Radars: Status, ob „Jetzt testen“ geht, und warum nicht."""
zeilen = []
for k in _get("/api/radar").get("kandidaten") or []:
teile = [f"{k.get('name')} (id={k.get('id')}, {k.get('rolle')}): {k.get('status')}"]
if k.get("testbar_jetzt"):
teile.append("jetzt testbar")
elif k.get("testbar_grund"):
teile.append(str(k["testbar_grund"]))
zeilen.append(" — ".join(teile))
return "\n".join(zeilen) or "Das Radar hat gerade keine Kandidaten."
# --- Handeln (nur auf Bitte des Commanders; der Grund wird protokolliert) --------------------------------------
def update_starten(ziel: str, baustein: str, grund: str) -> str:
def tun() -> str:
z = next((x for x in _ziele() if x.get("id") == ziel), None)
if not z:
raise Fehler(f"Das Gerät „{ziel}“ gibt es nicht. updates() zeigt die Kennungen.")
b = next((x for x in z.get("bausteine") or [] if x.get("id") == baustein), None)
if not b or not b.get("aktion"):
raise Fehler(f"Für {z['name']} / {baustein} gibt es gerade keinen Update-Knopf"
+ (f": {b.get('grund')}" if b and b.get("grund") else "."))
antwort = _post(_homelab_pfad(b["aktion"]["pfad"]))
hinweis = (" Ich bin dabei kurz nicht erreichbar." if baustein == "hermes" else "")
return (f"Gestartet: {z['name']} · {b.get('name')}. Vorher wird gesichert, danach geprüft; das Ergebnis kommt als "
f"Meldung.{hinweis}" + (f" (Lauf {antwort.get('lauf')})" if antwort.get("lauf") else ""))
return _handeln("update_starten", {"ziel": ziel, "baustein": baustein}, grund, tun)
def alle_aktualisieren(bereich: str, grund: str) -> str:
def tun() -> str:
if bereich == "homelab":
_post(f"{HL}/alle")
return ("„Alle aktualisieren“ im Homelab läuft: Gäste nacheinander, der Proxmox-Host zuletzt, ohne Neustart; "
"bei Rot hält es an. Am Ende kommt eine Sammelmeldung.")
if bereich == "ki-box":
_post(KI_BOX_ALLE)
return ("„Alle aktualisieren“ auf der KI-Box läuft (Motor, llama-swap, Hermes, Betriebssystem). Beim Hermes-"
"Schritt bin ich kurz nicht erreichbar.")
raise Fehler("bereich muss „homelab“ oder „ki-box“ sein.")
return _handeln("alle_aktualisieren", {"bereich": bereich}, grund, tun)
def nach_updates_suchen(grund: str) -> str:
def tun() -> str:
_post("/api/maintenance/check-updates")
return "Die KI-Box sucht nach Updates; in ein, zwei Minuten zeigt updates() das Ergebnis."
return _handeln("nach_updates_suchen", {}, grund, tun)
def dienst_neustarten(dienst: str, grund: str) -> str:
def tun() -> str:
antwort = _post("/api/maintenance/restart", {"service": dienst})
return str(antwort.get("text") or f"{dienst} wird neu gestartet.")
return _handeln("dienst_neustarten", {"dienst": dienst}, grund, tun)
def kernel_aufraeumen(grund: str) -> str:
def tun() -> str:
_post(f"{HL}/ziele/pve/kernel-aufraeumen")
return ("Alte Kernel werden entfernt (was Proxmox für entbehrlich hält; der laufende und der nächste bleiben). "
"Das Ergebnis kommt als Meldung.")
return _handeln("kernel_aufraeumen", {}, grund, tun)
def host_neustarten(grund: str) -> str:
def tun() -> str:
_post(f"{HL}/ziele/pve/neustart")
return ("Der Proxmox-Host startet neu. Alle Container und die Arcane-VM sind ein paar Minuten weg, auch AdGuard "
"(DNS im Heimnetz). Die KI-Box prüft danach, ob alles wiederkommt.")
return _handeln("host_neustarten", {}, grund, tun)
def radar_testen(kandidat: str, grund: str) -> str:
def tun() -> str:
antwort = _post(f"/api/radar/{kandidat}/testen")
return str(antwort.get("text") or "Der Test läuft; das Ergebnis kommt als Meldung.")
return _handeln("radar_testen", {"kandidat": kandidat}, grund, tun)
def radar_suchen(grund: str) -> str:
def tun() -> str:
antwort = _post("/api/radar/suche")
return str(antwort.get("text") or "Das Radar sucht nach neuen Modellen.")
return _handeln("radar_suchen", {}, grund, tun)