"""Agent soul profiles and Herman permissions.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any, Optional from app.db import execute, fetch_all, fetch_one from app.services.agent_names import aliases_for_key, normalize_agent_key def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str: if events_6h > 0 and errors_24h == 0: return "healthy" if events_6h > 0: return "warn" if event_count > 0: return "idle" return "offline" def _aggregate_event_stats() -> dict[str, dict[str, Any]]: rows = fetch_all( """ SELECT agent_name, title, status, created_at FROM agent_events WHERE created_at >= NOW() - INTERVAL '30 days' ORDER BY created_at DESC """ ) now = datetime.now(timezone.utc) by_key: dict[str, dict[str, Any]] = {} for r in rows: key = normalize_agent_key(str(r.get("agent_name") or "")) if not key: continue bucket = by_key.setdefault( key, { "event_count": 0, "events_6h": 0, "events_24h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None, "current_status": None, "current_event_type": None, }, ) bucket["event_count"] += 1 created = r.get("created_at") if bucket["last_event_at"] is None: bucket["last_event_at"] = created bucket["last_event_title"] = r.get("title") bucket["current_status"] = r.get("status") if created is not None: if hasattr(created, "tzinfo") and created.tzinfo is None: created = created.replace(tzinfo=timezone.utc) age_ok = True try: if created >= now - timedelta(hours=6): bucket["events_6h"] += 1 if created >= now - timedelta(hours=24): bucket["events_24h"] += 1 if str(r.get("status") or "") in ("error", "rejected"): bucket["errors_24h"] += 1 except Exception: age_ok = False _ = age_ok # Also count older totals beyond 30d window lightly try: totals = fetch_all( """ SELECT agent_name, COUNT(*)::int AS c FROM agent_events GROUP BY agent_name """ ) total_by: dict[str, int] = {} for r in totals: k = normalize_agent_key(str(r.get("agent_name") or "")) if not k: continue total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0) for k, c in total_by.items(): bucket = by_key.setdefault( k, { "event_count": 0, "events_6h": 0, "events_24h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None, "current_status": None, "current_event_type": None, }, ) bucket["event_count"] = c except Exception: pass return by_key def list_souls() -> list[dict[str, Any]]: rows = fetch_all("SELECT * FROM agent_souls ORDER BY display_name") stats = _aggregate_event_stats() out: list[dict[str, Any]] = [] for r in rows: soul = dict(r) key = str(soul.get("agent_key") or "").lower() st = stats.get(key, {}) events_6h = int(st.get("events_6h") or 0) errors_24h = int(st.get("errors_24h") or 0) event_count = int(st.get("event_count") or 0) health = _node_health(events_6h, errors_24h, event_count) lat = st.get("last_event_at") if lat is not None and hasattr(lat, "isoformat"): lat = lat.isoformat() soul["event_count"] = event_count soul["events_6h"] = events_6h soul["events_24h"] = int(st.get("events_24h") or 0) soul["errors_24h"] = errors_24h soul["health"] = health soul["last_event_at"] = lat soul["current_task"] = st.get("last_event_title") or soul.get("current_task") soul["current_status"] = st.get("current_status") # Working = recent activity (not just DB is_active flag) soul["working"] = events_6h > 0 # is_active souls are never "offline" in the registry (War Room / mesh ready) if not soul.get("is_active", True): soul["activity"] = "offline" else: soul["activity"] = ( "busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5) else ("active" if events_6h > 0 else "idle") ) out.append(soul) return out def get_soul(agent_key: str) -> Optional[dict[str, Any]]: row = fetch_one("SELECT * FROM agent_souls WHERE agent_key = %s", (agent_key.lower(),)) if not row: return None out = dict(row) key = agent_key.lower() stats = _aggregate_event_stats().get(key, {}) out["event_count"] = int(stats.get("event_count") or 0) out["events_6h"] = int(stats.get("events_6h") or 0) out["health"] = _node_health(out["events_6h"], int(stats.get("errors_24h") or 0), out["event_count"]) out["recent_events"] = list_agent_events(agent_key, limit=15) return out def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]: names = aliases_for_key(agent_key) if not names: return [] placeholders = ",".join(["%s"] * len(names)) rows = fetch_all( f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at FROM agent_events WHERE LOWER(agent_name) IN ({placeholders}) ORDER BY created_at DESC LIMIT %s""", (*[n.lower() for n in names], limit), ) out: list[dict[str, Any]] = [] for row in rows: ev = dict(row) for key in ("created_at", "completed_at"): if ev.get(key) is not None and hasattr(ev[key], "isoformat"): ev[key] = ev[key].isoformat() out.append(ev) return out def update_soul(agent_key: str, **fields: Any) -> dict[str, Any]: allowed = ("display_name", "role_title", "soul_md", "responsibilities", "permissions", "is_active") sets, params = [], [] for k, v in fields.items(): if k in allowed and v is not None: sets.append(f"{k} = %s") params.append(v) if not sets: soul = get_soul(agent_key) if not soul: raise ValueError("Agent not found") return soul params.append(agent_key.lower()) execute(f"UPDATE agent_souls SET {', '.join(sets)}, updated_at = NOW() WHERE agent_key = %s", tuple(params)) return get_soul(agent_key) or {} def list_permissions() -> list[dict[str, Any]]: return [dict(r) for r in fetch_all("SELECT * FROM herman_permissions ORDER BY category, module_label")] def update_permission(module_key: str, granted: bool) -> dict[str, Any]: execute( """UPDATE herman_permissions SET granted = %s, granted_at = CASE WHEN %s THEN NOW() ELSE NULL END, updated_at = NOW() WHERE module_key = %s""", (granted, granted, module_key), ) row = fetch_one("SELECT * FROM herman_permissions WHERE module_key = %s", (module_key,)) return dict(row or {}) def grant_all_permissions() -> int: execute("UPDATE herman_permissions SET granted = TRUE, granted_at = NOW(), updated_at = NOW()") row = fetch_one("SELECT COUNT(*) AS n FROM herman_permissions WHERE granted = TRUE") return int((row or {}).get("n") or 0)