""" Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md). """ import logging import os import re import httpx import psutil from config import HERMES_TERMINAL_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL log = logging.getLogger(__name__) def _hermes_version(name: str) -> float | None: """Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0.""" low = (name or "").lower() if "hermes" not in low: return None m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low) return float(m.group(1)) if m else None def hermes_brain_info() -> dict: """Aktuelles Agent-Hirn (hermes-Rolle) + bestes verfügbares NousResearch-Hermes-Modell, das auf diese Hardware passt. Für den Modell-Manager: Brain sichtbar + updatebar, sobald NousResearch eine neuere Hermes-Generation veröffentlicht.""" from services import discover, llamaswap from services.fit import evaluate_fit, extract_params_b models = llamaswap.list_models() cur = next((m for m in models if m.get("role") == "hermes"), None) cur_ver = _hermes_version(cur["name"]) if cur else None cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None current = None if cur: current = {"name": cur["name"], "filename": cur.get("filename"), "params_b": cur_params, "quant": cur.get("quant"), "size_bytes": cur.get("size_bytes"), "version": cur_ver, "gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")} ram = psutil.virtual_memory().total / (1024 ** 3) best = None try: cands = [] for r in discover._fetch_author_models("NousResearch"): rid = r.get("id", "") if "hermes" not in rid.lower(): continue pb = extract_params_b(rid) fit = evaluate_fit(pb, "Q4_K_M", 8192, ram, name=rid) if fit["level"] == "too_tight": continue cands.append({"repo": rid, "name": rid.split("/")[-1], "version": _hermes_version(rid) or 0.0, "params_b": pb, "downloads": int(r.get("downloads") or 0), "fit": fit}) # neueste Hermes-Version zuerst, dann größer/fähiger, dann beliebter cands.sort(key=lambda c: (c["version"], c["params_b"], c["downloads"]), reverse=True) best = cands[0] if cands else None except Exception: log.debug("hermes_brain_info: HF-Abfrage fehlgeschlagen", exc_info=True) update = False if best is not None: if cur_ver is None: update = True elif best["version"] > cur_ver: update = True elif best["version"] == cur_ver and best["params_b"] > (cur_params or 0) * 1.05: update = True # gleiche Datei schon installiert? dann kein Update if current and best["repo"].split("/")[-1].lower() in (current["name"] or "").lower(): update = False # Fit-Check: passt das EMPFOHLENE Brain als Always-On noch ins Budget, sodass das # größte on-demand-Modell daneben lädt? (Brain muss immer resident sein.) budget = None try: from services.budget import footprint_gb, gtt_budget_gb from services.fit import estimate_memory_gb groups = llamaswap.list_groups() persist = set() for g in groups.values(): if isinstance(g, dict) and g.get("persist"): persist.update(g.get("members") or []) cur_name = cur["name"] if cur else None brain_ctx = int((cur.get("ctx") if cur else None) or 32768) if best: brain_gb = estimate_memory_gb(float(best["params_b"]), "Q4_K_M", brain_ctx) elif cur: brain_gb = footprint_gb(cur) else: brain_gb = 0.0 # voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info warm = brain_gb + sum(footprint_gb(m) for m in models if m["name"] in persist and m["name"] != cur_name) largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0) gtt = gtt_budget_gb() # Brain muss immer resident sein → passt Brain + größtes on-demand zusammen? # (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.) budget = { "gtt_gb": gtt, "brain_gb": round(brain_gb, 1), "warm_projected_gb": round(warm, 1), "largest_ondemand_gb": round(largest_od, 1), "fits": (brain_gb + largest_od) <= gtt, "free_after_gb": round(gtt - brain_gb - largest_od, 1), } except Exception: log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True) return {"current": current, "recommended": best, "update_available": update, "budget": budget} def _reach(url: str, path: str = "") -> bool: try: with httpx.Client(timeout=3.0) as c: return c.get(f"{url}{path}").status_code < 500 except httpx.HTTPError: return False def _count_enabled_mcp_servers() -> int: config_path = HERMES_HOME / "config.yaml" if not config_path.exists(): return 0 try: from ruamel.yaml import YAML r_yaml = YAML() with config_path.open("r", encoding="utf-8") as f: cfg = r_yaml.load(f) or {} mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {} if not isinstance(mcp_servers, dict): return 0 return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True)) except Exception: log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True) return 0 def agent_status() -> dict: """Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise.""" home = HERMES_HOME brain_model = "auto" config_path = home / "config.yaml" if config_path.exists(): try: from ruamel.yaml import YAML r_yaml = YAML() with config_path.open("r", encoding="utf-8") as f: cfg = r_yaml.load(f) or {} if isinstance(cfg, dict): # Hermes nutzt model.default als aktives Modell (model.model = Provider-Param). m = cfg.get("model", {}) or {} brain_model = m.get("default") or m.get("model") or "auto" except Exception: log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True) return { "gateway_url": HERMES_API_URL, # Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2. "terminal_url": HERMES_TERMINAL_URL, "gateway_reachable": _reach(HERMES_API_URL, "/health"), "terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"), "home_exists": home.exists(), "brain_model": brain_model, # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). "has_config": (home / "config.yaml").exists() or (home / "config.json").exists(), "has_skills": (home / "skills").exists(), "has_memories": (home / "memories").exists(), # Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit. "telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")), "mcp_server_count": _count_enabled_mcp_servers(), "pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"), } def set_agent_brain(model_id: str) -> dict: """Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst: 1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot), 2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben), 3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart. So bleibt das neue Hirn warm und der Agent nutzt es sofort.""" from services import llamaswap models = {m["name"]: m for m in llamaswap.list_models()} if model_id not in models: return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."} old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None) if model_id == old: return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"} try: llamaswap.set_role(model_id, "hermes") # 1) Alias brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] new_members = [x for x in brains if x not in (old, model_id)] + [model_id] llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm except PermissionError as exc: return {"ok": False, "reason": str(exc)} update_brain_model("hermes") # 3) Config + Restart return {"ok": True, "old": old, "new": model_id} def update_brain_model(new_model: str) -> bool: from config import HERMES_HOME home = HERMES_HOME config_path = home / "config.yaml" # Ensure home directory exists home.mkdir(parents=True, exist_ok=True) cfg = {} if config_path.exists(): try: from ruamel.yaml import YAML r_yaml = YAML() with config_path.open("r", encoding="utf-8") as f: cfg = r_yaml.load(f) or {} except Exception: log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True) cfg = {} if not isinstance(cfg, dict): cfg = {} if "model" not in cfg or not isinstance(cfg["model"], dict): cfg["model"] = {} # Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. # Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt). cfg["model"]["default"] = new_model cfg["model"]["model"] = new_model try: from ruamel.yaml import YAML r_yaml = YAML() with config_path.open("w", encoding="utf-8") as f: r_yaml.dump(cfg, f) # Restart the user-space service to apply changes try: import services.maintenance as maintenance maintenance.restart_service("hermes-gateway") except Exception: log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) return True except Exception: log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True) return False