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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
44f33c31d3
commit
deaf35e953
@@ -0,0 +1,335 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user