From 22a17ed04812ce9e3f090f46d14a19957fcb5f89 Mon Sep 17 00:00:00 2001 From: Aissa Date: Sat, 1 Aug 2026 17:59:32 +0000 Subject: [PATCH] Activate agent mesh with honest live status and working animations. Souls now aggregate aliased events (e.g. halal_engine), KPIs reflect 6h activity, active agents animate, and morning scheduler/scripts target cockpit :8610. --- cockpit/app/services/agent_names.py | 18 +++ cockpit/app/services/agent_souls.py | 160 ++++++++++++++++++++---- cockpit/static/css/agent-characters.css | 93 ++++++++++++++ cockpit/templates/agents.html | 41 ++++-- scripts/agent_scheduler.py | 10 +- scripts/herman_daily_digest.py | 2 +- scripts/herman_platform_update.py | 2 +- scripts/marketing_publish_due.py | 2 +- 8 files changed, 291 insertions(+), 37 deletions(-) diff --git a/cockpit/app/services/agent_names.py b/cockpit/app/services/agent_names.py index 359feb4..b026d28 100644 --- a/cockpit/app/services/agent_names.py +++ b/cockpit/app/services/agent_names.py @@ -9,9 +9,15 @@ AGENT_NAME_MAP: dict[str, str] = { "rss_feeds": "marketing", "wholesale_scraper": "sourcing", "halal_registry": "halal", + "halal_engine": "halal", + "halal_reco": "halal", "branch_scraper": "sourcing", "hermes": "herman", "herman_delegate": "herman", + "ops": "sysops", + "sysop": "sysops", + "web_builder": "webbuilder", + "packaging_agent": "packaging", } @@ -20,3 +26,15 @@ def normalize_agent_key(name: str | None) -> str: if not key: return "" return AGENT_NAME_MAP.get(key, key) + + +def aliases_for_key(canonical: str) -> list[str]: + """All raw agent_name values that map to this soul key (including the key itself).""" + key = (canonical or "").strip().lower() + if not key: + return [] + names = {key} + for raw, canon in AGENT_NAME_MAP.items(): + if canon == key: + names.add(raw) + return sorted(names) diff --git a/cockpit/app/services/agent_souls.py b/cockpit/app/services/agent_souls.py index 3044260..8abd763 100644 --- a/cockpit/app/services/agent_souls.py +++ b/cockpit/app/services/agent_souls.py @@ -1,49 +1,165 @@ """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 s.*, - (SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count, - (SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at, - (SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key - ORDER BY created_at DESC LIMIT 1) AS current_task, - (SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key - ORDER BY created_at DESC LIMIT 1) AS current_status, - (SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key - ORDER BY created_at DESC LIMIT 1) AS current_event_type - FROM agent_souls s ORDER BY s.display_name""" - ) - return [dict(r) for r in rows] + 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 + 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" if event_count > 0 else "offline")) + ) + out.append(soul) + return out def get_soul(agent_key: str) -> Optional[dict[str, Any]]: - row = fetch_one( - """SELECT s.*, - (SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count - FROM agent_souls s WHERE agent_key = %s""", - (agent_key.lower(),), - ) + 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( - """SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at + f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at FROM agent_events - WHERE LOWER(agent_name) = %s + WHERE LOWER(agent_name) IN ({placeholders}) ORDER BY created_at DESC LIMIT %s""", - (agent_key.lower(), limit), + (*[n.lower() for n in names], limit), ) out: list[dict[str, Any]] = [] for row in rows: diff --git a/cockpit/static/css/agent-characters.css b/cockpit/static/css/agent-characters.css index dff29b2..8e3d4c0 100644 --- a/cockpit/static/css/agent-characters.css +++ b/cockpit/static/css/agent-characters.css @@ -208,3 +208,96 @@ white-space: pre-wrap; } .badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; } + +/* Live working animations */ +.agent-soul-card { + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; + border: 1px solid rgba(148,163,184,0.12); + border-radius: 14px; + background: rgba(12, 18, 28, 0.85); +} +.agent-soul-card.is-working { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 1px rgba(56,189,248,0.2), 0 12px 28px rgba(0, 180, 255, 0.12); + animation: agent-card-pulse 2.2s ease-in-out infinite; +} +.agent-soul-card.is-busy { + border-color: rgba(251, 191, 36, 0.65); + box-shadow: 0 0 0 1px rgba(251,191,36,0.25), 0 12px 28px rgba(251, 191, 36, 0.12); + animation: agent-card-pulse-busy 1.4s ease-in-out infinite; +} +.agent-soul-card.is-working .agent-char-wrap, +.agent-soul-card.is-busy .agent-char-wrap { + animation: agent-avatar-bob 1.1s ease-in-out infinite; +} +.agent-soul-card.is-working .agent-char-wrap::after, +.agent-soul-card.is-busy .agent-char-wrap::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 70% 20%, rgba(56,189,248,0.35), transparent 55%); + animation: agent-glow-sweep 2.4s ease-in-out infinite; +} +.agent-soul-card.is-busy .agent-char-wrap::after { + background: radial-gradient(circle at 70% 20%, rgba(251,191,36,0.4), transparent 55%); +} +.agent-activity-eq { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 12px; + margin-left: 0.35rem; + vertical-align: middle; +} +.agent-activity-eq span { + width: 3px; + border-radius: 2px; + background: #38bdf8; + animation: agent-eq 0.9s ease-in-out infinite; +} +.agent-soul-card.is-busy .agent-activity-eq span { background: #fbbf24; } +.agent-activity-eq span:nth-child(1) { height: 4px; animation-delay: 0s; } +.agent-activity-eq span:nth-child(2) { height: 8px; animation-delay: 0.12s; } +.agent-activity-eq span:nth-child(3) { height: 6px; animation-delay: 0.24s; } +.agent-activity-eq span:nth-child(4) { height: 10px; animation-delay: 0.08s; } + +.agent-status-pill.active { + border-color: rgba(34,197,94,0.45); + color: #86efac; + background: rgba(34,197,94,0.1); +} +.agent-status-pill.busy { + border-color: rgba(245,158,11,0.5); + color: #fcd34d; + background: rgba(245,158,11,0.12); + animation: agent-pill-blink 1.2s ease-in-out infinite; +} +.agent-status-pill.idle .dot { background: #64748b; } +.agent-status-pill.offline .dot { background: #475569; opacity: 0.7; } +.agent-status-pill.offline { color: #64748b; } + +@keyframes agent-card-pulse { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px); } +} +@keyframes agent-card-pulse-busy { + 0%, 100% { transform: translateY(0) scale(1); } + 50% { transform: translateY(-3px) scale(1.01); } +} +@keyframes agent-avatar-bob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} +@keyframes agent-glow-sweep { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 0.85; } +} +@keyframes agent-eq { + 0%, 100% { transform: scaleY(0.55); opacity: 0.7; } + 50% { transform: scaleY(1.25); opacity: 1; } +} +@keyframes agent-pill-blink { + 0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); } + 50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); } +} diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html index 9b390d4..7fc582d 100644 --- a/cockpit/templates/agents.html +++ b/cockpit/templates/agents.html @@ -4,7 +4,7 @@ - + {% endblock %} {% block content %} @@ -46,18 +46,21 @@
🤖
Agents
geregistreerd
0
âš¡
Events
totaal gelogd
0
-
✓
Actief
agents online
0
+
✓
Aan het werk
live status
0
👑
Herman
Co-CEO · takenverdeler
Co-CEO