agent-loop / main.py
Chris4K's picture
Upload 4 files
7a4ccb2 verified
"""
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"&#9889; *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"&#10003; *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
# ---------------------------------------------------------------------------
@asynccontextmanager
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
@app.post("/api/cycle")
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"})
@app.get("/api/cycles")
async def api_cycles(limit:int=Query(20)): return JSONResponse({"cycles":list_cycles(limit)})
@app.get("/api/proposals")
async def api_proposals(state:str=Query(""),agent:str=Query(""),limit:int=Query(50)):
return JSONResponse({"proposals":list_proposals(state,agent,limit)})
@app.post("/api/proposals/{pid}/approve")
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)
@app.post("/api/proposals/{pid}/reject")
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","")))
@app.get("/api/health/agents")
async def api_agent_health(): return JSONResponse({"health":get_agent_health()})
@app.get("/api/stats")
async def api_stats(): return JSONResponse(get_stats())
@app.get("/api/health")
async def api_health():
return JSONResponse({"ok":True,"cycle_enabled":CYCLE_ENABLED,
"cycle_minutes":CYCLE_MINUTES,"version":"1.0.0"})
@app.get("/mcp/sse")
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"})
@app.post("/mcp")
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>&#128257; LOOP &#8212; 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">&#128257; LOOP</div><div class="sub">Self-Improvement Orchestrator</div></div>
<div class="hstats">
<div class="hs"><div class="hs-n" id="hCy">&#8212;</div><div class="hs-l">Cycles</div></div>
<div class="hs"><div class="hs-n" id="hPe" style="color:var(--ye)">&#8212;</div><div class="hs-l">Pending</div></div>
<div class="hs"><div class="hs-n" id="hDe" style="color:var(--gr)">&#8212;</div><div class="hs-l">Deployed</div></div>
<div class="hs"><div class="hs-n" id="hDelta" style="color:var(--cy)">&#8212;</div><div class="hs-l">Avg delta</div></div>
</div>
</header>
<div class="tabs">
<div class="tab active" onclick="showTab('overview')">&#128202; Overview</div>
<div class="tab" onclick="showTab('proposals')">&#128221; Proposals</div>
<div class="tab" onclick="showTab('cycles')">&#128336; Cycle Log</div>
<div class="tab" onclick="showTab('config')">&#9881;&#65038; Config</div>
<button class="btn btn-trigger" id="triggerBtn" onclick="triggerCycle()" style="margin:auto 1rem auto auto;padding:.3rem .8rem">&#9889; 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">
${[
['&#128202;','TRACE','telemetry'],
['&#129504;','LEARN','rewards'],
['&#128257;','LOOP','orchestrates'],
['&#128172;','PROMPTS','drafts'],
['&#128101;','YOU','approves'],
['&#9881;','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">&#8594;</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}">&#9679; ${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} &middot; ${stats.last_cycle.state} &middot; ${stats.last_cycle.duration_s?.toFixed(1)||'?'}s</div>
<div style="color:var(--mu);margin-top:.25rem">Triggered by: ${stats.last_cycle.triggered_by} &middot; 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)">&#9888; 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)'}">&#916;${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}')">&#10003; Approve &amp; Deploy</button>
<button class="btn btn-reject" onclick="rejectProp('${p.id}')">&#10005; Reject</button>`:''}
<span class="prop-meta">
${p.prompt_draft_id?`draft: ${p.prompt_draft_id} &middot; `:''}
${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='&#9889; 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='&#9889; Run Cycle Now';},3000);
}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
loadAll(); setInterval(loadAll, 20000);
</script>
</body></html>"""
@app.get("/", response_class=HTMLResponse)
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")