Spaces:
Sleeping
Sleeping
| """ | |
| PULSE β Agent Nervous System & Scheduler | |
| The heartbeat, ReAct loop, timetable, and identity layer | |
| connecting all 7 ki-fusion-labs agent spaces. | |
| Connected spaces: | |
| FORGE β skill registry | |
| RELAY β communication hub | |
| MEMORY β multi-tier memory | |
| KANBAN β task board | |
| NEXUS β LLM routing (OpenAI-compatible) | |
| VAULT β file workspace + execution | |
| KNOWLEDGE β knowledge base (if available) | |
| Agent lifecycle per heartbeat tick: | |
| 1. Check RELAY inbox for messages | |
| 2. Check KANBAN for assigned open tasks | |
| 3. Check timetable for due jobs | |
| 4. If anything found β ReAct loop via NEXUS | |
| 5. ReAct: Thought β Action(tool) β Observation β repeat | |
| 6. Write results to MEMORY / KANBAN / RELAY / VAULT | |
| 7. Sleep β next tick | |
| """ | |
| import os, uuid, json, asyncio, time, re, logging | |
| from pathlib import Path | |
| from datetime import datetime, timezone, timedelta | |
| from typing import Optional, Any | |
| from collections import defaultdict | |
| import httpx | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.responses import JSONResponse, HTMLResponse, StreamingResponse | |
| from apscheduler.schedulers.asyncio import AsyncIOScheduler | |
| from apscheduler.triggers.cron import CronTrigger | |
| from apscheduler.triggers.date import DateTrigger | |
| logging.basicConfig(level=logging.INFO) | |
| log = logging.getLogger("pulse") | |
| BASE = Path(__file__).parent | |
| for d in ["data", "logs", "traces"]: | |
| (BASE / d).mkdir(exist_ok=True) | |
| # ββ Space URLs βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SPACES = { | |
| "pulse": os.environ.get("PULSE_URL", "http://localhost:7860"), | |
| "relay": os.environ.get("RELAY_URL", "https://chris4k-agent-relay.hf.space"), | |
| "memory": os.environ.get("MEMORY_URL", "https://chris4k-agent-memory.hf.space"), | |
| "kanban": os.environ.get("KANBAN_URL", "https://chris4k-agent-kanban-board.hf.space"), | |
| "nexus": os.environ.get("NEXUS_URL", "https://chris4k-agent-nexus.hf.space"), | |
| "vault": os.environ.get("VAULT_URL", "https://chris4k-agent-vault.hf.space"), | |
| "forge": os.environ.get("FORGE_URL", "https://chris4k-agent-forge.hf.space"), | |
| "knowledge": os.environ.get("KNOWLEDGE_URL", "https://chris4k-agent-knowledge.hf.space"), | |
| } | |
| NEXUS_MODEL = os.environ.get("NEXUS_MODEL", "nexus-auto") | |
| REACT_MAX = int(os.environ.get("REACT_MAX_STEPS", "6")) | |
| # ββ FORGE new infrastructure ββββββββββββββββββββββββββββββββββββββββ | |
| PROMPTS_URL = os.environ.get("PROMPTS_URL", "https://chris4k-agent-prompts.hf.space") | |
| TRACE_URL = os.environ.get("TRACE_URL", "https://chris4k-agent-trace.hf.space") | |
| LEARN_URL = os.environ.get("LEARN_URL", "https://chris4k-agent-learn.hf.space") | |
| LOOP_URL = os.environ.get("LOOP_URL", "https://chris4k-agent-loop.hf.space") | |
| HARNESS_URL = os.environ.get("HARNESS_URL", "https://chris4k-agent-harness.hf.space") | |
| APPROVE_URL = os.environ.get("APPROVE_URL", "https://chris4k-agent-approve.hf.space") | |
| COMPLIANCE_URL = os.environ.get("COMPLIANCE_URL", "https://chris4k-agent-compliance.hf.space") | |
| BRAVE_API_KEY = os.environ.get("BRAVE_API_KEY", "") | |
| # Risky tools that require approval gate | |
| RISKY_TOOLS = {"vault_exec"} | |
| RISKY_RUNTIMES = {"bash", "git"} # within vault_exec these trigger approve | |
| RISKY_PATTERNS = {"rm ", "rmdir", "git push", "git force", "dd ", "chmod 777"} | |
| # ββ Persona cache (fetched from agent-prompts, refreshed every 5min) β | |
| _persona_cache: dict = {} # agent_name β {system_prompt, max_steps, ...} | |
| _persona_loaded: dict = {} # agent_name β timestamp | |
| PERSONA_TTL = 300 | |
| PULSE_FALLBACK_PROMPT = ( | |
| "You are a specialized agent in the FORGE AI ecosystem. Execute assigned tasks using " | |
| "the ReAct loop: Thought β Action β Observation. Log every action, move kanban cards, " | |
| "reserve LLM slots before long tasks. Never hallucinate tool results." | |
| ) | |
| def get_agent_persona(agent_name: str) -> dict: | |
| """Fetch agent persona from agent-prompts. Falls back to local defaults.""" | |
| now = time.time() | |
| if agent_name in _persona_cache and (now - _persona_loaded.get(agent_name, 0)) < PERSONA_TTL: | |
| return _persona_cache[agent_name] | |
| try: | |
| import urllib.request as ureq | |
| with ureq.urlopen(f"{PROMPTS_URL}/api/personas/{agent_name}", timeout=3) as r: | |
| data = json.loads(r.read()) | |
| if data and data.get("system_prompt"): | |
| _persona_cache[agent_name] = data | |
| _persona_loaded[agent_name] = now | |
| log.info(f"[PROMPTS] Persona loaded for {agent_name}") | |
| return data | |
| except Exception as e: | |
| log.debug(f"[PROMPTS] Persona fetch failed for {agent_name}: {e}") | |
| # Fallback: return local agent config's persona field | |
| return {"system_prompt": PULSE_FALLBACK_PROMPT, "max_steps": REACT_MAX} | |
| def emit_trace(agent: str, event_type: str, payload: dict, status: str = "ok"): | |
| """Fire-and-forget trace event to agent-trace. Never blocks.""" | |
| try: | |
| import urllib.request as ureq | |
| body = json.dumps({"agent": agent, "event_type": event_type, | |
| "status": status, "payload": payload}).encode() | |
| req = ureq.Request(f"{TRACE_URL}/api/trace", data=body, | |
| headers={"Content-Type": "application/json"}, method="POST") | |
| ureq.urlopen(req, timeout=2) | |
| except Exception: pass | |
| def render_prompt(prompt_id: str, variables: dict) -> str: | |
| """Fetch rendered prompt from agent-prompts. Returns empty string on failure.""" | |
| try: | |
| import urllib.request as ureq, urllib.parse | |
| params = urllib.parse.urlencode({k: str(v) for k, v in variables.items()}) | |
| with ureq.urlopen(f"{PROMPTS_URL}/api/prompts/{prompt_id}/render?{params}", timeout=3) as r: | |
| return json.loads(r.read()).get("rendered", "") | |
| except Exception: | |
| return "" | |
| # ββ LLM Fallback Chain βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Priority order (matches your actual infra): | |
| # 1. NEXUS β routes to ki-fusion-labs.de (RTX 5090) when server is ON | |
| # β falls back to HF serverless inside NEXUS when server is OFF | |
| # 2. Anthropic claude-haiku (ANTHROPIC_API_KEY secret) | |
| # 3. HF Inference API (HF_TOKEN secret, rate-limited, use sparingly) | |
| # 4. NEXUS local_cpu model (Qwen 0.5B inside NEXUS container, always available) | |
| # | |
| # Every failure logs the HTTP status + full response body so you can see WHY it failed. | |
| ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| FALLBACK_HF_MODEL = os.environ.get("FALLBACK_HF_MODEL", "meta-llama/Meta-Llama-3.1-8B-Instruct") | |
| # Track provider health to skip recently-failed ones faster | |
| _provider_failures: dict = {} # provider β last_fail_ts | |
| _PROVIDER_COOLDOWN = 30 # seconds to skip a failed provider (was 120 β too long) | |
| def _provider_ok(name: str) -> bool: | |
| ts = _provider_failures.get(name, 0) | |
| return (time.time() - ts) > _PROVIDER_COOLDOWN | |
| def _provider_fail(name: str): | |
| _provider_failures[name] = time.time() | |
| log.warning(f"[LLM] Marking {name} as failed (cooldown {_PROVIDER_COOLDOWN}s)") | |
| def _provider_success(name: str): | |
| _provider_failures.pop(name, None) | |
| async def _call_nexus(messages: list, model: str, max_tokens: int) -> str: | |
| payload = {"model": model, "messages": messages, | |
| "max_tokens": max_tokens, "temperature": 0.3} | |
| # 120s timeout: NEXUS may chain ki_fusion(6s fail) + hf(30s) + local_cpu(30-60s) | |
| # 35s was too short β PULSE timed out before NEXUS even reached local_cpu | |
| timeout = httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=5.0) | |
| async with httpx.AsyncClient(timeout=timeout) as c: | |
| r = await c.post(f"{SPACES['nexus']}/v1/chat/completions", json=payload) | |
| if not r.is_success: | |
| body = r.text[:400] | |
| log.error(f"[LLM] NEXUS {r.status_code}: {body}") | |
| raise RuntimeError(f"NEXUS HTTP {r.status_code}: {body}") | |
| return r.json()["choices"][0]["message"]["content"] | |
| async def _call_nexus_local_cpu(messages: list, max_tokens: int) -> str: | |
| """Force NEXUS to use its built-in CPU model β always available, slow.""" | |
| payload = {"model": "local_cpu", "messages": messages, | |
| "max_tokens": max_tokens, "temperature": 0.3} | |
| async with httpx.AsyncClient(timeout=90) as c: # CPU model is slow | |
| r = await c.post(f"{SPACES['nexus']}/v1/chat/completions", json=payload) | |
| if not r.is_success: | |
| body = r.text[:400] | |
| log.error(f"[LLM] NEXUS/local_cpu {r.status_code}: {body}") | |
| raise RuntimeError(f"NEXUS/local_cpu HTTP {r.status_code}: {body}") | |
| return r.json()["choices"][0]["message"]["content"] | |
| async def _call_anthropic(messages: list, system: str, max_tokens: int) -> str: | |
| url = "https://api.anthropic.com/v1/messages" | |
| headers = {"x-api-key": ANTHROPIC_KEY, | |
| "anthropic-version": "2023-06-01", | |
| "content-type": "application/json"} | |
| msgs = [m for m in messages if m["role"] != "system"] | |
| payload = {"model": "claude-haiku-4-5-20251001", "max_tokens": max_tokens, | |
| "system": system, "messages": msgs} | |
| async with httpx.AsyncClient(timeout=40) as c: | |
| r = await c.post(url, headers=headers, json=payload) | |
| if not r.is_success: | |
| body = r.text[:400] | |
| log.error(f"[LLM] Anthropic {r.status_code}: {body}") | |
| raise RuntimeError(f"Anthropic HTTP {r.status_code}: {body}") | |
| return r.json()["content"][0]["text"] | |
| async def _call_hf(messages: list, max_tokens: int) -> str: | |
| url = f"https://api-inference.huggingface.co/models/{FALLBACK_HF_MODEL}/v1/chat/completions" | |
| headers = {"Authorization": f"Bearer {HF_TOKEN}"} | |
| payload = {"model": FALLBACK_HF_MODEL, "messages": messages, | |
| "max_tokens": max_tokens, "temperature": 0.3} | |
| async with httpx.AsyncClient(timeout=60) as c: | |
| r = await c.post(url, headers=headers, json=payload) | |
| if not r.is_success: | |
| body = r.text[:400] | |
| log.error(f"[LLM] HF Inference {r.status_code}: {body}") | |
| raise RuntimeError(f"HF HTTP {r.status_code}: {body}") | |
| return r.json()["choices"][0]["message"]["content"] | |
| async def call_llm(messages: list, system: str = "", max_tokens: int = 600) -> str: | |
| """ | |
| LLM chain: NEXUS(RTX5090) β Anthropic β HF API β NEXUS(local_cpu) | |
| Logs full error bodies so you can see exactly what's failing. | |
| Skips providers that failed recently (cooldown). | |
| """ | |
| errors = [] | |
| # ββ 1. NEXUS (primary: ki-fusion-labs RTX 5090 or HF serverless inside NEXUS) | |
| if _provider_ok("nexus"): | |
| try: | |
| content = await _call_nexus(messages, NEXUS_MODEL, max_tokens) | |
| _provider_success("nexus") | |
| log.info("[LLM] nexus β OK") | |
| return content | |
| except Exception as e: | |
| errors.append(f"nexus: {e}") | |
| _provider_fail("nexus") | |
| push_live({"type":"llm_fallback","provider":"nexus_failed", | |
| "reason": str(e)[:120]}) | |
| else: | |
| log.info("[LLM] nexus in cooldown, skipping") | |
| # ββ 2. Anthropic claude-haiku (fast, reliable, costs ~$0.00025/call) | |
| if ANTHROPIC_KEY and _provider_ok("anthropic"): | |
| try: | |
| content = await _call_anthropic(messages, system, max_tokens) | |
| _provider_success("anthropic") | |
| log.info("[LLM] anthropic β OK") | |
| push_live({"type":"llm_fallback","provider":"anthropic"}) | |
| return content | |
| except Exception as e: | |
| errors.append(f"anthropic: {e}") | |
| _provider_fail("anthropic") | |
| elif not ANTHROPIC_KEY: | |
| log.debug("[LLM] anthropic skipped (no key)") | |
| # ββ 3. HF Inference API (rate-limited, use sparingly) | |
| if HF_TOKEN and _provider_ok("hf_inference"): | |
| try: | |
| content = await _call_hf(messages, max_tokens) | |
| _provider_success("hf_inference") | |
| log.info("[LLM] hf_inference β OK") | |
| push_live({"type":"llm_fallback","provider":"hf_inference"}) | |
| return content | |
| except Exception as e: | |
| errors.append(f"hf_inference: {e}") | |
| _provider_fail("hf_inference") | |
| elif not HF_TOKEN: | |
| log.debug("[LLM] hf_inference skipped (no token)") | |
| # ββ 4. NEXUS again but force local_cpu model directly | |
| # Only reached if NEXUS itself failed entirely (e.g. space is down). | |
| # If NEXUS is UP, it already tried local_cpu internally β we don't retry. | |
| # This covers the case where the NEXUS space itself is unreachable. | |
| if not _provider_ok("nexus"): | |
| try: | |
| log.warning("[LLM] All cloud providers failed β forcing NEXUS local_cpu model directly") | |
| content = await _call_nexus_local_cpu(messages, min(max_tokens, 300)) | |
| log.info("[LLM] nexus/local_cpu β OK") | |
| push_live({"type":"llm_fallback","provider":"local_cpu"}) | |
| return content | |
| except Exception as e: | |
| errors.append(f"local_cpu: {e}") | |
| log.error(f"[LLM] local_cpu also failed: {e}") | |
| all_errors = " | ".join(errors) | |
| log.error(f"[LLM] ALL PROVIDERS FAILED: {all_errors}") | |
| raise RuntimeError(f"All LLM providers failed: {all_errors}") | |
| # ββ Persistence ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| AGENTS_FILE = BASE / "data" / "agents.json" | |
| SCHEDULE_FILE = BASE / "data" / "schedule.json" | |
| ACTIVITY_FILE = BASE / "data" / "activity.json" | |
| def load_json(p: Path, default): | |
| return json.loads(p.read_text()) if p.exists() else default | |
| def save_json(p: Path, data): | |
| p.write_text(json.dumps(data, indent=2, ensure_ascii=False)) | |
| # ββ Live feed ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| live_queues: list[asyncio.Queue] = [] | |
| agent_status: dict[str, dict] = {} # name β {running, last_tick, last_action, tick_count} | |
| def push_live(event: dict): | |
| event["ts"] = int(time.time()) | |
| for q in live_queues: | |
| try: q.put_nowait(json.dumps(event)) | |
| except: pass | |
| # Append to activity log (last 200) | |
| act = load_json(ACTIVITY_FILE, []) | |
| act.insert(0, event) | |
| save_json(ACTIVITY_FILE, act[:200]) | |
| # ββ Space clients ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| HTTP_TIMEOUT = 20 | |
| async def space_get(space: str, path: str, params: dict = {}) -> Optional[Any]: | |
| url = SPACES[space] + path | |
| try: | |
| async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as c: | |
| r = await c.get(url, params=params) | |
| r.raise_for_status() | |
| return r.json() | |
| except Exception as e: | |
| log.warning(f"space_get {space}{path}: {e}") | |
| return None | |
| async def space_post(space: str, path: str, data: dict) -> Optional[Any]: | |
| url = SPACES[space] + path | |
| try: | |
| async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as c: | |
| r = await c.post(url, json=data) | |
| r.raise_for_status() | |
| return r.json() | |
| except Exception as e: | |
| log.warning(f"space_post {space}{path}: {e}") | |
| return None | |
| # ββ Sprint 5: Middleware helpers ββββββββββββββββββββββββββββββββββββ | |
| async def harness_scan(agent: str, tool: str, content: str) -> tuple[bool, str]: | |
| """Scan tool output through agent-harness before LLM sees it. | |
| Returns (safe, sanitised_content). On harness unavailable, pass-through.""" | |
| if not HARNESS_URL: | |
| return True, content | |
| try: | |
| async with httpx.AsyncClient(timeout=4) as c: | |
| r = await c.post(f"{HARNESS_URL}/api/scan/output", | |
| json={"agent": agent, "tool": tool, "content": content}) | |
| if r.status_code == 200: | |
| d = r.json() | |
| return d.get("safe", True), d.get("sanitised", content) | |
| except Exception as e: | |
| log.debug(f"[HARNESS] scan failed (pass-through): {e}") | |
| return True, content | |
| async def request_approval(agent: str, tool: str, args: dict, risk: str = "high") -> tuple[bool, str]: | |
| """Gate risky tool calls through agent-approve. | |
| Returns (approved, reason). Timeout = auto-reject.""" | |
| if not APPROVE_URL: | |
| log.warning("[APPROVE] APPROVE_URL not set β auto-approving (unsafe!)") | |
| return True, "approve_url_missing" | |
| try: | |
| async with httpx.AsyncClient(timeout=6) as c: | |
| r = await c.post(f"{APPROVE_URL}/api/approval/request", | |
| json={"agent": agent, "tool": tool, "args": args, | |
| "risk": risk, "auto_timeout": 120}) | |
| if r.status_code == 200: | |
| d = r.json() | |
| approval_id = d.get("id") | |
| # Poll for up to 90s (Telegram keyboard gives christof 2 min) | |
| for _ in range(18): | |
| await asyncio.sleep(5) | |
| pr = await c.get(f"{APPROVE_URL}/api/approval/{approval_id}") | |
| if pr.status_code == 200: | |
| pd = pr.json() | |
| status = pd.get("status") | |
| if status == "approved": | |
| return True, "human_approved" | |
| if status in ("rejected", "expired"): | |
| return False, status | |
| return False, "timeout" | |
| except Exception as e: | |
| log.warning(f"[APPROVE] gate failed: {e} β blocking tool call") | |
| return False, f"approve_error: {e}" | |
| async def compliance_scan(agent: str, content: str) -> tuple[bool, str, list]: | |
| """Scan content for PII before writing to memory. | |
| Returns (safe, redacted_content, pii_types_found).""" | |
| if not COMPLIANCE_URL: | |
| return True, content, [] | |
| try: | |
| async with httpx.AsyncClient(timeout=4) as c: | |
| r = await c.post(f"{COMPLIANCE_URL}/api/scan/pii", | |
| json={"text": content, "agent": agent, "redact": True}) | |
| if r.status_code == 200: | |
| d = r.json() | |
| return (not d.get("pii_found", False), | |
| d.get("redacted", content), | |
| d.get("types_found", [])) | |
| except Exception as e: | |
| log.debug(f"[COMPLIANCE] scan failed (pass-through): {e}") | |
| return True, content, [] | |
| async def web_search_brave(query: str, count: int = 5) -> str: | |
| """Brave Search API call. Returns formatted results.""" | |
| if not BRAVE_API_KEY: | |
| return "web_search unavailable: BRAVE_API_KEY not configured" | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as c: | |
| r = await c.get("https://api.search.brave.com/res/v1/web/search", | |
| params={"q": query, "count": count, "text_decorations": False}, | |
| headers={"Accept": "application/json", | |
| "Accept-Encoding": "gzip", | |
| "X-Subscription-Token": BRAVE_API_KEY}) | |
| r.raise_for_status() | |
| data = r.json() | |
| results = data.get("web", {}).get("results", []) | |
| if not results: | |
| return "no results found" | |
| lines = [] | |
| for i, res in enumerate(results[:count], 1): | |
| lines.append(f"{i}. {res.get('title','?')} β {res.get('url','')}\n {res.get('description','')[:200]}") | |
| return "\n\n".join(lines) | |
| except Exception as e: | |
| return f"web_search error: {e}" | |
| async def fetch_url_content(url: str) -> str: | |
| """Fetch a URL and return stripped text (5000 char limit).""" | |
| try: | |
| async with httpx.AsyncClient(timeout=10, follow_redirects=True) as c: | |
| r = await c.get(url, headers={"User-Agent": "FORGE-Agent/1.0"}) | |
| r.raise_for_status() | |
| ct = r.headers.get("content-type", "") | |
| if "html" in ct: | |
| text = re.sub(r"<[^>]+>", " ", r.text) | |
| text = re.sub(r"\s{2,}", " ", text).strip() | |
| else: | |
| text = r.text.strip() | |
| return text[:5000] + ("β¦[truncated]" if len(text) > 5000 else "") | |
| except Exception as e: | |
| return f"fetch_url error: {e}" | |
| # ββ Sprint 5: Saga Orchestrator βββββββββββββββββββββββββββββββββββββ | |
| class SagaStep: | |
| def __init__(self, name: str, forward, compensate=None): | |
| self.name = name | |
| self.forward = forward # async callable β result str | |
| self.compensate = compensate # async callable β None (undo) | |
| class SagaOrchestrator: | |
| """Run a sequence of steps with automatic compensation on failure. | |
| Usage: | |
| saga = SagaOrchestrator(agent_name, saga_id) | |
| saga.add_step("reserve_slot", fwd=lambda: ..., comp=lambda: ...) | |
| saga.add_step("vault_write", fwd=lambda: ..., comp=lambda: ...) | |
| result = await saga.run() | |
| """ | |
| def __init__(self, agent: str, saga_id: str = ""): | |
| self.agent = agent | |
| self.saga_id = saga_id or str(uuid.uuid4())[:8] | |
| self.steps: list[SagaStep] = [] | |
| self.completed: list[tuple[str, str]] = [] # (name, result) | |
| def add_step(self, name: str, fwd, comp=None): | |
| self.steps.append(SagaStep(name, fwd, comp)) | |
| async def run(self) -> dict: | |
| emit_trace(self.agent, "saga_start", | |
| {"saga_id": self.saga_id, "steps": [s.name for s in self.steps]}) | |
| for step in self.steps: | |
| try: | |
| result = await step.forward() | |
| self.completed.append((step.name, str(result))) | |
| log.info(f"[SAGA {self.saga_id}] {step.name} OK: {str(result)[:80]}") | |
| except Exception as e: | |
| log.error(f"[SAGA {self.saga_id}] {step.name} FAILED: {e} β compensating") | |
| emit_trace(self.agent, "saga_failed", | |
| {"saga_id": self.saga_id, "failed_step": step.name, "error": str(e)}, | |
| status="error") | |
| # Compensate in reverse order | |
| for name, _ in reversed(self.completed): | |
| comp_step = next((s for s in self.steps if s.name == name), None) | |
| if comp_step and comp_step.compensate: | |
| try: | |
| await comp_step.compensate() | |
| log.info(f"[SAGA {self.saga_id}] compensated {name}") | |
| except Exception as ce: | |
| log.warning(f"[SAGA {self.saga_id}] compensate {name} failed: {ce}") | |
| # Alert christof | |
| try: | |
| async with httpx.AsyncClient(timeout=4) as c: | |
| await c.post(f"{SPACES['relay']}/api/notify", json={ | |
| "text": f"⚠️ SAGA {self.saga_id} failed at step <b>{step.name}</b>\nAgent: {self.agent}\nError: {str(e)[:200]}\nCompensations ran for: {[n for n,_ in self.completed]}", | |
| "parse_mode": "HTML"}) | |
| except Exception: | |
| pass | |
| return {"ok": False, "saga_id": self.saga_id, | |
| "failed_step": step.name, "error": str(e), | |
| "saga_compensated": True} | |
| emit_trace(self.agent, "saga_complete", | |
| {"saga_id": self.saga_id, "steps_completed": len(self.completed)}) | |
| return {"ok": True, "saga_id": self.saga_id, | |
| "steps": dict(self.completed)} | |
| # ββ smolagents β Tool definitions ββββββββββββββββββββββββββββββββββ | |
| # Each tool uses httpx synchronous client (tools run in a thread via asyncio.to_thread). | |
| # CodeAgent writes Python code to call these tools, enabling loops, conditionals, | |
| # and natural composition β far more powerful than JSON ReAct. | |
| try: | |
| from smolagents import CodeAgent, Tool, OpenAIServerModel, ToolCallingAgent | |
| from smolagents.monitoring import LogLevel | |
| SMOLAGENTS_OK = True | |
| except ImportError: | |
| SMOLAGENTS_OK = False | |
| log.warning("[SMOLAGENTS] not installed β install 'smolagents[litellm]'") | |
| def _sync_get(space: str, path: str, params: dict = {}) -> dict | None: | |
| url = SPACES.get(space, space) + path | |
| try: | |
| r = httpx.get(url, params=params, timeout=HTTP_TIMEOUT) | |
| r.raise_for_status() | |
| return r.json() | |
| except Exception as e: | |
| log.warning(f"_sync_get {space}{path}: {e}") | |
| return None | |
| def _sync_post(space: str, path: str, data: dict) -> dict | None: | |
| url = SPACES.get(space, space) + path | |
| try: | |
| r = httpx.post(url, json=data, timeout=HTTP_TIMEOUT) | |
| r.raise_for_status() | |
| return r.json() | |
| except Exception as e: | |
| log.warning(f"_sync_post {space}{path}: {e}") | |
| return None | |
| def _harness_scan_sync(agent: str, tool: str, content: str) -> str: | |
| """Synchronous harness scan β returns sanitised content.""" | |
| if not HARNESS_URL: | |
| return content | |
| try: | |
| r = httpx.post(f"{HARNESS_URL}/api/scan/output", | |
| json={"agent": agent, "tool": tool, "content": content}, timeout=4) | |
| if r.status_code == 200: | |
| d = r.json() | |
| return d.get("sanitised", content) | |
| except Exception: | |
| pass | |
| return content | |
| def _approve_sync(agent: str, tool: str, args: dict, risk: str = "high") -> tuple[bool, str]: | |
| """Synchronous approval gate. Polls up to 90s.""" | |
| if not APPROVE_URL: | |
| return True, "approve_url_missing" | |
| try: | |
| r = httpx.post(f"{APPROVE_URL}/api/approval/request", | |
| json={"agent": agent, "tool": tool, "args": args, | |
| "risk": risk, "auto_timeout": 120}, timeout=6) | |
| if r.status_code == 200: | |
| approval_id = r.json().get("id") | |
| for _ in range(18): | |
| time.sleep(5) | |
| pr = httpx.get(f"{APPROVE_URL}/api/approval/{approval_id}", timeout=4) | |
| if pr.status_code == 200: | |
| status = pr.json().get("status") | |
| if status == "approved": return True, "human_approved" | |
| if status in ("rejected", "expired"): return False, status | |
| return False, "timeout" | |
| except Exception as e: | |
| return False, f"approve_error: {e}" | |
| def _compliance_scan_sync(agent: str, content: str) -> str: | |
| """Compliance PII scan β returns redacted content.""" | |
| if not COMPLIANCE_URL: | |
| return content | |
| try: | |
| r = httpx.post(f"{COMPLIANCE_URL}/api/scan/pii", | |
| json={"text": content, "agent": agent, "redact": True}, timeout=4) | |
| if r.status_code == 200: | |
| return r.json().get("redacted", content) | |
| except Exception: | |
| pass | |
| return content | |
| # ββ FORGE Tool classes ββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RelaySendTool(Tool): | |
| name = "relay_send" | |
| description = "Send a message to an agent or broadcast via RELAY. Use for notifications, delegations, status updates." | |
| inputs = { | |
| "to": {"type":"string","description":"Recipient agent name or 'broadcast'"}, | |
| "subject": {"type":"string","description":"Message subject (short)"}, | |
| "body": {"type":"string","description":"Full message body"}, | |
| "priority": {"type":"string","description":"low | normal | high | urgent", "nullable":True}, | |
| "channel": {"type":"string","description":"internal | telegram | browser", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, to, subject, body, priority="normal", channel="internal"): | |
| r = _sync_post("relay", "/api/messages", { | |
| "from": self._agent, "to": to, "subject": subject, "body": body, | |
| "priority": priority or "normal", "channel": channel or "internal"}) | |
| return f"sent id={r.get('id','?')}" if r else "relay_send failed" | |
| class MemorySearchTool(Tool): | |
| name = "memory_search" | |
| description = "Search agent memory across tiers. Always search before answering questions β you may have relevant memories." | |
| inputs = { | |
| "query": {"type":"string","description":"Search query"}, | |
| "tier": {"type":"string","description":"all | episodic | semantic | procedural | working", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, query, tier="all"): | |
| r = _sync_get("memory", "/api/memories/search", | |
| {"q": query, "tier": tier or "all", "limit": 8}) | |
| if not r: return "no results" | |
| results = r if isinstance(r, list) else r.get("results", []) | |
| import json as _json | |
| return _json.dumps([{"content": m.get("content","")[:200], | |
| "tier": m.get("tier"), "tags": m.get("tags")} for m in results[:5]]) | |
| class MemoryStoreTool(Tool): | |
| name = "memory_store" | |
| description = "Store a memory in MEMORY space. Content is PII-scanned before writing." | |
| inputs = { | |
| "content": {"type":"string","description":"Memory content to store"}, | |
| "tier": {"type":"string","description":"episodic | semantic | procedural | working"}, | |
| "tags": {"type":"array","description":"List of tag strings", "nullable":True}, | |
| "importance": {"type":"integer","description":"0-10 importance score", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, content, tier="episodic", tags=None, importance=6): | |
| # Compliance: PII scan before writing | |
| safe_content = _compliance_scan_sync(self._agent, content) | |
| r = _sync_post("memory", "/api/memories", { | |
| "content": safe_content, "tier": tier, "tags": tags or [], | |
| "importance": importance or 6, "agent": self._agent}) | |
| return f"stored id={r.get('id','?')}" if r else "memory_store failed" | |
| class KanbanListTool(Tool): | |
| name = "kanban_list" | |
| description = "List tasks from KANBAN board. Filter by status and/or agent." | |
| inputs = { | |
| "status": {"type":"string","description":"todo | doing | done | blocked | failed", "nullable":True}, | |
| "agent": {"type":"string","description":"Filter by agent name", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, status=None, agent=None): | |
| params = {} | |
| if status: params["status"] = status | |
| if agent: params["agent"] = agent | |
| import json as _json | |
| r = _sync_get("kanban", "/api/tasks", params) or [] | |
| tasks = r if isinstance(r, list) else [] | |
| return _json.dumps([{"id":t.get("id"),"title":t.get("title"), | |
| "status":t.get("status"),"priority":t.get("priority")} for t in tasks[:8]]) | |
| class KanbanMoveTool(Tool): | |
| name = "kanban_move" | |
| description = "Move a task to a new status on the KANBAN board." | |
| inputs = { | |
| "id": {"type":"string","description":"Task ID"}, | |
| "status": {"type":"string","description":"todo | doing | done | blocked | failed"}, | |
| "slot_id": {"type":"string","description":"GPU slot ID if applicable", "nullable":True}, | |
| "react_steps": {"type":"integer","description":"Number of ReAct steps taken", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, id, status, slot_id=None, react_steps=None): | |
| payload = {"id": id, "status": status} | |
| if slot_id: payload["slot_id"] = slot_id | |
| if react_steps: payload["react_steps"] = react_steps | |
| r = _sync_post("kanban", "/api/move", payload) | |
| return f"moved {id} → {status}" if r else "kanban_move failed" | |
| class KanbanCreateTool(Tool): | |
| name = "kanban_create" | |
| description = "Create a new task on the KANBAN board and assign it to an agent." | |
| inputs = { | |
| "title": {"type":"string","description":"Short task title"}, | |
| "body": {"type":"string","description":"Full task description with context"}, | |
| "priority": {"type":"string","description":"low | medium | high | critical"}, | |
| "agent": {"type":"string","description":"Agent to assign task to"}, | |
| "est_minutes": {"type":"integer","description":"Estimated completion minutes", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, title, body, priority="medium", agent=None, est_minutes=None): | |
| payload = {"title": title, "body": body, "priority": priority, | |
| "agent": agent or self._agent, "type": "ai"} | |
| if est_minutes: payload["est_minutes"] = est_minutes | |
| r = _sync_post("kanban", "/api/tasks", payload) | |
| return f"created task id={r.get('id','?')}" if r else "kanban_create failed" | |
| class VaultExecTool(Tool): | |
| name = "vault_exec" | |
| description = ( | |
| "Execute code in VAULT workspace. Runtimes: python3, bash, node, npm, pip, git. " | |
| "IMPORTANT: cwd must be one of: code, reports, scratch, shared. " | |
| "Bash and git commands that are destructive require human approval." | |
| ) | |
| inputs = { | |
| "runtime": {"type":"string","description":"python3 | bash | node | npm | pip | git"}, | |
| "code": {"type":"string","description":"Code or command to execute"}, | |
| "cwd": {"type":"string","description":"Working directory: code | reports | scratch | shared", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, runtime, code, cwd="scratch"): | |
| _VALID_CWDS = {"code","reports","scratch","shared",""} | |
| safe_cwd = (cwd or "scratch").strip("/") if (cwd or "scratch").strip("/") in _VALID_CWDS else "scratch" | |
| # Approval gate for risky bash/git | |
| if runtime in RISKY_RUNTIMES or any(p in code for p in RISKY_PATTERNS): | |
| approved, reason = _approve_sync(self._agent, "vault_exec", | |
| {"runtime": runtime, "code": code[:200], "cwd": safe_cwd}, | |
| risk="high") | |
| if not approved: | |
| return f"vault_exec BLOCKED by approval gate: {reason}" | |
| r = _sync_post("vault", "/api/exec", { | |
| "runtime": runtime, "code": code, "cwd": safe_cwd, "timeout": 30}) | |
| if not r: return "vault_exec failed" | |
| out = _harness_scan_sync(self._agent, "vault_exec", | |
| f"exit={r.get('exit_code')} ms={r.get('ms')}\n{r.get('output','')[:500]}") | |
| return out | |
| class VaultReadTool(Tool): | |
| name = "vault_read" | |
| description = "Read a file from the VAULT workspace." | |
| inputs = {"path": {"type":"string","description":"File path relative to workspace"}} | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, path): | |
| r = _sync_get("vault", "/api/read", {"path": path}) | |
| return r.get("content","")[:800] if r else "vault_read failed" | |
| class VaultWriteTool(Tool): | |
| name = "vault_write" | |
| description = "Write a file to the VAULT workspace. Always write complete file content." | |
| inputs = { | |
| "path": {"type":"string","description":"File path, e.g. code/script.py"}, | |
| "content": {"type":"string","description":"Complete file content"}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, path, content): | |
| r = _sync_post("vault", "/api/write", {"path": path, "content": content, "agent": self._agent}) | |
| return f"written: {path} snap={r.get('snapshot',{}).get('id','?')}" if r else "vault_write failed" | |
| class ForgeSearchTool(Tool): | |
| name = "forge_search" | |
| description = "Search for skills and tools in the FORGE skill registry." | |
| inputs = {"query": {"type":"string","description":"Search query"}} | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, query): | |
| import json as _json | |
| r = _sync_get("forge", "/api/capabilities", {"q": query, "limit": 5}) | |
| items = r if isinstance(r, list) else (r.get("skills",[]) if r else []) | |
| return _json.dumps([{"name":s.get("name"),"description":s.get("description","")[:100]} for s in items[:5]]) | |
| class SlotReserveTool(Tool): | |
| name = "slot_reserve" | |
| description = "Reserve the RTX 5090 GPU slot before a long task. Returns slot_id or queue position." | |
| inputs = { | |
| "task_id": {"type":"string","description":"Task identifier"}, | |
| "est_minutes": {"type":"integer","description":"Estimated minutes needed (1-60)"}, | |
| "priority": {"type":"integer","description":"Priority 1=critical 5=normal 10=low", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, task_id, est_minutes=5, priority=5): | |
| r = _sync_post("nexus", "/api/slot/reserve", { | |
| "agent": self._agent, "task_id": task_id, | |
| "est_minutes": est_minutes, "priority": priority or 5}) | |
| if not r: return "slot_reserve failed" | |
| status = r.get("status","unknown") | |
| if status == "active": | |
| return f"slot ACTIVE slot_id={r['slot_id']} expires_in={est_minutes}min" | |
| elif status == "queued": | |
| return f"slot QUEUED position={r.get('queue_position')} eta={r.get('eta_seconds',0)}s holder={r.get('current_holder','?')} β wait or use local_cpu" | |
| return f"slot status={status}" | |
| class SlotReleaseTool(Tool): | |
| name = "slot_release" | |
| description = "Release the GPU slot when done. Always call after finishing to unblock other agents." | |
| inputs = {"slot_id": {"type":"string","description":"Slot ID from slot_reserve"}} | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, slot_id): | |
| r = _sync_post("nexus", "/api/slot/release", {"slot_id": slot_id}) | |
| return f"slot released (held {r.get('held_seconds',0)}s)" if r and r.get("released") else "slot_release failed" | |
| class SlotStatusTool(Tool): | |
| name = "slot_status" | |
| description = "Check who holds the GPU slot and current queue. Use before slot_reserve." | |
| inputs = {} | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self): | |
| r = _sync_get("nexus", "/api/slot/status", {}) | |
| if not r: return "slot_status failed" | |
| active = r.get("active") | |
| queue = r.get("queue", []) | |
| result = f"OCCUPIED by {active['agent']} expires_in={int(active.get('expires_at',0)-time.time())}s" if active else "FREE" | |
| if queue: result += f" | Queue: {[q['agent'] for q in queue]}" | |
| return result | |
| class TriggerAgentTool(Tool): | |
| name = "trigger_agent" | |
| description = "Wake another agent immediately with a task. Always call after delegate to ensure pickup." | |
| inputs = { | |
| "agent": {"type":"string","description":"Agent name to wake"}, | |
| "content": {"type":"string","description":"Task content or context for the agent"}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, agent, content=""): | |
| r = _sync_post("pulse", f"/api/trigger/{agent}", | |
| {"from": self._agent, "content": content or f"Task delegated by {self._agent}"}) | |
| return f"triggered {agent}" if r else f"trigger queued for {agent} (heartbeat pickup)" | |
| class WebSearchTool(Tool): | |
| name = "web_search" | |
| description = "Search the web via Brave Search API. Returns titles, URLs and snippets." | |
| inputs = { | |
| "query": {"type":"string","description":"Search query"}, | |
| "count": {"type":"integer","description":"Number of results 1-10, default 5", "nullable":True}, | |
| } | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, query, count=5): | |
| if not BRAVE_API_KEY: | |
| return "web_search unavailable: BRAVE_API_KEY not set" | |
| try: | |
| r = httpx.get("https://api.search.brave.com/res/v1/web/search", | |
| params={"q": query, "count": min(count or 5, 10), "text_decorations": False}, | |
| headers={"Accept": "application/json", | |
| "X-Subscription-Token": BRAVE_API_KEY}, timeout=8) | |
| r.raise_for_status() | |
| results = r.json().get("web", {}).get("results", []) | |
| if not results: return "no results" | |
| lines = [f"{i}. {res.get('title','?')} β {res.get('url','')}\n {res.get('description','')[:200]}" | |
| for i, res in enumerate(results[:count or 5], 1)] | |
| return "\n\n".join(lines) | |
| except Exception as e: | |
| return f"web_search error: {e}" | |
| class FetchUrlTool(Tool): | |
| name = "fetch_url" | |
| description = "Fetch a URL and return stripped text (5000 char limit). Use after web_search." | |
| inputs = {"url": {"type":"string","description":"Full URL to fetch"}} | |
| output_type = "string" | |
| def __init__(self, agent_name): super().__init__(); self._agent = agent_name | |
| def forward(self, url): | |
| try: | |
| r = httpx.get(url, headers={"User-Agent": "FORGE-Agent/1.0"}, | |
| timeout=10, follow_redirects=True) | |
| r.raise_for_status() | |
| ct = r.headers.get("content-type", "") | |
| text = re.sub(r"<[^>]+>", " ", r.text) if "html" in ct else r.text | |
| text = re.sub(r"\s{2,}", " ", text).strip() | |
| return text[:5000] + ("…[truncated]" if len(text) > 5000 else "") | |
| except Exception as e: | |
| return f"fetch_url error: {e}" | |
| # ββ FORGE OpenAI-compatible model (NEXUS backend) βββββββββββββββββββ | |
| def build_forge_model(cost_mode: str = "balanced") -> object | None: | |
| """Build smolagents model pointing at NEXUS (OpenAI-compatible).""" | |
| if not SMOLAGENTS_OK: | |
| return None | |
| nexus_url = SPACES.get("nexus", "") | |
| model_name = { | |
| "cheap": "nexus-fast", | |
| "balanced": "nexus-auto", | |
| "best": "nexus-best", | |
| }.get(cost_mode, "nexus-auto") | |
| try: | |
| from smolagents import OpenAIServerModel | |
| return OpenAIServerModel( | |
| model_id = model_name, | |
| api_base = nexus_url + "/v1", | |
| api_key = os.environ.get("NEXUS_API_KEY", "forge-internal"), | |
| ) | |
| except Exception as e: | |
| log.warning(f"[SMOLAGENTS] OpenAIServerModel failed: {e}") | |
| return None | |
| def build_agent_tools(agent_name: str) -> list: | |
| """Instantiate all FORGE tools for the given agent.""" | |
| return [ | |
| RelaySendTool(agent_name), | |
| MemorySearchTool(agent_name), | |
| MemoryStoreTool(agent_name), | |
| KanbanListTool(agent_name), | |
| KanbanMoveTool(agent_name), | |
| KanbanCreateTool(agent_name), | |
| VaultExecTool(agent_name), | |
| VaultReadTool(agent_name), | |
| VaultWriteTool(agent_name), | |
| ForgeSearchTool(agent_name), | |
| SlotReserveTool(agent_name), | |
| SlotReleaseTool(agent_name), | |
| SlotStatusTool(agent_name), | |
| TriggerAgentTool(agent_name), | |
| WebSearchTool(agent_name), | |
| FetchUrlTool(agent_name), | |
| ] | |
| # ββ Step callback β trace + harness ββββββββββββββββββββββββββββββββ | |
| def make_step_callback(agent_name: str, trace: dict): | |
| """Returns a step callback that emits trace events and scans tool outputs.""" | |
| from smolagents.memory import ActionStep | |
| def _callback(step_log, agent=None): | |
| if not isinstance(step_log, ActionStep): | |
| return | |
| # Harness: scan tool output before LLM re-ingests | |
| obs = getattr(step_log, "observations", None) or "" | |
| if obs and HARNESS_URL: | |
| tool_name = "" | |
| if step_log.tool_calls: | |
| tool_name = step_log.tool_calls[0].name if hasattr(step_log.tool_calls[0], "name") else "" | |
| sanitised = _harness_scan_sync(agent_name, tool_name, str(obs)) | |
| if sanitised != str(obs): | |
| step_log.observations = sanitised | |
| # Trace | |
| step_info = { | |
| "step": getattr(step_log, "step_number", len(trace["steps"])), | |
| "thought": str(getattr(step_log, "model_output_message", ""))[:200], | |
| "tool": step_log.tool_calls[0].name if getattr(step_log, "tool_calls", None) else "", | |
| "obs": str(getattr(step_log, "observations", ""))[:200], | |
| "error": str(step_log.error) if getattr(step_log, "error", None) else "", | |
| } | |
| trace["steps"].append(step_info) | |
| push_live({"type": "step", "agent": agent_name, **step_info}) | |
| emit_trace(agent_name, "react_step", step_info, | |
| status="error" if step_info["error"] else "ok") | |
| return _callback | |
| # ββ smolagents CodeAgent react_loop ββββββββββββββββββββββββββββββββ | |
| async def react_loop(agent: dict, trigger_type: str, trigger_content: str) -> dict: | |
| """ | |
| Run a smolagents CodeAgent for this agent tick. | |
| The agent writes Python code to call FORGE tools β loops, conditionals, | |
| multi-step composition all work naturally. | |
| Falls back to ToolCallingAgent if CodeAgent unavailable. | |
| """ | |
| name = agent["name"] | |
| cost_mode = agent.get("cost_mode", "balanced") | |
| max_steps = agent.get("max_react_steps", REACT_MAX) | |
| trace = {"agent": name, "trigger": trigger_type, | |
| "started": int(time.time()), "steps": [], "result": "", "ok": True} | |
| if not SMOLAGENTS_OK: | |
| trace["result"] = "smolagents not installed" | |
| trace["ok"] = False | |
| return trace | |
| # Fetch persona from agent-prompts (cached) | |
| persona_data = get_agent_persona(name) | |
| system_prompt = persona_data.get("system_prompt", agent.get("persona", "You are a helpful AI agent.")) | |
| max_steps = persona_data.get("max_steps", max_steps) | |
| # Load soul.md and user.md for context injection | |
| soul_ctx = "" | |
| try: | |
| sv = _sync_get("vault", "/api/read", {"path": "soul.md"}) | |
| if sv: soul_ctx = sv.get("content", "")[:500] | |
| except Exception: pass | |
| user_ctx = "" | |
| try: | |
| uv = _sync_get("vault", "/api/read", {"path": "user.md"}) | |
| if uv: user_ctx = uv.get("content", "")[:300] | |
| except Exception: pass | |
| # Auto-load skills from FORGE at ReAct start | |
| skills_ctx = "" | |
| try: | |
| skills = _sync_get("forge", "/api/capabilities", {"q": name, "limit": 5}) | |
| if skills: | |
| items = skills if isinstance(skills, list) else skills.get("skills", []) | |
| skills_ctx = "AVAILABLE SKILLS:\n" + "\n".join( | |
| f" - {s.get('name')}: {s.get('description','')[:80]}" for s in items[:5]) | |
| except Exception: pass | |
| full_system = "\n\n".join(filter(None, [system_prompt, soul_ctx, skills_ctx])) | |
| task = ( | |
| f"TRIGGER: {trigger_type}\n" | |
| f"CONTEXT: {trigger_content}\n" | |
| + (f"OPERATOR: {user_ctx}\n" if user_ctx else "") | |
| + f"UTC: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}\n" | |
| "Execute your assigned task using the available tools." | |
| ) | |
| push_live({"type": "react_start", "agent": name, "trigger": trigger_type}) | |
| def _run_agent(): | |
| """Sync function β runs in thread pool via asyncio.to_thread.""" | |
| model = build_forge_model(cost_mode) | |
| if model is None: | |
| return {"ok": False, "result": "NEXUS model unavailable", "steps": []} | |
| tools = build_agent_tools(name) | |
| try: | |
| from smolagents import ToolCallingAgent | |
| agent_obj = ToolCallingAgent( | |
| tools = tools, | |
| model = model, | |
| max_steps = max_steps, | |
| instructions = full_system, | |
| verbosity_level = LogLevel.WARNING, | |
| step_callbacks = [make_step_callback(name, trace)], | |
| name = name, | |
| ) | |
| except Exception as e: | |
| log.error(f"[SMOLAGENTS] agent init failed: {e}") | |
| return {"ok": False, "result": str(e), "steps": []} | |
| try: | |
| result = agent_obj.run(task, reset=True) | |
| return {"ok": True, "result": str(result)[:500], "steps": trace["steps"]} | |
| except Exception as e: | |
| log.error(f"[SMOLAGENTS] agent.run failed: {e}") | |
| return {"ok": False, "result": str(e), "steps": trace["steps"]} | |
| try: | |
| outcome = await asyncio.to_thread(_run_agent) | |
| except Exception as e: | |
| outcome = {"ok": False, "result": str(e), "steps": []} | |
| trace["ok"] = outcome["ok"] | |
| trace["result"] = outcome["result"] | |
| trace["ms"] = int((time.time() - trace["started"]) * 1000) | |
| # Emit final trace to TRACE + LEARN | |
| emit_trace(name, "react_complete", | |
| {"result": trace["result"], "steps": len(trace["steps"]), | |
| "trigger": trigger_type, "ms": trace["ms"]}, | |
| status="ok" if trace["ok"] else "error") | |
| push_live({"type": "react_done", "agent": name, | |
| "ok": trace["ok"], "ms": trace["ms"], "steps": len(trace["steps"])}) | |
| return trace | |
| # ββ Heartbeat engine βββββββββββββββββββββββββββββββββββββββββββββββ | |
| scheduler = AsyncIOScheduler(timezone="UTC") | |
| # ββ Heartbeat engine βββββββββββββββββββββββββββββββββββββββββββββββ | |
| scheduler = AsyncIOScheduler(timezone="UTC") | |
| async def agent_tick(agent_name: str, trigger_type: str = "heartbeat", content: str = ""): | |
| agents = load_json(AGENTS_FILE, []) | |
| agent = next((a for a in agents if a["name"] == agent_name), None) | |
| if not agent or not agent.get("enabled", True): | |
| return | |
| if agent_status.get(agent_name, {}).get("running"): | |
| log.info(f"Agent {agent_name} already running, skip tick") | |
| return | |
| agent_status[agent_name] = {**agent_status.get(agent_name,{}), | |
| "running": True, "last_tick": int(time.time())} | |
| push_live({"type":"heartbeat","agent":agent_name,"trigger":trigger_type}) | |
| try: | |
| # Build context from multiple sources | |
| context_parts = [] | |
| if content: context_parts.append(content) | |
| # Check relay inbox β read AND ack so same messages don't repeat next tick | |
| inbox = await space_get("relay", f"/api/inbox/{agent_name}", {"unread":"true","limit":"5"}) | |
| if isinstance(inbox, list) and inbox: | |
| msgs = [f"[from:{m.get('from')}] {m.get('subject','')} β {m.get('body','')[:150]}" for m in inbox[:5]] | |
| context_parts.append("UNREAD MESSAGES:\n" + "\n".join(msgs)) | |
| # ACK each message so it doesn't re-appear next tick | |
| for m in inbox[:5]: | |
| mid = m.get("id","") | |
| if mid: | |
| asyncio.create_task(space_post("relay", f"/api/messages/{mid}/ack", {"agent": agent_name})) | |
| # Check kanban for assigned tasks β include task body so agent knows what to do | |
| kanban = await space_get("kanban", "/api/tasks", {"agent":agent_name,"status":"todo"}) | |
| if isinstance(kanban, list) and kanban: | |
| tasks = [f"[{t.get('priority','?')}] id={t.get('id','')} title={t.get('title','')} | {t.get('body','')[:200]}" for t in kanban[:5]] | |
| context_parts.append("YOUR OPEN TASKS (act on these):\n" + "\n".join(tasks)) | |
| if not context_parts and trigger_type == "heartbeat": | |
| # Nothing to do | |
| agent_status[agent_name]["running"] = False | |
| agent_status[agent_name]["last_result"] = "idle" | |
| push_live({"type":"idle","agent":agent_name}) | |
| return | |
| # For manual/UI triggers with no specific content, give agent clear instruction | |
| if trigger_type in ("manual",) and not content: | |
| content = f"You have been manually triggered. Check your open tasks above and execute them. If a task says to create a file, use vault_write then vault_exec." | |
| full_context = "\n\n".join(context_parts) if context_parts else "Routine heartbeat check." | |
| trace = await react_loop(agent, trigger_type, full_context) | |
| tc = agent_status.get(agent_name, {}).get("tick_count", 0) + 1 | |
| agent_status[agent_name] = {**agent_status.get(agent_name,{}), | |
| "running": False, "tick_count": tc, | |
| "last_result": trace["result"][:100], | |
| "last_ok": trace["ok"], "last_ms": trace.get("ms",0)} | |
| except Exception as e: | |
| log.error(f"agent_tick {agent_name}: {e}") | |
| agent_status[agent_name] = {**agent_status.get(agent_name,{}), | |
| "running": False, "last_result": f"error: {e}", "last_ok": False} | |
| push_live({"type":"error","agent":agent_name,"message":str(e)}) | |
| def register_agent_jobs(agent: dict): | |
| """Register APScheduler jobs for an agent.""" | |
| name = agent["name"] | |
| # Remove existing jobs for this agent | |
| for job in scheduler.get_jobs(): | |
| if job.id.startswith(f"hb_{name}"): | |
| job.remove() | |
| if not agent.get("enabled", True): | |
| return | |
| # Heartbeat | |
| interval = agent.get("heartbeat_seconds", 0) | |
| if interval and interval > 0: | |
| from apscheduler.triggers.interval import IntervalTrigger | |
| scheduler.add_job( | |
| agent_tick, IntervalTrigger(seconds=max(interval, 30)), | |
| args=[name, "heartbeat", ""], | |
| id=f"hb_{name}_interval", replace_existing=True, | |
| max_instances=1, misfire_grace_time=60) | |
| log.info(f"Registered heartbeat for {name} every {interval}s") | |
| def register_schedule_job(entry: dict): | |
| """Register a timetable entry as APScheduler job.""" | |
| eid = entry["id"] | |
| agent = entry.get("agent","") | |
| if not entry.get("enabled", True): return | |
| trigger_content = entry.get("prompt","Scheduled task: " + entry.get("title","")) | |
| job_id = f"sch_{eid}" | |
| for job in scheduler.get_jobs(): | |
| if job.id == job_id: job.remove() | |
| if entry.get("recurrence") == "once": | |
| dt_str = entry.get("datetime","") | |
| if dt_str: | |
| try: | |
| dt = datetime.fromisoformat(dt_str) | |
| scheduler.add_job(agent_tick, DateTrigger(run_date=dt), | |
| args=[agent, "scheduled", trigger_content], | |
| id=job_id, replace_existing=True, max_instances=1) | |
| except: pass | |
| else: | |
| # weekly / daily cron | |
| day = entry.get("day", 0) # 0=Mon | |
| hour = entry.get("hour", 9) | |
| minute = entry.get("minute", 0) | |
| day_map = {0:"mon",1:"tue",2:"wed",3:"thu",4:"fri",5:"sat",6:"sun"} | |
| if entry.get("recurrence") == "daily": | |
| cron = CronTrigger(hour=hour, minute=minute) | |
| else: | |
| cron = CronTrigger(day_of_week=day_map.get(day,"mon"), hour=hour, minute=minute) | |
| scheduler.add_job(agent_tick, cron, | |
| args=[agent, "scheduled", trigger_content], | |
| id=job_id, replace_existing=True, max_instances=1) | |
| def reload_all_jobs(): | |
| agents = load_json(AGENTS_FILE, []) | |
| schedule = load_json(SCHEDULE_FILE, []) | |
| for a in agents: register_agent_jobs(a) | |
| for e in schedule: register_schedule_job(e) | |
| # ββ Default data βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DEFAULT_AGENTS = [ | |
| {"name":"researcher","persona":"You are a deep research specialist and self-improvement engine. RESEARCH WORKFLOW: 1) kanban_move(doing), 2) slot_reserve(est_minutes=8, priority=3) for complex queries, 3) memory_search across all tiers, 4) synthesize findings, 5) memory_store(semantic tier, importance=7+), 6) relay_send to christof with digest, 7) slot_release, 8) kanban_move(done). SELF-IMPROVEMENT DUTY (weekly or when triggered): call self_reflect() β read your own traces, find failure patterns, propose persona improvements. PATTERN DETECTION: After completing research, check if you found the same insight 3+ times across memories. If yes: vault_write('code/utils/<insight_name>.py', utility_code), then relay_send('christof', 'Skill candidate: <name>'). DEEP RESEARCH means: use memory_search multiple times with different queries, cross-reference findings, never stop at first result.","enabled":True,"heartbeat_seconds":0,"cost_mode":"best","max_react_steps":8,"color":"#0ea5e9","tags":["research","analysis","self-improvement"]}, | |
| {"name":"coder","persona":"You are a senior software engineer with RTX 5090 access. WORKFLOW for any coding task: 1) kanban_move(id, status=doing), 2) slot_reserve(task_id=id, est_minutes=10, priority=2) β if QUEUED, wait or proceed with local_cpu for small tasks, 3) vault_write(path=code/filename.py, content=<full complete code>), 4) vault_exec(runtime=python3, code='exec(open(\"code/filename.py\").read())', cwd=code) to verify it runs, 5) slot_release(slot_id=<id from step 2>), 6) kanban_move(id, status=done, slot_id=<slot_id>, react_steps=<step count>), 7) finish with result. Always write COMPLETE working code, never placeholders.","enabled":True,"heartbeat_seconds":0,"cost_mode":"best","max_react_steps":7,"color":"#2ed573","tags":["coding","execution"]}, | |
| {"name":"planner","persona":"You are a strategic planner. Break goals into kanban tasks with kanban_create(title, body, agent, est_minutes, deps=[dep_task_id]). After creating a task for another agent: ALWAYS call trigger_agent(agent=target, content=task_body) to wake them immediately β do not just delegate and hope. Track progress with kanban_list. If a task has dependencies, set deps=[id1,id2] so it stays blocked until deps are done. Report sprint status to christof weekly.","enabled":True,"heartbeat_seconds":0,"cost_mode":"balanced","max_react_steps":6,"color":"#ff9500","tags":["planning","coordination"]}, | |
| {"name":"monitor","persona":"You are a system watchdog. Check kanban for stuck/failed tasks, check relay for urgent messages, report to christof. When you find a critical task stuck in todo: relay_send to the assigned agent reminding them. Keep reports short and factual.","enabled":True,"heartbeat_seconds":300,"cost_mode":"cheap","max_react_steps":5,"color":"#ff6b9d","tags":["monitoring","alerts"]}, | |
| {"name":"christof","persona":"You are Christof's personal AI coordinator at ki-fusion-labs.de. Summarize daily progress from kanban and memory. Flag blockers immediately. Create tasks for things that need doing. Write concisely in German or English as appropriate.","enabled":True,"heartbeat_seconds":0,"cost_mode":"best","max_react_steps":6,"color":"#ff6b00","tags":["personal","coordinator"]}, | |
| ] | |
| DEFAULT_SCHEDULE = [ | |
| {"id":"s1","agent":"monitor","title":"Morning system check","recurrence":"daily","hour":8,"minute":0,"day":0,"prompt":"Run a full system health check: check relay for urgent messages, scan kanban for blocked/failed tasks, report to christof.","enabled":True,"color":"#ff6b9d"}, | |
| {"id":"s2","agent":"researcher","title":"Daily AI research digest","recurrence":"daily","hour":9,"minute":0,"day":0,"prompt":"Search memory and knowledge for recent AI topics. Find the top 3 relevant developments for ki-fusion-labs projects. Store summary in memory (semantic tier). Send digest to christof via relay.","enabled":True,"color":"#0ea5e9"}, | |
| {"id":"s3","agent":"planner","title":"Sprint planning","recurrence":"weekly","hour":9,"minute":30,"day":0,"prompt":"Review all todo tasks in kanban. Prioritize by urgency and dependencies. Create a sprint plan for the week. Send summary to christof via relay.","enabled":True,"color":"#ff9500"}, | |
| {"id":"s4","agent":"coder","title":"Code quality check","recurrence":"weekly","hour":10,"minute":0,"day":2,"prompt":"List files in vault/code. Run basic linting on Python files. Report any issues to kanban as tasks. Store quality report in vault/reports.","enabled":True,"color":"#2ed573"}, | |
| {"id":"s5","agent":"monitor","title":"Evening task review","recurrence":"daily","hour":18,"minute":0,"day":0,"prompt":"Review today's kanban activity: tasks completed, blocked, or failed. Send end-of-day summary to christof. Archive completed tasks.","enabled":True,"color":"#ff6b9d"}, | |
| {"id":"s6","agent":"planner","title":"Weekly retrospective","recurrence":"weekly","hour":16,"minute":0,"day":4,"prompt":"Review the week: what was completed, what is blocked, what needs attention next week. Generate a markdown retrospective report and store in vault/reports. Send highlights to christof.","enabled":True,"color":"#ff9500"}, | |
| ] | |
| def seed(): | |
| if not AGENTS_FILE.exists(): save_json(AGENTS_FILE, DEFAULT_AGENTS) | |
| if not SCHEDULE_FILE.exists(): save_json(SCHEDULE_FILE, DEFAULT_SCHEDULE) | |
| if not ACTIVITY_FILE.exists(): save_json(ACTIVITY_FILE, []) | |
| seed() | |
| # ββ FastAPI βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="PULSE β Agent Nervous System") | |
| async def startup(): | |
| scheduler.start() | |
| reload_all_jobs() | |
| log.info("PULSE scheduler started") | |
| async def shutdown(): | |
| scheduler.shutdown(wait=False) | |
| def jresp(d, s=200): return JSONResponse(content=d, status_code=s) | |
| # ββ Agent API βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_agents(): | |
| agents = load_json(AGENTS_FILE, []) | |
| return jresp([{**a, "status": agent_status.get(a["name"],{"running":False,"tick_count":0})} for a in agents]) | |
| async def upsert_agent(request: Request): | |
| data = await request.json() | |
| name = data.get("name","").strip().lower() | |
| if not name: raise HTTPException(400, "name required") | |
| agents = load_json(AGENTS_FILE, []) | |
| existing = next((i for i,a in enumerate(agents) if a["name"]==name), None) | |
| agent = {**(agents[existing] if existing is not None else {}), **data, "name": name} | |
| if existing is not None: agents[existing] = agent | |
| else: agents.append(agent) | |
| save_json(AGENTS_FILE, agents) | |
| register_agent_jobs(agent) | |
| return jresp({"status":"saved","agent":agent}) | |
| async def delete_agent(name: str): | |
| agents = load_json(AGENTS_FILE, []) | |
| agents = [a for a in agents if a["name"] != name] | |
| save_json(AGENTS_FILE, agents) | |
| for job in scheduler.get_jobs(): | |
| if job.id.startswith(f"hb_{name}"): job.remove() | |
| return jresp({"status":"deleted"}) | |
| async def trigger_agent(name: str, request: Request): | |
| data = await request.json() | |
| trigger = data.get("trigger","manual") | |
| content = data.get("content","Manual trigger from PULSE UI") | |
| asyncio.create_task(agent_tick(name, trigger, content)) | |
| return jresp({"status":"triggered","agent":name}) | |
| async def trigger_agent_shortcut(agent_name: str, request: Request): | |
| """Shortcut trigger β called by other agents after delegation. No auth needed.""" | |
| body = await request.json() if request.headers.get("content-type","").startswith("application/json") else {} | |
| content = body.get("content", f"Triggered by delegation from {body.get('from','system')}") | |
| agents_cfg = load_json(AGENTS_FILE, []) | |
| by_name = {a["name"]: a for a in agents_cfg} | |
| if agent_name not in by_name: | |
| return jresp({"error": f"unknown agent: {agent_name}"}, 404) | |
| asyncio.create_task(agent_tick(agent_name, "delegation", content)) | |
| log.info(f"[TRIGGER] {agent_name} woken by delegation: {content[:60]}") | |
| return jresp({"triggered": agent_name, "ts": datetime.now(timezone.utc).isoformat()}) | |
| async def self_reflect(agent_name: str, request: Request): | |
| """Self-reflection delegated to agent-loop. PULSE triggers the cycle, loop orchestrates.""" | |
| try: | |
| import urllib.request as ureq | |
| body = json.dumps({"triggered_by": f"pulse:{agent_name}"}).encode() | |
| req = ureq.Request(f"{LOOP_URL}/api/cycle", data=body, | |
| headers={"Content-Type": "application/json"}, method="POST") | |
| with ureq.urlopen(req, timeout=5) as r: | |
| resp = json.loads(r.read()) | |
| emit_trace(agent_name, "self_reflect", {"delegated_to": "agent-loop"}) | |
| return jresp({"status": "delegated_to_loop", "agent": agent_name, | |
| "loop_response": resp, | |
| "note": "Self-improvement cycle started in agent-loop"}) | |
| except Exception as e: | |
| return jresp({"status": "loop_unreachable", "agent": agent_name, "error": str(e), | |
| "note": "agent-loop is not reachable β check LOOP_URL secret"}) | |
| async def reflection_proposals(): | |
| """Pending persona proposals β read from agent-loop.""" | |
| try: | |
| import urllib.request as ureq | |
| with ureq.urlopen(f"{LOOP_URL}/api/proposals?state=pending&limit=20", timeout=4) as r: | |
| data = json.loads(r.read()) | |
| return jresp(data) | |
| except Exception as e: | |
| return jresp({"error": str(e), "note": "Proposals now live in agent-loop"}) | |
| async def apply_reflection(agent_name: str, request: Request): | |
| """Approve a proposal β delegated to agent-loop.""" | |
| body = await request.json() | |
| proposal_id = body.get("proposal_id", "") | |
| if not proposal_id: | |
| return jresp({"error": "proposal_id required β get it from /api/reflect/proposals"}, 400) | |
| try: | |
| import urllib.request as ureq | |
| approve_body = json.dumps({"approved_by": "pulse"}).encode() | |
| req = ureq.Request(f"{LOOP_URL}/api/proposals/{proposal_id}/approve", | |
| data=approve_body, headers={"Content-Type":"application/json"}, method="POST") | |
| with ureq.urlopen(req, timeout=5) as r: | |
| resp = json.loads(r.read()) | |
| return jresp(resp) | |
| except Exception as e: | |
| return jresp({"error": str(e)}, 500) | |
| async def agent_traces(name: str, limit: int = 10): | |
| traces = [] | |
| for p in sorted((BASE/"traces").glob("*.json"), reverse=True)[:50]: | |
| try: | |
| t = json.loads(p.read_text()) | |
| if t.get("agent") == name: traces.append(t) | |
| if len(traces) >= limit: break | |
| except: pass | |
| return jresp(traces) | |
| async def all_status(): | |
| return jresp(agent_status) | |
| # ββ Schedule API ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_schedule(): | |
| return jresp(load_json(SCHEDULE_FILE, [])) | |
| async def upsert_schedule(request: Request): | |
| data = await request.json() | |
| if not data.get("id"): data["id"] = uuid.uuid4().hex[:8] | |
| entries = load_json(SCHEDULE_FILE, []) | |
| existing = next((i for i,e in enumerate(entries) if e["id"]==data["id"]), None) | |
| if existing is not None: entries[existing] = data | |
| else: entries.append(data) | |
| save_json(SCHEDULE_FILE, entries) | |
| register_schedule_job(data) | |
| return jresp({"status":"saved","entry":data}) | |
| async def delete_schedule(eid: str): | |
| entries = load_json(SCHEDULE_FILE, []) | |
| entries = [e for e in entries if e["id"] != eid] | |
| save_json(SCHEDULE_FILE, entries) | |
| for job in scheduler.get_jobs(): | |
| if job.id == f"sch_{eid}": job.remove() | |
| return jresp({"status":"deleted"}) | |
| async def run_now(eid: str): | |
| entries = load_json(SCHEDULE_FILE, []) | |
| entry = next((e for e in entries if e["id"]==eid), None) | |
| if not entry: raise HTTPException(404) | |
| asyncio.create_task(agent_tick(entry.get("agent",""), "manual_schedule", | |
| entry.get("prompt","scheduled"))) | |
| return jresp({"status":"triggered"}) | |
| # ββ Activity + SSE ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def activity(limit: int = 50): | |
| return jresp(load_json(ACTIVITY_FILE, [])[:limit]) | |
| async def api_stats_pulse(): | |
| """Basic stats for external health checks.""" | |
| agents = load_json(AGENTS_FILE, []) | |
| running = sum(1 for a in agents if agent_status.get(a["name"],{}).get("running")) | |
| ticks = sum(agent_status.get(a["name"],{}).get("tick_count",0) for a in agents) | |
| return jresp({ | |
| "ok": True, "version": "2.0.0", | |
| "agents": len(agents), | |
| "running": running, | |
| "total_ticks": ticks, | |
| "smolagents": SMOLAGENTS_OK, | |
| }) | |
| async def live_feed(): | |
| q = asyncio.Queue() | |
| live_queues.append(q) | |
| async def stream(): | |
| try: | |
| # Send recent activity on connect | |
| recent = load_json(ACTIVITY_FILE, [])[:5] | |
| for ev in reversed(recent): | |
| yield f"data: {json.dumps(ev)}\n\n" | |
| yield f"data: {json.dumps({'type':'connected','spaces':list(SPACES.keys())})}\n\n" | |
| while True: | |
| try: | |
| payload = await asyncio.wait_for(q.get(), timeout=25) | |
| yield f"data: {payload}\n\n" | |
| except asyncio.TimeoutError: | |
| yield f"data: {json.dumps({'type':'ping','ts':int(time.time())})}\n\n" | |
| finally: | |
| live_queues.remove(q) | |
| return StreamingResponse(stream(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"}) | |
| async def spaces_health(): | |
| results = {} | |
| async def check(name, url): | |
| # forge doesn't expose /api/stats β fall back to root | |
| health_path = "/api/stats" if name != "forge" else "/" | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as c: | |
| r = await c.get(url + health_path) | |
| results[name] = {"ok": r.status_code < 400, "status": r.status_code, "url": url} | |
| except Exception as e: | |
| results[name] = {"ok": False, "error": str(e)[:60], "url": url} | |
| await asyncio.gather(*[check(n,u) for n,u in SPACES.items()]) | |
| return jresp(results) | |
| async def all_traces(limit: int = 20): | |
| traces = [] | |
| for p in sorted((BASE/"traces").glob("*.json"), reverse=True)[:limit]: | |
| try: traces.append(json.loads(p.read_text())) | |
| except: pass | |
| return jresp(traces) | |
| async def llm_status(): | |
| """Show which LLM providers are configured and their cooldown state.""" | |
| now = time.time() | |
| providers = [ | |
| { | |
| "name": "nexus", | |
| "label": "NEXUS (ki-fusion-labs RTX 5090)", | |
| "configured": True, | |
| "url": SPACES["nexus"], | |
| "priority": 1, | |
| "ok": _provider_ok("nexus"), | |
| "last_fail": _provider_failures.get("nexus"), | |
| "cooldown_remaining": max(0, int(_PROVIDER_COOLDOWN - (now - _provider_failures.get("nexus", 0)))), | |
| }, | |
| { | |
| "name": "anthropic", | |
| "label": "Anthropic claude-haiku", | |
| "configured": bool(ANTHROPIC_KEY), | |
| "priority": 2, | |
| "ok": _provider_ok("anthropic"), | |
| "last_fail": _provider_failures.get("anthropic"), | |
| "cooldown_remaining": max(0, int(_PROVIDER_COOLDOWN - (now - _provider_failures.get("anthropic", 0)))), | |
| }, | |
| { | |
| "name": "hf_inference", | |
| "label": f"HF Inference ({FALLBACK_HF_MODEL})", | |
| "configured": bool(HF_TOKEN), | |
| "priority": 3, | |
| "ok": _provider_ok("hf_inference"), | |
| "last_fail": _provider_failures.get("hf_inference"), | |
| "cooldown_remaining": max(0, int(_PROVIDER_COOLDOWN - (now - _provider_failures.get("hf_inference", 0)))), | |
| }, | |
| { | |
| "name": "local_cpu", | |
| "label": "NEXUS local_cpu (Qwen 0.5B, always available)", | |
| "configured": True, | |
| "priority": 4, | |
| "ok": True, # always try this last | |
| "last_fail": None, | |
| "cooldown_remaining": 0, | |
| "note": "slow ~10-30s, last resort", | |
| }, | |
| ] | |
| return jresp({"providers": providers, "cooldown_seconds": _PROVIDER_COOLDOWN}) | |
| async def llm_test(request: Request): | |
| """Fire a minimal test call through the full chain and return which provider answered.""" | |
| data = await request.json() | |
| probe = data.get("prompt", "Reply with exactly: PULSE_OK") | |
| try: | |
| result = await call_llm( | |
| [{"role": "user", "content": probe}], | |
| system="You are a test probe. Follow instructions exactly.", | |
| max_tokens=20) | |
| return jresp({"ok": True, "response": result[:200]}) | |
| except Exception as e: | |
| return jresp({"ok": False, "error": str(e)}, 500) | |
| # ββ MCP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MCP_TOOLS = [ | |
| {"name":"pulse_trigger","description":"Trigger an agent to run its ReAct loop", | |
| "inputSchema":{"type":"object","required":["agent"],"properties":{ | |
| "agent":{"type":"string"},"content":{"type":"string"},"trigger":{"type":"string"}}}}, | |
| {"name":"pulse_schedule","description":"Add or update a schedule entry", | |
| "inputSchema":{"type":"object","required":["agent","title"],"properties":{ | |
| "agent":{"type":"string"},"title":{"type":"string"}, | |
| "recurrence":{"type":"string","enum":["daily","weekly","once"]}, | |
| "hour":{"type":"integer"},"minute":{"type":"integer"},"day":{"type":"integer"}, | |
| "prompt":{"type":"string"},"enabled":{"type":"boolean"}}}}, | |
| {"name":"pulse_status","description":"Get status of all agents", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| ] | |
| async def mcp_call(name, args): | |
| if name == "pulse_trigger": | |
| asyncio.create_task(agent_tick(args["agent"], args.get("trigger","mcp"), args.get("content",""))) | |
| return json.dumps({"triggered": args["agent"]}) | |
| if name == "pulse_schedule": | |
| if not args.get("id"): args["id"] = uuid.uuid4().hex[:8] | |
| entries = load_json(SCHEDULE_FILE, []) | |
| entries.append(args); save_json(SCHEDULE_FILE, entries) | |
| register_schedule_job(args) | |
| return json.dumps({"scheduled": args["id"]}) | |
| if name == "pulse_status": | |
| return json.dumps(agent_status) | |
| return json.dumps({"error": f"unknown: {name}"}) | |
| async def mcp_sse(): | |
| async def stream(): | |
| init = {"jsonrpc":"2.0","method":"notifications/initialized", | |
| "params":{"serverInfo":{"name":"pulse","version":"1.0"},"capabilities":{"tools":{}}}} | |
| yield f"data: {json.dumps(init)}\n\n" | |
| await asyncio.sleep(0.1) | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools/list_changed','params':{}})}\n\n" | |
| while True: | |
| await asyncio.sleep(25) | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'ping'})}\n\n" | |
| return StreamingResponse(stream(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"}) | |
| async def mcp_rpc(request: Request): | |
| body = await request.json(); method = body.get("method",""); rid = body.get("id",1) | |
| if method == "initialize": | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"serverInfo":{"name":"pulse","version":"1.0"},"capabilities":{"tools":{}}}}) | |
| if method == "tools/list": | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"tools":MCP_TOOLS}}) | |
| if method == "tools/call": | |
| p = body.get("params",{}); res = await mcp_call(p.get("name",""), p.get("arguments",{})) | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"content":[{"type":"text","text":res}]}}) | |
| return jresp({"jsonrpc":"2.0","id":rid,"error":{"code":-32601,"message":"not found"}}) | |
| async def ui(): | |
| return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8") | |
| SPA = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>PULSE — Agent Nervous System</title> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --bg:#05050e;--s1:#090916;--s2:#0d0d1f;--bd:#161630;--bd2:#1e1e3c; | |
| --acc:#ff6b00;--acc2:#ff9240;--pulse:#f0c040;--lo:#39ff6a;--cr:#ff2255; | |
| --info:#38bdf8;--violet:#8b5cf6;--pink:#ec4899; | |
| --txt:#e0e0ff;--dim:#2a2a50;--sub:#484878; | |
| --font:'Syne',sans-serif;--mono:'DM Mono',monospace; | |
| --mon-w:150px; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| html,body{height:100%;overflow:hidden;} | |
| body{font-family:var(--font);background:var(--bg);color:var(--txt);display:flex;flex-direction:column;} | |
| /* Scanline overlay */ | |
| body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:1000; | |
| background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,20,.12) 2px,rgba(0,0,20,.12) 3px);} | |
| /* HEADER */ | |
| #hdr{flex-shrink:0;display:flex;align-items:center;gap:1.2rem;padding:.65rem 1.5rem; | |
| border-bottom:1px solid var(--bd);background:linear-gradient(180deg,#0a0a1a,var(--bg));z-index:10; | |
| position:relative;} | |
| #hdr::after{content:'';position:absolute;bottom:0;left:0;right:0;height:1px; | |
| background:linear-gradient(90deg,transparent,var(--acc),transparent);} | |
| #logo{display:flex;flex-direction:column;gap:1px;flex-shrink:0;} | |
| #logo-main{font-size:1.3rem;font-weight:800;letter-spacing:4px; | |
| background:linear-gradient(90deg,var(--acc),var(--pulse),var(--acc2)); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} | |
| #logo-sub{font-size:.45rem;color:var(--sub);letter-spacing:.3em;text-transform:uppercase;} | |
| #pulse-dot{width:10px;height:10px;border-radius:50%;background:var(--pulse);flex-shrink:0; | |
| box-shadow:0 0 12px var(--pulse);animation:pulsebeat 2s ease-in-out infinite;} | |
| @keyframes pulsebeat{0%,100%{transform:scale(1);opacity:1;}50%{transform:scale(1.4);opacity:.6;}} | |
| #hdr-center{flex:1;display:flex;gap:.4rem;flex-wrap:wrap;align-items:center;} | |
| .hs{display:flex;align-items:center;gap:.3rem;background:var(--s1);border:1px solid var(--bd); | |
| border-radius:5px;padding:.22rem .55rem;font-size:.52rem;color:var(--sub);} | |
| .hs-n{font-size:.85rem;font-weight:700;color:var(--txt);line-height:1;} | |
| #nav{display:flex;gap:.2rem;flex-shrink:0;} | |
| .nav-btn{background:transparent;border:1px solid var(--bd);color:var(--sub); | |
| padding:.32rem .75rem;font-family:var(--font);font-size:.6rem;font-weight:600; | |
| border-radius:5px;cursor:pointer;transition:all .12s;letter-spacing:.08em;} | |
| .nav-btn:hover{border-color:var(--bd2);color:var(--txt);} | |
| .nav-btn.on{background:var(--acc);color:#000;border-color:var(--acc);} | |
| /* LAYOUT */ | |
| #layout{flex:1;display:flex;min-height:0;overflow:hidden;} | |
| #sidebar{width:220px;flex-shrink:0;border-right:1px solid var(--bd); | |
| display:flex;flex-direction:column;overflow:hidden;background:var(--s1);} | |
| #main-area{flex:1;overflow:hidden;display:flex;flex-direction:column;} | |
| /* SIDEBAR */ | |
| .sb-section{padding:.5rem .7rem .3rem;font-size:.44rem;font-weight:700;letter-spacing:.18em; | |
| color:var(--sub);text-transform:uppercase;border-top:1px solid var(--bd);margin-top:.3rem;} | |
| .sb-section:first-child{border-top:none;margin-top:0;} | |
| .agent-card{margin:.2rem .5rem;border-radius:7px;border:1px solid var(--bd); | |
| background:var(--s2);overflow:hidden;cursor:pointer;transition:all .12s;} | |
| .agent-card:hover{border-color:var(--bd2);} | |
| .agent-card.active{border-color:var(--acc);} | |
| .ac-header{display:flex;align-items:center;gap:.4rem;padding:.38rem .5rem;} | |
| .ac-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;} | |
| .ac-dot.running{animation:pulsebeat 1s ease-in-out infinite;} | |
| .ac-name{font-size:.65rem;font-weight:700;flex:1;} | |
| .ac-hb{font-size:.45rem;color:var(--sub);} | |
| .ac-status{font-size:.45rem;padding:.15rem .38rem;border-radius:3px;} | |
| .ac-status.idle{background:rgba(255,107,0,.08);color:var(--acc);} | |
| .ac-status.running{background:rgba(57,255,106,.12);color:var(--lo);} | |
| .ac-status.error{background:rgba(255,34,85,.1);color:var(--cr);} | |
| .ac-result{font-size:.5rem;color:var(--sub);padding:0 .5rem .38rem; | |
| overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;} | |
| .ac-actions{display:flex;gap:.2rem;padding:0 .5rem .38rem;} | |
| .ac-btn{font-size:.48rem;background:var(--s1);border:1px solid var(--bd);border-radius:3px; | |
| padding:1px 6px;cursor:pointer;color:var(--sub);font-family:var(--font);} | |
| .ac-btn:hover{color:var(--lo);border-color:var(--lo);} | |
| .sb-scroll{flex:1;overflow-y:auto;padding:.3rem 0;} | |
| .sb-scroll::-webkit-scrollbar{width:2px;} | |
| .sb-scroll::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| /* SPACES HEALTH */ | |
| .space-row{display:flex;align-items:center;gap:.4rem;padding:.22rem .65rem;font-size:.55rem;} | |
| .sp-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;} | |
| .sp-dot.ok{background:var(--lo);} | |
| .sp-dot.bad{background:var(--cr);} | |
| .sp-dot.unk{background:var(--sub);} | |
| .sp-name{flex:1;color:var(--sub);} | |
| .sp-status{font-size:.45rem;color:var(--dim);} | |
| /* PANELS */ | |
| .panel{display:none;flex:1;overflow:hidden;flex-direction:column;} | |
| .panel.on{display:flex;} | |
| /* TIMETABLE */ | |
| #panel-timetable{padding:0;} | |
| #cal-header{flex-shrink:0;display:flex;align-items:center;gap:.8rem; | |
| padding:.6rem 1.2rem;border-bottom:1px solid var(--bd);background:var(--s1);} | |
| #cal-title{font-size:.9rem;font-weight:700;letter-spacing:.08em;} | |
| .cal-nav{background:var(--s2);border:1px solid var(--bd);color:var(--sub); | |
| width:28px;height:28px;border-radius:5px;display:flex;align-items:center;justify-content:center; | |
| cursor:pointer;font-size:.8rem;} | |
| .cal-nav:hover{border-color:var(--acc);color:var(--acc);} | |
| #btn-add-sched{background:var(--acc);color:#000;border:none;padding:.32rem .75rem; | |
| font-family:var(--font);font-size:.6rem;font-weight:700;letter-spacing:.08em; | |
| border-radius:5px;cursor:pointer;margin-left:auto;} | |
| #btn-add-sched:hover{background:var(--acc2);} | |
| #cal-body{flex:1;overflow:hidden;display:flex;flex-direction:column;} | |
| #cal-days-hdr{display:grid;grid-template-columns:var(--mon-w) repeat(7,1fr); | |
| border-bottom:1px solid var(--bd);flex-shrink:0;} | |
| .cal-day-hdr{padding:.35rem .5rem;font-size:.55rem;font-weight:600;text-align:center; | |
| color:var(--sub);border-left:1px solid var(--bd);} | |
| .cal-day-hdr.today{color:var(--acc);} | |
| .cal-day-hdr:first-child{border-left:none;font-size:.45rem;color:var(--dim);} | |
| #cal-grid{flex:1;overflow-y:auto;position:relative;} | |
| #cal-grid::-webkit-scrollbar{width:4px;} | |
| #cal-grid::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .cal-row{display:grid;grid-template-columns:var(--mon-w) repeat(7,1fr); | |
| border-bottom:1px solid var(--bd);min-height:52px;} | |
| .cal-time-cell{padding:.3rem .5rem;font-size:.5rem;color:var(--sub);font-family:var(--mono); | |
| border-right:1px solid var(--bd);display:flex;align-items:flex-start;justify-content:flex-end; | |
| padding-top:.6rem;} | |
| .cal-cell{border-left:1px solid var(--bd);padding:.2rem .18rem;position:relative;min-height:52px;} | |
| .cal-cell.today{background:rgba(255,107,0,.02);} | |
| .cal-event{border-radius:5px;padding:.22rem .38rem;margin:.08rem 0;cursor:pointer; | |
| font-size:.52rem;font-weight:600;border-left:3px solid;transition:all .1s; | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:.3rem;} | |
| .cal-event:hover{opacity:.85;transform:translateX(2px);} | |
| .cal-event .ev-agent{font-size:.42rem;opacity:.75;font-weight:400;} | |
| .ev-new{animation:evslide .2s ease;} | |
| @keyframes evslide{from{opacity:0;transform:translateX(-4px)}to{opacity:1;transform:none}} | |
| /* AGENTS PANEL */ | |
| #panel-agents{padding:.8rem 1.2rem;overflow-y:auto;} | |
| #panel-agents::-webkit-scrollbar{width:4px;} | |
| #panel-agents::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:.75rem;} | |
| .agent-full-card{background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:1rem; | |
| transition:border-color .12s;} | |
| .agent-full-card:hover{border-color:var(--bd2);} | |
| .afc-header{display:flex;align-items:center;gap:.6rem;margin-bottom:.7rem;} | |
| .afc-dot{width:12px;height:12px;border-radius:50%;} | |
| .afc-name{font-size:.95rem;font-weight:700;flex:1;} | |
| .afc-toggle{background:var(--s2);border:1px solid var(--bd);border-radius:4px;padding:.24rem .55rem; | |
| font-size:.52rem;cursor:pointer;font-family:var(--font);} | |
| .afc-toggle.on{border-color:var(--lo);color:var(--lo);} | |
| .afc-toggle.off{border-color:var(--cr);color:var(--cr);} | |
| .afc-persona{font-size:.58rem;color:var(--sub);line-height:1.5;margin-bottom:.7rem; | |
| max-height:60px;overflow:hidden;text-overflow:ellipsis;} | |
| .afc-meta{display:flex;gap:.35rem;flex-wrap:wrap;margin-bottom:.7rem;} | |
| .afc-tag{font-size:.48rem;background:var(--s2);border:1px solid var(--bd);border-radius:3px; | |
| padding:1px 6px;color:var(--sub);} | |
| .afc-actions{display:flex;gap:.35rem;} | |
| .afc-btn{font-size:.55rem;background:var(--s2);border:1px solid var(--bd);border-radius:4px; | |
| padding:.26rem .6rem;cursor:pointer;font-family:var(--font);color:var(--sub);} | |
| .afc-btn:hover{border-color:var(--acc);color:var(--acc);} | |
| .afc-btn.run{border-color:rgba(57,255,106,.3);color:var(--lo);background:rgba(57,255,106,.04);} | |
| .afc-btn.run:hover{border-color:var(--lo);background:rgba(57,255,106,.1);} | |
| #btn-add-agent{background:var(--acc);color:#000;border:none;padding:.38rem .9rem; | |
| font-family:var(--font);font-size:.62rem;font-weight:700;border-radius:5px; | |
| cursor:pointer;margin-bottom:.8rem;letter-spacing:.06em;} | |
| /* LIVE FEED */ | |
| #panel-live{padding:0;overflow:hidden;} | |
| #live-wrap{display:flex;height:100%;} | |
| #live-feed{flex:1;overflow-y:auto;padding:.6rem .9rem;font-family:var(--mono);font-size:.65rem;} | |
| #live-feed::-webkit-scrollbar{width:4px;} | |
| #live-feed::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .lf-item{display:flex;align-items:flex-start;gap:.55rem;padding:.32rem .45rem; | |
| border-radius:5px;margin-bottom:.22rem;animation:lfin .18s ease;} | |
| @keyframes lfin{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}} | |
| .lf-icon{font-size:.8rem;flex-shrink:0;width:18px;text-align:center;} | |
| .lf-ts{font-size:.48rem;color:var(--dim);flex-shrink:0;width:52px;padding-top:2px;} | |
| .lf-body{flex:1;} | |
| .lf-agent{font-size:.6rem;font-weight:700;margin-right:.35rem;} | |
| .lf-msg{font-size:.6rem;color:var(--sub);} | |
| .lf-type-heartbeat{background:rgba(240,192,64,.03);} | |
| .lf-type-react_start{background:rgba(56,189,248,.04);} | |
| .lf-type-react_step{background:rgba(139,92,246,.04);} | |
| .lf-type-react_done{background:rgba(57,255,106,.04);} | |
| .lf-type-error{background:rgba(255,34,85,.06);} | |
| .lf-type-idle{opacity:.4;} | |
| #live-sidebar{width:300px;border-left:1px solid var(--bd);display:flex;flex-direction:column;overflow:hidden;} | |
| #live-sidebar-hdr{padding:.55rem .8rem;border-bottom:1px solid var(--bd);font-size:.55rem; | |
| font-weight:700;letter-spacing:.12em;color:var(--acc);} | |
| #trace-list{flex:1;overflow-y:auto;padding:.4rem;} | |
| #trace-list::-webkit-scrollbar{width:2px;} | |
| #trace-list::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .trace-item{background:var(--s2);border:1px solid var(--bd);border-radius:6px; | |
| padding:.5rem .65rem;margin-bottom:.3rem;cursor:pointer;transition:all .1s;} | |
| .trace-item:hover{border-color:var(--bd2);} | |
| .tr-agent{font-size:.62rem;font-weight:700;margin-bottom:.2rem;} | |
| .tr-result{font-size:.55rem;color:var(--sub);overflow:hidden;text-overflow:ellipsis; | |
| white-space:nowrap;margin-bottom:.25rem;} | |
| .tr-meta{display:flex;gap:.4rem;font-size:.48rem;color:var(--dim);} | |
| .tr-ok{color:var(--lo);}.tr-fail{color:var(--cr);} | |
| /* TRACE DETAIL MODAL */ | |
| #trace-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200; | |
| backdrop-filter:blur(6px);align-items:center;justify-content:center;} | |
| #trace-modal.open{display:flex;} | |
| .tm-box{background:var(--s1);border:1px solid var(--bd2);border-top:2px solid var(--acc); | |
| border-radius:12px;width:750px;max-width:98vw;max-height:90vh;display:flex;flex-direction:column; | |
| animation:mdin .16s ease;} | |
| @keyframes mdin{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:none}} | |
| .tm-hdr{display:flex;align-items:center;gap:.7rem;padding:.8rem 1.1rem;border-bottom:1px solid var(--bd);} | |
| .tm-title{font-size:.8rem;font-weight:700;flex:1;color:var(--acc);} | |
| .tm-close{background:none;border:none;color:var(--sub);cursor:pointer;font-size:1rem; | |
| width:28px;height:28px;border-radius:4px;display:flex;align-items:center;justify-content:center;} | |
| .tm-close:hover{background:var(--bd2);color:var(--txt);} | |
| .tm-body{flex:1;overflow-y:auto;padding:.9rem 1.1rem;} | |
| .tm-body::-webkit-scrollbar{width:4px;} | |
| .tm-body::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .step-block{margin-bottom:.9rem;background:var(--s2);border:1px solid var(--bd);border-radius:8px;overflow:hidden;} | |
| .step-hdr{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;background:var(--s1); | |
| border-bottom:1px solid var(--bd);} | |
| .step-n{font-size:.58rem;background:var(--acc);color:#000;border-radius:3px;padding:1px 6px;font-weight:700;} | |
| .step-action{font-size:.62rem;font-weight:700;font-family:var(--mono);} | |
| .step-body{padding:.55rem .75rem;} | |
| .step-label{font-size:.48rem;color:var(--sub);font-weight:700;letter-spacing:.12em; | |
| text-transform:uppercase;margin-bottom:.18rem;} | |
| .step-text{font-size:.6rem;color:var(--txt);font-family:var(--mono);white-space:pre-wrap; | |
| word-break:break-word;line-height:1.55;} | |
| .step-obs{background:rgba(57,255,106,.04);border-top:1px solid var(--bd);padding:.45rem .75rem;} | |
| /* MODAL (agents / schedule) */ | |
| #modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:100; | |
| backdrop-filter:blur(5px);align-items:center;justify-content:center;} | |
| #modal.open{display:flex;} | |
| .mdl{background:var(--s1);border:1px solid var(--bd2);border-top:2px solid var(--acc); | |
| border-radius:12px;width:520px;max-width:98vw;max-height:90vh;display:flex;flex-direction:column; | |
| animation:mdin .16s ease;position:relative;} | |
| .mdl-hdr{padding:.85rem 1.1rem;border-bottom:1px solid var(--bd);display:flex;align-items:center;} | |
| .mdl-title{font-size:.75rem;font-weight:700;letter-spacing:2px;flex:1;color:var(--acc);} | |
| .mdl-x{background:none;border:none;color:var(--sub);cursor:pointer;font-size:.9rem; | |
| width:26px;height:26px;border-radius:4px;display:flex;align-items:center;justify-content:center;} | |
| .mdl-x:hover{background:var(--bd2);} | |
| .mdl-body{flex:1;overflow-y:auto;padding:1rem 1.1rem;} | |
| .mdl-body::-webkit-scrollbar{width:3px;} | |
| .mdl-body::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .mfl{margin-bottom:.7rem;} | |
| .mfl label{display:block;font-size:.46rem;color:var(--sub);text-transform:uppercase; | |
| letter-spacing:.12em;margin-bottom:.22rem;font-weight:700;} | |
| .mfl input,.mfl select,.mfl textarea{width:100%;background:var(--s2);border:1px solid var(--bd2); | |
| border-radius:5px;padding:.38rem .55rem;font-family:var(--font);font-size:.65rem;color:var(--txt); | |
| outline:none;transition:border-color .12s;} | |
| .mfl input:focus,.mfl select:focus,.mfl textarea:focus{border-color:var(--acc);} | |
| .mfl textarea{resize:vertical;min-height:80px;font-family:var(--mono);} | |
| .mfl-row{display:grid;grid-template-columns:1fr 1fr;gap:.5rem;} | |
| .mfl-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem;} | |
| .mdl-footer{padding:.75rem 1.1rem;border-top:1px solid var(--bd);display:flex;gap:.4rem;} | |
| .mdl-ok{flex:1;background:var(--acc);color:#000;border:none;padding:.44rem; | |
| font-family:var(--font);font-size:.65rem;font-weight:700;letter-spacing:.1em; | |
| text-transform:uppercase;border-radius:5px;cursor:pointer;} | |
| .mdl-ok:hover{background:var(--acc2);} | |
| .mdl-cancel{background:var(--s2);color:var(--sub);border:1px solid var(--bd2); | |
| padding:.44rem .9rem;font-family:var(--font);font-size:.65rem;border-radius:5px;cursor:pointer;} | |
| .mdl-cancel:hover{color:var(--txt);} | |
| /* TOAST */ | |
| #toasts{position:fixed;bottom:1rem;right:1rem;z-index:300;display:flex;flex-direction:column;gap:.3rem;} | |
| .tst{background:var(--s1);border:1px solid var(--bd2);border-left:3px solid var(--acc); | |
| padding:.38rem .72rem;font-size:.58rem;border-radius:5px;animation:tin .14s ease;color:var(--txt);} | |
| .tst.ok{border-left-color:var(--lo);}.tst.err{border-left-color:var(--cr);} | |
| .tst.warn{border-left-color:var(--pulse);} | |
| @keyframes tin{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:none}} | |
| /* SPACES OVERVIEW */ | |
| #panel-spaces{padding:.8rem 1.2rem;overflow-y:auto;} | |
| #panel-spaces::-webkit-scrollbar{width:4px;} | |
| #panel-spaces::-webkit-scrollbar-thumb{background:var(--bd2);} | |
| .spaces-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.65rem;} | |
| .space-card{background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:.85rem 1rem; | |
| transition:all .12s;} | |
| .space-card:hover{border-color:var(--bd2);} | |
| .sc-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;} | |
| .sc-dot{width:10px;height:10px;border-radius:50%;} | |
| .sc-dot.ok{background:var(--lo);box-shadow:0 0 8px var(--lo);} | |
| .sc-dot.bad{background:var(--cr);} | |
| .sc-dot.unk{background:var(--sub);animation:pulsebeat 2s ease-in-out infinite;} | |
| .sc-name{font-size:.78rem;font-weight:700;flex:1;} | |
| .sc-desc{font-size:.55rem;color:var(--sub);line-height:1.5;margin-bottom:.5rem;} | |
| .sc-tools{display:flex;flex-wrap:wrap;gap:.2rem;margin-bottom:.5rem;} | |
| .sc-tool{font-size:.44rem;background:var(--s2);border:1px solid var(--bd);border-radius:3px; | |
| padding:1px 5px;color:var(--dim);} | |
| .sc-url{font-size:.48rem;color:var(--acc);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;} | |
| .sc-link{text-decoration:none;font-size:.5rem;background:var(--s2);border:1px solid var(--bd); | |
| border-radius:3px;padding:2px 7px;color:var(--sub);margin-top:.4rem;display:inline-block;} | |
| .sc-link:hover{color:var(--acc);border-color:var(--acc);} | |
| /* Flashing run indicator */ | |
| @keyframes runglow{0%,100%{box-shadow:0 0 0 0 transparent}50%{box-shadow:0 0 12px 2px var(--lo)}} | |
| .running-glow{animation:runglow 1.2s ease-in-out infinite;} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="hdr"> | |
| <span id="pulse-dot"></span> | |
| <div id="logo"> | |
| <div id="logo-main">PULSE</div> | |
| <div id="logo-sub">Agent Nervous System — ki-fusion-labs.de</div> | |
| </div> | |
| <div id="hdr-center"> | |
| <div class="hs"><span class="hs-n" id="hs-agents">0</span>AGENTS</div> | |
| <div class="hs"><span class="hs-n" id="hs-running">0</span>RUNNING</div> | |
| <div class="hs"><span class="hs-n" id="hs-jobs">0</span>SCHEDULED</div> | |
| <div class="hs"><span class="hs-n" id="hs-ticks">0</span>TICKS TODAY</div> | |
| </div> | |
| <div id="nav"> | |
| <button class="nav-btn on" data-panel="timetable">📅 Timetable</button> | |
| <button class="nav-btn" data-panel="agents">🤖 Agents</button> | |
| <button class="nav-btn" data-panel="live">⚡ Live</button> | |
| <button class="nav-btn" data-panel="spaces">🌐 Spaces</button> | |
| </div> | |
| </div> | |
| <div id="layout"> | |
| <div id="sidebar"> | |
| <div class="sb-scroll"> | |
| <div class="sb-section">Active Agents</div> | |
| <div id="agent-list"></div> | |
| <div class="sb-section" style="margin-top:.5rem">Connected Spaces</div> | |
| <div id="space-health-list"></div> | |
| </div> | |
| </div> | |
| <div id="main-area"> | |
| <!-- TIMETABLE --> | |
| <div id="panel-timetable" class="panel on"> | |
| <div id="cal-header"> | |
| <button class="cal-nav" id="btn-prev-week">‹</button> | |
| <div id="cal-title">Week</div> | |
| <button class="cal-nav" id="btn-next-week">›</button> | |
| <button class="cal-nav" id="btn-today" title="Go to today" style="font-size:.55rem;width:auto;padding:0 .5rem">Today</button> | |
| <button id="btn-add-sched">+ Schedule</button> | |
| </div> | |
| <div id="cal-body"> | |
| <div id="cal-days-hdr"> | |
| <div class="cal-day-hdr" style="border-left:none">UTC</div> | |
| </div> | |
| <div id="cal-grid"></div> | |
| </div> | |
| </div> | |
| <!-- AGENTS --> | |
| <div id="panel-agents" class="panel"> | |
| <button id="btn-add-agent">+ New Agent</button> | |
| <div class="agents-grid" id="agents-full-grid"></div> | |
| </div> | |
| <!-- LIVE --> | |
| <div id="panel-live" class="panel"> | |
| <div id="live-wrap"> | |
| <div id="live-feed"></div> | |
| <div id="live-sidebar"> | |
| <div id="live-sidebar-hdr">RECENT TRACES</div> | |
| <div id="trace-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SPACES --> | |
| <div id="panel-spaces" class="panel"> | |
| <div class="spaces-grid" id="spaces-grid"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- TRACE MODAL --> | |
| <div id="trace-modal"> | |
| <div class="tm-box"> | |
| <div class="tm-hdr"> | |
| <span class="tm-title" id="tm-title">TRACE</span> | |
| <button class="tm-close" id="tm-close">✕</button> | |
| </div> | |
| <div class="tm-body" id="tm-body"></div> | |
| </div> | |
| </div> | |
| <!-- MODAL --> | |
| <div id="modal"> | |
| <div class="mdl"> | |
| <div class="mdl-hdr"> | |
| <span class="mdl-title" id="mdl-title">MODAL</span> | |
| <button class="mdl-x" id="mdl-x">✕</button> | |
| </div> | |
| <div class="mdl-body" id="mdl-body"></div> | |
| <div class="mdl-footer"> | |
| <button class="mdl-ok" id="mdl-ok">Save</button> | |
| <button class="mdl-cancel" id="mdl-cancel">Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toasts"></div> | |
| <script> | |
| // ββ Utils ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| var S = function(id){return document.getElementById(id);}; | |
| function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| function post(u,d){return fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});} | |
| function del(u,d){return fetch(u,{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify(d||{})});} | |
| function toast(msg,t){var e=document.createElement('div');e.className='tst'+(t?' '+t:'');e.textContent=msg;S('toasts').appendChild(e);setTimeout(function(){e.remove();},2600);} | |
| function fmtTime(ts){return new Date(ts*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});} | |
| function fmtDateTime(ts){return new Date(ts*1000).toLocaleString([],{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});} | |
| // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| var AGENTS = [], SCHEDULE = [], SPACES_HEALTH = {}; | |
| var WEEK_START = getMonday(new Date()); | |
| var SELECTED_AGENT = null; | |
| var MODAL_MODE = '', MODAL_DATA = {}; | |
| var TICKS_TODAY = 0; | |
| function getMonday(d){ | |
| var dd=new Date(d); var day=dd.getDay(); var diff=dd.getDate()-day+(day==0?-6:1); | |
| dd.setDate(diff); dd.setHours(0,0,0,0); return dd; | |
| } | |
| // ββ Panel nav βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.querySelectorAll('.nav-btn[data-panel]').forEach(function(btn){ | |
| btn.addEventListener('click',function(){ | |
| document.querySelectorAll('.nav-btn').forEach(function(b){b.classList.remove('on');}); | |
| document.querySelectorAll('.panel').forEach(function(p){p.classList.remove('on');}); | |
| this.classList.add('on'); | |
| S('panel-'+this.getAttribute('data-panel')).classList.add('on'); | |
| if(this.getAttribute('data-panel')==='live') loadTraces(); | |
| if(this.getAttribute('data-panel')==='spaces') renderSpacesPanel(); | |
| }); | |
| }); | |
| // ββ Load agents βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadAgents(){ | |
| fetch('/api/agents').then(function(r){return r.json();}).then(function(data){ | |
| AGENTS=data; | |
| renderSidebarAgents(); | |
| renderAgentsFull(); | |
| S('hs-agents').textContent=data.length; | |
| var running=data.filter(function(a){return (a.status||{}).running;}).length; | |
| S('hs-running').textContent=running; | |
| }); | |
| } | |
| function agentColor(a){return a.color||'#ff6b00';} | |
| function agentStatusClass(a){ | |
| var st=a.status||{}; | |
| if(st.running) return 'running'; | |
| if((st.last_ok===false)&&st.last_result) return 'error'; | |
| return 'idle'; | |
| } | |
| function renderSidebarAgents(){ | |
| var list=S('agent-list'); list.innerHTML=''; | |
| AGENTS.forEach(function(a){ | |
| var st=a.status||{}; var sc=agentStatusClass(a); | |
| var div=document.createElement('div'); | |
| div.className='agent-card'+(SELECTED_AGENT===a.name?' active':''); | |
| div.innerHTML= | |
| '<div class="ac-header">' | |
| +'<div class="ac-dot '+sc+(sc==='running'?' running-glow':'')+'" style="background:'+agentColor(a)+'"></div>' | |
| +'<span class="ac-name">'+esc(a.name)+'</span>' | |
| +'<span class="ac-status '+sc+'">'+sc+'</span>' | |
| +'</div>' | |
| +(st.last_result?'<div class="ac-result" title="'+esc(st.last_result)+'">'+esc(st.last_result)+'</div>':'') | |
| +'<div class="ac-actions">' | |
| +'<button class="ac-btn" data-name="'+esc(a.name)+'" data-action="run">► Run</button>' | |
| +'<button class="ac-btn" data-name="'+esc(a.name)+'" data-action="edit">Edit</button>' | |
| +'</div>'; | |
| list.appendChild(div); | |
| div.querySelectorAll('[data-action]').forEach(function(btn){ | |
| btn.addEventListener('click',function(e){e.stopPropagation(); | |
| var n=this.getAttribute('data-name'), ac=this.getAttribute('data-action'); | |
| if(ac==='run') triggerAgent(n,'manual','Manual trigger from UI'); | |
| else openAgentModal(n); | |
| }); | |
| }); | |
| div.addEventListener('click',function(){SELECTED_AGENT=a.name;renderSidebarAgents();}); | |
| }); | |
| } | |
| function renderAgentsFull(){ | |
| var grid=S('agents-full-grid'); grid.innerHTML=''; | |
| AGENTS.forEach(function(a){ | |
| var st=a.status||{}; var en=a.enabled!==false; | |
| var div=document.createElement('div'); div.className='agent-full-card'; | |
| div.innerHTML= | |
| '<div class="afc-header">' | |
| +'<div class="afc-dot" style="background:'+agentColor(a)+';box-shadow:0 0 10px '+agentColor(a)+'44"></div>' | |
| +'<span class="afc-name">'+esc(a.name)+'</span>' | |
| +'<button class="afc-toggle '+(en?'on':'off')+'" data-name="'+esc(a.name)+'">'+(en?'✔ ON':'✖ OFF')+'</button>' | |
| +'</div>' | |
| +'<div class="afc-persona">'+esc(a.persona||'')+'</div>' | |
| +'<div class="afc-meta">' | |
| +(a.heartbeat_seconds>0?'<span class="afc-tag">♥ every '+a.heartbeat_seconds+'s</span>':'') | |
| +'<span class="afc-tag">'+esc(a.cost_mode||'balanced')+'</span>' | |
| +'<span class="afc-tag">max '+a.max_react_steps+' steps</span>' | |
| +((a.tags||[]).map(function(t){return '<span class="afc-tag">'+esc(t)+'</span>';}).join('')) | |
| +'</div>' | |
| +(st.last_result?'<div style="font-size:.52rem;color:var(--sub);margin-bottom:.5rem;font-family:var(--mono)">Last: '+esc(st.last_result.substring(0,80))+'</div>':'') | |
| +'<div class="afc-actions">' | |
| +'<button class="afc-btn run" data-name="'+esc(a.name)+'" data-action="run">► Run Now</button>' | |
| +'<button class="afc-btn" data-name="'+esc(a.name)+'" data-action="edit">✏ Edit</button>' | |
| +'<button class="afc-btn" data-name="'+esc(a.name)+'" data-action="confer" style="border-color:rgba(139,92,246,.3);color:var(--violet)">💬 Confer</button>' | |
| +'<button class="afc-btn" data-name="'+esc(a.name)+'" data-action="delete" style="border-color:rgba(255,34,85,.2);color:var(--cr)">🗑</button>' | |
| +'</div>'; | |
| grid.appendChild(div); | |
| div.querySelectorAll('[data-action]').forEach(function(btn){ | |
| btn.addEventListener('click',function(e){e.stopPropagation(); | |
| var n=this.getAttribute('data-name'), ac=this.getAttribute('data-action'); | |
| if(ac==='run') triggerAgent(n,'manual','Manual trigger from Agents panel'); | |
| else if(ac==='edit') openAgentModal(n); | |
| else if(ac==='confer') openConferModal(n); | |
| else if(ac==='delete'){if(confirm('Delete agent '+n+'?')) deleteAgent(n);} | |
| }); | |
| }); | |
| div.querySelector('.afc-toggle').addEventListener('click',function(e){e.stopPropagation(); | |
| var n=this.getAttribute('data-name'), a2=AGENTS.find(function(x){return x.name===n;}); | |
| if(a2){a2.enabled=!(a2.enabled!==false);post('/api/agents',a2).then(function(){loadAgents();});} | |
| }); | |
| }); | |
| } | |
| function triggerAgent(name, trigger, content){ | |
| post('/api/agents/'+name+'/run',{trigger:trigger||'manual',content:content||''}) | |
| .then(function(){toast('Triggered: '+name,'ok');setTimeout(loadAgents,800);}); | |
| } | |
| function deleteAgent(name){ | |
| del('/api/agents/'+name).then(function(){toast('Deleted: '+name);loadAgents();}); | |
| } | |
| // ββ Calendar ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| var HOURS = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23]; | |
| var DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; | |
| var DAY_MAP = {0:'Mon',1:'Tue',2:'Wed',3:'Thu',4:'Fri',5:'Sat',6:'Sun'}; | |
| function loadSchedule(){ | |
| fetch('/api/schedule').then(function(r){return r.json();}).then(function(data){ | |
| SCHEDULE=data; renderCalendar(); S('hs-jobs').textContent=data.filter(function(e){return e.enabled;}).length; | |
| }); | |
| } | |
| function renderCalendar(){ | |
| // Day headers | |
| var hdr=S('cal-days-hdr'); | |
| var weekDates=getWeekDates(WEEK_START); | |
| var today=new Date(); today.setHours(0,0,0,0); | |
| hdr.innerHTML='<div class="cal-day-hdr" style="border-left:none">UTC</div>'; | |
| weekDates.forEach(function(d,i){ | |
| var isToday=d.getTime()===today.getTime(); | |
| hdr.innerHTML+='<div class="cal-day-hdr'+(isToday?' today':'')+'">'+DAYS[i]+'<br>' | |
| +'<span style="font-size:.7rem;font-weight:700;color:'+(isToday?'var(--acc)':'var(--txt)')+'">'+d.getDate()+'</span></div>'; | |
| }); | |
| // Update title | |
| var y=WEEK_START.getFullYear(); | |
| var m1=WEEK_START.toLocaleString('default',{month:'short'}); | |
| var end=new Date(WEEK_START); end.setDate(end.getDate()+6); | |
| var m2=end.toLocaleString('default',{month:'short'}); | |
| S('cal-title').textContent='Week '+getWeekNum(WEEK_START)+' β '+m1+(m2!==m1?' / '+m2:'')+' '+y; | |
| // Grid | |
| var grid=S('cal-grid'); grid.innerHTML=''; | |
| HOURS.forEach(function(h){ | |
| var row=document.createElement('div'); row.className='cal-row'; | |
| var timeCell=document.createElement('div'); timeCell.className='cal-time-cell'; | |
| timeCell.textContent=String(h).padStart(2,'0')+':00'; | |
| row.appendChild(timeCell); | |
| weekDates.forEach(function(d,di){ | |
| var cell=document.createElement('div'); | |
| var isToday=d.getTime()===today.getTime(); | |
| cell.className='cal-cell'+(isToday?' today':''); | |
| // Find events for this day+hour | |
| var dayIdx=di; // 0=Mon | |
| SCHEDULE.forEach(function(e){ | |
| var matches=false; | |
| if(e.recurrence==='daily' && e.hour===h) matches=true; | |
| if(e.recurrence==='weekly' && e.hour===h && e.day===dayIdx) matches=true; | |
| if(e.recurrence==='once'){ | |
| var dt=e.datetime?new Date(e.datetime):null; | |
| if(dt && dt.getDay()===((dayIdx+1)%7) && dt.getHours()===h) matches=true; | |
| } | |
| if(matches){ | |
| var ev=document.createElement('div'); | |
| var col=e.color||'#ff6b00'; | |
| ev.className='cal-event'; ev.style.borderLeftColor=col; | |
| ev.style.background=col+'18'; ev.style.color=col; | |
| ev.innerHTML='<span>'+esc(e.title)+'</span><span class="ev-agent">@'+esc(e.agent)+'</span>' | |
| +(e.enabled?'':' <span style="opacity:.5">(off)</span>'); | |
| ev.setAttribute('data-eid',e.id); | |
| ev.addEventListener('click',function(evt){evt.stopPropagation();openScheduleModal(e.id);}); | |
| cell.appendChild(ev); | |
| } | |
| }); | |
| row.appendChild(cell); | |
| }); | |
| grid.appendChild(row); | |
| }); | |
| // Scroll to 7am | |
| setTimeout(function(){ | |
| var rows=grid.querySelectorAll('.cal-row'); | |
| if(rows[7]) rows[7].scrollIntoView({block:'start'}); | |
| },50); | |
| } | |
| function getWeekDates(monday){ | |
| var dates=[]; for(var i=0;i<7;i++){var d=new Date(monday);d.setDate(monday.getDate()+i);dates.push(d);} return dates; | |
| } | |
| function getWeekNum(d){ | |
| var date=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate())); | |
| var day=date.getUTCDay()||7; date.setUTCDate(date.getUTCDate()+4-day); | |
| var yearStart=new Date(Date.UTC(date.getUTCFullYear(),0,1)); | |
| return Math.ceil((((date-yearStart)/86400000)+1)/7); | |
| } | |
| S('btn-prev-week').addEventListener('click',function(){WEEK_START.setDate(WEEK_START.getDate()-7);renderCalendar();}); | |
| S('btn-next-week').addEventListener('click',function(){WEEK_START.setDate(WEEK_START.getDate()+7);renderCalendar();}); | |
| S('btn-today').addEventListener('click',function(){WEEK_START=getMonday(new Date());renderCalendar();}); | |
| S('btn-add-sched').addEventListener('click',function(){openScheduleModal(null);}); | |
| // ββ Schedule modal βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openScheduleModal(eid){ | |
| var existing=eid?SCHEDULE.find(function(e){return e.id===eid;}):null; | |
| MODAL_MODE='schedule'; MODAL_DATA=existing||{}; | |
| S('mdl-title').textContent=existing?'EDIT SCHEDULE':'ADD SCHEDULE'; | |
| var agents=AGENTS.map(function(a){return '<option value="'+esc(a.name)+'"'+(existing&&existing.agent===a.name?' selected':'')+'>'+esc(a.name)+'</option>';}).join(''); | |
| S('mdl-body').innerHTML= | |
| '<div class="mfl"><label>Title</label><input id="mi-title" value="'+esc(existing?existing.title:'')+'"></div>' | |
| +'<div class="mfl"><label>Agent</label><select id="mi-agent">'+agents+'</select></div>' | |
| +'<div class="mfl"><label>Prompt (what the agent should do)</label><textarea id="mi-prompt">'+esc(existing?existing.prompt:'')+'</textarea></div>' | |
| +'<div class="mfl-row">' | |
| +'<div class="mfl"><label>Recurrence</label><select id="mi-rec">' | |
| +'<option value="daily"'+(existing&&existing.recurrence==='daily'?' selected':'')+'>Daily</option>' | |
| +'<option value="weekly"'+((!existing||existing.recurrence==='weekly')?' selected':'')+'>Weekly</option>' | |
| +'<option value="once"'+(existing&&existing.recurrence==='once'?' selected':'')+'>Once</option>' | |
| +'</select></div>' | |
| +'<div class="mfl"><label>Day (weekly)</label><select id="mi-day">' | |
| +['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map(function(d,i){ | |
| return '<option value="'+i+'"'+(existing&&existing.day===i?' selected':'')+'>'+d+'</option>';}).join('') | |
| +'</select></div>' | |
| +'</div>' | |
| +'<div class="mfl-row">' | |
| +'<div class="mfl"><label>Hour (UTC)</label><input id="mi-hour" type="number" min="0" max="23" value="'+(existing?existing.hour:9)+'"></div>' | |
| +'<div class="mfl"><label>Minute</label><input id="mi-minute" type="number" min="0" max="59" value="'+(existing?existing.minute:0)+'"></div>' | |
| +'</div>' | |
| +'<div class="mfl-row">' | |
| +'<div class="mfl"><label>Color</label><input id="mi-color" type="color" value="'+(existing&&existing.color?existing.color:'#ff6b00')+'" style="height:36px;padding:2px"></div>' | |
| +'<div class="mfl"><label>Enabled</label><select id="mi-enabled">' | |
| +'<option value="1"'+((!existing||existing.enabled)?' selected':'')+'>Yes</option>' | |
| +'<option value="0"'+(existing&&!existing.enabled?' selected':'')+'>No</option>' | |
| +'</select></div>' | |
| +'</div>' | |
| +(existing?'<div style="margin-top:.5rem;display:flex;gap:.4rem">' | |
| +'<button onclick="runScheduleNow(\''+esc(eid)+'\')" style="font-size:.6rem;background:rgba(57,255,106,.08);border:1px solid rgba(57,255,106,.3);color:var(--lo);border-radius:4px;padding:.3rem .7rem;cursor:pointer;font-family:var(--font)">► Run Now</button>' | |
| +'<button onclick="deleteSchedule(\''+esc(eid)+'\')" style="font-size:.6rem;background:rgba(255,34,85,.06);border:1px solid rgba(255,34,85,.2);color:var(--cr);border-radius:4px;padding:.3rem .7rem;cursor:pointer;font-family:var(--font)">🗑 Delete</button>' | |
| +'</div>':''); | |
| S('modal').classList.add('open'); | |
| } | |
| function runScheduleNow(eid){ | |
| post('/api/schedule/'+eid+'/run',{}).then(function(){toast('Triggered!','ok');closeModal();}); | |
| } | |
| function deleteSchedule(eid){ | |
| del('/api/schedule/'+eid).then(function(){toast('Deleted','ok');closeModal();loadSchedule();}); | |
| } | |
| // ββ Agent modal ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openAgentModal(name){ | |
| var existing=name?AGENTS.find(function(a){return a.name===name;}):null; | |
| MODAL_MODE='agent'; MODAL_DATA=existing||{}; | |
| S('mdl-title').textContent=existing?'EDIT AGENT':'NEW AGENT'; | |
| S('mdl-body').innerHTML= | |
| '<div class="mfl"><label>Name (lowercase, no spaces)</label><input id="mi-name" value="'+esc(existing?existing.name:'')+'" placeholder="researcher"></div>' | |
| +'<div class="mfl"><label>Persona (system prompt)</label><textarea id="mi-persona" style="min-height:120px">'+esc(existing?existing.persona:'You are a helpful AI agent.')+'</textarea></div>' | |
| +'<div class="mfl-row">' | |
| +'<div class="mfl"><label>Heartbeat (sec, 0=off)</label><input id="mi-hb" type="number" min="0" value="'+(existing?existing.heartbeat_seconds:0)+'"></div>' | |
| +'<div class="mfl"><label>Cost mode</label><select id="mi-cost"><option value="cheap">cheap</option><option value="balanced"'+((!existing||existing.cost_mode==='balanced')?' selected':'')+'>balanced</option><option value="best"'+(existing&&existing.cost_mode==='best'?' selected':'')+'>best</option></select></div>' | |
| +'</div>' | |
| +'<div class="mfl-row">' | |
| +'<div class="mfl"><label>Max ReAct steps</label><input id="mi-steps" type="number" min="1" max="10" value="'+(existing?existing.max_react_steps:5)+'"></div>' | |
| +'<div class="mfl"><label>Color</label><input id="mi-acolor" type="color" value="'+(existing&&existing.color?existing.color:'#ff6b00')+'" style="height:36px;padding:2px"></div>' | |
| +'</div>' | |
| +'<div class="mfl"><label>Tags (comma separated)</label><input id="mi-tags" value="'+esc(existing&&existing.tags?(existing.tags).join(', '):'')+'"></div>'; | |
| S('modal').classList.add('open'); | |
| } | |
| // ββ Conference modal βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openConferModal(name){ | |
| MODAL_MODE='confer'; MODAL_DATA={initiator:name}; | |
| S('mdl-title').textContent='AGENT CONFERENCE'; | |
| var others=AGENTS.filter(function(a){return a.name!==name;}); | |
| S('mdl-body').innerHTML= | |
| '<div style="font-size:.6rem;color:var(--sub);margin-bottom:.7rem">Initiate a multi-agent conference. The task will be sent to selected agents via RELAY.</div>' | |
| +'<div class="mfl"><label>Conference Topic / Task</label><textarea id="mi-conf-topic" style="min-height:80px" placeholder="Analyze our codebase and create a sprint plan for next week"></textarea></div>' | |
| +'<div class="mfl"><label>Invite Agents</label>' | |
| +others.map(function(a){ | |
| return '<label style="display:flex;align-items:center;gap:.4rem;padding:.2rem 0;font-size:.62rem;color:var(--txt);cursor:pointer">' | |
| +'<input type="checkbox" value="'+esc(a.name)+'" style="accent-color:var(--acc)"> ' | |
| +'<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:'+agentColor(a)+'"></span> ' | |
| +esc(a.name)+'</label>'; | |
| }).join('') | |
| +'</div>' | |
| +'<div class="mfl"><label>Priority</label><select id="mi-conf-prio"><option value="normal">normal</option><option value="high">high</option><option value="urgent">urgent</option></select></div>'; | |
| S('modal').classList.add('open'); | |
| } | |
| // ββ Modal save βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| S('mdl-ok').addEventListener('click',function(){ | |
| if(MODAL_MODE==='schedule'){ | |
| var data={ | |
| id: MODAL_DATA.id||'', | |
| title: S('mi-title').value.trim(), | |
| agent: S('mi-agent').value, | |
| prompt: S('mi-prompt').value.trim(), | |
| recurrence: S('mi-rec').value, | |
| day: parseInt(S('mi-day').value), | |
| hour: parseInt(S('mi-hour').value), | |
| minute: parseInt(S('mi-minute').value), | |
| color: S('mi-color').value, | |
| enabled: S('mi-enabled').value==='1', | |
| }; | |
| if(!data.title||!data.agent){toast('Title and agent required','warn');return;} | |
| post('/api/schedule',data).then(function(){toast('Schedule saved','ok');closeModal();loadSchedule();}); | |
| } else if(MODAL_MODE==='agent'){ | |
| var data={ | |
| name: S('mi-name').value.trim().toLowerCase(), | |
| persona: S('mi-persona').value.trim(), | |
| heartbeat_seconds: parseInt(S('mi-hb').value)||0, | |
| cost_mode: S('mi-cost').value, | |
| max_react_steps: parseInt(S('mi-steps').value)||5, | |
| color: S('mi-acolor').value, | |
| tags: S('mi-tags').value.split(',').map(function(t){return t.trim();}).filter(Boolean), | |
| enabled: MODAL_DATA.enabled!==false, | |
| }; | |
| if(!data.name){toast('Name required','warn');return;} | |
| post('/api/agents',data).then(function(){toast('Agent saved','ok');closeModal();loadAgents();}); | |
| } else if(MODAL_MODE==='confer'){ | |
| var topic=S('mi-conf-topic').value.trim(); | |
| var invited=[...document.querySelectorAll('#mdl-body input[type=checkbox]:checked')].map(function(cb){return cb.value;}); | |
| var prio=S('mi-conf-prio').value; | |
| if(!topic||!invited.length){toast('Topic and at least one agent required','warn');return;} | |
| var promises=invited.map(function(ag){ | |
| return post('/api/agents/'+ag+'/run',{trigger:'conference',content:'[CONFERENCE from '+MODAL_DATA.initiator+']\nTopic: '+topic+'\nCoordinate with other agents: '+invited.join(', ')}); | |
| }); | |
| Promise.all(promises).then(function(){toast('Conference started with '+invited.length+' agents','ok');closeModal();}); | |
| } | |
| }); | |
| function closeModal(){S('modal').classList.remove('open');} | |
| S('mdl-x').addEventListener('click',closeModal); | |
| S('mdl-cancel').addEventListener('click',closeModal); | |
| S('modal').addEventListener('click',function(e){if(e.target===this)closeModal();}); | |
| S('btn-add-agent').addEventListener('click',function(){openAgentModal(null);}); | |
| // ββ Live feed ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| var LIVE_COUNT = 0; | |
| var EVENT_ICONS = {heartbeat:'♥',react_start:'⚡',react_step:'🔵',react_done:'✔',react_obs:'👁',idle:'💤',error:'⚠',connected:'🌐',llm_fallback:'🔄',ping:''}; | |
| var EVENT_COLORS = {heartbeat:'var(--pulse)',react_start:'var(--info)',react_step:'var(--violet)',react_done:'var(--lo)',error:'var(--cr)',idle:'var(--sub)',connected:'var(--acc)',llm_fallback:'var(--warn)'}; | |
| function initLiveFeed(){ | |
| var src=new EventSource('/api/live'); | |
| src.onmessage=function(e){ | |
| try{var ev=JSON.parse(e.data); if(ev.type==='ping') return; appendLive(ev);}catch(err){} | |
| }; | |
| src.onerror=function(){setTimeout(initLiveFeed,3000);}; | |
| } | |
| function appendLive(ev){ | |
| var feed=S('live-feed'); | |
| if(!feed) return; | |
| if(ev.type==='react_done'||ev.type==='heartbeat') TICKS_TODAY++; | |
| S('hs-ticks').textContent=TICKS_TODAY; | |
| var icon=EVENT_ICONS[ev.type]||'●'; | |
| var col=EVENT_COLORS[ev.type]||'var(--sub)'; | |
| var msg=''; | |
| if(ev.type==='react_start') msg='Starting ReAct loop ['+esc(ev.trigger)+']'; | |
| else if(ev.type==='react_step') msg='Step '+ev.step+': <span style="color:var(--violet)">'+esc(ev.action)+'</span> β '+esc((ev.thought||'').substring(0,80)); | |
| else if(ev.type==='react_obs') msg='👁 '+esc((ev.observation||'').substring(0,100)); | |
| else if(ev.type==='react_done') msg='Done in '+ev.steps+' steps ('+ev.ms+'ms): <span style="color:'+(ev.ok?'var(--lo)':'var(--cr)')+'">'+esc((ev.result||'').substring(0,80))+'</span>'; | |
| else if(ev.type==='heartbeat') msg='Heartbeat tick ['+esc(ev.trigger)+']'; | |
| else if(ev.type==='idle') msg='Nothing to do β idle'; | |
| else if(ev.type==='llm_fallback') msg='⚠ NEXUS unavailable, used fallback: <span style="color:var(--warn)">'+esc(ev.provider)+'</span>'; | |
| else if(ev.type==='error') msg='<span style="color:var(--cr)">'+esc(ev.message||'')+'</span>'; | |
| else msg=esc(JSON.stringify(ev).substring(0,100)); | |
| var item=document.createElement('div'); | |
| item.className='lf-item lf-type-'+ev.type; | |
| item.innerHTML='<span class="lf-icon" style="color:'+col+'">'+icon+'</span>' | |
| +'<span class="lf-ts">'+new Date(ev.ts*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})+'</span>' | |
| +'<div class="lf-body">' | |
| +(ev.agent?'<span class="lf-agent" style="color:'+agentColorByName(ev.agent)+'">@'+esc(ev.agent)+'</span>':'') | |
| +'<span class="lf-msg">'+msg+'</span>' | |
| +'</div>'; | |
| feed.insertBefore(item, feed.firstChild); | |
| // Keep last 150 | |
| LIVE_COUNT++; | |
| while(feed.children.length>150) feed.removeChild(feed.lastChild); | |
| // Update running state in sidebar | |
| if(ev.type==='react_start'||ev.type==='heartbeat') setTimeout(loadAgents,200); | |
| if(ev.type==='react_done'||ev.type==='idle'||ev.type==='error') setTimeout(loadAgents,400); | |
| } | |
| function agentColorByName(n){ | |
| var a=AGENTS.find(function(x){return x.name===n;}); | |
| return a?agentColor(a):'var(--acc)'; | |
| } | |
| // ββ Traces βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadTraces(){ | |
| fetch('/api/traces?limit=20').then(function(r){return r.json();}).then(function(traces){ | |
| var list=S('trace-list'); list.innerHTML=''; | |
| traces.forEach(function(t){ | |
| var div=document.createElement('div'); div.className='trace-item'; | |
| div.innerHTML='<div class="tr-agent" style="color:'+agentColorByName(t.agent)+'">@'+esc(t.agent)+'</div>' | |
| +'<div class="tr-result">'+esc((t.result||'').substring(0,80))+'</div>' | |
| +'<div class="tr-meta">' | |
| +'<span class="'+(t.ok?'tr-ok':'tr-fail')+'">'+(t.ok?'✔':'✖')+'</span>' | |
| +'<span>'+t.steps.length+' steps</span>' | |
| +'<span>'+Math.round((t.ms||0)/1000)+'s</span>' | |
| +'<span style="color:var(--dim)">'+fmtDateTime(t.started)+'</span>' | |
| +'</div>'; | |
| div.addEventListener('click',function(){showTrace(t);}); | |
| list.appendChild(div); | |
| }); | |
| }); | |
| } | |
| function showTrace(t){ | |
| S('tm-title').textContent='TRACE: @'+t.agent+' β '+t.trigger; | |
| var body=S('tm-body'); | |
| body.innerHTML='<div style="display:flex;gap:.5rem;margin-bottom:.8rem;flex-wrap:wrap">' | |
| +'<span class="afc-tag">trigger: '+esc(t.trigger)+'</span>' | |
| +'<span class="afc-tag '+(t.ok?'tr-ok':'tr-fail')+'">'+(t.ok?'✔ OK':'✖ FAILED')+'</span>' | |
| +'<span class="afc-tag">'+t.steps.length+' steps</span>' | |
| +'<span class="afc-tag">'+Math.round((t.ms||0)/1000)+'s</span>' | |
| +'</div>' | |
| +'<div style="font-size:.62rem;color:var(--lo);margin-bottom:1rem;font-family:var(--mono)">Result: '+esc(t.result||'')+'</div>' | |
| +t.steps.map(function(step){ | |
| return '<div class="step-block">' | |
| +'<div class="step-hdr"><span class="step-n">'+step.n+'</span>' | |
| +'<span class="step-action" style="color:var(--info)">'+esc(step.action)+'</span>' | |
| +(step.args?'<span style="font-size:.52rem;color:var(--sub);font-family:var(--mono)"> ('+esc(JSON.stringify(step.args).substring(0,60))+')</span>':'') | |
| +'</div>' | |
| +'<div class="step-body"><div class="step-label">Thought</div>' | |
| +'<div class="step-text">'+esc(step.thought||'')+'</div></div>' | |
| +'<div class="step-obs"><div class="step-label">Observation</div>' | |
| +'<div class="step-text" style="color:var(--lo)">'+esc(step.observation||'')+'</div></div>' | |
| +'</div>'; | |
| }).join(''); | |
| S('trace-modal').classList.add('open'); | |
| } | |
| S('tm-close').addEventListener('click',function(){S('trace-modal').classList.remove('open');}); | |
| S('trace-modal').addEventListener('click',function(e){if(e.target===this)S('trace-modal').classList.remove('open');}); | |
| // ββ Spaces health ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadSpacesHealth(){ | |
| fetch('/api/spaces/health').then(function(r){return r.json();}).then(function(h){ | |
| SPACES_HEALTH=h; | |
| var list=S('space-health-list'); list.innerHTML=''; | |
| Object.entries(h).forEach(function(kv){ | |
| var k=kv[0],v=kv[1]; | |
| var cls=v.ok?'ok':'bad'; | |
| list.innerHTML+='<div class="space-row"><div class="sp-dot '+cls+'"></div>' | |
| +'<span class="sp-name">'+esc(k)+'</span>' | |
| +'<span class="sp-status">'+(v.ok?'✔':(v.error?v.error.substring(0,18):'err'))+'</span></div>'; | |
| }); | |
| }).catch(function(){}); | |
| } | |
| var SPACE_META = { | |
| relay: {desc:'Communication hub β messages, channels, broadcast',color:'#ff6b9d',tools:['relay_send','relay_inbox','relay_broadcast','relay_ack']}, | |
| memory: {desc:'Multi-tier memory β episodic, semantic, procedural, working',color:'#8b5cf6',tools:['memory_store','memory_search','memory_recall','memory_update']}, | |
| kanban: {desc:'Task board β create, assign, track, complete tasks',color:'#f0c040',tools:['kanban_list','kanban_create','kanban_move','kanban_claim']}, | |
| nexus: {desc:'LLM gateway β OpenAI-compatible model router (RTX 5090 + HF)',color:'#38bdf8',tools:['chat/completions','classify','route','health']}, | |
| vault: {desc:'File workspace + execution β read, write, run code',color:'#2ed573',tools:['vault_read','vault_write','vault_exec','vault_versions']}, | |
| forge: {desc:'Skill registry β search and use agent skills & tools',color:'#ff6b00',tools:['forge_search','skill_invoke','tool_list']}, | |
| knowledge:{desc:'Knowledge base β documents, RAG, semantic search',color:'#ec4899',tools:['kb_search','kb_store','kb_retrieve']}, | |
| }; | |
| function renderSpacesPanel(){ | |
| var grid=S('spaces-grid'); grid.innerHTML=''; | |
| // LLM Provider status card first | |
| fetch('/api/llm/status').then(function(r){return r.json();}).then(function(d){ | |
| var card=document.createElement('div'); card.className='space-card'; | |
| card.style.borderTopColor='#ff9500'; card.style.gridColumn='1/-1'; | |
| var rows=d.providers.map(function(p){ | |
| var col=p.ok?(p.configured?'var(--lo)':'var(--sub)'):'var(--cr)'; | |
| var badge=p.configured?(p.ok?'✔ ready':'⏳ cooldown '+p.cooldown_remaining+'s'):'⚠ not configured'; | |
| return '<div style="display:flex;align-items:center;gap:.6rem;padding:.28rem 0;border-bottom:1px solid var(--bd)">' | |
| +'<span style="font-size:.65rem;font-weight:700;min-width:18px;color:var(--sub)">'+p.priority+'.</span>' | |
| +'<span style="flex:1;font-size:.62rem;font-family:var(--mono)">'+esc(p.label)+'</span>' | |
| +'<span style="font-size:.52rem;color:'+col+'">'+badge+'</span>' | |
| +(p.note?'<span style="font-size:.45rem;color:var(--dim)">'+esc(p.note)+'</span>':'') | |
| +'</div>'; | |
| }).join(''); | |
| card.innerHTML='<div class="sc-header"><span style="font-size:1rem">⚡</span>' | |
| +'<span class="sc-name" style="color:#ff9500">LLM FALLBACK CHAIN</span>' | |
| +'<button id="btn-llm-test" style="font-size:.52rem;background:rgba(255,149,0,.08);border:1px solid rgba(255,149,0,.3);color:#ff9500;border-radius:3px;padding:2px 8px;cursor:pointer;font-family:var(--font)">Test</button>' | |
| +'</div>'+rows; | |
| grid.insertBefore(card, grid.firstChild); | |
| card.querySelector('#btn-llm-test').addEventListener('click',function(){ | |
| this.textContent='...'; | |
| var btn=this; | |
| post('/api/llm/test',{prompt:'Reply with exactly: PULSE_OK'}).then(function(r){return r.json();}).then(function(d){ | |
| btn.textContent=d.ok?('✔ '+d.response.substring(0,20)):'✖ fail'; | |
| toast(d.ok?'LLM OK: '+d.response.substring(0,40):'LLM FAIL: '+d.error,d.ok?'ok':'err'); | |
| }); | |
| }); | |
| }).catch(function(){}); | |
| Object.entries(SPACE_META).forEach(function(kv){ | |
| var k=kv[0], meta=kv[1]; | |
| var health=SPACES_HEALTH[k]||{}; | |
| var cls=health.ok?'ok':(health.error?'bad':'unk'); | |
| var url='https://huggingface.co/spaces/Chris4K/agent-'+k; | |
| var card=document.createElement('div'); card.className='space-card'; | |
| card.style.borderTopColor=meta.color; | |
| card.innerHTML='<div class="sc-header">' | |
| +'<div class="sc-dot '+cls+'" style="background:'+meta.color+'"></div>' | |
| +'<span class="sc-name" style="color:'+meta.color+'">'+k.toUpperCase()+'</span>' | |
| +'</div>' | |
| +'<div class="sc-desc">'+esc(meta.desc)+'</div>' | |
| +'<div class="sc-tools">'+meta.tools.map(function(t){return '<span class="sc-tool">'+esc(t)+'</span>';}).join('')+'</div>' | |
| +'<div class="sc-url">'+url+'</div>' | |
| +'<a class="sc-link" href="'+url+'" target="_blank">Open Space ↗</a>'; | |
| grid.appendChild(card); | |
| }); | |
| } | |
| // ββ Keyboard βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.addEventListener('keydown',function(e){ | |
| if(e.key==='Escape'){ | |
| closeModal(); | |
| S('trace-modal').classList.remove('open'); | |
| } | |
| }); | |
| // ββ Init βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| loadAgents(); | |
| loadSchedule(); | |
| loadSpacesHealth(); | |
| initLiveFeed(); | |
| setInterval(loadAgents, 15000); | |
| setInterval(loadSpacesHealth, 60000); | |
| setInterval(function(){if(S('panel-live').classList.contains('on'))loadTraces();}, 10000); | |
| </script> | |
| </body> | |
| </html>""" |