Spaces:
Running
Running
Upload 4 files
Browse files- Dockerfile +7 -0
- README.md +74 -6
- main.py +990 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
| 8 |
-
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"⚡ *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"✓ *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>🔁 LOOP — 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">🔁 LOOP</div><div class="sub">Self-Improvement Orchestrator</div></div>
|
| 766 |
+
<div class="hstats">
|
| 767 |
+
<div class="hs"><div class="hs-n" id="hCy">—</div><div class="hs-l">Cycles</div></div>
|
| 768 |
+
<div class="hs"><div class="hs-n" id="hPe" style="color:var(--ye)">—</div><div class="hs-l">Pending</div></div>
|
| 769 |
+
<div class="hs"><div class="hs-n" id="hDe" style="color:var(--gr)">—</div><div class="hs-l">Deployed</div></div>
|
| 770 |
+
<div class="hs"><div class="hs-n" id="hDelta" style="color:var(--cy)">—</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')">📊 Overview</div>
|
| 775 |
+
<div class="tab" onclick="showTab('proposals')">📝 Proposals</div>
|
| 776 |
+
<div class="tab" onclick="showTab('cycles')">🕐 Cycle Log</div>
|
| 777 |
+
<div class="tab" onclick="showTab('config')">⚙︎ Config</div>
|
| 778 |
+
<button class="btn btn-trigger" id="triggerBtn" onclick="triggerCycle()" style="margin:auto 1rem auto auto;padding:.3rem .8rem">⚡ 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 |
+
['📊','TRACE','telemetry'],
|
| 830 |
+
['🧠','LEARN','rewards'],
|
| 831 |
+
['🔁','LOOP','orchestrates'],
|
| 832 |
+
['💬','PROMPTS','drafts'],
|
| 833 |
+
['👥','YOU','approves'],
|
| 834 |
+
['⚙','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">→</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}">● ${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} · ${stats.last_cycle.state} · ${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} · 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)">⚠ 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)'}">Δ${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}')">✓ Approve & Deploy</button>
|
| 894 |
+
<button class="btn btn-reject" onclick="rejectProp('${p.id}')">✕ Reject</button>`:''}
|
| 895 |
+
<span class="prop-meta">
|
| 896 |
+
${p.prompt_draft_id?`draft: ${p.prompt_draft_id} · `:''}
|
| 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='⚡ 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='⚡ Run Cycle Now';},3000);
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
| 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
|