Spaces:
Running
Running
| """ | |
| agent-loop — FORGE Self-Improvement Orchestrator | |
| Closes the feedback loop: trace → learn → prompts → deploy. | |
| Cycle: | |
| 1. Pull reward trend from agent-learn | |
| 2. Identify agents with avg_reward < threshold | |
| 3. Fetch their recent self-reflection traces from agent-trace | |
| 4. Call NEXUS to generate a prompt improvement proposal | |
| 5. POST draft to agent-prompts | |
| 6. Notify operator via RELAY (Telegram) | |
| 7. Wait for approval → on approve: POST /approve triggers deployment | |
| 8. After 24h: measure reward delta, log outcome | |
| """ | |
| import asyncio, json, os, sqlite3, time, uuid | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| import uvicorn | |
| from fastapi import FastAPI, HTTPException, Query, Request | |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| DB_PATH = Path(os.getenv("LOOP_DB", "/tmp/loop.db")) | |
| PORT = int(os.getenv("PORT", "7860")) | |
| LOOP_KEY = os.getenv("LOOP_KEY", "") | |
| LEARN_URL = os.getenv("LEARN_URL", "https://chris4k-agent-learn.hf.space") | |
| TRACE_URL = os.getenv("TRACE_URL", "https://chris4k-agent-trace.hf.space") | |
| PROMPTS_URL = os.getenv("PROMPTS_URL","https://chris4k-agent-prompts.hf.space") | |
| NEXUS_URL = os.getenv("NEXUS_URL", "https://chris4k-agent-nexus.hf.space") | |
| RELAY_URL = os.getenv("RELAY_URL", "https://chris4k-agent-relay.hf.space") | |
| CYCLE_MINUTES = int(os.getenv("CYCLE_MINUTES", "60")) | |
| REWARD_THRESHOLD = float(os.getenv("REWARD_THRESHOLD", "0.2")) # trigger below this | |
| ERROR_ESCALATE = float(os.getenv("ERROR_ESCALATE", "0.15")) # error rate > 15% → escalate | |
| NOTIFY_AGENT = os.getenv("NOTIFY_AGENT", "Chris4K") | |
| DELTA_WINDOW_H = int(os.getenv("DELTA_WINDOW_H", "24")) # hours to measure improvement | |
| CYCLE_ENABLED = os.getenv("CYCLE_ENABLED", "true").lower() == "true" | |
| VALID_STATES = {"idle","running","awaiting_approval","deploying","done","failed","skipped"} | |
| # --------------------------------------------------------------------------- | |
| # HTTP helpers (stdlib only — no httpx dep in base image) | |
| # --------------------------------------------------------------------------- | |
| def _get(url: str, params: dict = None, timeout: int = 8) -> dict: | |
| import urllib.request, urllib.parse | |
| if params: | |
| url = url + "?" + urllib.parse.urlencode({k:v for k,v in params.items() if v is not None}) | |
| try: | |
| with urllib.request.urlopen(url, timeout=timeout) as r: | |
| return json.loads(r.read()) | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def _post(url: str, data: dict, timeout: int = 15) -> dict: | |
| import urllib.request | |
| req = urllib.request.Request( | |
| url, data=json.dumps(data).encode(), | |
| headers={"Content-Type": "application/json"}, method="POST") | |
| try: | |
| with urllib.request.urlopen(req, timeout=timeout) as r: | |
| return json.loads(r.read()) | |
| except Exception as e: | |
| return {"error": str(e)} | |
| # --------------------------------------------------------------------------- | |
| # Database | |
| # --------------------------------------------------------------------------- | |
| def get_db(): | |
| conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA journal_mode=WAL") | |
| conn.execute("PRAGMA synchronous=NORMAL") | |
| return conn | |
| def init_db(): | |
| conn = get_db() | |
| conn.executescript(""" | |
| CREATE TABLE IF NOT EXISTS cycles ( | |
| id TEXT PRIMARY KEY, | |
| cycle_num INTEGER NOT NULL DEFAULT 0, | |
| state TEXT NOT NULL DEFAULT 'idle', | |
| triggered_by TEXT NOT NULL DEFAULT 'cron', | |
| agents_checked TEXT NOT NULL DEFAULT '[]', | |
| underperformers TEXT NOT NULL DEFAULT '[]', | |
| proposals_created INTEGER NOT NULL DEFAULT 0, | |
| proposals_approved INTEGER NOT NULL DEFAULT 0, | |
| error_msg TEXT, | |
| started_at REAL NOT NULL, | |
| finished_at REAL, | |
| duration_s REAL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_cy_num ON cycles(cycle_num DESC); | |
| CREATE INDEX IF NOT EXISTS idx_cy_state ON cycles(state); | |
| CREATE TABLE IF NOT EXISTS proposals ( | |
| id TEXT PRIMARY KEY, | |
| cycle_id TEXT NOT NULL, | |
| agent TEXT NOT NULL, | |
| reason TEXT NOT NULL, | |
| current_prompt_id TEXT NOT NULL, | |
| current_reward REAL, | |
| proposed_prompt TEXT NOT NULL, | |
| prompt_draft_id TEXT, | |
| state TEXT NOT NULL DEFAULT 'pending', | |
| approved_by TEXT, | |
| reward_before REAL, | |
| reward_after REAL, | |
| reward_delta REAL, | |
| created_at REAL NOT NULL, | |
| resolved_at REAL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_pr_cycle ON proposals(cycle_id); | |
| CREATE INDEX IF NOT EXISTS idx_pr_agent ON proposals(agent); | |
| CREATE INDEX IF NOT EXISTS idx_pr_state ON proposals(state); | |
| CREATE TABLE IF NOT EXISTS agent_health ( | |
| agent TEXT PRIMARY KEY, | |
| avg_reward REAL NOT NULL DEFAULT 0.0, | |
| error_rate REAL NOT NULL DEFAULT 0.0, | |
| total_events INTEGER NOT NULL DEFAULT 0, | |
| last_checked REAL NOT NULL, | |
| status TEXT NOT NULL DEFAULT 'unknown' | |
| ); | |
| CREATE TABLE IF NOT EXISTS cycle_counter ( | |
| id INTEGER PRIMARY KEY DEFAULT 1, | |
| n INTEGER NOT NULL DEFAULT 0 | |
| ); | |
| INSERT OR IGNORE INTO cycle_counter (id, n) VALUES (1, 0); | |
| """) | |
| conn.commit(); conn.close() | |
| def _next_cycle_num() -> int: | |
| conn = get_db() | |
| conn.execute("UPDATE cycle_counter SET n=n+1 WHERE id=1") | |
| n = conn.execute("SELECT n FROM cycle_counter WHERE id=1").fetchone()[0] | |
| conn.commit(); conn.close() | |
| return n | |
| # --------------------------------------------------------------------------- | |
| # Core loop cycle | |
| # --------------------------------------------------------------------------- | |
| _running = False | |
| async def run_cycle(triggered_by: str = "cron") -> dict: | |
| global _running | |
| if _running: | |
| return {"ok": False, "reason": "Cycle already running"} | |
| _running = True | |
| cycle_id = str(uuid.uuid4()) | |
| cycle_num = _next_cycle_num() | |
| now = time.time() | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO cycles (id,cycle_num,state,triggered_by,started_at) | |
| VALUES (?,?,?,?,?) | |
| """, (cycle_id, cycle_num, "running", triggered_by, now)) | |
| conn.commit(); conn.close() | |
| summary = { | |
| "cycle_id": cycle_id, "cycle_num": cycle_num, | |
| "agents_checked": [], "underperformers": [], | |
| "proposals_created": 0, "error": None, | |
| } | |
| try: | |
| # ── Step 1: pull reward stats from agent-learn ────────────────── | |
| stats = _get(f"{LEARN_URL}/api/stats") | |
| if "error" in stats: | |
| raise RuntimeError(f"agent-learn unreachable: {stats['error']}") | |
| by_agent = stats.get("rewards", {}) | |
| learn_agents = stats.get("qtable", {}).get("by_agent", []) | |
| # Also pull agent-trace stats for error rates | |
| trace_stats = _get(f"{TRACE_URL}/api/stats", {"window_hours": DELTA_WINDOW_H}) | |
| trace_by_agent = {} | |
| for a in trace_stats.get("by_agent", []): | |
| trace_by_agent[a["agent"]] = a | |
| # ── Step 2: assess each agent ─────────────────────────────────── | |
| # Build health snapshot | |
| known_agents = set(a["agent"] for a in learn_agents) | |
| known_agents.update(trace_by_agent.keys()) | |
| conn = get_db() | |
| underperformers = [] | |
| checked = [] | |
| for agent in sorted(known_agents): | |
| agent_trace = trace_by_agent.get(agent, {}) | |
| total_ev = agent_trace.get("cnt", 0) | |
| errs = agent_trace.get("errs", 0) | |
| err_rate = errs / max(total_ev, 1) | |
| # Get reward from learn | |
| rw_trend = _get(f"{LEARN_URL}/api/reward-trend", | |
| {"hours": DELTA_WINDOW_H}) | |
| # Approximate per-agent avg from trace reward column | |
| agent_rw = _get(f"{TRACE_URL}/api/traces", | |
| {"agent": agent, "has_reward": "true", | |
| "since_hours": DELTA_WINDOW_H, "limit": 100}) | |
| rw_vals = [e["reward"] for e in agent_rw.get("events", []) | |
| if e.get("reward") is not None] | |
| avg_rw = sum(rw_vals) / max(len(rw_vals), 1) if rw_vals else None | |
| status = "healthy" | |
| if avg_rw is not None and avg_rw < REWARD_THRESHOLD: | |
| status = "underperforming" | |
| if err_rate > ERROR_ESCALATE: | |
| status = "critical" | |
| conn.execute(""" | |
| INSERT INTO agent_health (agent,avg_reward,error_rate,total_events,last_checked,status) | |
| VALUES (?,?,?,?,?,?) | |
| ON CONFLICT(agent) DO UPDATE SET | |
| avg_reward=excluded.avg_reward, error_rate=excluded.error_rate, | |
| total_events=excluded.total_events, last_checked=excluded.last_checked, | |
| status=excluded.status | |
| """, (agent, avg_rw or 0.0, err_rate, total_ev, time.time(), status)) | |
| checked.append(agent) | |
| if status in ("underperforming", "critical"): | |
| underperformers.append({ | |
| "agent": agent, "avg_reward": avg_rw, "error_rate": err_rate, | |
| "status": status | |
| }) | |
| conn.commit(); conn.close() | |
| summary["agents_checked"] = checked | |
| summary["underperformers"] = underperformers | |
| # ── Step 3: for each underperformer, generate proposal ────────── | |
| for up in underperformers: | |
| agent = up["agent"] | |
| # Get recent self-reflections for this agent | |
| reflections = _get(f"{TRACE_URL}/api/traces", | |
| {"agent": agent, "event_type": "self_reflect", | |
| "since_hours": DELTA_WINDOW_H * 2, "limit": 5}) | |
| reflect_texts = [] | |
| for ev in reflections.get("events", []): | |
| p = ev.get("payload", {}) | |
| if isinstance(p, dict) and p: | |
| reflect_texts.append(json.dumps(p)[:300]) | |
| # Get current persona prompt id | |
| persona = _get(f"{PROMPTS_URL}/api/personas/{agent}") | |
| current_prompt_id = persona.get("system_prompt_id", f"{agent}_system") | |
| # Build improvement prompt for NEXUS | |
| improve_prompt = ( | |
| f"Agent '{agent}' has avg_reward={up['avg_reward']:.3f} (threshold={REWARD_THRESHOLD}), " | |
| f"error_rate={up['error_rate']:.1%}.\n\n" | |
| f"Recent self-reflections:\n" + | |
| ("\n---\n".join(reflect_texts) if reflect_texts else "No reflections available.") + | |
| f"\n\nCurrent system prompt ID: {current_prompt_id}\n\n" | |
| f"Write an improved system prompt for the '{agent}' agent that:\n" | |
| f"1. Addresses the performance issues above\n" | |
| f"2. Maintains its core role and responsibilities\n" | |
| f"3. Adds clearer guidance on the failure patterns identified\n" | |
| f"4. Is concise and actionable\n\n" | |
| f"Output ONLY the improved system prompt text. No preamble, no explanation." | |
| ) | |
| # Call NEXUS for LLM generation | |
| nexus_resp = _post(f"{NEXUS_URL}/api/chat", { | |
| "messages": [{"role": "user", "content": improve_prompt}], | |
| "max_tokens": 600, | |
| "temperature": 0.4, | |
| }) | |
| proposed_text = ( | |
| nexus_resp.get("choices", [{}])[0].get("message", {}).get("content", "") | |
| or nexus_resp.get("content", "") | |
| or nexus_resp.get("response", "") | |
| ) | |
| if not proposed_text or "error" in nexus_resp: | |
| # Fallback: generate a structural improvement template | |
| proposed_text = ( | |
| f"You are {agent.upper()}, a specialized agent in the FORGE AI ecosystem.\n\n" | |
| f"Performance note: Recent avg reward was {up['avg_reward']:.3f}. " | |
| f"Focus on:\n" | |
| f"- Reducing error rate (currently {up['error_rate']:.1%})\n" | |
| f"- Using FORGE skills instead of reimplementing capabilities\n" | |
| f"- Logging every significant action to agent-trace\n" | |
| f"- Reserving LLM slots before long tasks\n\n" | |
| f"[AUTO-GENERATED DRAFT — Review and improve before approving]" | |
| ) | |
| # Create draft prompt in agent-prompts | |
| draft_resp = _post(f"{PROMPTS_URL}/api/prompts", { | |
| "id": f"{agent}_improved_c{cycle_num}", | |
| "type": "system", | |
| "agent": agent, | |
| "name": f"{agent.capitalize()} Improved (Cycle {cycle_num})", | |
| "description": f"Auto-proposed improvement. Reason: {up['status']}. " | |
| f"avg_reward={up['avg_reward']:.3f}, error_rate={up['error_rate']:.1%}", | |
| "template": proposed_text, | |
| "tags": ["auto-proposed", f"cycle-{cycle_num}", agent, "needs-review"], | |
| "status": "draft", | |
| "author": "agent-loop", | |
| }) | |
| draft_id = draft_resp.get("message","").split("'")[1] if "'" in draft_resp.get("message","") else f"{agent}_improved_c{cycle_num}" | |
| # Create proposal record | |
| prop_id = str(uuid.uuid4()) | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO proposals | |
| (id,cycle_id,agent,reason,current_prompt_id,current_reward, | |
| proposed_prompt,prompt_draft_id,state,reward_before,created_at) | |
| VALUES (?,?,?,?,?,?,?,?,?,?,?) | |
| """, (prop_id, cycle_id, agent, | |
| f"{up['status']}: avg_reward={up['avg_reward']:.3f} err={up['error_rate']:.1%}", | |
| current_prompt_id, up["avg_reward"], | |
| proposed_text, draft_id, "pending", | |
| up["avg_reward"], time.time())) | |
| conn.commit(); conn.close() | |
| summary["proposals_created"] += 1 | |
| # Notify via RELAY (best-effort) | |
| _post(f"{RELAY_URL}/api/notify", { | |
| "channel": "telegram", | |
| "message": ( | |
| f"⚡ *LOOP Cycle {cycle_num}*\n" | |
| f"Agent `{agent}` underperforming (reward={up['avg_reward']:.3f})\n" | |
| f"Draft improvement created: `{draft_id}`\n" | |
| f"Approve at: {PROMPTS_URL}\n" | |
| f"Proposal ID: `{prop_id}`" | |
| ) | |
| }) | |
| # ── Step 4: check proposals awaiting 24h measurement ──────────── | |
| await _measure_deployed_proposals() | |
| # ── Finish ─────────────────────────────────────────────────────── | |
| finish = time.time() | |
| state = "awaiting_approval" if summary["proposals_created"] > 0 else "done" | |
| conn = get_db() | |
| conn.execute(""" | |
| UPDATE cycles SET state=?, agents_checked=?, underperformers=?, | |
| proposals_created=?, finished_at=?, duration_s=? | |
| WHERE id=? | |
| """, (state, json.dumps(checked), json.dumps(underperformers), | |
| summary["proposals_created"], finish, round(finish-now, 2), cycle_id)) | |
| conn.commit(); conn.close() | |
| summary["state"] = state | |
| except Exception as e: | |
| finish = time.time() | |
| conn = get_db() | |
| conn.execute("UPDATE cycles SET state='failed', error_msg=?, finished_at=?, duration_s=? WHERE id=?", | |
| (str(e)[:512], finish, round(finish-now,2), cycle_id)) | |
| conn.commit(); conn.close() | |
| summary["error"] = str(e) | |
| finally: | |
| _running = False | |
| return summary | |
| async def _measure_deployed_proposals(): | |
| """For proposals deployed 24h+ ago with no after-reward, measure now.""" | |
| cutoff = time.time() - DELTA_WINDOW_H * 3600 | |
| conn = get_db() | |
| deployed = conn.execute( | |
| "SELECT * FROM proposals WHERE state='deployed' AND reward_after IS NULL AND resolved_at < ?", | |
| (cutoff,)).fetchall() | |
| conn.close() | |
| for p in deployed: | |
| agent = p["agent"] | |
| # Pull current reward avg from agent-trace | |
| rw_data = _get(f"{TRACE_URL}/api/traces", | |
| {"agent": agent, "has_reward": "true", | |
| "since_hours": DELTA_WINDOW_H, "limit": 100}) | |
| rw_vals = [e["reward"] for e in rw_data.get("events", []) | |
| if e.get("reward") is not None] | |
| if not rw_vals: | |
| continue | |
| rw_after = sum(rw_vals) / len(rw_vals) | |
| delta = rw_after - (p["reward_before"] or 0) | |
| conn = get_db() | |
| conn.execute(""" | |
| UPDATE proposals SET reward_after=?, reward_delta=?, state='measured' | |
| WHERE id=? | |
| """, (rw_after, delta, p["id"])) | |
| conn.commit(); conn.close() | |
| # Log to agent-trace | |
| _post(f"{TRACE_URL}/api/trace", { | |
| "agent": "loop", | |
| "event_type": "custom", | |
| "payload": { | |
| "type": "improvement_measurement", | |
| "proposal_id": p["id"], | |
| "agent": agent, | |
| "reward_before": p["reward_before"], | |
| "reward_after": rw_after, | |
| "delta": delta, | |
| "outcome": "positive" if delta > 0.05 else "inconclusive" if delta > -0.05 else "negative", | |
| } | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Proposal management | |
| # --------------------------------------------------------------------------- | |
| def approve_proposal(proposal_id: str, approved_by: str = "operator") -> dict: | |
| conn = get_db() | |
| prop = conn.execute("SELECT * FROM proposals WHERE id=?", (proposal_id,)).fetchone() | |
| conn.close() | |
| if not prop: | |
| return {"ok": False, "error": "Proposal not found"} | |
| if prop["state"] != "pending": | |
| return {"ok": False, "error": f"Proposal state is '{prop['state']}', must be 'pending'"} | |
| # Approve the draft in agent-prompts | |
| approve_resp = _post(f"{PROMPTS_URL}/api/prompts/{prop['prompt_draft_id']}/approve", {}) | |
| # Upsert persona to point to new prompt | |
| persona_resp = _post(f"{PROMPTS_URL}/api/personas", { | |
| "agent": prop["agent"], | |
| "system_prompt_id": prop["prompt_draft_id"], | |
| "name": f"{prop['agent'].capitalize()} (Cycle {prop['cycle_id'][:8]})", | |
| }) | |
| # Update proposal state | |
| conn = get_db() | |
| conn.execute(""" | |
| UPDATE proposals SET state='deployed', approved_by=?, resolved_at=? WHERE id=? | |
| """, (approved_by, time.time(), proposal_id)) | |
| # Update cycle state | |
| conn.execute(""" | |
| UPDATE cycles SET proposals_approved=proposals_approved+1, state='deploying' WHERE id=? | |
| """, (prop["cycle_id"],)) | |
| conn.commit(); conn.close() | |
| # Notify | |
| _post(f"{RELAY_URL}/api/notify", { | |
| "channel": "telegram", | |
| "message": ( | |
| f"✓ *LOOP: Proposal approved*\n" | |
| f"Agent: `{prop['agent']}`\n" | |
| f"New prompt: `{prop['prompt_draft_id']}`\n" | |
| f"Approved by: {approved_by}" | |
| ) | |
| }) | |
| return {"ok": True, "proposal_id": proposal_id, "agent": prop["agent"], | |
| "prompt_id": prop["prompt_draft_id"]} | |
| def reject_proposal(proposal_id: str, reason: str = "") -> dict: | |
| conn = get_db() | |
| n = conn.execute( | |
| "UPDATE proposals SET state='rejected', resolved_at=? WHERE id=? AND state='pending'", | |
| (time.time(), proposal_id)).rowcount | |
| conn.commit(); conn.close() | |
| return {"ok": n > 0, "proposal_id": proposal_id} | |
| def list_proposals(state: str = "", agent: str = "", limit: int = 50) -> list: | |
| conn = get_db() | |
| where, params = [], [] | |
| if state: where.append("state=?"); params.append(state) | |
| if agent: where.append("agent=?"); params.append(agent) | |
| sql = ("SELECT * FROM proposals" + | |
| (f" WHERE {' AND '.join(where)}" if where else "") + | |
| " ORDER BY created_at DESC LIMIT ?") | |
| rows = conn.execute(sql, params+[limit]).fetchall() | |
| conn.close() | |
| return [dict(r) for r in rows] | |
| def list_cycles(limit: int = 20) -> list: | |
| conn = get_db() | |
| rows = conn.execute("SELECT * FROM cycles ORDER BY cycle_num DESC LIMIT ?", (limit,)).fetchall() | |
| conn.close() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| for f in ("agents_checked","underperformers"): | |
| try: d[f] = json.loads(d[f]) | |
| except Exception: pass | |
| result.append(d) | |
| return result | |
| def get_agent_health() -> list: | |
| conn = get_db() | |
| rows = conn.execute("SELECT * FROM agent_health ORDER BY status DESC, last_checked DESC").fetchall() | |
| conn.close() | |
| return [dict(r) for r in rows] | |
| def get_stats() -> dict: | |
| conn = get_db() | |
| total_cy = conn.execute("SELECT COUNT(*) FROM cycles").fetchone()[0] | |
| total_pr = conn.execute("SELECT COUNT(*) FROM proposals").fetchone()[0] | |
| pending_pr = conn.execute("SELECT COUNT(*) FROM proposals WHERE state='pending'").fetchone()[0] | |
| deployed = conn.execute("SELECT COUNT(*) FROM proposals WHERE state='deployed'").fetchone()[0] | |
| avg_delta = conn.execute("SELECT AVG(reward_delta) FROM proposals WHERE reward_delta IS NOT NULL").fetchone()[0] | |
| last_cy = conn.execute("SELECT * FROM cycles ORDER BY cycle_num DESC LIMIT 1").fetchone() | |
| conn.close() | |
| return { | |
| "total_cycles": total_cy, | |
| "total_proposals": total_pr, | |
| "pending_proposals": pending_pr, | |
| "deployed_proposals": deployed, | |
| "avg_reward_delta": round(avg_delta or 0, 4), | |
| "cycle_minutes": CYCLE_MINUTES, | |
| "reward_threshold": REWARD_THRESHOLD, | |
| "last_cycle": dict(last_cy) if last_cy else None, | |
| "cycle_enabled": CYCLE_ENABLED, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Background loop | |
| # --------------------------------------------------------------------------- | |
| async def _cron_loop(): | |
| if not CYCLE_ENABLED: | |
| return | |
| # Initial delay — let other services start first | |
| await asyncio.sleep(90) | |
| while True: | |
| try: | |
| await run_cycle("cron") | |
| except Exception: | |
| pass | |
| await asyncio.sleep(CYCLE_MINUTES * 60) | |
| # --------------------------------------------------------------------------- | |
| # MCP | |
| # --------------------------------------------------------------------------- | |
| MCP_TOOLS = [ | |
| {"name":"loop_status","description":"Get current loop status: agent health, pending proposals, last cycle.", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| {"name":"loop_trigger","description":"Manually trigger an improvement cycle immediately.", | |
| "inputSchema":{"type":"object","properties":{"reason":{"type":"string"}}}}, | |
| {"name":"loop_proposals","description":"List improvement proposals.", | |
| "inputSchema":{"type":"object","properties":{"state":{"type":"string","description":"pending|deployed|rejected|measured"},"agent":{"type":"string"},"limit":{"type":"integer"}}}}, | |
| {"name":"loop_approve","description":"Approve a proposal — deploys the improved prompt and updates agent persona.", | |
| "inputSchema":{"type":"object","required":["proposal_id"], | |
| "properties":{"proposal_id":{"type":"string"},"approved_by":{"type":"string"}}}}, | |
| {"name":"loop_reject","description":"Reject a proposal.", | |
| "inputSchema":{"type":"object","required":["proposal_id"], | |
| "properties":{"proposal_id":{"type":"string"},"reason":{"type":"string"}}}}, | |
| {"name":"loop_health","description":"Get agent health snapshot (reward, error rate, status per agent).", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| {"name":"loop_cycles","description":"List recent improvement cycles.", | |
| "inputSchema":{"type":"object","properties":{"limit":{"type":"integer","default":10}}}}, | |
| ] | |
| def handle_mcp(method, params, req_id): | |
| def ok(r): return {"jsonrpc":"2.0","id":req_id,"result":r} | |
| def txt(d): return ok({"content":[{"type":"text","text":json.dumps(d)}]}) | |
| if method=="initialize": | |
| return ok({"protocolVersion":"2024-11-05", | |
| "serverInfo":{"name":"agent-loop","version":"1.0.0"}, | |
| "capabilities":{"tools":{}}}) | |
| if method=="tools/list": return ok({"tools":MCP_TOOLS}) | |
| if method=="tools/call": | |
| n, a = params.get("name",""), params.get("arguments",{}) | |
| if n=="loop_status": return txt(get_stats()) | |
| if n=="loop_trigger": | |
| asyncio.create_task(run_cycle(a.get("reason","manual"))) | |
| return txt({"ok":True,"message":"Cycle started (async)"}) | |
| if n=="loop_proposals": | |
| return txt({"proposals":list_proposals(a.get("state",""),a.get("agent",""),a.get("limit",20))}) | |
| if n=="loop_approve": return txt(approve_proposal(a["proposal_id"],a.get("approved_by","mcp"))) | |
| if n=="loop_reject": return txt(reject_proposal(a["proposal_id"],a.get("reason",""))) | |
| if n=="loop_health": return txt({"health":get_agent_health()}) | |
| if n=="loop_cycles": return txt({"cycles":list_cycles(a.get("limit",10))}) | |
| return {"jsonrpc":"2.0","id":req_id,"error":{"code":-32601,"message":f"Unknown tool: {n}"}} | |
| if method in ("notifications/initialized","notifications/cancelled"): return None | |
| return {"jsonrpc":"2.0","id":req_id,"error":{"code":-32601,"message":f"Method not found: {method}"}} | |
| # --------------------------------------------------------------------------- | |
| # App | |
| # --------------------------------------------------------------------------- | |
| async def lifespan(app): | |
| init_db() | |
| asyncio.create_task(_cron_loop()) | |
| yield | |
| app = FastAPI(title="agent-loop", version="1.0.0", lifespan=lifespan) | |
| def _auth(r): return not LOOP_KEY or r.headers.get("x-loop-key","") == LOOP_KEY | |
| async def api_trigger(request: Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Loop-Key") | |
| body = {} | |
| try: body = await request.json() | |
| except Exception: pass | |
| asyncio.create_task(run_cycle(body.get("triggered_by","api"))) | |
| return JSONResponse({"ok":True,"message":"Cycle started"}) | |
| async def api_cycles(limit:int=Query(20)): return JSONResponse({"cycles":list_cycles(limit)}) | |
| async def api_proposals(state:str=Query(""),agent:str=Query(""),limit:int=Query(50)): | |
| return JSONResponse({"proposals":list_proposals(state,agent,limit)}) | |
| async def api_approve(pid:str, request:Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Loop-Key") | |
| body = {} | |
| try: body = await request.json() | |
| except Exception: pass | |
| result = approve_proposal(pid, body.get("approved_by","operator")) | |
| if not result.get("ok"): raise HTTPException(400, result.get("error","Error")) | |
| return JSONResponse(result) | |
| async def api_reject(pid:str, request:Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Loop-Key") | |
| body = {} | |
| try: body = await request.json() | |
| except Exception: pass | |
| return JSONResponse(reject_proposal(pid, body.get("reason",""))) | |
| async def api_agent_health(): return JSONResponse({"health":get_agent_health()}) | |
| async def api_stats(): return JSONResponse(get_stats()) | |
| async def api_health(): | |
| return JSONResponse({"ok":True,"cycle_enabled":CYCLE_ENABLED, | |
| "cycle_minutes":CYCLE_MINUTES,"version":"1.0.0"}) | |
| async def mcp_sse(request:Request): | |
| async def gen(): | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'connected','params':{}})}\n\n" | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools','params':{'tools':MCP_TOOLS}})}\n\n" | |
| while True: | |
| if await request.is_disconnected(): break | |
| yield ": ping\n\n"; await asyncio.sleep(15) | |
| return StreamingResponse(gen(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","Connection":"keep-alive","X-Accel-Buffering":"no"}) | |
| async def mcp_rpc(request:Request): | |
| try: body = await request.json() | |
| except Exception: return JSONResponse({"jsonrpc":"2.0","id":None,"error":{"code":-32700,"message":"Parse error"}}) | |
| if isinstance(body,list): | |
| return JSONResponse([r for r in [handle_mcp(x.get("method",""),x.get("params",{}),x.get("id")) for x in body] if r]) | |
| r = handle_mcp(body.get("method",""),body.get("params",{}),body.get("id")) | |
| return JSONResponse(r or {"jsonrpc":"2.0","id":body.get("id"),"result":{}}) | |
| # --------------------------------------------------------------------------- | |
| # SPA | |
| # --------------------------------------------------------------------------- | |
| SPA = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>🔁 LOOP — FORGE Self-Improvement</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&family=DM+Mono:wght@300;400;500&display=swap'); | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| :root{--bg:#06060d;--sf:#0d0d18;--sf2:#121222;--br:#1a1a2e;--ac:#ff6b00;--ac2:#ff9500;--tx:#dde0f0;--mu:#50507a;--gr:#00ff88;--rd:#ff4455;--cy:#06b6d4;--pu:#8b5cf6;--ye:#f59e0b} | |
| html,body{height:100%;background:var(--bg);color:var(--tx);font-family:'Syne',sans-serif} | |
| ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--sf)}::-webkit-scrollbar-thumb{background:var(--br);border-radius:3px} | |
| .app{display:grid;grid-template-rows:52px auto 1fr;height:100vh;overflow:hidden} | |
| .hdr{display:flex;align-items:center;gap:1rem;padding:0 1.5rem;border-bottom:1px solid var(--br);background:var(--sf)} | |
| .logo{font-family:'Space Mono',monospace;font-size:1.1rem;font-weight:700;color:var(--ac)} | |
| .sub{font-family:'DM Mono',monospace;font-size:.6rem;color:var(--mu);letter-spacing:.2em;text-transform:uppercase} | |
| .hstats{display:flex;gap:1.5rem;margin-left:auto} | |
| .hs{text-align:center}.hs-n{font-family:'Space Mono',monospace;font-size:1rem;font-weight:700;color:var(--ac)} | |
| .hs-l{font-family:'DM Mono',monospace;font-size:.58rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em} | |
| .tabs{display:flex;border-bottom:1px solid var(--br);background:var(--sf);align-items:center;flex-shrink:0} | |
| .tab{padding:.55rem 1.3rem;font-family:'DM Mono',monospace;font-size:.72rem;color:var(--mu);border-bottom:2px solid transparent;cursor:pointer;letter-spacing:.05em;transition:all .15s} | |
| .tab.active{color:var(--ac);border-bottom-color:var(--ac)}.tab:hover{color:var(--tx)} | |
| .body{overflow-y:auto;padding:1.25rem} | |
| .btn{padding:.4rem .9rem;border:none;border-radius:5px;cursor:pointer;font-family:'DM Mono',monospace;font-size:.7rem;font-weight:700;transition:all .15s;letter-spacing:.03em} | |
| .btn-trigger{background:var(--ac);color:#000}.btn-trigger:hover{filter:brightness(1.1)} | |
| .btn-trigger:disabled{opacity:.4;cursor:not-allowed} | |
| .btn-approve{background:#001a08;color:var(--gr);border:1px solid #004422}.btn-approve:hover{background:#003010} | |
| .btn-reject{background:#1a0000;color:var(--rd);border:1px solid #440011}.btn-reject:hover{background:#300010} | |
| /* Pipeline viz */ | |
| .pipeline{display:flex;align-items:center;gap:0;margin-bottom:1.5rem} | |
| .pipe-step{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.7rem 1rem;text-align:center;flex:1} | |
| .pipe-step-name{font-family:'Space Mono',monospace;font-size:.78rem;font-weight:700} | |
| .pipe-step-sub{font-family:'DM Mono',monospace;font-size:.6rem;color:var(--mu);margin-top:3px;text-transform:uppercase;letter-spacing:.08em} | |
| .pipe-arrow{color:var(--mu);font-size:1.2rem;padding:0 .4rem;flex-shrink:0} | |
| .pipe-active{border-color:var(--ac);box-shadow:0 0 12px rgba(255,107,0,.2)} | |
| /* Agent health grid */ | |
| .health-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.75rem;margin-bottom:1.5rem} | |
| .health-card{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.8rem 1rem;position:relative;overflow:hidden} | |
| .health-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px} | |
| .health-card.healthy::before{background:var(--gr)} | |
| .health-card.underperforming::before{background:var(--ye)} | |
| .health-card.critical::before{background:var(--rd)} | |
| .health-card.unknown::before{background:var(--mu)} | |
| .hc-agent{font-family:'Space Mono',monospace;font-size:.9rem;font-weight:700;color:var(--ac);margin-bottom:.4rem} | |
| .hc-stat{font-family:'DM Mono',monospace;font-size:.72rem;color:var(--mu);margin-bottom:.15rem} | |
| .hc-stat span{color:var(--tx)} | |
| .hc-status{font-family:'DM Mono',monospace;font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;margin-top:.4rem} | |
| .hc-status.healthy{color:var(--gr)}.hc-status.underperforming{color:var(--ye)}.hc-status.critical{color:var(--rd)} | |
| /* Proposal cards */ | |
| .prop-card{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:1rem;margin-bottom:.75rem} | |
| .prop-hdr{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem} | |
| .prop-agent{font-family:'Space Mono',monospace;font-size:.9rem;font-weight:700;color:var(--ac)} | |
| .prop-reason{font-family:'DM Mono',monospace;font-size:.7rem;color:var(--mu)} | |
| .prop-text{background:#0a0a14;border:1px solid var(--br);border-radius:5px;padding:.65rem;font-family:'DM Mono',monospace;font-size:.7rem;color:var(--gr);white-space:pre-wrap;line-height:1.7;max-height:150px;overflow-y:auto;margin:.6rem 0} | |
| .prop-actions{display:flex;gap:.5rem;align-items:center} | |
| .prop-meta{font-family:'DM Mono',monospace;font-size:.62rem;color:var(--mu);margin-left:auto} | |
| .state-badge{font-family:'DM Mono',monospace;font-size:.62rem;padding:2px 8px;border-radius:4px} | |
| .state-pending{background:#1a1000;color:var(--ye);border:1px solid #442200} | |
| .state-deployed{background:#001a08;color:var(--gr);border:1px solid #004422} | |
| .state-rejected{background:#1a0000;color:var(--rd);border:1px solid #440011} | |
| .state-measured{background:#0a001a;color:var(--pu);border:1px solid #2a0066} | |
| /* Cycle log */ | |
| .cycle-row{display:grid;grid-template-columns:40px 80px 100px 60px 60px 1fr;gap:.6rem;align-items:center;padding:.4rem .75rem;border-bottom:1px solid #0d0d18;font-family:'DM Mono',monospace;font-size:.72rem} | |
| .cycle-row:hover{background:var(--sf)} | |
| .cy-num{font-weight:700;color:var(--ac)} | |
| .cy-state{padding:1px 7px;border-radius:3px;font-size:.62rem;text-align:center} | |
| .cy-running{background:#001a00;color:var(--gr);border:1px solid #004400} | |
| .cy-done{background:#0a0a1a;color:var(--pu);border:1px solid #1a1a44} | |
| .cy-failed{background:#1a0000;color:var(--rd);border:1px solid #440011} | |
| .cy-awaiting{background:#1a1000;color:var(--ye);border:1px solid #442200} | |
| .cy-skipped{background:var(--sf2);color:var(--mu);border:1px solid var(--br)} | |
| .cy-other{background:var(--sf2);color:var(--mu);border:1px solid var(--br)} | |
| /* Config table */ | |
| .cfg-row{display:flex;align-items:center;padding:.55rem 1rem;border-bottom:1px solid var(--br);font-family:'DM Mono',monospace;font-size:.75rem} | |
| .cfg-k{color:var(--mu);text-transform:uppercase;letter-spacing:.1em;font-size:.62rem;width:160px} | |
| .cfg-v{color:var(--cy);font-weight:700} | |
| .cfg-d{color:var(--mu);font-size:.65rem;margin-left:.75rem} | |
| .section{font-family:'DM Mono',monospace;font-size:.62rem;color:var(--pu);text-transform:uppercase;letter-spacing:.15em;margin:.75rem 0 .4rem} | |
| .empty{text-align:center;padding:2rem;color:var(--mu);font-family:'DM Mono',monospace;font-size:.8rem} | |
| .kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem;margin-bottom:1.25rem} | |
| .kpi{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.8rem 1rem} | |
| .kpi-n{font-family:'Space Mono',monospace;font-size:1.5rem;font-weight:700;color:var(--ac);line-height:1} | |
| .kpi-l{font-family:'DM Mono',monospace;font-size:.58rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em;margin-top:3px} | |
| </style></head><body> | |
| <div class="app"> | |
| <header class="hdr"> | |
| <div><div class="logo">🔁 LOOP</div><div class="sub">Self-Improvement Orchestrator</div></div> | |
| <div class="hstats"> | |
| <div class="hs"><div class="hs-n" id="hCy">—</div><div class="hs-l">Cycles</div></div> | |
| <div class="hs"><div class="hs-n" id="hPe" style="color:var(--ye)">—</div><div class="hs-l">Pending</div></div> | |
| <div class="hs"><div class="hs-n" id="hDe" style="color:var(--gr)">—</div><div class="hs-l">Deployed</div></div> | |
| <div class="hs"><div class="hs-n" id="hDelta" style="color:var(--cy)">—</div><div class="hs-l">Avg delta</div></div> | |
| </div> | |
| </header> | |
| <div class="tabs"> | |
| <div class="tab active" onclick="showTab('overview')">📊 Overview</div> | |
| <div class="tab" onclick="showTab('proposals')">📝 Proposals</div> | |
| <div class="tab" onclick="showTab('cycles')">🕐 Cycle Log</div> | |
| <div class="tab" onclick="showTab('config')">⚙︎ Config</div> | |
| <button class="btn btn-trigger" id="triggerBtn" onclick="triggerCycle()" style="margin:auto 1rem auto auto;padding:.3rem .8rem">⚡ Run Cycle Now</button> | |
| </div> | |
| <div class="body" id="tabBody"></div> | |
| </div> | |
| <script> | |
| let stats=null, health=[], proposals=[], cycles=[]; | |
| async function loadAll(){ | |
| [stats,health] = await Promise.all([ | |
| fetch('/api/stats').then(r=>r.json()), | |
| fetch('/api/health/agents').then(r=>r.json()).then(d=>d.health||[]) | |
| ]); | |
| document.getElementById('hCy').textContent = stats.total_cycles||0; | |
| document.getElementById('hPe').textContent = stats.pending_proposals||0; | |
| document.getElementById('hDe').textContent = stats.deployed_proposals||0; | |
| const d=stats.avg_reward_delta; | |
| const de=document.getElementById('hDelta'); | |
| de.textContent=(d>=0?'+':'')+d.toFixed(3); | |
| de.style.color=d>0.05?'var(--gr)':d<-0.05?'var(--rd)':'var(--cy)'; | |
| renderTab(); | |
| } | |
| async function loadProposals(){ proposals=(await fetch('/api/proposals?limit=30').then(r=>r.json())).proposals||[]; } | |
| async function loadCycles() { cycles=(await fetch('/api/cycles?limit=25').then(r=>r.json())).cycles||[]; } | |
| let currentTab='overview'; | |
| function showTab(t){ | |
| currentTab=t; | |
| document.querySelectorAll('.tab').forEach((el,i)=>el.classList.toggle('active',['overview','proposals','cycles','config'][i]===t)); | |
| renderTab(); | |
| } | |
| async function renderTab(){ | |
| if(currentTab==='overview') renderOverview(); | |
| else if(currentTab==='proposals') { await loadProposals(); renderProposals(); } | |
| else if(currentTab==='cycles') { await loadCycles(); renderCycles(); } | |
| else if(currentTab==='config') renderConfig(); | |
| } | |
| function renderOverview(){ | |
| const pending=proposals.filter?proposals.filter(p=>p.state==='pending'):[]; | |
| document.getElementById('tabBody').innerHTML=` | |
| <div class="kpis"> | |
| <div class="kpi"><div class="kpi-n">${stats.total_cycles||0}</div><div class="kpi-l">Total cycles</div></div> | |
| <div class="kpi"><div class="kpi-n" style="color:var(--ye)">${stats.pending_proposals||0}</div><div class="kpi-l">Pending approval</div></div> | |
| <div class="kpi"><div class="kpi-n" style="color:var(--gr)">${stats.deployed_proposals||0}</div><div class="kpi-l">Deployed</div></div> | |
| <div class="kpi"><div class="kpi-n" style="color:var(--cy)">${stats.cycle_minutes||60}min</div><div class="kpi-l">Cycle interval</div></div> | |
| </div> | |
| <div class="section">Improvement Pipeline</div> | |
| <div class="pipeline"> | |
| ${[ | |
| ['📊','TRACE','telemetry'], | |
| ['🧠','LEARN','rewards'], | |
| ['🔁','LOOP','orchestrates'], | |
| ['💬','PROMPTS','drafts'], | |
| ['👥','YOU','approves'], | |
| ['⚙','AGENTS','deployed'], | |
| ].map(([ico,name,sub],i)=>` | |
| <div class="pipe-step${name==='LOOP'?' pipe-active':''}"> | |
| <div style="font-size:1.2rem">${ico}</div> | |
| <div class="pipe-step-name">${name}</div> | |
| <div class="pipe-step-sub">${sub}</div> | |
| </div> | |
| ${i<5?'<div class="pipe-arrow">→</div>':''}`).join('')} | |
| </div> | |
| <div class="section">Agent Health</div> | |
| <div class="health-grid"> | |
| ${health.length ? health.map(h=>` | |
| <div class="health-card ${h.status}"> | |
| <div class="hc-agent">${h.agent}</div> | |
| <div class="hc-stat">Avg reward: <span style="color:${h.avg_reward>=0.3?'var(--gr)':h.avg_reward>=0.1?'var(--ye)':'var(--rd)'}">${h.avg_reward.toFixed(3)}</span></div> | |
| <div class="hc-stat">Error rate: <span style="color:${h.error_rate<0.05?'var(--gr)':h.error_rate<0.15?'var(--ye)':'var(--rd)'}">${(h.error_rate*100).toFixed(1)}%</span></div> | |
| <div class="hc-stat">Events: <span>${h.total_events}</span></div> | |
| <div class="hc-status ${h.status}">● ${h.status}</div> | |
| </div>`).join('') : '<div class="empty" style="grid-column:1/-1">No health data yet. Run a cycle to assess agents.</div>'} | |
| </div> | |
| ${stats.last_cycle ? ` | |
| <div class="section">Last Cycle</div> | |
| <div style="background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.9rem 1rem;font-family:'DM Mono',monospace;font-size:.75rem"> | |
| <div>Cycle #${stats.last_cycle.cycle_num} · ${stats.last_cycle.state} · ${stats.last_cycle.duration_s?.toFixed(1)||'?'}s</div> | |
| <div style="color:var(--mu);margin-top:.25rem">Triggered by: ${stats.last_cycle.triggered_by} · Proposals: ${stats.last_cycle.proposals_created}</div> | |
| ${stats.last_cycle.error_msg?`<div style="color:var(--rd);margin-top:.3rem">Error: ${esc(stats.last_cycle.error_msg)}</div>`:''} | |
| </div>` : ''}`; | |
| } | |
| async function renderProposals(){ | |
| await loadProposals(); | |
| const pending = proposals.filter(p=>p.state==='pending'); | |
| const others = proposals.filter(p=>p.state!=='pending'); | |
| document.getElementById('tabBody').innerHTML=` | |
| ${pending.length?` | |
| <div class="section" style="color:var(--ye)">⚠ Awaiting Approval (${pending.length})</div> | |
| ${pending.map(p=>propCard(p,true)).join('')}`:''} | |
| ${others.length?` | |
| <div class="section">History</div> | |
| ${others.map(p=>propCard(p,false)).join('')}`:''} | |
| ${!proposals.length?'<div class="empty">No proposals yet. Run a cycle to generate improvements.</div>':''}`; | |
| } | |
| function propCard(p,interactive){ | |
| const deltaHtml = p.reward_delta!=null | |
| ? `<span style="color:${p.reward_delta>0?'var(--gr)':p.reward_delta<-0.05?'var(--rd)':'var(--cy)'}">Δ${p.reward_delta>0?'+':''}${p.reward_delta.toFixed(3)}</span>` | |
| : ''; | |
| return `<div class="prop-card"> | |
| <div class="prop-hdr"> | |
| <span class="prop-agent">${p.agent}</span> | |
| <span class="state-badge state-${p.state}">${p.state}</span> | |
| ${deltaHtml} | |
| </div> | |
| <div class="prop-reason">${esc(p.reason)}</div> | |
| <div class="prop-text">${esc(p.proposed_prompt||'')}</div> | |
| <div class="prop-actions"> | |
| ${interactive?` | |
| <button class="btn btn-approve" onclick="approveProp('${p.id}')">✓ Approve & Deploy</button> | |
| <button class="btn btn-reject" onclick="rejectProp('${p.id}')">✕ Reject</button>`:''} | |
| <span class="prop-meta"> | |
| ${p.prompt_draft_id?`draft: ${p.prompt_draft_id} · `:''} | |
| ${new Date(p.created_at*1000).toLocaleString()} | |
| </span> | |
| </div> | |
| </div>`; | |
| } | |
| async function approveProp(id){ | |
| const r=await fetch(`/api/proposals/${id}/approve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({approved_by:'operator'})}); | |
| const d=await r.json(); | |
| alert(d.ok?`Deployed! Agent ${d.agent} now uses ${d.prompt_id}`:`Error: ${d.error}`); | |
| await loadAll(); | |
| } | |
| async function rejectProp(id){ | |
| await fetch(`/api/proposals/${id}/reject`,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); | |
| await loadProposals();renderProposals(); | |
| } | |
| async function renderCycles(){ | |
| await loadCycles(); | |
| document.getElementById('tabBody').innerHTML=` | |
| <div style="background:var(--sf);border:1px solid var(--br);border-radius:8px;overflow:hidden"> | |
| <div class="cycle-row" style="font-family:'DM Mono',monospace;font-size:.62rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em;border-bottom:1px solid var(--br)"> | |
| <span>#</span><span>State</span><span>Triggered</span><span>Agents</span><span>Props</span><span>Duration / Error</span> | |
| </div> | |
| ${cycles.length ? cycles.map(c=>{ | |
| const sc=c.state==='running'?'cy-running':c.state==='done'?'cy-done':c.state==='failed'?'cy-failed':c.state==='awaiting_approval'?'cy-awaiting':c.state==='skipped'?'cy-skipped':'cy-other'; | |
| return `<div class="cycle-row"> | |
| <span class="cy-num">${c.cycle_num}</span> | |
| <span class="cy-state ${sc}">${c.state}</span> | |
| <span style="color:var(--mu)">${c.triggered_by}</span> | |
| <span style="color:var(--cy)">${(c.agents_checked||[]).length}</span> | |
| <span style="color:var(--ye)">${c.proposals_created||0}</span> | |
| <span style="color:${c.error_msg?'var(--rd)':'var(--mu)'}"> | |
| ${c.error_msg?esc(c.error_msg.slice(0,60)):c.duration_s!=null?(c.duration_s.toFixed(1)+'s'):'—'} | |
| </span> | |
| </div>`; | |
| }).join('') : '<div class="empty">No cycles run yet</div>'} | |
| </div>`; | |
| } | |
| function renderConfig(){ | |
| document.getElementById('tabBody').innerHTML=` | |
| <div class="section">Runtime config</div> | |
| <div style="background:var(--sf);border:1px solid var(--br);border-radius:8px;overflow:hidden"> | |
| ${[ | |
| ['CYCLE_MINUTES', stats.cycle_minutes+'min', 'How often the improvement loop runs'], | |
| ['REWARD_THRESHOLD', stats.reward_threshold, 'Agents below this trigger a proposal'], | |
| ['ERROR_ESCALATE', '15%', 'Error rate above this = critical escalation'], | |
| ['CYCLE_ENABLED', String(stats.cycle_enabled), 'Set to false to pause the loop'], | |
| ['LEARN_URL', 'env: LEARN_URL', 'agent-learn endpoint'], | |
| ['TRACE_URL', 'env: TRACE_URL', 'agent-trace endpoint'], | |
| ['PROMPTS_URL', 'env: PROMPTS_URL', 'agent-prompts endpoint'], | |
| ['NEXUS_URL', 'env: NEXUS_URL', 'NEXUS LLM gateway'], | |
| ['RELAY_URL', 'env: RELAY_URL', 'RELAY notification endpoint'], | |
| ].map(([k,v,d])=>`<div class="cfg-row"><span class="cfg-k">${k}</span><span class="cfg-v">${v}</span><span class="cfg-d">${d}</span></div>`).join('')} | |
| </div> | |
| <div class="section" style="margin-top:1rem">Full data flow</div> | |
| <pre style="background:var(--sf);border:1px solid var(--br);border-radius:6px;padding:.75rem;font-family:'DM Mono',monospace;font-size:.7rem;color:var(--mu);line-height:1.9"> | |
| TRACE ──(events)──▶ LEARN ──(scored)──▶ LOOP | |
| │ │ | |
| └─(reward written back) └──(underperformer detected) | |
| │ | |
| NEXUS ◀──(generate proposal) | |
| │ | |
| PROMPTS ◀──(POST draft) | |
| │ | |
| Telegram ◀──(RELAY notify) | |
| │ | |
| YOU ──(approve)──▶ PROMPTS (approve) | |
| │ | |
| PROMPTS persona updated | |
| │ | |
| Agents fetch at next startup</pre> | |
| <div class="section" style="margin-top:1rem">MCP</div> | |
| <pre style="background:var(--sf);border:1px solid var(--br);border-radius:6px;padding:.75rem;font-family:'DM Mono',monospace;font-size:.7rem;color:var(--cy)">{"mcpServers":{"loop":{"command":"npx","args":["-y","mcp-remote","${window.location.origin}/mcp/sse"]}}}</pre>`; | |
| } | |
| async function triggerCycle(){ | |
| const btn=document.getElementById('triggerBtn'); | |
| btn.disabled=true; btn.textContent='⚡ Running...'; | |
| await fetch('/api/cycle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({triggered_by:'manual'})}); | |
| setTimeout(async()=>{await loadAll();btn.disabled=false;btn.textContent='⚡ Run Cycle Now';},3000); | |
| } | |
| function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')} | |
| loadAll(); setInterval(loadAll, 20000); | |
| </script> | |
| </body></html>""" | |
| async def root(): return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8") | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info") | |