Chris4K commited on
Commit
7a4ccb2
·
verified ·
1 Parent(s): b2f6da7

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +7 -0
  2. README.md +74 -6
  3. main.py +990 -0
  4. requirements.txt +2 -0
Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY main.py .
6
+ EXPOSE 7860
7
+ CMD ["python", "main.py"]
README.md CHANGED
@@ -1,11 +1,79 @@
1
  ---
2
- title: Agent Loop
3
- emoji: 🐨
4
- colorFrom: pink
5
  colorTo: red
6
  sdk: docker
7
- pinned: false
8
- short_description: agent-loop
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: agent-loop — FORGE Self-Improvement Orchestrator
3
+ emoji: 🔄
4
+ colorFrom: blue
5
  colorTo: red
6
  sdk: docker
7
+ pinned: true
8
+ license: mit
9
+ short_description: Feedback loop — trace→learn→propose→approve→deploy
10
  ---
11
 
12
+ # 🔄 agent-loop
13
+ ### FORGE Self-Improvement Orchestrator
14
+
15
+ Closes the feedback loop. Runs on a configurable cron interval, detects underperforming agents,
16
+ generates improved system prompts via NEXUS, creates drafts in agent-prompts, and notifies you
17
+ for approval. **Never auto-deploys — every change requires human approval.**
18
+
19
+ ## Improvement pipeline
20
+
21
+ ```
22
+ TRACE ──(events)──▶ LEARN ──(scored)──▶ LOOP
23
+
24
+ NEXUS ◀──(generate proposal)
25
+
26
+ PROMPTS ◀──(POST draft, status=draft)
27
+
28
+ RELAY ◀──(Telegram notify)
29
+
30
+ YOU ──(approve via dashboard or API)
31
+
32
+ PROMPTS persona updated → agents fetch at next startup
33
+ ```
34
+
35
+ ## Decision thresholds
36
+
37
+ | Condition | Action |
38
+ |-----------|--------|
39
+ | `avg_reward < REWARD_THRESHOLD (0.2)` | Generate improvement proposal |
40
+ | `error_rate > ERROR_ESCALATE (15%)` | Mark critical, escalate via RELAY |
41
+ | `reward_delta < 0.05` after 24h | Mark cycle inconclusive |
42
+ | `reward_delta > 0.05` after 24h | Mark cycle successful |
43
+
44
+ ## REST API
45
+
46
+ ```
47
+ POST /api/cycle Trigger a cycle manually
48
+ GET /api/cycles List recent cycles
49
+ GET /api/proposals List proposals (state, agent filters)
50
+ POST /api/proposals/{id}/approve Approve → deploy to agent-prompts
51
+ POST /api/proposals/{id}/reject Reject proposal
52
+ GET /api/health/agents Agent health snapshot
53
+ GET /api/stats Loop statistics
54
+ ```
55
+
56
+ ## MCP
57
+
58
+ ```
59
+ GET /mcp/sse · POST /mcp
60
+
61
+ Tools: loop_status, loop_trigger, loop_proposals, loop_approve,
62
+ loop_reject, loop_health, loop_cycles
63
+ ```
64
+
65
+ ## Secrets
66
+
67
+ | Key | Description |
68
+ |-----|-------------|
69
+ | `LOOP_KEY` | Optional write auth (X-Loop-Key header) |
70
+ | `LEARN_URL` | `https://chris4k-agent-learn.hf.space` |
71
+ | `TRACE_URL` | `https://chris4k-agent-trace.hf.space` |
72
+ | `PROMPTS_URL` | `https://chris4k-agent-prompts.hf.space` |
73
+ | `NEXUS_URL` | `https://chris4k-agent-nexus.hf.space` |
74
+ | `RELAY_URL` | `https://chris4k-agent-relay.hf.space` |
75
+ | `CYCLE_MINUTES` | Default: `60` |
76
+ | `REWARD_THRESHOLD` | Default: `0.2` |
77
+ | `CYCLE_ENABLED` | Set `false` to pause |
78
+
79
+ Built by [Chris4K](https://huggingface.co/Chris4K) — ki-fusion-labs.de
main.py ADDED
@@ -0,0 +1,990 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent-loop — FORGE Self-Improvement Orchestrator
3
+ Closes the feedback loop: trace → learn → prompts → deploy.
4
+
5
+ Cycle:
6
+ 1. Pull reward trend from agent-learn
7
+ 2. Identify agents with avg_reward < threshold
8
+ 3. Fetch their recent self-reflection traces from agent-trace
9
+ 4. Call NEXUS to generate a prompt improvement proposal
10
+ 5. POST draft to agent-prompts
11
+ 6. Notify operator via RELAY (Telegram)
12
+ 7. Wait for approval → on approve: POST /approve triggers deployment
13
+ 8. After 24h: measure reward delta, log outcome
14
+ """
15
+
16
+ import asyncio, json, os, sqlite3, time, uuid
17
+ from contextlib import asynccontextmanager
18
+ from pathlib import Path
19
+
20
+ import uvicorn
21
+ from fastapi import FastAPI, HTTPException, Query, Request
22
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Config
26
+ # ---------------------------------------------------------------------------
27
+ DB_PATH = Path(os.getenv("LOOP_DB", "/tmp/loop.db"))
28
+ PORT = int(os.getenv("PORT", "7860"))
29
+ LOOP_KEY = os.getenv("LOOP_KEY", "")
30
+
31
+ LEARN_URL = os.getenv("LEARN_URL", "https://chris4k-agent-learn.hf.space")
32
+ TRACE_URL = os.getenv("TRACE_URL", "https://chris4k-agent-trace.hf.space")
33
+ PROMPTS_URL = os.getenv("PROMPTS_URL","https://chris4k-agent-prompts.hf.space")
34
+ NEXUS_URL = os.getenv("NEXUS_URL", "https://chris4k-agent-nexus.hf.space")
35
+ RELAY_URL = os.getenv("RELAY_URL", "https://chris4k-agent-relay.hf.space")
36
+
37
+ CYCLE_MINUTES = int(os.getenv("CYCLE_MINUTES", "60"))
38
+ REWARD_THRESHOLD = float(os.getenv("REWARD_THRESHOLD", "0.2")) # trigger below this
39
+ ERROR_ESCALATE = float(os.getenv("ERROR_ESCALATE", "0.15")) # error rate > 15% → escalate
40
+ NOTIFY_AGENT = os.getenv("NOTIFY_AGENT", "Chris4K")
41
+ DELTA_WINDOW_H = int(os.getenv("DELTA_WINDOW_H", "24")) # hours to measure improvement
42
+
43
+ CYCLE_ENABLED = os.getenv("CYCLE_ENABLED", "true").lower() == "true"
44
+
45
+ VALID_STATES = {"idle","running","awaiting_approval","deploying","done","failed","skipped"}
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # HTTP helpers (stdlib only — no httpx dep in base image)
49
+ # ---------------------------------------------------------------------------
50
+ def _get(url: str, params: dict = None, timeout: int = 8) -> dict:
51
+ import urllib.request, urllib.parse
52
+ if params:
53
+ url = url + "?" + urllib.parse.urlencode({k:v for k,v in params.items() if v is not None})
54
+ try:
55
+ with urllib.request.urlopen(url, timeout=timeout) as r:
56
+ return json.loads(r.read())
57
+ except Exception as e:
58
+ return {"error": str(e)}
59
+
60
+ def _post(url: str, data: dict, timeout: int = 15) -> dict:
61
+ import urllib.request
62
+ req = urllib.request.Request(
63
+ url, data=json.dumps(data).encode(),
64
+ headers={"Content-Type": "application/json"}, method="POST")
65
+ try:
66
+ with urllib.request.urlopen(req, timeout=timeout) as r:
67
+ return json.loads(r.read())
68
+ except Exception as e:
69
+ return {"error": str(e)}
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Database
73
+ # ---------------------------------------------------------------------------
74
+ def get_db():
75
+ conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
76
+ conn.row_factory = sqlite3.Row
77
+ conn.execute("PRAGMA journal_mode=WAL")
78
+ conn.execute("PRAGMA synchronous=NORMAL")
79
+ return conn
80
+
81
+ def init_db():
82
+ conn = get_db()
83
+ conn.executescript("""
84
+ CREATE TABLE IF NOT EXISTS cycles (
85
+ id TEXT PRIMARY KEY,
86
+ cycle_num INTEGER NOT NULL DEFAULT 0,
87
+ state TEXT NOT NULL DEFAULT 'idle',
88
+ triggered_by TEXT NOT NULL DEFAULT 'cron',
89
+ agents_checked TEXT NOT NULL DEFAULT '[]',
90
+ underperformers TEXT NOT NULL DEFAULT '[]',
91
+ proposals_created INTEGER NOT NULL DEFAULT 0,
92
+ proposals_approved INTEGER NOT NULL DEFAULT 0,
93
+ error_msg TEXT,
94
+ started_at REAL NOT NULL,
95
+ finished_at REAL,
96
+ duration_s REAL
97
+ );
98
+ CREATE INDEX IF NOT EXISTS idx_cy_num ON cycles(cycle_num DESC);
99
+ CREATE INDEX IF NOT EXISTS idx_cy_state ON cycles(state);
100
+
101
+ CREATE TABLE IF NOT EXISTS proposals (
102
+ id TEXT PRIMARY KEY,
103
+ cycle_id TEXT NOT NULL,
104
+ agent TEXT NOT NULL,
105
+ reason TEXT NOT NULL,
106
+ current_prompt_id TEXT NOT NULL,
107
+ current_reward REAL,
108
+ proposed_prompt TEXT NOT NULL,
109
+ prompt_draft_id TEXT,
110
+ state TEXT NOT NULL DEFAULT 'pending',
111
+ approved_by TEXT,
112
+ reward_before REAL,
113
+ reward_after REAL,
114
+ reward_delta REAL,
115
+ created_at REAL NOT NULL,
116
+ resolved_at REAL
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_pr_cycle ON proposals(cycle_id);
119
+ CREATE INDEX IF NOT EXISTS idx_pr_agent ON proposals(agent);
120
+ CREATE INDEX IF NOT EXISTS idx_pr_state ON proposals(state);
121
+
122
+ CREATE TABLE IF NOT EXISTS agent_health (
123
+ agent TEXT PRIMARY KEY,
124
+ avg_reward REAL NOT NULL DEFAULT 0.0,
125
+ error_rate REAL NOT NULL DEFAULT 0.0,
126
+ total_events INTEGER NOT NULL DEFAULT 0,
127
+ last_checked REAL NOT NULL,
128
+ status TEXT NOT NULL DEFAULT 'unknown'
129
+ );
130
+
131
+ CREATE TABLE IF NOT EXISTS cycle_counter (
132
+ id INTEGER PRIMARY KEY DEFAULT 1,
133
+ n INTEGER NOT NULL DEFAULT 0
134
+ );
135
+ INSERT OR IGNORE INTO cycle_counter (id, n) VALUES (1, 0);
136
+ """)
137
+ conn.commit(); conn.close()
138
+
139
+ def _next_cycle_num() -> int:
140
+ conn = get_db()
141
+ conn.execute("UPDATE cycle_counter SET n=n+1 WHERE id=1")
142
+ n = conn.execute("SELECT n FROM cycle_counter WHERE id=1").fetchone()[0]
143
+ conn.commit(); conn.close()
144
+ return n
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Core loop cycle
148
+ # ---------------------------------------------------------------------------
149
+ _running = False
150
+
151
+ async def run_cycle(triggered_by: str = "cron") -> dict:
152
+ global _running
153
+ if _running:
154
+ return {"ok": False, "reason": "Cycle already running"}
155
+ _running = True
156
+
157
+ cycle_id = str(uuid.uuid4())
158
+ cycle_num = _next_cycle_num()
159
+ now = time.time()
160
+
161
+ conn = get_db()
162
+ conn.execute("""
163
+ INSERT INTO cycles (id,cycle_num,state,triggered_by,started_at)
164
+ VALUES (?,?,?,?,?)
165
+ """, (cycle_id, cycle_num, "running", triggered_by, now))
166
+ conn.commit(); conn.close()
167
+
168
+ summary = {
169
+ "cycle_id": cycle_id, "cycle_num": cycle_num,
170
+ "agents_checked": [], "underperformers": [],
171
+ "proposals_created": 0, "error": None,
172
+ }
173
+
174
+ try:
175
+ # ── Step 1: pull reward stats from agent-learn ──────────────────
176
+ stats = _get(f"{LEARN_URL}/api/stats")
177
+ if "error" in stats:
178
+ raise RuntimeError(f"agent-learn unreachable: {stats['error']}")
179
+
180
+ by_agent = stats.get("rewards", {})
181
+ learn_agents = stats.get("qtable", {}).get("by_agent", [])
182
+
183
+ # Also pull agent-trace stats for error rates
184
+ trace_stats = _get(f"{TRACE_URL}/api/stats", {"window_hours": DELTA_WINDOW_H})
185
+
186
+ trace_by_agent = {}
187
+ for a in trace_stats.get("by_agent", []):
188
+ trace_by_agent[a["agent"]] = a
189
+
190
+ # ── Step 2: assess each agent ───────────────────────────────────
191
+ # Build health snapshot
192
+ known_agents = set(a["agent"] for a in learn_agents)
193
+ known_agents.update(trace_by_agent.keys())
194
+
195
+ conn = get_db()
196
+ underperformers = []
197
+ checked = []
198
+
199
+ for agent in sorted(known_agents):
200
+ agent_trace = trace_by_agent.get(agent, {})
201
+ total_ev = agent_trace.get("cnt", 0)
202
+ errs = agent_trace.get("errs", 0)
203
+ err_rate = errs / max(total_ev, 1)
204
+
205
+ # Get reward from learn
206
+ rw_trend = _get(f"{LEARN_URL}/api/reward-trend",
207
+ {"hours": DELTA_WINDOW_H})
208
+ # Approximate per-agent avg from trace reward column
209
+ agent_rw = _get(f"{TRACE_URL}/api/traces",
210
+ {"agent": agent, "has_reward": "true",
211
+ "since_hours": DELTA_WINDOW_H, "limit": 100})
212
+ rw_vals = [e["reward"] for e in agent_rw.get("events", [])
213
+ if e.get("reward") is not None]
214
+ avg_rw = sum(rw_vals) / max(len(rw_vals), 1) if rw_vals else None
215
+
216
+ status = "healthy"
217
+ if avg_rw is not None and avg_rw < REWARD_THRESHOLD:
218
+ status = "underperforming"
219
+ if err_rate > ERROR_ESCALATE:
220
+ status = "critical"
221
+
222
+ conn.execute("""
223
+ INSERT INTO agent_health (agent,avg_reward,error_rate,total_events,last_checked,status)
224
+ VALUES (?,?,?,?,?,?)
225
+ ON CONFLICT(agent) DO UPDATE SET
226
+ avg_reward=excluded.avg_reward, error_rate=excluded.error_rate,
227
+ total_events=excluded.total_events, last_checked=excluded.last_checked,
228
+ status=excluded.status
229
+ """, (agent, avg_rw or 0.0, err_rate, total_ev, time.time(), status))
230
+
231
+ checked.append(agent)
232
+ if status in ("underperforming", "critical"):
233
+ underperformers.append({
234
+ "agent": agent, "avg_reward": avg_rw, "error_rate": err_rate,
235
+ "status": status
236
+ })
237
+
238
+ conn.commit(); conn.close()
239
+ summary["agents_checked"] = checked
240
+ summary["underperformers"] = underperformers
241
+
242
+ # ── Step 3: for each underperformer, generate proposal ──────────
243
+ for up in underperformers:
244
+ agent = up["agent"]
245
+
246
+ # Get recent self-reflections for this agent
247
+ reflections = _get(f"{TRACE_URL}/api/traces",
248
+ {"agent": agent, "event_type": "self_reflect",
249
+ "since_hours": DELTA_WINDOW_H * 2, "limit": 5})
250
+ reflect_texts = []
251
+ for ev in reflections.get("events", []):
252
+ p = ev.get("payload", {})
253
+ if isinstance(p, dict) and p:
254
+ reflect_texts.append(json.dumps(p)[:300])
255
+
256
+ # Get current persona prompt id
257
+ persona = _get(f"{PROMPTS_URL}/api/personas/{agent}")
258
+ current_prompt_id = persona.get("system_prompt_id", f"{agent}_system")
259
+
260
+ # Build improvement prompt for NEXUS
261
+ improve_prompt = (
262
+ f"Agent '{agent}' has avg_reward={up['avg_reward']:.3f} (threshold={REWARD_THRESHOLD}), "
263
+ f"error_rate={up['error_rate']:.1%}.\n\n"
264
+ f"Recent self-reflections:\n" +
265
+ ("\n---\n".join(reflect_texts) if reflect_texts else "No reflections available.") +
266
+ f"\n\nCurrent system prompt ID: {current_prompt_id}\n\n"
267
+ f"Write an improved system prompt for the '{agent}' agent that:\n"
268
+ f"1. Addresses the performance issues above\n"
269
+ f"2. Maintains its core role and responsibilities\n"
270
+ f"3. Adds clearer guidance on the failure patterns identified\n"
271
+ f"4. Is concise and actionable\n\n"
272
+ f"Output ONLY the improved system prompt text. No preamble, no explanation."
273
+ )
274
+
275
+ # Call NEXUS for LLM generation
276
+ nexus_resp = _post(f"{NEXUS_URL}/api/chat", {
277
+ "messages": [{"role": "user", "content": improve_prompt}],
278
+ "max_tokens": 600,
279
+ "temperature": 0.4,
280
+ })
281
+
282
+ proposed_text = (
283
+ nexus_resp.get("choices", [{}])[0].get("message", {}).get("content", "")
284
+ or nexus_resp.get("content", "")
285
+ or nexus_resp.get("response", "")
286
+ )
287
+
288
+ if not proposed_text or "error" in nexus_resp:
289
+ # Fallback: generate a structural improvement template
290
+ proposed_text = (
291
+ f"You are {agent.upper()}, a specialized agent in the FORGE AI ecosystem.\n\n"
292
+ f"Performance note: Recent avg reward was {up['avg_reward']:.3f}. "
293
+ f"Focus on:\n"
294
+ f"- Reducing error rate (currently {up['error_rate']:.1%})\n"
295
+ f"- Using FORGE skills instead of reimplementing capabilities\n"
296
+ f"- Logging every significant action to agent-trace\n"
297
+ f"- Reserving LLM slots before long tasks\n\n"
298
+ f"[AUTO-GENERATED DRAFT — Review and improve before approving]"
299
+ )
300
+
301
+ # Create draft prompt in agent-prompts
302
+ draft_resp = _post(f"{PROMPTS_URL}/api/prompts", {
303
+ "id": f"{agent}_improved_c{cycle_num}",
304
+ "type": "system",
305
+ "agent": agent,
306
+ "name": f"{agent.capitalize()} Improved (Cycle {cycle_num})",
307
+ "description": f"Auto-proposed improvement. Reason: {up['status']}. "
308
+ f"avg_reward={up['avg_reward']:.3f}, error_rate={up['error_rate']:.1%}",
309
+ "template": proposed_text,
310
+ "tags": ["auto-proposed", f"cycle-{cycle_num}", agent, "needs-review"],
311
+ "status": "draft",
312
+ "author": "agent-loop",
313
+ })
314
+
315
+ draft_id = draft_resp.get("message","").split("'")[1] if "'" in draft_resp.get("message","") else f"{agent}_improved_c{cycle_num}"
316
+
317
+ # Create proposal record
318
+ prop_id = str(uuid.uuid4())
319
+ conn = get_db()
320
+ conn.execute("""
321
+ INSERT INTO proposals
322
+ (id,cycle_id,agent,reason,current_prompt_id,current_reward,
323
+ proposed_prompt,prompt_draft_id,state,reward_before,created_at)
324
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)
325
+ """, (prop_id, cycle_id, agent,
326
+ f"{up['status']}: avg_reward={up['avg_reward']:.3f} err={up['error_rate']:.1%}",
327
+ current_prompt_id, up["avg_reward"],
328
+ proposed_text, draft_id, "pending",
329
+ up["avg_reward"], time.time()))
330
+ conn.commit(); conn.close()
331
+ summary["proposals_created"] += 1
332
+
333
+ # Notify via RELAY (best-effort)
334
+ _post(f"{RELAY_URL}/api/notify", {
335
+ "channel": "telegram",
336
+ "message": (
337
+ f"&#9889; *LOOP Cycle {cycle_num}*\n"
338
+ f"Agent `{agent}` underperforming (reward={up['avg_reward']:.3f})\n"
339
+ f"Draft improvement created: `{draft_id}`\n"
340
+ f"Approve at: {PROMPTS_URL}\n"
341
+ f"Proposal ID: `{prop_id}`"
342
+ )
343
+ })
344
+
345
+ # ── Step 4: check proposals awaiting 24h measurement ────────────
346
+ await _measure_deployed_proposals()
347
+
348
+ # ── Finish ───────────────────────────────────────────────────────
349
+ finish = time.time()
350
+ state = "awaiting_approval" if summary["proposals_created"] > 0 else "done"
351
+ conn = get_db()
352
+ conn.execute("""
353
+ UPDATE cycles SET state=?, agents_checked=?, underperformers=?,
354
+ proposals_created=?, finished_at=?, duration_s=?
355
+ WHERE id=?
356
+ """, (state, json.dumps(checked), json.dumps(underperformers),
357
+ summary["proposals_created"], finish, round(finish-now, 2), cycle_id))
358
+ conn.commit(); conn.close()
359
+ summary["state"] = state
360
+
361
+ except Exception as e:
362
+ finish = time.time()
363
+ conn = get_db()
364
+ conn.execute("UPDATE cycles SET state='failed', error_msg=?, finished_at=?, duration_s=? WHERE id=?",
365
+ (str(e)[:512], finish, round(finish-now,2), cycle_id))
366
+ conn.commit(); conn.close()
367
+ summary["error"] = str(e)
368
+
369
+ finally:
370
+ _running = False
371
+
372
+ return summary
373
+
374
+
375
+ async def _measure_deployed_proposals():
376
+ """For proposals deployed 24h+ ago with no after-reward, measure now."""
377
+ cutoff = time.time() - DELTA_WINDOW_H * 3600
378
+ conn = get_db()
379
+ deployed = conn.execute(
380
+ "SELECT * FROM proposals WHERE state='deployed' AND reward_after IS NULL AND resolved_at < ?",
381
+ (cutoff,)).fetchall()
382
+ conn.close()
383
+
384
+ for p in deployed:
385
+ agent = p["agent"]
386
+ # Pull current reward avg from agent-trace
387
+ rw_data = _get(f"{TRACE_URL}/api/traces",
388
+ {"agent": agent, "has_reward": "true",
389
+ "since_hours": DELTA_WINDOW_H, "limit": 100})
390
+ rw_vals = [e["reward"] for e in rw_data.get("events", [])
391
+ if e.get("reward") is not None]
392
+ if not rw_vals:
393
+ continue
394
+ rw_after = sum(rw_vals) / len(rw_vals)
395
+ delta = rw_after - (p["reward_before"] or 0)
396
+
397
+ conn = get_db()
398
+ conn.execute("""
399
+ UPDATE proposals SET reward_after=?, reward_delta=?, state='measured'
400
+ WHERE id=?
401
+ """, (rw_after, delta, p["id"]))
402
+ conn.commit(); conn.close()
403
+
404
+ # Log to agent-trace
405
+ _post(f"{TRACE_URL}/api/trace", {
406
+ "agent": "loop",
407
+ "event_type": "custom",
408
+ "payload": {
409
+ "type": "improvement_measurement",
410
+ "proposal_id": p["id"],
411
+ "agent": agent,
412
+ "reward_before": p["reward_before"],
413
+ "reward_after": rw_after,
414
+ "delta": delta,
415
+ "outcome": "positive" if delta > 0.05 else "inconclusive" if delta > -0.05 else "negative",
416
+ }
417
+ })
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # Proposal management
422
+ # ---------------------------------------------------------------------------
423
+ def approve_proposal(proposal_id: str, approved_by: str = "operator") -> dict:
424
+ conn = get_db()
425
+ prop = conn.execute("SELECT * FROM proposals WHERE id=?", (proposal_id,)).fetchone()
426
+ conn.close()
427
+ if not prop:
428
+ return {"ok": False, "error": "Proposal not found"}
429
+ if prop["state"] != "pending":
430
+ return {"ok": False, "error": f"Proposal state is '{prop['state']}', must be 'pending'"}
431
+
432
+ # Approve the draft in agent-prompts
433
+ approve_resp = _post(f"{PROMPTS_URL}/api/prompts/{prop['prompt_draft_id']}/approve", {})
434
+
435
+ # Upsert persona to point to new prompt
436
+ persona_resp = _post(f"{PROMPTS_URL}/api/personas", {
437
+ "agent": prop["agent"],
438
+ "system_prompt_id": prop["prompt_draft_id"],
439
+ "name": f"{prop['agent'].capitalize()} (Cycle {prop['cycle_id'][:8]})",
440
+ })
441
+
442
+ # Update proposal state
443
+ conn = get_db()
444
+ conn.execute("""
445
+ UPDATE proposals SET state='deployed', approved_by=?, resolved_at=? WHERE id=?
446
+ """, (approved_by, time.time(), proposal_id))
447
+ # Update cycle state
448
+ conn.execute("""
449
+ UPDATE cycles SET proposals_approved=proposals_approved+1, state='deploying' WHERE id=?
450
+ """, (prop["cycle_id"],))
451
+ conn.commit(); conn.close()
452
+
453
+ # Notify
454
+ _post(f"{RELAY_URL}/api/notify", {
455
+ "channel": "telegram",
456
+ "message": (
457
+ f"&#10003; *LOOP: Proposal approved*\n"
458
+ f"Agent: `{prop['agent']}`\n"
459
+ f"New prompt: `{prop['prompt_draft_id']}`\n"
460
+ f"Approved by: {approved_by}"
461
+ )
462
+ })
463
+
464
+ return {"ok": True, "proposal_id": proposal_id, "agent": prop["agent"],
465
+ "prompt_id": prop["prompt_draft_id"]}
466
+
467
+
468
+ def reject_proposal(proposal_id: str, reason: str = "") -> dict:
469
+ conn = get_db()
470
+ n = conn.execute(
471
+ "UPDATE proposals SET state='rejected', resolved_at=? WHERE id=? AND state='pending'",
472
+ (time.time(), proposal_id)).rowcount
473
+ conn.commit(); conn.close()
474
+ return {"ok": n > 0, "proposal_id": proposal_id}
475
+
476
+
477
+ def list_proposals(state: str = "", agent: str = "", limit: int = 50) -> list:
478
+ conn = get_db()
479
+ where, params = [], []
480
+ if state: where.append("state=?"); params.append(state)
481
+ if agent: where.append("agent=?"); params.append(agent)
482
+ sql = ("SELECT * FROM proposals" +
483
+ (f" WHERE {' AND '.join(where)}" if where else "") +
484
+ " ORDER BY created_at DESC LIMIT ?")
485
+ rows = conn.execute(sql, params+[limit]).fetchall()
486
+ conn.close()
487
+ return [dict(r) for r in rows]
488
+
489
+
490
+ def list_cycles(limit: int = 20) -> list:
491
+ conn = get_db()
492
+ rows = conn.execute("SELECT * FROM cycles ORDER BY cycle_num DESC LIMIT ?", (limit,)).fetchall()
493
+ conn.close()
494
+ result = []
495
+ for r in rows:
496
+ d = dict(r)
497
+ for f in ("agents_checked","underperformers"):
498
+ try: d[f] = json.loads(d[f])
499
+ except Exception: pass
500
+ result.append(d)
501
+ return result
502
+
503
+
504
+ def get_agent_health() -> list:
505
+ conn = get_db()
506
+ rows = conn.execute("SELECT * FROM agent_health ORDER BY status DESC, last_checked DESC").fetchall()
507
+ conn.close()
508
+ return [dict(r) for r in rows]
509
+
510
+
511
+ def get_stats() -> dict:
512
+ conn = get_db()
513
+ total_cy = conn.execute("SELECT COUNT(*) FROM cycles").fetchone()[0]
514
+ total_pr = conn.execute("SELECT COUNT(*) FROM proposals").fetchone()[0]
515
+ pending_pr = conn.execute("SELECT COUNT(*) FROM proposals WHERE state='pending'").fetchone()[0]
516
+ deployed = conn.execute("SELECT COUNT(*) FROM proposals WHERE state='deployed'").fetchone()[0]
517
+ avg_delta = conn.execute("SELECT AVG(reward_delta) FROM proposals WHERE reward_delta IS NOT NULL").fetchone()[0]
518
+ last_cy = conn.execute("SELECT * FROM cycles ORDER BY cycle_num DESC LIMIT 1").fetchone()
519
+ conn.close()
520
+ return {
521
+ "total_cycles": total_cy,
522
+ "total_proposals": total_pr,
523
+ "pending_proposals": pending_pr,
524
+ "deployed_proposals": deployed,
525
+ "avg_reward_delta": round(avg_delta or 0, 4),
526
+ "cycle_minutes": CYCLE_MINUTES,
527
+ "reward_threshold": REWARD_THRESHOLD,
528
+ "last_cycle": dict(last_cy) if last_cy else None,
529
+ "cycle_enabled": CYCLE_ENABLED,
530
+ }
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # Background loop
534
+ # ---------------------------------------------------------------------------
535
+ async def _cron_loop():
536
+ if not CYCLE_ENABLED:
537
+ return
538
+ # Initial delay — let other services start first
539
+ await asyncio.sleep(90)
540
+ while True:
541
+ try:
542
+ await run_cycle("cron")
543
+ except Exception:
544
+ pass
545
+ await asyncio.sleep(CYCLE_MINUTES * 60)
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # MCP
549
+ # ---------------------------------------------------------------------------
550
+ MCP_TOOLS = [
551
+ {"name":"loop_status","description":"Get current loop status: agent health, pending proposals, last cycle.",
552
+ "inputSchema":{"type":"object","properties":{}}},
553
+ {"name":"loop_trigger","description":"Manually trigger an improvement cycle immediately.",
554
+ "inputSchema":{"type":"object","properties":{"reason":{"type":"string"}}}},
555
+ {"name":"loop_proposals","description":"List improvement proposals.",
556
+ "inputSchema":{"type":"object","properties":{"state":{"type":"string","description":"pending|deployed|rejected|measured"},"agent":{"type":"string"},"limit":{"type":"integer"}}}},
557
+ {"name":"loop_approve","description":"Approve a proposal — deploys the improved prompt and updates agent persona.",
558
+ "inputSchema":{"type":"object","required":["proposal_id"],
559
+ "properties":{"proposal_id":{"type":"string"},"approved_by":{"type":"string"}}}},
560
+ {"name":"loop_reject","description":"Reject a proposal.",
561
+ "inputSchema":{"type":"object","required":["proposal_id"],
562
+ "properties":{"proposal_id":{"type":"string"},"reason":{"type":"string"}}}},
563
+ {"name":"loop_health","description":"Get agent health snapshot (reward, error rate, status per agent).",
564
+ "inputSchema":{"type":"object","properties":{}}},
565
+ {"name":"loop_cycles","description":"List recent improvement cycles.",
566
+ "inputSchema":{"type":"object","properties":{"limit":{"type":"integer","default":10}}}},
567
+ ]
568
+
569
+ def handle_mcp(method, params, req_id):
570
+ def ok(r): return {"jsonrpc":"2.0","id":req_id,"result":r}
571
+ def txt(d): return ok({"content":[{"type":"text","text":json.dumps(d)}]})
572
+ if method=="initialize":
573
+ return ok({"protocolVersion":"2024-11-05",
574
+ "serverInfo":{"name":"agent-loop","version":"1.0.0"},
575
+ "capabilities":{"tools":{}}})
576
+ if method=="tools/list": return ok({"tools":MCP_TOOLS})
577
+ if method=="tools/call":
578
+ n, a = params.get("name",""), params.get("arguments",{})
579
+ if n=="loop_status": return txt(get_stats())
580
+ if n=="loop_trigger":
581
+ asyncio.create_task(run_cycle(a.get("reason","manual")))
582
+ return txt({"ok":True,"message":"Cycle started (async)"})
583
+ if n=="loop_proposals":
584
+ return txt({"proposals":list_proposals(a.get("state",""),a.get("agent",""),a.get("limit",20))})
585
+ if n=="loop_approve": return txt(approve_proposal(a["proposal_id"],a.get("approved_by","mcp")))
586
+ if n=="loop_reject": return txt(reject_proposal(a["proposal_id"],a.get("reason","")))
587
+ if n=="loop_health": return txt({"health":get_agent_health()})
588
+ if n=="loop_cycles": return txt({"cycles":list_cycles(a.get("limit",10))})
589
+ return {"jsonrpc":"2.0","id":req_id,"error":{"code":-32601,"message":f"Unknown tool: {n}"}}
590
+ if method in ("notifications/initialized","notifications/cancelled"): return None
591
+ return {"jsonrpc":"2.0","id":req_id,"error":{"code":-32601,"message":f"Method not found: {method}"}}
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # App
595
+ # ---------------------------------------------------------------------------
596
+ @asynccontextmanager
597
+ async def lifespan(app):
598
+ init_db()
599
+ asyncio.create_task(_cron_loop())
600
+ yield
601
+
602
+ app = FastAPI(title="agent-loop", version="1.0.0", lifespan=lifespan)
603
+
604
+ def _auth(r): return not LOOP_KEY or r.headers.get("x-loop-key","") == LOOP_KEY
605
+
606
+ @app.post("/api/cycle")
607
+ async def api_trigger(request: Request):
608
+ if not _auth(request): raise HTTPException(403,"Invalid X-Loop-Key")
609
+ body = {}
610
+ try: body = await request.json()
611
+ except Exception: pass
612
+ asyncio.create_task(run_cycle(body.get("triggered_by","api")))
613
+ return JSONResponse({"ok":True,"message":"Cycle started"})
614
+
615
+ @app.get("/api/cycles")
616
+ async def api_cycles(limit:int=Query(20)): return JSONResponse({"cycles":list_cycles(limit)})
617
+
618
+ @app.get("/api/proposals")
619
+ async def api_proposals(state:str=Query(""),agent:str=Query(""),limit:int=Query(50)):
620
+ return JSONResponse({"proposals":list_proposals(state,agent,limit)})
621
+
622
+ @app.post("/api/proposals/{pid}/approve")
623
+ async def api_approve(pid:str, request:Request):
624
+ if not _auth(request): raise HTTPException(403,"Invalid X-Loop-Key")
625
+ body = {}
626
+ try: body = await request.json()
627
+ except Exception: pass
628
+ result = approve_proposal(pid, body.get("approved_by","operator"))
629
+ if not result.get("ok"): raise HTTPException(400, result.get("error","Error"))
630
+ return JSONResponse(result)
631
+
632
+ @app.post("/api/proposals/{pid}/reject")
633
+ async def api_reject(pid:str, request:Request):
634
+ if not _auth(request): raise HTTPException(403,"Invalid X-Loop-Key")
635
+ body = {}
636
+ try: body = await request.json()
637
+ except Exception: pass
638
+ return JSONResponse(reject_proposal(pid, body.get("reason","")))
639
+
640
+ @app.get("/api/health/agents")
641
+ async def api_agent_health(): return JSONResponse({"health":get_agent_health()})
642
+
643
+ @app.get("/api/stats")
644
+ async def api_stats(): return JSONResponse(get_stats())
645
+
646
+ @app.get("/api/health")
647
+ async def api_health():
648
+ return JSONResponse({"ok":True,"cycle_enabled":CYCLE_ENABLED,
649
+ "cycle_minutes":CYCLE_MINUTES,"version":"1.0.0"})
650
+
651
+ @app.get("/mcp/sse")
652
+ async def mcp_sse(request:Request):
653
+ async def gen():
654
+ yield f"data: {json.dumps({'jsonrpc':'2.0','method':'connected','params':{}})}\n\n"
655
+ yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools','params':{'tools':MCP_TOOLS}})}\n\n"
656
+ while True:
657
+ if await request.is_disconnected(): break
658
+ yield ": ping\n\n"; await asyncio.sleep(15)
659
+ return StreamingResponse(gen(), media_type="text/event-stream",
660
+ headers={"Cache-Control":"no-cache","Connection":"keep-alive","X-Accel-Buffering":"no"})
661
+
662
+ @app.post("/mcp")
663
+ async def mcp_rpc(request:Request):
664
+ try: body = await request.json()
665
+ except Exception: return JSONResponse({"jsonrpc":"2.0","id":None,"error":{"code":-32700,"message":"Parse error"}})
666
+ if isinstance(body,list):
667
+ return JSONResponse([r for r in [handle_mcp(x.get("method",""),x.get("params",{}),x.get("id")) for x in body] if r])
668
+ r = handle_mcp(body.get("method",""),body.get("params",{}),body.get("id"))
669
+ return JSONResponse(r or {"jsonrpc":"2.0","id":body.get("id"),"result":{}})
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # SPA
673
+ # ---------------------------------------------------------------------------
674
+ SPA = r"""<!DOCTYPE html>
675
+ <html lang="en">
676
+ <head>
677
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
678
+ <title>&#128257; LOOP &#8212; FORGE Self-Improvement</title>
679
+ <style>
680
+ @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');
681
+ *{box-sizing:border-box;margin:0;padding:0}
682
+ :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}
683
+ html,body{height:100%;background:var(--bg);color:var(--tx);font-family:'Syne',sans-serif}
684
+ ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--sf)}::-webkit-scrollbar-thumb{background:var(--br);border-radius:3px}
685
+ .app{display:grid;grid-template-rows:52px auto 1fr;height:100vh;overflow:hidden}
686
+ .hdr{display:flex;align-items:center;gap:1rem;padding:0 1.5rem;border-bottom:1px solid var(--br);background:var(--sf)}
687
+ .logo{font-family:'Space Mono',monospace;font-size:1.1rem;font-weight:700;color:var(--ac)}
688
+ .sub{font-family:'DM Mono',monospace;font-size:.6rem;color:var(--mu);letter-spacing:.2em;text-transform:uppercase}
689
+ .hstats{display:flex;gap:1.5rem;margin-left:auto}
690
+ .hs{text-align:center}.hs-n{font-family:'Space Mono',monospace;font-size:1rem;font-weight:700;color:var(--ac)}
691
+ .hs-l{font-family:'DM Mono',monospace;font-size:.58rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em}
692
+ .tabs{display:flex;border-bottom:1px solid var(--br);background:var(--sf);align-items:center;flex-shrink:0}
693
+ .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}
694
+ .tab.active{color:var(--ac);border-bottom-color:var(--ac)}.tab:hover{color:var(--tx)}
695
+ .body{overflow-y:auto;padding:1.25rem}
696
+ .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}
697
+ .btn-trigger{background:var(--ac);color:#000}.btn-trigger:hover{filter:brightness(1.1)}
698
+ .btn-trigger:disabled{opacity:.4;cursor:not-allowed}
699
+ .btn-approve{background:#001a08;color:var(--gr);border:1px solid #004422}.btn-approve:hover{background:#003010}
700
+ .btn-reject{background:#1a0000;color:var(--rd);border:1px solid #440011}.btn-reject:hover{background:#300010}
701
+
702
+ /* Pipeline viz */
703
+ .pipeline{display:flex;align-items:center;gap:0;margin-bottom:1.5rem}
704
+ .pipe-step{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.7rem 1rem;text-align:center;flex:1}
705
+ .pipe-step-name{font-family:'Space Mono',monospace;font-size:.78rem;font-weight:700}
706
+ .pipe-step-sub{font-family:'DM Mono',monospace;font-size:.6rem;color:var(--mu);margin-top:3px;text-transform:uppercase;letter-spacing:.08em}
707
+ .pipe-arrow{color:var(--mu);font-size:1.2rem;padding:0 .4rem;flex-shrink:0}
708
+ .pipe-active{border-color:var(--ac);box-shadow:0 0 12px rgba(255,107,0,.2)}
709
+
710
+ /* Agent health grid */
711
+ .health-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.75rem;margin-bottom:1.5rem}
712
+ .health-card{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.8rem 1rem;position:relative;overflow:hidden}
713
+ .health-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
714
+ .health-card.healthy::before{background:var(--gr)}
715
+ .health-card.underperforming::before{background:var(--ye)}
716
+ .health-card.critical::before{background:var(--rd)}
717
+ .health-card.unknown::before{background:var(--mu)}
718
+ .hc-agent{font-family:'Space Mono',monospace;font-size:.9rem;font-weight:700;color:var(--ac);margin-bottom:.4rem}
719
+ .hc-stat{font-family:'DM Mono',monospace;font-size:.72rem;color:var(--mu);margin-bottom:.15rem}
720
+ .hc-stat span{color:var(--tx)}
721
+ .hc-status{font-family:'DM Mono',monospace;font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;margin-top:.4rem}
722
+ .hc-status.healthy{color:var(--gr)}.hc-status.underperforming{color:var(--ye)}.hc-status.critical{color:var(--rd)}
723
+
724
+ /* Proposal cards */
725
+ .prop-card{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:1rem;margin-bottom:.75rem}
726
+ .prop-hdr{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem}
727
+ .prop-agent{font-family:'Space Mono',monospace;font-size:.9rem;font-weight:700;color:var(--ac)}
728
+ .prop-reason{font-family:'DM Mono',monospace;font-size:.7rem;color:var(--mu)}
729
+ .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}
730
+ .prop-actions{display:flex;gap:.5rem;align-items:center}
731
+ .prop-meta{font-family:'DM Mono',monospace;font-size:.62rem;color:var(--mu);margin-left:auto}
732
+ .state-badge{font-family:'DM Mono',monospace;font-size:.62rem;padding:2px 8px;border-radius:4px}
733
+ .state-pending{background:#1a1000;color:var(--ye);border:1px solid #442200}
734
+ .state-deployed{background:#001a08;color:var(--gr);border:1px solid #004422}
735
+ .state-rejected{background:#1a0000;color:var(--rd);border:1px solid #440011}
736
+ .state-measured{background:#0a001a;color:var(--pu);border:1px solid #2a0066}
737
+
738
+ /* Cycle log */
739
+ .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}
740
+ .cycle-row:hover{background:var(--sf)}
741
+ .cy-num{font-weight:700;color:var(--ac)}
742
+ .cy-state{padding:1px 7px;border-radius:3px;font-size:.62rem;text-align:center}
743
+ .cy-running{background:#001a00;color:var(--gr);border:1px solid #004400}
744
+ .cy-done{background:#0a0a1a;color:var(--pu);border:1px solid #1a1a44}
745
+ .cy-failed{background:#1a0000;color:var(--rd);border:1px solid #440011}
746
+ .cy-awaiting{background:#1a1000;color:var(--ye);border:1px solid #442200}
747
+ .cy-skipped{background:var(--sf2);color:var(--mu);border:1px solid var(--br)}
748
+ .cy-other{background:var(--sf2);color:var(--mu);border:1px solid var(--br)}
749
+
750
+ /* Config table */
751
+ .cfg-row{display:flex;align-items:center;padding:.55rem 1rem;border-bottom:1px solid var(--br);font-family:'DM Mono',monospace;font-size:.75rem}
752
+ .cfg-k{color:var(--mu);text-transform:uppercase;letter-spacing:.1em;font-size:.62rem;width:160px}
753
+ .cfg-v{color:var(--cy);font-weight:700}
754
+ .cfg-d{color:var(--mu);font-size:.65rem;margin-left:.75rem}
755
+
756
+ .section{font-family:'DM Mono',monospace;font-size:.62rem;color:var(--pu);text-transform:uppercase;letter-spacing:.15em;margin:.75rem 0 .4rem}
757
+ .empty{text-align:center;padding:2rem;color:var(--mu);font-family:'DM Mono',monospace;font-size:.8rem}
758
+ .kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem;margin-bottom:1.25rem}
759
+ .kpi{background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.8rem 1rem}
760
+ .kpi-n{font-family:'Space Mono',monospace;font-size:1.5rem;font-weight:700;color:var(--ac);line-height:1}
761
+ .kpi-l{font-family:'DM Mono',monospace;font-size:.58rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em;margin-top:3px}
762
+ </style></head><body>
763
+ <div class="app">
764
+ <header class="hdr">
765
+ <div><div class="logo">&#128257; LOOP</div><div class="sub">Self-Improvement Orchestrator</div></div>
766
+ <div class="hstats">
767
+ <div class="hs"><div class="hs-n" id="hCy">&#8212;</div><div class="hs-l">Cycles</div></div>
768
+ <div class="hs"><div class="hs-n" id="hPe" style="color:var(--ye)">&#8212;</div><div class="hs-l">Pending</div></div>
769
+ <div class="hs"><div class="hs-n" id="hDe" style="color:var(--gr)">&#8212;</div><div class="hs-l">Deployed</div></div>
770
+ <div class="hs"><div class="hs-n" id="hDelta" style="color:var(--cy)">&#8212;</div><div class="hs-l">Avg delta</div></div>
771
+ </div>
772
+ </header>
773
+ <div class="tabs">
774
+ <div class="tab active" onclick="showTab('overview')">&#128202; Overview</div>
775
+ <div class="tab" onclick="showTab('proposals')">&#128221; Proposals</div>
776
+ <div class="tab" onclick="showTab('cycles')">&#128336; Cycle Log</div>
777
+ <div class="tab" onclick="showTab('config')">&#9881;&#65038; Config</div>
778
+ <button class="btn btn-trigger" id="triggerBtn" onclick="triggerCycle()" style="margin:auto 1rem auto auto;padding:.3rem .8rem">&#9889; Run Cycle Now</button>
779
+ </div>
780
+ <div class="body" id="tabBody"></div>
781
+ </div>
782
+
783
+ <script>
784
+ let stats=null, health=[], proposals=[], cycles=[];
785
+
786
+ async function loadAll(){
787
+ [stats,health] = await Promise.all([
788
+ fetch('/api/stats').then(r=>r.json()),
789
+ fetch('/api/health/agents').then(r=>r.json()).then(d=>d.health||[])
790
+ ]);
791
+ document.getElementById('hCy').textContent = stats.total_cycles||0;
792
+ document.getElementById('hPe').textContent = stats.pending_proposals||0;
793
+ document.getElementById('hDe').textContent = stats.deployed_proposals||0;
794
+ const d=stats.avg_reward_delta;
795
+ const de=document.getElementById('hDelta');
796
+ de.textContent=(d>=0?'+':'')+d.toFixed(3);
797
+ de.style.color=d>0.05?'var(--gr)':d<-0.05?'var(--rd)':'var(--cy)';
798
+ renderTab();
799
+ }
800
+ async function loadProposals(){ proposals=(await fetch('/api/proposals?limit=30').then(r=>r.json())).proposals||[]; }
801
+ async function loadCycles() { cycles=(await fetch('/api/cycles?limit=25').then(r=>r.json())).cycles||[]; }
802
+
803
+ let currentTab='overview';
804
+ function showTab(t){
805
+ currentTab=t;
806
+ document.querySelectorAll('.tab').forEach((el,i)=>el.classList.toggle('active',['overview','proposals','cycles','config'][i]===t));
807
+ renderTab();
808
+ }
809
+ async function renderTab(){
810
+ if(currentTab==='overview') renderOverview();
811
+ else if(currentTab==='proposals') { await loadProposals(); renderProposals(); }
812
+ else if(currentTab==='cycles') { await loadCycles(); renderCycles(); }
813
+ else if(currentTab==='config') renderConfig();
814
+ }
815
+
816
+ function renderOverview(){
817
+ const pending=proposals.filter?proposals.filter(p=>p.state==='pending'):[];
818
+ document.getElementById('tabBody').innerHTML=`
819
+ <div class="kpis">
820
+ <div class="kpi"><div class="kpi-n">${stats.total_cycles||0}</div><div class="kpi-l">Total cycles</div></div>
821
+ <div class="kpi"><div class="kpi-n" style="color:var(--ye)">${stats.pending_proposals||0}</div><div class="kpi-l">Pending approval</div></div>
822
+ <div class="kpi"><div class="kpi-n" style="color:var(--gr)">${stats.deployed_proposals||0}</div><div class="kpi-l">Deployed</div></div>
823
+ <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>
824
+ </div>
825
+
826
+ <div class="section">Improvement Pipeline</div>
827
+ <div class="pipeline">
828
+ ${[
829
+ ['&#128202;','TRACE','telemetry'],
830
+ ['&#129504;','LEARN','rewards'],
831
+ ['&#128257;','LOOP','orchestrates'],
832
+ ['&#128172;','PROMPTS','drafts'],
833
+ ['&#128101;','YOU','approves'],
834
+ ['&#9881;','AGENTS','deployed'],
835
+ ].map(([ico,name,sub],i)=>`
836
+ <div class="pipe-step${name==='LOOP'?' pipe-active':''}">
837
+ <div style="font-size:1.2rem">${ico}</div>
838
+ <div class="pipe-step-name">${name}</div>
839
+ <div class="pipe-step-sub">${sub}</div>
840
+ </div>
841
+ ${i<5?'<div class="pipe-arrow">&#8594;</div>':''}`).join('')}
842
+ </div>
843
+
844
+ <div class="section">Agent Health</div>
845
+ <div class="health-grid">
846
+ ${health.length ? health.map(h=>`
847
+ <div class="health-card ${h.status}">
848
+ <div class="hc-agent">${h.agent}</div>
849
+ <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>
850
+ <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>
851
+ <div class="hc-stat">Events: <span>${h.total_events}</span></div>
852
+ <div class="hc-status ${h.status}">&#9679; ${h.status}</div>
853
+ </div>`).join('') : '<div class="empty" style="grid-column:1/-1">No health data yet. Run a cycle to assess agents.</div>'}
854
+ </div>
855
+
856
+ ${stats.last_cycle ? `
857
+ <div class="section">Last Cycle</div>
858
+ <div style="background:var(--sf);border:1px solid var(--br);border-radius:8px;padding:.9rem 1rem;font-family:'DM Mono',monospace;font-size:.75rem">
859
+ <div>Cycle #${stats.last_cycle.cycle_num} &middot; ${stats.last_cycle.state} &middot; ${stats.last_cycle.duration_s?.toFixed(1)||'?'}s</div>
860
+ <div style="color:var(--mu);margin-top:.25rem">Triggered by: ${stats.last_cycle.triggered_by} &middot; Proposals: ${stats.last_cycle.proposals_created}</div>
861
+ ${stats.last_cycle.error_msg?`<div style="color:var(--rd);margin-top:.3rem">Error: ${esc(stats.last_cycle.error_msg)}</div>`:''}
862
+ </div>` : ''}`;
863
+ }
864
+
865
+ async function renderProposals(){
866
+ await loadProposals();
867
+ const pending = proposals.filter(p=>p.state==='pending');
868
+ const others = proposals.filter(p=>p.state!=='pending');
869
+ document.getElementById('tabBody').innerHTML=`
870
+ ${pending.length?`
871
+ <div class="section" style="color:var(--ye)">&#9888; Awaiting Approval (${pending.length})</div>
872
+ ${pending.map(p=>propCard(p,true)).join('')}`:''}
873
+ ${others.length?`
874
+ <div class="section">History</div>
875
+ ${others.map(p=>propCard(p,false)).join('')}`:''}
876
+ ${!proposals.length?'<div class="empty">No proposals yet. Run a cycle to generate improvements.</div>':''}`;
877
+ }
878
+
879
+ function propCard(p,interactive){
880
+ const deltaHtml = p.reward_delta!=null
881
+ ? `<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>`
882
+ : '';
883
+ return `<div class="prop-card">
884
+ <div class="prop-hdr">
885
+ <span class="prop-agent">${p.agent}</span>
886
+ <span class="state-badge state-${p.state}">${p.state}</span>
887
+ ${deltaHtml}
888
+ </div>
889
+ <div class="prop-reason">${esc(p.reason)}</div>
890
+ <div class="prop-text">${esc(p.proposed_prompt||'')}</div>
891
+ <div class="prop-actions">
892
+ ${interactive?`
893
+ <button class="btn btn-approve" onclick="approveProp('${p.id}')">&#10003; Approve &amp; Deploy</button>
894
+ <button class="btn btn-reject" onclick="rejectProp('${p.id}')">&#10005; Reject</button>`:''}
895
+ <span class="prop-meta">
896
+ ${p.prompt_draft_id?`draft: ${p.prompt_draft_id} &middot; `:''}
897
+ ${new Date(p.created_at*1000).toLocaleString()}
898
+ </span>
899
+ </div>
900
+ </div>`;
901
+ }
902
+
903
+ async function approveProp(id){
904
+ const r=await fetch(`/api/proposals/${id}/approve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({approved_by:'operator'})});
905
+ const d=await r.json();
906
+ alert(d.ok?`Deployed! Agent ${d.agent} now uses ${d.prompt_id}`:`Error: ${d.error}`);
907
+ await loadAll();
908
+ }
909
+ async function rejectProp(id){
910
+ await fetch(`/api/proposals/${id}/reject`,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
911
+ await loadProposals();renderProposals();
912
+ }
913
+
914
+ async function renderCycles(){
915
+ await loadCycles();
916
+ document.getElementById('tabBody').innerHTML=`
917
+ <div style="background:var(--sf);border:1px solid var(--br);border-radius:8px;overflow:hidden">
918
+ <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)">
919
+ <span>#</span><span>State</span><span>Triggered</span><span>Agents</span><span>Props</span><span>Duration / Error</span>
920
+ </div>
921
+ ${cycles.length ? cycles.map(c=>{
922
+ 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';
923
+ return `<div class="cycle-row">
924
+ <span class="cy-num">${c.cycle_num}</span>
925
+ <span class="cy-state ${sc}">${c.state}</span>
926
+ <span style="color:var(--mu)">${c.triggered_by}</span>
927
+ <span style="color:var(--cy)">${(c.agents_checked||[]).length}</span>
928
+ <span style="color:var(--ye)">${c.proposals_created||0}</span>
929
+ <span style="color:${c.error_msg?'var(--rd)':'var(--mu)'}">
930
+ ${c.error_msg?esc(c.error_msg.slice(0,60)):c.duration_s!=null?(c.duration_s.toFixed(1)+'s'):'—'}
931
+ </span>
932
+ </div>`;
933
+ }).join('') : '<div class="empty">No cycles run yet</div>'}
934
+ </div>`;
935
+ }
936
+
937
+ function renderConfig(){
938
+ document.getElementById('tabBody').innerHTML=`
939
+ <div class="section">Runtime config</div>
940
+ <div style="background:var(--sf);border:1px solid var(--br);border-radius:8px;overflow:hidden">
941
+ ${[
942
+ ['CYCLE_MINUTES', stats.cycle_minutes+'min', 'How often the improvement loop runs'],
943
+ ['REWARD_THRESHOLD', stats.reward_threshold, 'Agents below this trigger a proposal'],
944
+ ['ERROR_ESCALATE', '15%', 'Error rate above this = critical escalation'],
945
+ ['CYCLE_ENABLED', String(stats.cycle_enabled), 'Set to false to pause the loop'],
946
+ ['LEARN_URL', 'env: LEARN_URL', 'agent-learn endpoint'],
947
+ ['TRACE_URL', 'env: TRACE_URL', 'agent-trace endpoint'],
948
+ ['PROMPTS_URL', 'env: PROMPTS_URL', 'agent-prompts endpoint'],
949
+ ['NEXUS_URL', 'env: NEXUS_URL', 'NEXUS LLM gateway'],
950
+ ['RELAY_URL', 'env: RELAY_URL', 'RELAY notification endpoint'],
951
+ ].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('')}
952
+ </div>
953
+ <div class="section" style="margin-top:1rem">Full data flow</div>
954
+ <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">
955
+ TRACE ──(events)──▶ LEARN ──(scored)──▶ LOOP
956
+ │ │
957
+ └─(reward written back) └──(underperformer detected)
958
+
959
+ NEXUS ◀──(generate proposal)
960
+
961
+ PROMPTS ◀──(POST draft)
962
+
963
+ Telegram ◀──(RELAY notify)
964
+
965
+ YOU ──(approve)──▶ PROMPTS (approve)
966
+
967
+ PROMPTS persona updated
968
+
969
+ Agents fetch at next startup</pre>
970
+ <div class="section" style="margin-top:1rem">MCP</div>
971
+ <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>`;
972
+ }
973
+
974
+ async function triggerCycle(){
975
+ const btn=document.getElementById('triggerBtn');
976
+ btn.disabled=true; btn.textContent='&#9889; Running...';
977
+ await fetch('/api/cycle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({triggered_by:'manual'})});
978
+ setTimeout(async()=>{await loadAll();btn.disabled=false;btn.textContent='&#9889; Run Cycle Now';},3000);
979
+ }
980
+
981
+ function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
982
+ loadAll(); setInterval(loadAll, 20000);
983
+ </script>
984
+ </body></html>"""
985
+
986
+ @app.get("/", response_class=HTMLResponse)
987
+ async def root(): return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8")
988
+
989
+ if __name__ == "__main__":
990
+ uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fastapi>=0.111.0
2
+ uvicorn>=0.30.0