Spaces:
Sleeping
Sleeping
AI Developer Agent commited on
Commit ·
763ef0d
1
Parent(s): d720a61
AI Developer Agent v1.0 backend
Browse files- Dockerfile +39 -0
- README.md +15 -4
- backend/__init__.py +2 -0
- backend/agent.py +160 -0
- backend/app.py +311 -0
- backend/browser.py +148 -0
- backend/classifier.py +98 -0
- backend/deployers.py +281 -0
- backend/executor.py +238 -0
- backend/llm_router.py +407 -0
- backend/planner.py +130 -0
- backend/repair.py +70 -0
- backend/requirements.txt +9 -0
- backend/retry.py +45 -0
- backend/tasks.py +254 -0
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PIP_NO_CACHE_DIR=1 \
|
| 5 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 6 |
+
DEBIAN_FRONTEND=noninteractive \
|
| 7 |
+
HOME=/home/user \
|
| 8 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 9 |
+
TASKS_DB_PATH=/data/tasks.db \
|
| 10 |
+
PYTHONPATH=/home/user/app
|
| 11 |
+
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential git git-lfs curl ca-certificates \
|
| 14 |
+
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 \
|
| 15 |
+
libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
|
| 16 |
+
libasound2 libatspi2.0-0 \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 18 |
+
&& git lfs install --system
|
| 19 |
+
|
| 20 |
+
RUN useradd -m -u 1000 user
|
| 21 |
+
WORKDIR /home/user/app
|
| 22 |
+
|
| 23 |
+
COPY --chown=user:user backend/requirements.txt /home/user/app/backend/requirements.txt
|
| 24 |
+
RUN pip install --no-cache-dir -r /home/user/app/backend/requirements.txt
|
| 25 |
+
|
| 26 |
+
# Playwright Chromium (best-effort; skip if it fails so build still completes)
|
| 27 |
+
RUN python -m playwright install --with-deps chromium || \
|
| 28 |
+
python -m playwright install chromium || \
|
| 29 |
+
echo "playwright install failed; continuing"
|
| 30 |
+
|
| 31 |
+
RUN mkdir -p /data && chown -R user:user /data
|
| 32 |
+
|
| 33 |
+
COPY --chown=user:user . /home/user/app
|
| 34 |
+
RUN chown -R user:user /home/user/app
|
| 35 |
+
|
| 36 |
+
USER user
|
| 37 |
+
EXPOSE 7860
|
| 38 |
+
|
| 39 |
+
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AI Developer Agent
|
| 3 |
+
emoji: 🤖
|
| 4 |
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# AI Developer Agent (Backend)
|
| 13 |
+
|
| 14 |
+
Persistent autonomous AI Developer Agent. FastAPI service that plans, executes,
|
| 15 |
+
repairs and deploys software tasks end-to-end.
|
| 16 |
+
|
| 17 |
+
See `/health`, `/api/runtime`, `/api/tasks`, `/api/tasks/{id}/stream`, `/api/chat`.
|
| 18 |
+
|
| 19 |
+
Configure via Space Secrets:
|
| 20 |
+
- `GEMINI_API_KEYS`, `SAMBANOVA_API_KEYS`, `OPENAI_API_KEYS`, `GITHUB_LLM_API_KEYS`, `OPENROUTER_API_KEYS`
|
| 21 |
+
- `E2B_API_KEY`, `HF_TOKEN`, `VERCEL_TOKEN`, `GITHUB_TOKEN`
|
backend/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Developer Agent - backend package."""
|
| 2 |
+
__version__ = "1.0.0"
|
backend/agent.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent orchestrator - ties planner + executor + retry + repair + browser.
|
| 3 |
+
|
| 4 |
+
`run_task(task_id, title, description)` is a generator yielding event dicts
|
| 5 |
+
suitable for SSE streaming. Persists everything to tasks.db.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
import logging
|
| 12 |
+
import traceback
|
| 13 |
+
from typing import Any, Dict, Generator, List, Optional
|
| 14 |
+
|
| 15 |
+
from . import tasks
|
| 16 |
+
from .planner import plan_task, repair_plan
|
| 17 |
+
from .executor import get_executor
|
| 18 |
+
from .classifier import classify
|
| 19 |
+
from .browser import run_browser_action
|
| 20 |
+
from .llm_router import get_router
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("agent")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _event(task_id: str, kind: str, message: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 26 |
+
payload = {
|
| 27 |
+
"task_id": task_id,
|
| 28 |
+
"kind": kind,
|
| 29 |
+
"message": message,
|
| 30 |
+
"ts": time.time(),
|
| 31 |
+
"data": data or {},
|
| 32 |
+
}
|
| 33 |
+
tasks.log_event(task_id, kind, json.dumps({"message": message, "data": data or {}})[:7000])
|
| 34 |
+
return payload
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def run_task(task_id: str, title: str, description: str) -> Generator[Dict[str, Any], None, None]:
|
| 38 |
+
"""Generator yielding event dicts. Persists to SQLite."""
|
| 39 |
+
try:
|
| 40 |
+
tasks.update_state(task_id, "planning")
|
| 41 |
+
yield _event(task_id, "state", "planning")
|
| 42 |
+
yield _event(task_id, "thought", f"Planning task: {title}")
|
| 43 |
+
|
| 44 |
+
plan = plan_task(title, description)
|
| 45 |
+
yield _event(task_id, "plan", "Plan generated", {"steps": plan})
|
| 46 |
+
|
| 47 |
+
if not plan:
|
| 48 |
+
yield _event(task_id, "warn", "Empty plan – using fallback note")
|
| 49 |
+
plan = [{"type": "note", "msg": "No actions planned"}]
|
| 50 |
+
|
| 51 |
+
tasks.update_state(task_id, "executing")
|
| 52 |
+
yield _event(task_id, "state", "executing")
|
| 53 |
+
|
| 54 |
+
executor = get_executor()
|
| 55 |
+
for idx, step in enumerate(plan):
|
| 56 |
+
tasks.save_checkpoint(task_id, idx, {"plan": plan, "current": step})
|
| 57 |
+
stype = step.get("type", "note")
|
| 58 |
+
yield _event(task_id, "step.start", f"[{idx + 1}/{len(plan)}] {stype}", {"step": step})
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
result = _execute_step(task_id, step)
|
| 62 |
+
except Exception as e:
|
| 63 |
+
tb = traceback.format_exc()
|
| 64 |
+
result = {"ok": False, "stderr": str(e), "traceback": tb}
|
| 65 |
+
|
| 66 |
+
yield _event(task_id, "step.result", "ok" if result.get("ok") else "fail", {"step_index": idx, "result": result})
|
| 67 |
+
|
| 68 |
+
# If failed, attempt one repair cycle
|
| 69 |
+
if not result.get("ok"):
|
| 70 |
+
tasks.update_state(task_id, "repairing")
|
| 71 |
+
yield _event(task_id, "state", "repairing")
|
| 72 |
+
err_text = (result.get("stderr") or "") + "\n" + (result.get("traceback") or "")
|
| 73 |
+
err_class = classify(err_text)
|
| 74 |
+
if err_class:
|
| 75 |
+
yield _event(task_id, "diagnose", f"Detected: {err_class.category}", {"detail": err_class.detail, "fix": err_class.suggested_fix})
|
| 76 |
+
repair_actions = repair_plan(err_class.category, err_class.detail)
|
| 77 |
+
for ridx, ra in enumerate(repair_actions):
|
| 78 |
+
yield _event(task_id, "repair.start", f"repair[{ridx + 1}]", {"action": ra})
|
| 79 |
+
rresult = _execute_step(task_id, ra)
|
| 80 |
+
yield _event(task_id, "repair.result", "ok" if rresult.get("ok") else "fail", {"result": rresult})
|
| 81 |
+
# retry original step once
|
| 82 |
+
tasks.update_state(task_id, "retrying")
|
| 83 |
+
yield _event(task_id, "state", "retrying")
|
| 84 |
+
tasks.record_retry(task_id, 1, err_text[:1000])
|
| 85 |
+
try:
|
| 86 |
+
retry_result = _execute_step(task_id, step)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
retry_result = {"ok": False, "stderr": str(e)}
|
| 89 |
+
yield _event(task_id, "retry.result", "ok" if retry_result.get("ok") else "fail", {"step_index": idx, "result": retry_result})
|
| 90 |
+
else:
|
| 91 |
+
yield _event(task_id, "warn", "No automatic repair – continuing")
|
| 92 |
+
tasks.update_state(task_id, "executing")
|
| 93 |
+
yield _event(task_id, "state", "executing")
|
| 94 |
+
|
| 95 |
+
tasks.update_state(task_id, "completed")
|
| 96 |
+
yield _event(task_id, "state", "completed")
|
| 97 |
+
yield _event(task_id, "done", f"Task {task_id} completed")
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.exception("run_task fatal")
|
| 100 |
+
tasks.update_state(task_id, "failed")
|
| 101 |
+
yield _event(task_id, "error", f"Fatal: {e}", {"traceback": traceback.format_exc()})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _execute_step(task_id: str, step: Dict[str, Any]) -> Dict[str, Any]:
|
| 105 |
+
executor = get_executor()
|
| 106 |
+
stype = step.get("type", "note")
|
| 107 |
+
|
| 108 |
+
if stype == "shell":
|
| 109 |
+
cmd = step.get("cmd", "")
|
| 110 |
+
if not cmd:
|
| 111 |
+
return {"ok": True, "stdout": "(empty cmd)"}
|
| 112 |
+
r = executor.shell(cmd, timeout=float(step.get("timeout", 120)))
|
| 113 |
+
return {"ok": r.ok, "stdout": r.stdout[-3000:], "stderr": r.stderr[-3000:], "exit_code": r.exit_code, "duration_ms": r.duration_ms}
|
| 114 |
+
|
| 115 |
+
if stype == "python":
|
| 116 |
+
code = step.get("code", "")
|
| 117 |
+
r = executor.python(code, timeout=float(step.get("timeout", 120)))
|
| 118 |
+
return {"ok": r.ok, "stdout": r.stdout[-3000:], "stderr": r.stderr[-3000:], "exit_code": r.exit_code, "duration_ms": r.duration_ms}
|
| 119 |
+
|
| 120 |
+
if stype == "browser":
|
| 121 |
+
br = run_browser_action(step)
|
| 122 |
+
return {
|
| 123 |
+
"ok": br.ok,
|
| 124 |
+
"stdout": (br.text or "")[:3000],
|
| 125 |
+
"stderr": br.error or "",
|
| 126 |
+
"exit_code": 0 if br.ok else 1,
|
| 127 |
+
"screenshot_b64": br.screenshot_b64[:500] if br.screenshot_b64 else "",
|
| 128 |
+
"url": br.url,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if stype == "git":
|
| 132 |
+
op = step.get("op", "status")
|
| 133 |
+
args = step.get("args", "")
|
| 134 |
+
cmd = f"git {op} {args}".strip()
|
| 135 |
+
r = executor.shell(cmd, timeout=120)
|
| 136 |
+
return {"ok": r.ok, "stdout": r.stdout, "stderr": r.stderr, "exit_code": r.exit_code}
|
| 137 |
+
|
| 138 |
+
if stype == "deploy":
|
| 139 |
+
# Real deploy is invoked via dedicated /deploy endpoints; here we just log.
|
| 140 |
+
target = step.get("target", "unknown")
|
| 141 |
+
msg = f"Deploy step requested: {target}. Use /deploy endpoints for real deployment."
|
| 142 |
+
return {"ok": True, "stdout": msg}
|
| 143 |
+
|
| 144 |
+
if stype == "note":
|
| 145 |
+
return {"ok": True, "stdout": step.get("msg", "")}
|
| 146 |
+
|
| 147 |
+
if stype == "sleep":
|
| 148 |
+
time.sleep(float(step.get("seconds", 1)))
|
| 149 |
+
return {"ok": True, "stdout": f"slept {step.get('seconds', 1)}s"}
|
| 150 |
+
|
| 151 |
+
if stype == "llm":
|
| 152 |
+
router = get_router()
|
| 153 |
+
prompt = step.get("prompt", "")
|
| 154 |
+
try:
|
| 155 |
+
out = router.chat([{"role": "user", "content": prompt}], temperature=0.2, max_tokens=800)
|
| 156 |
+
return {"ok": True, "stdout": out[:3000]}
|
| 157 |
+
except Exception as e:
|
| 158 |
+
return {"ok": False, "stderr": str(e)}
|
| 159 |
+
|
| 160 |
+
return {"ok": False, "stderr": f"unknown step type: {stype}"}
|
backend/app.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI backend - AI Developer Agent
|
| 3 |
+
====================================
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET / service info
|
| 6 |
+
GET /health health check
|
| 7 |
+
GET /api/runtime runtime + provider telemetry
|
| 8 |
+
POST /api/tasks create + run a task (sync queued)
|
| 9 |
+
GET /api/tasks list tasks
|
| 10 |
+
GET /api/tasks/{id} get task
|
| 11 |
+
GET /api/tasks/{id}/events list events (REST)
|
| 12 |
+
GET /api/tasks/{id}/stream SSE event stream (live)
|
| 13 |
+
POST /api/chat one-shot chat (streams)
|
| 14 |
+
POST /api/llm/chat chat (non-streaming JSON)
|
| 15 |
+
POST /api/deploy/huggingface push backend dir to HF Space
|
| 16 |
+
POST /api/deploy/vercel deploy frontend dir to Vercel
|
| 17 |
+
POST /api/git/push commit + push to GitHub branch
|
| 18 |
+
|
| 19 |
+
All endpoints accept JSON bodies and return JSON unless documented otherwise.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import asyncio
|
| 24 |
+
import json
|
| 25 |
+
import logging
|
| 26 |
+
import os
|
| 27 |
+
import threading
|
| 28 |
+
import time
|
| 29 |
+
from typing import Any, Dict, List, Optional
|
| 30 |
+
|
| 31 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 32 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 33 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 34 |
+
from pydantic import BaseModel
|
| 35 |
+
|
| 36 |
+
from . import tasks
|
| 37 |
+
from .agent import run_task
|
| 38 |
+
from .llm_router import get_router
|
| 39 |
+
from .executor import get_executor
|
| 40 |
+
from . import deployers
|
| 41 |
+
|
| 42 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 43 |
+
logger = logging.getLogger("app")
|
| 44 |
+
|
| 45 |
+
app = FastAPI(title="AI Developer Agent", version="1.0.0")
|
| 46 |
+
|
| 47 |
+
app.add_middleware(
|
| 48 |
+
CORSMiddleware,
|
| 49 |
+
allow_origins=os.getenv("CORS_ALLOW_ORIGINS", "*").split(","),
|
| 50 |
+
allow_credentials=False,
|
| 51 |
+
allow_methods=["*"],
|
| 52 |
+
allow_headers=["*"],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# In-memory task queue (background worker)
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
_task_queue: "asyncio.Queue" = asyncio.Queue()
|
| 60 |
+
_active_subscribers: Dict[str, List[asyncio.Queue]] = {}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _publish(task_id: str, event: Dict[str, Any]) -> None:
|
| 64 |
+
for q in list(_active_subscribers.get(task_id, [])):
|
| 65 |
+
try:
|
| 66 |
+
q.put_nowait(event)
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _worker_run(task_id: str, title: str, description: str) -> None:
|
| 72 |
+
"""Run the agent generator in a thread and publish events."""
|
| 73 |
+
try:
|
| 74 |
+
for ev in run_task(task_id, title, description):
|
| 75 |
+
_publish(task_id, ev)
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.exception("worker crashed")
|
| 78 |
+
_publish(task_id, {"task_id": task_id, "kind": "error", "message": str(e), "ts": time.time(), "data": {}})
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Schemas
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
class CreateTaskBody(BaseModel):
|
| 85 |
+
title: str
|
| 86 |
+
description: str = ""
|
| 87 |
+
payload: Optional[Dict[str, Any]] = None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class ChatBody(BaseModel):
|
| 91 |
+
messages: List[Dict[str, str]]
|
| 92 |
+
model: Optional[str] = None
|
| 93 |
+
temperature: float = 0.2
|
| 94 |
+
max_tokens: int = 1500
|
| 95 |
+
preferred_provider: Optional[str] = None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class HFDeployBody(BaseModel):
|
| 99 |
+
repo_id: str
|
| 100 |
+
source_dir: str = "."
|
| 101 |
+
commit_message: str = "Update from AI Developer Agent"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class VercelDeployBody(BaseModel):
|
| 105 |
+
project_name: str
|
| 106 |
+
source_dir: str
|
| 107 |
+
framework: Optional[str] = "nextjs"
|
| 108 |
+
target: str = "production"
|
| 109 |
+
install_command: Optional[str] = None
|
| 110 |
+
build_command: Optional[str] = None
|
| 111 |
+
env: Optional[Dict[str, str]] = None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class GitPushBody(BaseModel):
|
| 115 |
+
repo_dir: str = "."
|
| 116 |
+
branch: str = "genspark_ai_developer"
|
| 117 |
+
commit_message: str = "AI Developer Agent commit"
|
| 118 |
+
remote_url: Optional[str] = None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
# Routes
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
@app.get("/")
|
| 125 |
+
def index():
|
| 126 |
+
return {
|
| 127 |
+
"service": "AI Developer Agent",
|
| 128 |
+
"version": "1.0.0",
|
| 129 |
+
"ok": True,
|
| 130 |
+
"endpoints": [
|
| 131 |
+
"/health", "/api/runtime", "/api/tasks", "/api/tasks/{id}/stream",
|
| 132 |
+
"/api/chat", "/api/llm/chat",
|
| 133 |
+
"/api/deploy/huggingface", "/api/deploy/vercel", "/api/git/push",
|
| 134 |
+
],
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.get("/health")
|
| 139 |
+
def health():
|
| 140 |
+
router = get_router()
|
| 141 |
+
return {
|
| 142 |
+
"ok": True,
|
| 143 |
+
"ts": time.time(),
|
| 144 |
+
"providers": list(router.telemetry().keys()),
|
| 145 |
+
"executor": "e2b" if (get_executor().sandbox and get_executor().sandbox.available) else "local",
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@app.get("/api/runtime")
|
| 150 |
+
def runtime():
|
| 151 |
+
info = get_executor().inspect_runtime()
|
| 152 |
+
info["providers"] = get_router().telemetry()
|
| 153 |
+
info["db"] = tasks.DB_PATH
|
| 154 |
+
return info
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ----- Tasks ---------------------------------------------------------------
|
| 158 |
+
@app.post("/api/tasks")
|
| 159 |
+
def create_task(body: CreateTaskBody):
|
| 160 |
+
task_id = tasks.create_task(body.title, body.description, body.payload or {})
|
| 161 |
+
t = threading.Thread(target=_worker_run, args=(task_id, body.title, body.description), daemon=True)
|
| 162 |
+
t.start()
|
| 163 |
+
return {"task_id": task_id, "title": body.title, "state": "queued"}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@app.get("/api/tasks")
|
| 167 |
+
def list_tasks(limit: int = 50):
|
| 168 |
+
return {"tasks": tasks.list_tasks(limit=limit)}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.get("/api/tasks/{task_id}")
|
| 172 |
+
def get_task(task_id: str):
|
| 173 |
+
t = tasks.get_task(task_id)
|
| 174 |
+
if not t:
|
| 175 |
+
raise HTTPException(404, "task not found")
|
| 176 |
+
return t
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@app.get("/api/tasks/{task_id}/events")
|
| 180 |
+
def get_events(task_id: str, since_id: int = 0, limit: int = 1000):
|
| 181 |
+
return {"events": tasks.get_events(task_id, since_id=since_id, limit=limit)}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@app.get("/api/tasks/{task_id}/stream")
|
| 185 |
+
async def stream_events(task_id: str, request: Request):
|
| 186 |
+
"""Server-Sent Events stream. Replays historical events then live events."""
|
| 187 |
+
|
| 188 |
+
async def gen():
|
| 189 |
+
# 1) Replay history
|
| 190 |
+
last_id = 0
|
| 191 |
+
history = tasks.get_events(task_id, since_id=0, limit=2000)
|
| 192 |
+
for ev in history:
|
| 193 |
+
last_id = ev["id"]
|
| 194 |
+
yield f"id: {ev['id']}\nevent: {ev['kind']}\ndata: {json.dumps(ev)}\n\n"
|
| 195 |
+
|
| 196 |
+
# 2) Subscribe for live events
|
| 197 |
+
q: asyncio.Queue = asyncio.Queue()
|
| 198 |
+
_active_subscribers.setdefault(task_id, []).append(q)
|
| 199 |
+
try:
|
| 200 |
+
while True:
|
| 201 |
+
if await request.is_disconnected():
|
| 202 |
+
break
|
| 203 |
+
try:
|
| 204 |
+
ev = await asyncio.wait_for(q.get(), timeout=15.0)
|
| 205 |
+
yield f"event: {ev['kind']}\ndata: {json.dumps(ev)}\n\n"
|
| 206 |
+
if ev["kind"] in ("done", "error") and ev.get("data", {}).get("final"):
|
| 207 |
+
break
|
| 208 |
+
except asyncio.TimeoutError:
|
| 209 |
+
# heartbeat
|
| 210 |
+
yield ":keepalive\n\n"
|
| 211 |
+
finally:
|
| 212 |
+
try:
|
| 213 |
+
_active_subscribers.get(task_id, []).remove(q)
|
| 214 |
+
except ValueError:
|
| 215 |
+
pass
|
| 216 |
+
|
| 217 |
+
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ----- LLM endpoints -------------------------------------------------------
|
| 221 |
+
@app.post("/api/llm/chat")
|
| 222 |
+
def llm_chat(body: ChatBody):
|
| 223 |
+
router = get_router()
|
| 224 |
+
try:
|
| 225 |
+
text = router.chat(
|
| 226 |
+
body.messages, model=body.model, temperature=body.temperature,
|
| 227 |
+
max_tokens=body.max_tokens, preferred_provider=body.preferred_provider,
|
| 228 |
+
)
|
| 229 |
+
return {"ok": True, "text": text, "telemetry": router.telemetry()}
|
| 230 |
+
except Exception as e:
|
| 231 |
+
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
@app.post("/api/chat")
|
| 235 |
+
def chat_stream(body: ChatBody):
|
| 236 |
+
"""SSE chat stream."""
|
| 237 |
+
router = get_router()
|
| 238 |
+
|
| 239 |
+
def gen():
|
| 240 |
+
for chunk in router.stream(
|
| 241 |
+
body.messages, model=body.model, temperature=body.temperature,
|
| 242 |
+
max_tokens=body.max_tokens, preferred_provider=body.preferred_provider,
|
| 243 |
+
):
|
| 244 |
+
yield f"data: {json.dumps({'delta': chunk})}\n\n"
|
| 245 |
+
yield "data: [DONE]\n\n"
|
| 246 |
+
|
| 247 |
+
return StreamingResponse(gen(), media_type="text/event-stream",
|
| 248 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ----- Deploy endpoints ----------------------------------------------------
|
| 252 |
+
@app.post("/api/deploy/huggingface")
|
| 253 |
+
def deploy_hf(body: HFDeployBody):
|
| 254 |
+
src = os.path.abspath(body.source_dir)
|
| 255 |
+
if not os.path.isdir(src):
|
| 256 |
+
raise HTTPException(400, f"source_dir not found: {src}")
|
| 257 |
+
r = deployers.hf_push_space(source_dir=src, repo_id=body.repo_id, commit_message=body.commit_message)
|
| 258 |
+
if r.get("ok"):
|
| 259 |
+
tasks.record_deployment("", "huggingface", r.get("url", ""), "ok")
|
| 260 |
+
else:
|
| 261 |
+
tasks.record_deployment("", "huggingface", "", "failed")
|
| 262 |
+
return r
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@app.post("/api/deploy/vercel")
|
| 266 |
+
def deploy_vercel(body: VercelDeployBody):
|
| 267 |
+
src = os.path.abspath(body.source_dir)
|
| 268 |
+
if not os.path.isdir(src):
|
| 269 |
+
raise HTTPException(400, f"source_dir not found: {src}")
|
| 270 |
+
files = deployers.collect_files_for_vercel(src)
|
| 271 |
+
r = deployers.vercel_deploy_via_api(
|
| 272 |
+
project_name=body.project_name, files=files, target=body.target,
|
| 273 |
+
env=body.env, framework=body.framework,
|
| 274 |
+
install_command=body.install_command, build_command=body.build_command,
|
| 275 |
+
)
|
| 276 |
+
if r.get("ok"):
|
| 277 |
+
tasks.record_deployment("", "vercel", r.get("url", ""), "ok")
|
| 278 |
+
return r
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@app.post("/api/git/push")
|
| 282 |
+
def git_push(body: GitPushBody):
|
| 283 |
+
repo_dir = os.path.abspath(body.repo_dir)
|
| 284 |
+
if not os.path.isdir(repo_dir):
|
| 285 |
+
raise HTTPException(400, f"repo_dir not found: {repo_dir}")
|
| 286 |
+
return deployers.github_push(
|
| 287 |
+
repo_dir=repo_dir, branch=body.branch,
|
| 288 |
+
commit_message=body.commit_message, remote_url=body.remote_url,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# ---------------------------------------------------------------------------
|
| 293 |
+
# Startup self-check
|
| 294 |
+
# ---------------------------------------------------------------------------
|
| 295 |
+
@app.on_event("startup")
|
| 296 |
+
def startup_check():
|
| 297 |
+
logger.info("AI Developer Agent starting")
|
| 298 |
+
try:
|
| 299 |
+
tasks.init_db()
|
| 300 |
+
info = get_executor().inspect_runtime()
|
| 301 |
+
logger.info("Runtime: %s", info)
|
| 302 |
+
logger.info("Providers: %s", list(get_router().telemetry().keys()))
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.warning("Startup check error: %s", e)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# Allow running directly
|
| 308 |
+
if __name__ == "__main__":
|
| 309 |
+
import uvicorn
|
| 310 |
+
port = int(os.getenv("PORT", "7860"))
|
| 311 |
+
uvicorn.run("apps.backend.app:app", host="0.0.0.0", port=port, log_level="info")
|
backend/browser.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Browser automation via Playwright (runs inside E2B sandbox when available,
|
| 3 |
+
otherwise locally). Provides retry-safe, structured browser actions.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import base64
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger("browser")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class BrowserResult:
|
| 18 |
+
ok: bool
|
| 19 |
+
action: str
|
| 20 |
+
url: str = ""
|
| 21 |
+
text: str = ""
|
| 22 |
+
screenshot_b64: str = ""
|
| 23 |
+
error: str = ""
|
| 24 |
+
meta: Dict[str, Any] = field(default_factory=dict)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class BrowserController:
|
| 28 |
+
"""Lightweight controller; lazy-initializes Playwright."""
|
| 29 |
+
|
| 30 |
+
def __init__(self) -> None:
|
| 31 |
+
self._playwright = None
|
| 32 |
+
self._browser = None
|
| 33 |
+
self._context = None
|
| 34 |
+
self._page = None
|
| 35 |
+
self._available: Optional[bool] = None
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def available(self) -> bool:
|
| 39 |
+
if self._available is None:
|
| 40 |
+
try:
|
| 41 |
+
import playwright # noqa: F401
|
| 42 |
+
from playwright.sync_api import sync_playwright # noqa: F401
|
| 43 |
+
self._available = True
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.warning("Playwright not installed: %s", e)
|
| 46 |
+
self._available = False
|
| 47 |
+
return self._available
|
| 48 |
+
|
| 49 |
+
def _ensure(self):
|
| 50 |
+
if self._page is not None:
|
| 51 |
+
return self._page
|
| 52 |
+
from playwright.sync_api import sync_playwright
|
| 53 |
+
self._playwright = sync_playwright().start()
|
| 54 |
+
self._browser = self._playwright.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
|
| 55 |
+
self._context = self._browser.new_context()
|
| 56 |
+
self._page = self._context.new_page()
|
| 57 |
+
return self._page
|
| 58 |
+
|
| 59 |
+
def navigate(self, url: str, timeout_ms: int = 30000) -> BrowserResult:
|
| 60 |
+
if not self.available:
|
| 61 |
+
return BrowserResult(ok=False, action="navigate", url=url, error="playwright not available")
|
| 62 |
+
try:
|
| 63 |
+
page = self._ensure()
|
| 64 |
+
page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded")
|
| 65 |
+
return BrowserResult(ok=True, action="navigate", url=page.url, text=page.title())
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.exception("navigate failed")
|
| 68 |
+
return BrowserResult(ok=False, action="navigate", url=url, error=str(e))
|
| 69 |
+
|
| 70 |
+
def click(self, selector: str, timeout_ms: int = 10000) -> BrowserResult:
|
| 71 |
+
if not self.available:
|
| 72 |
+
return BrowserResult(ok=False, action="click", error="playwright not available")
|
| 73 |
+
try:
|
| 74 |
+
page = self._ensure()
|
| 75 |
+
page.click(selector, timeout=timeout_ms)
|
| 76 |
+
return BrowserResult(ok=True, action="click", meta={"selector": selector})
|
| 77 |
+
except Exception as e:
|
| 78 |
+
return BrowserResult(ok=False, action="click", error=str(e))
|
| 79 |
+
|
| 80 |
+
def type_text(self, selector: str, text: str, timeout_ms: int = 10000) -> BrowserResult:
|
| 81 |
+
if not self.available:
|
| 82 |
+
return BrowserResult(ok=False, action="type", error="playwright not available")
|
| 83 |
+
try:
|
| 84 |
+
page = self._ensure()
|
| 85 |
+
page.fill(selector, text, timeout=timeout_ms)
|
| 86 |
+
return BrowserResult(ok=True, action="type", meta={"selector": selector})
|
| 87 |
+
except Exception as e:
|
| 88 |
+
return BrowserResult(ok=False, action="type", error=str(e))
|
| 89 |
+
|
| 90 |
+
def screenshot(self) -> BrowserResult:
|
| 91 |
+
if not self.available:
|
| 92 |
+
return BrowserResult(ok=False, action="screenshot", error="playwright not available")
|
| 93 |
+
try:
|
| 94 |
+
page = self._ensure()
|
| 95 |
+
png = page.screenshot(full_page=False)
|
| 96 |
+
b64 = base64.b64encode(png).decode("ascii")
|
| 97 |
+
return BrowserResult(ok=True, action="screenshot", url=page.url, screenshot_b64=b64)
|
| 98 |
+
except Exception as e:
|
| 99 |
+
return BrowserResult(ok=False, action="screenshot", error=str(e))
|
| 100 |
+
|
| 101 |
+
def scrape_text(self) -> BrowserResult:
|
| 102 |
+
if not self.available:
|
| 103 |
+
return BrowserResult(ok=False, action="scrape", error="playwright not available")
|
| 104 |
+
try:
|
| 105 |
+
page = self._ensure()
|
| 106 |
+
content = page.evaluate("() => document.body ? document.body.innerText : ''")
|
| 107 |
+
return BrowserResult(ok=True, action="scrape", url=page.url, text=(content or "")[:20000])
|
| 108 |
+
except Exception as e:
|
| 109 |
+
return BrowserResult(ok=False, action="scrape", error=str(e))
|
| 110 |
+
|
| 111 |
+
def close(self):
|
| 112 |
+
try:
|
| 113 |
+
if self._context: self._context.close()
|
| 114 |
+
except Exception: pass
|
| 115 |
+
try:
|
| 116 |
+
if self._browser: self._browser.close()
|
| 117 |
+
except Exception: pass
|
| 118 |
+
try:
|
| 119 |
+
if self._playwright: self._playwright.stop()
|
| 120 |
+
except Exception: pass
|
| 121 |
+
self._context = self._browser = self._page = self._playwright = None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
_browser: Optional[BrowserController] = None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def get_browser() -> BrowserController:
|
| 128 |
+
global _browser
|
| 129 |
+
if _browser is None:
|
| 130 |
+
_browser = BrowserController()
|
| 131 |
+
return _browser
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def run_browser_action(action: Dict[str, Any]) -> BrowserResult:
|
| 135 |
+
"""action: {"action": "navigate"|"click"|"type"|"screenshot"|"scrape", ...}"""
|
| 136 |
+
b = get_browser()
|
| 137 |
+
op = action.get("action", "")
|
| 138 |
+
if op == "navigate":
|
| 139 |
+
return b.navigate(action.get("url", ""))
|
| 140 |
+
if op == "click":
|
| 141 |
+
return b.click(action.get("selector", ""))
|
| 142 |
+
if op == "type":
|
| 143 |
+
return b.type_text(action.get("selector", ""), action.get("text", ""))
|
| 144 |
+
if op == "screenshot":
|
| 145 |
+
return b.screenshot()
|
| 146 |
+
if op == "scrape":
|
| 147 |
+
return b.scrape_text()
|
| 148 |
+
return BrowserResult(ok=False, action=op, error=f"unknown action: {op}")
|
backend/classifier.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Error classifier - identifies error categories from tracebacks/output.
|
| 3 |
+
Used by repair engine to produce targeted repair plans.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class ErrorClass:
|
| 14 |
+
category: str
|
| 15 |
+
detail: str
|
| 16 |
+
suggested_fix: str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
RULES = [
|
| 20 |
+
# python module / pip
|
| 21 |
+
(r"ModuleNotFoundError: No module named ['\"]([\w\.\-]+)['\"]", "missing_python_module"),
|
| 22 |
+
(r"ImportError: No module named ([\w\.\-]+)", "missing_python_module"),
|
| 23 |
+
(r"pip(?:3)?: command not found", "missing_pip"),
|
| 24 |
+
# node / npm
|
| 25 |
+
(r"command not found: (npm|node|npx)", "missing_node"),
|
| 26 |
+
(r"npm ERR! code E?ENOENT", "npm_failure"),
|
| 27 |
+
(r"npm ERR! code (E\w+)", "npm_failure"),
|
| 28 |
+
# playwright
|
| 29 |
+
(r"playwright[^\s]*: command not found", "missing_playwright"),
|
| 30 |
+
(r"Executable doesn't exist at .+(chrom|firefox|webkit)", "playwright_browsers_missing"),
|
| 31 |
+
(r"BrowserType\.launch:.*Host system is missing dependencies", "playwright_missing_deps"),
|
| 32 |
+
# git
|
| 33 |
+
(r"fatal: unable to auto-detect email address", "git_identity_missing"),
|
| 34 |
+
(r"Please tell me who you are\.", "git_identity_missing"),
|
| 35 |
+
(r"fatal: not a git repository", "not_a_git_repo"),
|
| 36 |
+
(r"Authentication failed", "git_auth_failed"),
|
| 37 |
+
# python version / build
|
| 38 |
+
(r"greenlet[^\n]*failed to build", "greenlet_build_failure"),
|
| 39 |
+
(r"Could not build wheels for ([\w\-]+)", "python_build_failure"),
|
| 40 |
+
(r"requires Python ['\"][^'\"]+['\"]", "python_version_mismatch"),
|
| 41 |
+
# network
|
| 42 |
+
(r"Could not resolve host", "network_failure"),
|
| 43 |
+
(r"Connection refused", "network_failure"),
|
| 44 |
+
(r"Read timed out", "network_failure"),
|
| 45 |
+
# http
|
| 46 |
+
(r"\b429\b", "rate_limited"),
|
| 47 |
+
(r"\b401\b|\b403\b", "auth_failure"),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def classify(output: str) -> Optional[ErrorClass]:
|
| 52 |
+
if not output:
|
| 53 |
+
return None
|
| 54 |
+
for pattern, category in RULES:
|
| 55 |
+
m = re.search(pattern, output)
|
| 56 |
+
if m:
|
| 57 |
+
detail = m.group(0)
|
| 58 |
+
return ErrorClass(category=category, detail=detail, suggested_fix=_fix_for(category, m))
|
| 59 |
+
# Generic exception detection
|
| 60 |
+
if "Traceback (most recent call last):" in output:
|
| 61 |
+
return ErrorClass(category="python_exception", detail="Unclassified Python exception", suggested_fix="inspect traceback and retry")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _fix_for(category: str, m: re.Match) -> str:
|
| 66 |
+
if category == "missing_python_module":
|
| 67 |
+
return f"pip install {m.group(1)}"
|
| 68 |
+
if category == "missing_pip":
|
| 69 |
+
return "ensure python3-pip is installed"
|
| 70 |
+
if category == "missing_node":
|
| 71 |
+
return "install node + npm"
|
| 72 |
+
if category == "npm_failure":
|
| 73 |
+
return "delete node_modules and reinstall"
|
| 74 |
+
if category == "missing_playwright":
|
| 75 |
+
return "pip install playwright && python -m playwright install"
|
| 76 |
+
if category == "playwright_browsers_missing":
|
| 77 |
+
return "python -m playwright install chromium"
|
| 78 |
+
if category == "playwright_missing_deps":
|
| 79 |
+
return "python -m playwright install-deps chromium"
|
| 80 |
+
if category == "git_identity_missing":
|
| 81 |
+
return "git config user.email and user.name"
|
| 82 |
+
if category == "not_a_git_repo":
|
| 83 |
+
return "git init or cd into repo"
|
| 84 |
+
if category == "git_auth_failed":
|
| 85 |
+
return "verify GITHUB_TOKEN and remote URL"
|
| 86 |
+
if category == "greenlet_build_failure":
|
| 87 |
+
return "pin Python 3.11 and install build essentials"
|
| 88 |
+
if category == "python_build_failure":
|
| 89 |
+
return "install build-essential and retry"
|
| 90 |
+
if category == "python_version_mismatch":
|
| 91 |
+
return "use Python 3.11"
|
| 92 |
+
if category == "network_failure":
|
| 93 |
+
return "retry after backoff"
|
| 94 |
+
if category == "rate_limited":
|
| 95 |
+
return "rotate provider key or wait"
|
| 96 |
+
if category == "auth_failure":
|
| 97 |
+
return "rotate API key"
|
| 98 |
+
return "retry"
|
backend/deployers.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Deployment helpers for HuggingFace Spaces, Vercel, and GitHub.
|
| 3 |
+
All real - no mocks. Each helper returns a structured result dict.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import shutil
|
| 11 |
+
import subprocess
|
| 12 |
+
import tempfile
|
| 13 |
+
import time
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("deployers")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# GitHub
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
def github_push(
|
| 25 |
+
repo_dir: str,
|
| 26 |
+
branch: str = "genspark_ai_developer",
|
| 27 |
+
commit_message: str = "AI Developer Agent commit",
|
| 28 |
+
token: Optional[str] = None,
|
| 29 |
+
remote_url: Optional[str] = None,
|
| 30 |
+
) -> Dict[str, Any]:
|
| 31 |
+
token = token or os.getenv("GITHUB_TOKEN") or os.getenv("GITHUB_PAT")
|
| 32 |
+
if not token:
|
| 33 |
+
return {"ok": False, "error": "GITHUB_TOKEN missing"}
|
| 34 |
+
try:
|
| 35 |
+
env = os.environ.copy()
|
| 36 |
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
| 37 |
+
|
| 38 |
+
def run(cmd: List[str], check: bool = True) -> subprocess.CompletedProcess:
|
| 39 |
+
r = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True, env=env, timeout=120)
|
| 40 |
+
if check and r.returncode != 0:
|
| 41 |
+
raise RuntimeError(f"{' '.join(cmd)}: {r.stderr[:500]}")
|
| 42 |
+
return r
|
| 43 |
+
|
| 44 |
+
# ensure identity
|
| 45 |
+
run(["git", "config", "user.email", "ai-developer@genspark.ai"], check=False)
|
| 46 |
+
run(["git", "config", "user.name", "AI Developer Agent"], check=False)
|
| 47 |
+
|
| 48 |
+
# set token URL
|
| 49 |
+
if remote_url:
|
| 50 |
+
authed = remote_url.replace("https://", f"https://x-access-token:{token}@")
|
| 51 |
+
run(["git", "remote", "set-url", "origin", authed], check=False)
|
| 52 |
+
|
| 53 |
+
run(["git", "add", "-A"], check=False)
|
| 54 |
+
# commit may fail if nothing to commit; that's OK
|
| 55 |
+
commit = subprocess.run(["git", "commit", "-m", commit_message], cwd=repo_dir, capture_output=True, text=True, env=env)
|
| 56 |
+
run(["git", "checkout", "-B", branch], check=False)
|
| 57 |
+
push = subprocess.run(["git", "push", "-u", "origin", branch, "--force"], cwd=repo_dir, capture_output=True, text=True, env=env, timeout=180)
|
| 58 |
+
ok = push.returncode == 0
|
| 59 |
+
return {
|
| 60 |
+
"ok": ok,
|
| 61 |
+
"branch": branch,
|
| 62 |
+
"commit_out": commit.stdout + commit.stderr,
|
| 63 |
+
"push_out": push.stdout + push.stderr,
|
| 64 |
+
}
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.exception("github_push failed")
|
| 67 |
+
return {"ok": False, "error": str(e)}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
# Hugging Face Space
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
def hf_ensure_space(
|
| 74 |
+
repo_id: str,
|
| 75 |
+
token: Optional[str] = None,
|
| 76 |
+
sdk: str = "docker",
|
| 77 |
+
private: bool = False,
|
| 78 |
+
) -> Dict[str, Any]:
|
| 79 |
+
"""Create the Space if it doesn't exist (idempotent)."""
|
| 80 |
+
token = token or os.getenv("HF_TOKEN")
|
| 81 |
+
if not token:
|
| 82 |
+
return {"ok": False, "error": "HF_TOKEN missing"}
|
| 83 |
+
try:
|
| 84 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 85 |
+
info = httpx.get(f"https://huggingface.co/api/spaces/{repo_id}", headers=headers, timeout=30.0)
|
| 86 |
+
if info.status_code == 200:
|
| 87 |
+
return {"ok": True, "created": False, "url": f"https://huggingface.co/spaces/{repo_id}"}
|
| 88 |
+
# create
|
| 89 |
+
owner, name = repo_id.split("/", 1)
|
| 90 |
+
payload = {
|
| 91 |
+
"name": name,
|
| 92 |
+
"organization": None if owner == _hf_whoami(token) else owner,
|
| 93 |
+
"type": "space",
|
| 94 |
+
"sdk": sdk,
|
| 95 |
+
"private": private,
|
| 96 |
+
}
|
| 97 |
+
r = httpx.post(
|
| 98 |
+
"https://huggingface.co/api/repos/create",
|
| 99 |
+
headers={**headers, "Content-Type": "application/json"},
|
| 100 |
+
json=payload,
|
| 101 |
+
timeout=30.0,
|
| 102 |
+
)
|
| 103 |
+
if r.status_code >= 400:
|
| 104 |
+
return {"ok": False, "error": f"create failed: {r.status_code} {r.text[:300]}"}
|
| 105 |
+
return {"ok": True, "created": True, "url": f"https://huggingface.co/spaces/{repo_id}"}
|
| 106 |
+
except Exception as e:
|
| 107 |
+
return {"ok": False, "error": str(e)}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _hf_whoami(token: str) -> str:
|
| 111 |
+
try:
|
| 112 |
+
r = httpx.get("https://huggingface.co/api/whoami-v2", headers={"Authorization": f"Bearer {token}"}, timeout=15)
|
| 113 |
+
if r.status_code == 200:
|
| 114 |
+
return r.json().get("name", "")
|
| 115 |
+
except Exception:
|
| 116 |
+
pass
|
| 117 |
+
return ""
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def hf_push_space(
|
| 121 |
+
source_dir: str,
|
| 122 |
+
repo_id: str,
|
| 123 |
+
token: Optional[str] = None,
|
| 124 |
+
commit_message: str = "Update from AI Developer Agent",
|
| 125 |
+
) -> Dict[str, Any]:
|
| 126 |
+
"""Push contents of source_dir to a HuggingFace Space using git."""
|
| 127 |
+
token = token or os.getenv("HF_TOKEN")
|
| 128 |
+
if not token:
|
| 129 |
+
return {"ok": False, "error": "HF_TOKEN missing"}
|
| 130 |
+
try:
|
| 131 |
+
# First ensure space exists
|
| 132 |
+
ensure = hf_ensure_space(repo_id, token=token, sdk="docker")
|
| 133 |
+
if not ensure.get("ok"):
|
| 134 |
+
return {"ok": False, "error": f"ensure_space: {ensure.get('error')}"}
|
| 135 |
+
|
| 136 |
+
tmp = tempfile.mkdtemp(prefix="hfpush_")
|
| 137 |
+
try:
|
| 138 |
+
remote = f"https://user:{token}@huggingface.co/spaces/{repo_id}"
|
| 139 |
+
# Clone (may be empty)
|
| 140 |
+
clone = subprocess.run(["git", "clone", remote, tmp], capture_output=True, text=True, timeout=120)
|
| 141 |
+
if clone.returncode != 0:
|
| 142 |
+
# try init
|
| 143 |
+
subprocess.run(["git", "init"], cwd=tmp, capture_output=True, text=True)
|
| 144 |
+
subprocess.run(["git", "remote", "add", "origin", remote], cwd=tmp, capture_output=True, text=True)
|
| 145 |
+
|
| 146 |
+
# Copy source files into tmp (preserve .git)
|
| 147 |
+
for entry in os.listdir(source_dir):
|
| 148 |
+
if entry == ".git":
|
| 149 |
+
continue
|
| 150 |
+
src = os.path.join(source_dir, entry)
|
| 151 |
+
dst = os.path.join(tmp, entry)
|
| 152 |
+
if os.path.isdir(src):
|
| 153 |
+
if os.path.exists(dst):
|
| 154 |
+
shutil.rmtree(dst, ignore_errors=True)
|
| 155 |
+
shutil.copytree(src, dst)
|
| 156 |
+
else:
|
| 157 |
+
shutil.copy2(src, dst)
|
| 158 |
+
|
| 159 |
+
subprocess.run(["git", "config", "user.email", "ai-developer@genspark.ai"], cwd=tmp, capture_output=True, text=True)
|
| 160 |
+
subprocess.run(["git", "config", "user.name", "AI Developer Agent"], cwd=tmp, capture_output=True, text=True)
|
| 161 |
+
subprocess.run(["git", "lfs", "install"], cwd=tmp, capture_output=True, text=True)
|
| 162 |
+
subprocess.run(["git", "add", "-A"], cwd=tmp, capture_output=True, text=True)
|
| 163 |
+
commit = subprocess.run(["git", "commit", "-m", commit_message], cwd=tmp, capture_output=True, text=True)
|
| 164 |
+
push = subprocess.run(["git", "push", "origin", "main", "--force"], cwd=tmp, capture_output=True, text=True, timeout=300)
|
| 165 |
+
ok = push.returncode == 0
|
| 166 |
+
return {
|
| 167 |
+
"ok": ok,
|
| 168 |
+
"url": f"https://huggingface.co/spaces/{repo_id}",
|
| 169 |
+
"commit_out": (commit.stdout + commit.stderr)[-800:],
|
| 170 |
+
"push_out": (push.stdout + push.stderr)[-800:],
|
| 171 |
+
}
|
| 172 |
+
finally:
|
| 173 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.exception("hf_push_space failed")
|
| 176 |
+
return {"ok": False, "error": str(e)}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def hf_space_health(repo_id: str, path: str = "/health", timeout: float = 15.0) -> Dict[str, Any]:
|
| 180 |
+
"""Check the live space URL for health."""
|
| 181 |
+
owner, name = repo_id.split("/", 1)
|
| 182 |
+
url = f"https://{owner}-{name}.hf.space{path}"
|
| 183 |
+
try:
|
| 184 |
+
r = httpx.get(url, timeout=timeout)
|
| 185 |
+
return {"ok": r.status_code < 500, "status": r.status_code, "url": url, "body": r.text[:500]}
|
| 186 |
+
except Exception as e:
|
| 187 |
+
return {"ok": False, "url": url, "error": str(e)}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Vercel
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
def vercel_deploy_via_api(
|
| 194 |
+
project_name: str,
|
| 195 |
+
files: List[Dict[str, Any]],
|
| 196 |
+
token: Optional[str] = None,
|
| 197 |
+
target: str = "production",
|
| 198 |
+
env: Optional[Dict[str, str]] = None,
|
| 199 |
+
framework: Optional[str] = "nextjs",
|
| 200 |
+
install_command: Optional[str] = None,
|
| 201 |
+
build_command: Optional[str] = None,
|
| 202 |
+
) -> Dict[str, Any]:
|
| 203 |
+
"""
|
| 204 |
+
Deploy via Vercel HTTP API.
|
| 205 |
+
|
| 206 |
+
files: list of {"file": "path/in/repo", "data": "file contents"}
|
| 207 |
+
"""
|
| 208 |
+
token = token or os.getenv("VERCEL_TOKEN")
|
| 209 |
+
if not token:
|
| 210 |
+
return {"ok": False, "error": "VERCEL_TOKEN missing"}
|
| 211 |
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 212 |
+
body: Dict[str, Any] = {
|
| 213 |
+
"name": project_name,
|
| 214 |
+
"files": files,
|
| 215 |
+
"target": target,
|
| 216 |
+
"projectSettings": {
|
| 217 |
+
"framework": framework,
|
| 218 |
+
"installCommand": install_command,
|
| 219 |
+
"buildCommand": build_command,
|
| 220 |
+
},
|
| 221 |
+
}
|
| 222 |
+
if env:
|
| 223 |
+
body["env"] = env
|
| 224 |
+
body["build"] = {"env": env}
|
| 225 |
+
try:
|
| 226 |
+
r = httpx.post("https://api.vercel.com/v13/deployments", headers=headers, json=body, timeout=180.0)
|
| 227 |
+
if r.status_code >= 400:
|
| 228 |
+
return {"ok": False, "status": r.status_code, "error": r.text[:1000]}
|
| 229 |
+
data = r.json()
|
| 230 |
+
url = data.get("url") or ""
|
| 231 |
+
full = f"https://{url}" if url and not url.startswith("http") else url
|
| 232 |
+
return {"ok": True, "url": full, "id": data.get("id"), "data": data}
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.exception("vercel_deploy_via_api failed")
|
| 235 |
+
return {"ok": False, "error": str(e)}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def vercel_deployment_status(deployment_id: str, token: Optional[str] = None) -> Dict[str, Any]:
|
| 239 |
+
token = token or os.getenv("VERCEL_TOKEN")
|
| 240 |
+
if not token:
|
| 241 |
+
return {"ok": False, "error": "VERCEL_TOKEN missing"}
|
| 242 |
+
try:
|
| 243 |
+
r = httpx.get(
|
| 244 |
+
f"https://api.vercel.com/v13/deployments/{deployment_id}",
|
| 245 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 246 |
+
timeout=30.0,
|
| 247 |
+
)
|
| 248 |
+
if r.status_code >= 400:
|
| 249 |
+
return {"ok": False, "status": r.status_code, "error": r.text[:500]}
|
| 250 |
+
d = r.json()
|
| 251 |
+
return {"ok": True, "state": d.get("readyState"), "url": d.get("url"), "data": d}
|
| 252 |
+
except Exception as e:
|
| 253 |
+
return {"ok": False, "error": str(e)}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def collect_files_for_vercel(root_dir: str) -> List[Dict[str, Any]]:
|
| 257 |
+
"""Walk root_dir and produce list of {file, data} for Vercel API.
|
| 258 |
+
|
| 259 |
+
Skips node_modules, .git, .next, .vercel and other build artifacts.
|
| 260 |
+
"""
|
| 261 |
+
SKIP_DIRS = {"node_modules", ".git", ".next", ".vercel", "dist", "build", "__pycache__"}
|
| 262 |
+
SKIP_EXTS = {".log"}
|
| 263 |
+
files: List[Dict[str, Any]] = []
|
| 264 |
+
for cur, dirs, fns in os.walk(root_dir):
|
| 265 |
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
| 266 |
+
for fn in fns:
|
| 267 |
+
if any(fn.endswith(e) for e in SKIP_EXTS):
|
| 268 |
+
continue
|
| 269 |
+
full = os.path.join(cur, fn)
|
| 270 |
+
rel = os.path.relpath(full, root_dir).replace("\\", "/")
|
| 271 |
+
try:
|
| 272 |
+
with open(full, "r", encoding="utf-8") as f:
|
| 273 |
+
data = f.read()
|
| 274 |
+
except UnicodeDecodeError:
|
| 275 |
+
with open(full, "rb") as f:
|
| 276 |
+
import base64 as _b
|
| 277 |
+
data = _b.b64encode(f.read()).decode("ascii")
|
| 278 |
+
files.append({"file": rel, "data": data, "encoding": "base64"})
|
| 279 |
+
continue
|
| 280 |
+
files.append({"file": rel, "data": data})
|
| 281 |
+
return files
|
backend/executor.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Executor - runs plan actions. Uses E2B sandbox when E2B_API_KEY is set,
|
| 3 |
+
otherwise falls back to local subprocess execution (with strict allowlist).
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import shlex
|
| 9 |
+
import subprocess
|
| 10 |
+
import logging
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, Dict, Generator, List, Optional
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("executor")
|
| 17 |
+
|
| 18 |
+
E2B_API_KEY = os.getenv("E2B_API_KEY", "")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class ExecutionResult:
|
| 23 |
+
ok: bool
|
| 24 |
+
stdout: str = ""
|
| 25 |
+
stderr: str = ""
|
| 26 |
+
exit_code: int = 0
|
| 27 |
+
duration_ms: float = 0.0
|
| 28 |
+
meta: Dict[str, Any] = field(default_factory=dict)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# E2B sandbox wrapper (with graceful fallback)
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
class E2BSandbox:
|
| 35 |
+
"""Thin wrapper around e2b_code_interpreter. Created lazily."""
|
| 36 |
+
|
| 37 |
+
def __init__(self) -> None:
|
| 38 |
+
self._sbx = None
|
| 39 |
+
self._lock = threading.Lock()
|
| 40 |
+
self._available = False
|
| 41 |
+
if E2B_API_KEY:
|
| 42 |
+
try:
|
| 43 |
+
from e2b_code_interpreter import Sandbox # type: ignore
|
| 44 |
+
self._Sandbox = Sandbox
|
| 45 |
+
self._available = True
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.warning("E2B SDK not available: %s", e)
|
| 48 |
+
self._available = False
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def available(self) -> bool:
|
| 52 |
+
return self._available
|
| 53 |
+
|
| 54 |
+
def _ensure(self):
|
| 55 |
+
if self._sbx is None:
|
| 56 |
+
self._sbx = self._Sandbox(api_key=E2B_API_KEY)
|
| 57 |
+
return self._sbx
|
| 58 |
+
|
| 59 |
+
def run_shell(self, cmd: str, timeout: float = 120.0) -> ExecutionResult:
|
| 60 |
+
started = time.time()
|
| 61 |
+
with self._lock:
|
| 62 |
+
try:
|
| 63 |
+
sbx = self._ensure()
|
| 64 |
+
# Newer e2b SDKs use sbx.commands.run
|
| 65 |
+
try:
|
| 66 |
+
cmd_result = sbx.commands.run(cmd, timeout=int(timeout))
|
| 67 |
+
stdout = getattr(cmd_result, "stdout", "") or ""
|
| 68 |
+
stderr = getattr(cmd_result, "stderr", "") or ""
|
| 69 |
+
exit_code = getattr(cmd_result, "exit_code", 0) or 0
|
| 70 |
+
except AttributeError:
|
| 71 |
+
# Fallback for legacy SDK
|
| 72 |
+
res = sbx.run_code(f"import subprocess; r=subprocess.run({cmd!r}, shell=True, capture_output=True, text=True, timeout={timeout}); print(r.stdout); print(r.stderr)")
|
| 73 |
+
stdout = "\n".join([str(getattr(r, "text", "")) for r in getattr(res, "logs", {}).get("stdout", []) or []])
|
| 74 |
+
stderr = ""
|
| 75 |
+
exit_code = 0
|
| 76 |
+
ok = exit_code == 0
|
| 77 |
+
return ExecutionResult(
|
| 78 |
+
ok=ok, stdout=stdout, stderr=stderr, exit_code=exit_code,
|
| 79 |
+
duration_ms=(time.time() - started) * 1000,
|
| 80 |
+
meta={"engine": "e2b"},
|
| 81 |
+
)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.exception("E2B run_shell failed")
|
| 84 |
+
return ExecutionResult(
|
| 85 |
+
ok=False, stderr=str(e), exit_code=1,
|
| 86 |
+
duration_ms=(time.time() - started) * 1000,
|
| 87 |
+
meta={"engine": "e2b", "error": True},
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def run_python(self, code: str, timeout: float = 120.0) -> ExecutionResult:
|
| 91 |
+
started = time.time()
|
| 92 |
+
with self._lock:
|
| 93 |
+
try:
|
| 94 |
+
sbx = self._ensure()
|
| 95 |
+
try:
|
| 96 |
+
res = sbx.run_code(code, timeout=int(timeout))
|
| 97 |
+
stdout_logs = []
|
| 98 |
+
stderr_logs = []
|
| 99 |
+
if hasattr(res, "logs"):
|
| 100 |
+
for entry in getattr(res.logs, "stdout", []) or []:
|
| 101 |
+
stdout_logs.append(str(entry))
|
| 102 |
+
for entry in getattr(res.logs, "stderr", []) or []:
|
| 103 |
+
stderr_logs.append(str(entry))
|
| 104 |
+
return ExecutionResult(
|
| 105 |
+
ok=True,
|
| 106 |
+
stdout="\n".join(stdout_logs),
|
| 107 |
+
stderr="\n".join(stderr_logs),
|
| 108 |
+
exit_code=0,
|
| 109 |
+
duration_ms=(time.time() - started) * 1000,
|
| 110 |
+
meta={"engine": "e2b"},
|
| 111 |
+
)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
return ExecutionResult(
|
| 114 |
+
ok=False, stderr=str(e), exit_code=1,
|
| 115 |
+
duration_ms=(time.time() - started) * 1000,
|
| 116 |
+
meta={"engine": "e2b", "error": True},
|
| 117 |
+
)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
return ExecutionResult(
|
| 120 |
+
ok=False, stderr=str(e), exit_code=1,
|
| 121 |
+
duration_ms=(time.time() - started) * 1000,
|
| 122 |
+
meta={"engine": "e2b", "error": True},
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def close(self):
|
| 126 |
+
with self._lock:
|
| 127 |
+
try:
|
| 128 |
+
if self._sbx is not None:
|
| 129 |
+
self._sbx.kill()
|
| 130 |
+
except Exception:
|
| 131 |
+
pass
|
| 132 |
+
self._sbx = None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# Local subprocess fallback - LIMITED commands only
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
_DISALLOWED_PATTERNS = [
|
| 139 |
+
"rm -rf /",
|
| 140 |
+
":(){:|:&};:",
|
| 141 |
+
"mkfs",
|
| 142 |
+
"> /dev/sda",
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _local_run_shell(cmd: str, timeout: float = 120.0) -> ExecutionResult:
|
| 147 |
+
started = time.time()
|
| 148 |
+
if any(p in cmd for p in _DISALLOWED_PATTERNS):
|
| 149 |
+
return ExecutionResult(ok=False, stderr="Disallowed command", exit_code=126,
|
| 150 |
+
duration_ms=(time.time() - started) * 1000)
|
| 151 |
+
try:
|
| 152 |
+
res = subprocess.run(
|
| 153 |
+
cmd, shell=True, capture_output=True, text=True, timeout=timeout,
|
| 154 |
+
)
|
| 155 |
+
return ExecutionResult(
|
| 156 |
+
ok=res.returncode == 0,
|
| 157 |
+
stdout=res.stdout or "",
|
| 158 |
+
stderr=res.stderr or "",
|
| 159 |
+
exit_code=res.returncode,
|
| 160 |
+
duration_ms=(time.time() - started) * 1000,
|
| 161 |
+
meta={"engine": "local"},
|
| 162 |
+
)
|
| 163 |
+
except subprocess.TimeoutExpired as e:
|
| 164 |
+
return ExecutionResult(ok=False, stderr=f"timeout: {e}", exit_code=124,
|
| 165 |
+
duration_ms=(time.time() - started) * 1000,
|
| 166 |
+
meta={"engine": "local"})
|
| 167 |
+
except Exception as e:
|
| 168 |
+
return ExecutionResult(ok=False, stderr=str(e), exit_code=1,
|
| 169 |
+
duration_ms=(time.time() - started) * 1000,
|
| 170 |
+
meta={"engine": "local"})
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _local_run_python(code: str, timeout: float = 120.0) -> ExecutionResult:
|
| 174 |
+
# Run python in subprocess for isolation
|
| 175 |
+
started = time.time()
|
| 176 |
+
try:
|
| 177 |
+
res = subprocess.run(
|
| 178 |
+
["python3", "-c", code], capture_output=True, text=True, timeout=timeout,
|
| 179 |
+
)
|
| 180 |
+
return ExecutionResult(
|
| 181 |
+
ok=res.returncode == 0,
|
| 182 |
+
stdout=res.stdout or "",
|
| 183 |
+
stderr=res.stderr or "",
|
| 184 |
+
exit_code=res.returncode,
|
| 185 |
+
duration_ms=(time.time() - started) * 1000,
|
| 186 |
+
meta={"engine": "local"},
|
| 187 |
+
)
|
| 188 |
+
except subprocess.TimeoutExpired as e:
|
| 189 |
+
return ExecutionResult(ok=False, stderr=f"timeout: {e}", exit_code=124,
|
| 190 |
+
duration_ms=(time.time() - started) * 1000)
|
| 191 |
+
except Exception as e:
|
| 192 |
+
return ExecutionResult(ok=False, stderr=str(e), exit_code=1,
|
| 193 |
+
duration_ms=(time.time() - started) * 1000)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ---------------------------------------------------------------------------
|
| 197 |
+
# Executor singleton
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
class Executor:
|
| 200 |
+
def __init__(self) -> None:
|
| 201 |
+
self.sandbox = E2BSandbox() if E2B_API_KEY else None
|
| 202 |
+
|
| 203 |
+
def shell(self, cmd: str, timeout: float = 120.0) -> ExecutionResult:
|
| 204 |
+
if self.sandbox and self.sandbox.available:
|
| 205 |
+
return self.sandbox.run_shell(cmd, timeout=timeout)
|
| 206 |
+
return _local_run_shell(cmd, timeout=timeout)
|
| 207 |
+
|
| 208 |
+
def python(self, code: str, timeout: float = 120.0) -> ExecutionResult:
|
| 209 |
+
if self.sandbox and self.sandbox.available:
|
| 210 |
+
return self.sandbox.run_python(code, timeout=timeout)
|
| 211 |
+
return _local_run_python(code, timeout=timeout)
|
| 212 |
+
|
| 213 |
+
def inspect_runtime(self) -> Dict[str, str]:
|
| 214 |
+
info: Dict[str, str] = {}
|
| 215 |
+
for label, cmd in [
|
| 216 |
+
("python", "python3 --version"),
|
| 217 |
+
("node", "node --version"),
|
| 218 |
+
("npm", "npm --version"),
|
| 219 |
+
("git", "git --version"),
|
| 220 |
+
("playwright", "python3 -c 'import playwright; print(playwright.__version__)' 2>/dev/null || echo 'not installed'"),
|
| 221 |
+
]:
|
| 222 |
+
r = self.shell(cmd, timeout=15)
|
| 223 |
+
info[label] = (r.stdout or r.stderr).strip()[:200]
|
| 224 |
+
return info
|
| 225 |
+
|
| 226 |
+
def close(self):
|
| 227 |
+
if self.sandbox:
|
| 228 |
+
self.sandbox.close()
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
_executor: Optional[Executor] = None
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def get_executor() -> Executor:
|
| 235 |
+
global _executor
|
| 236 |
+
if _executor is None:
|
| 237 |
+
_executor = Executor()
|
| 238 |
+
return _executor
|
backend/llm_router.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-Provider LLM Router with key rotation, cooldowns, health tracking,
|
| 3 |
+
and adaptive provider selection.
|
| 4 |
+
|
| 5 |
+
Supported providers:
|
| 6 |
+
- OpenAI (chat completions, OpenAI compatible)
|
| 7 |
+
- Gemini (Google Generative AI)
|
| 8 |
+
- SambaNova (OpenAI-compatible API)
|
| 9 |
+
- GitHub Models (OpenAI-compatible /inference endpoint)
|
| 10 |
+
- OpenRouter (OpenAI-compatible)
|
| 11 |
+
|
| 12 |
+
Environment variables (comma-separated key lists):
|
| 13 |
+
OPENAI_API_KEYS
|
| 14 |
+
GEMINI_API_KEYS
|
| 15 |
+
SAMBANOVA_API_KEYS
|
| 16 |
+
GITHUB_LLM_API_KEYS
|
| 17 |
+
OPENROUTER_API_KEYS
|
| 18 |
+
|
| 19 |
+
Public API:
|
| 20 |
+
router = LLMRouter()
|
| 21 |
+
text = router.chat(messages, model=None, temperature=0.2)
|
| 22 |
+
for chunk in router.stream(messages, model=None): ...
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
import random
|
| 29 |
+
import time
|
| 30 |
+
import threading
|
| 31 |
+
import logging
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
from typing import Generator, Iterable, List, Optional, Dict, Any
|
| 34 |
+
|
| 35 |
+
import httpx
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger("llm_router")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _split_env(name: str) -> List[str]:
|
| 41 |
+
raw = os.getenv(name, "")
|
| 42 |
+
return [k.strip() for k in raw.split(",") if k.strip()]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Key registry
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
@dataclass
|
| 49 |
+
class KeyState:
|
| 50 |
+
key: str
|
| 51 |
+
provider: str
|
| 52 |
+
cooldown_until: float = 0.0
|
| 53 |
+
failures: int = 0
|
| 54 |
+
requests: int = 0
|
| 55 |
+
last_latency_ms: float = 0.0
|
| 56 |
+
last_error: str = ""
|
| 57 |
+
|
| 58 |
+
def healthy(self) -> bool:
|
| 59 |
+
return time.time() >= self.cooldown_until
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class ProviderConfig:
|
| 64 |
+
name: str
|
| 65 |
+
default_model: str
|
| 66 |
+
base_url: str
|
| 67 |
+
api_style: str # "openai" | "gemini"
|
| 68 |
+
# Optional extra headers (e.g. for GitHub Models)
|
| 69 |
+
extra_headers: Dict[str, str] = field(default_factory=dict)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
PROVIDER_CONFIGS: Dict[str, ProviderConfig] = {
|
| 73 |
+
"openai": ProviderConfig(
|
| 74 |
+
name="openai",
|
| 75 |
+
default_model="gpt-4o-mini",
|
| 76 |
+
base_url="https://api.openai.com/v1",
|
| 77 |
+
api_style="openai",
|
| 78 |
+
),
|
| 79 |
+
"gemini": ProviderConfig(
|
| 80 |
+
name="gemini",
|
| 81 |
+
default_model="gemini-1.5-flash-latest",
|
| 82 |
+
base_url="https://generativelanguage.googleapis.com/v1beta",
|
| 83 |
+
api_style="gemini",
|
| 84 |
+
),
|
| 85 |
+
"sambanova": ProviderConfig(
|
| 86 |
+
name="sambanova",
|
| 87 |
+
default_model="Meta-Llama-3.3-70B-Instruct",
|
| 88 |
+
base_url="https://api.sambanova.ai/v1",
|
| 89 |
+
api_style="openai",
|
| 90 |
+
),
|
| 91 |
+
"github": ProviderConfig(
|
| 92 |
+
name="github",
|
| 93 |
+
default_model="gpt-4o-mini",
|
| 94 |
+
base_url="https://models.github.ai/inference",
|
| 95 |
+
api_style="openai",
|
| 96 |
+
),
|
| 97 |
+
"openrouter": ProviderConfig(
|
| 98 |
+
name="openrouter",
|
| 99 |
+
default_model="openai/gpt-4o-mini",
|
| 100 |
+
base_url="https://openrouter.ai/api/v1",
|
| 101 |
+
api_style="openai",
|
| 102 |
+
extra_headers={"HTTP-Referer": "https://github.com/ai-developer-agent", "X-Title": "AI Developer Agent"},
|
| 103 |
+
),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class LLMRouter:
|
| 108 |
+
"""Thread-safe, multi-provider LLM router with rotation + failover."""
|
| 109 |
+
|
| 110 |
+
def __init__(self) -> None:
|
| 111 |
+
self._lock = threading.Lock()
|
| 112 |
+
self._registry: Dict[str, List[KeyState]] = {}
|
| 113 |
+
self._rr_index: Dict[str, int] = {}
|
| 114 |
+
self._provider_priority: List[str] = []
|
| 115 |
+
self._load_keys()
|
| 116 |
+
|
| 117 |
+
# -------------------- key registry --------------------
|
| 118 |
+
def _load_keys(self) -> None:
|
| 119 |
+
mapping = [
|
| 120 |
+
("openai", "OPENAI_API_KEYS"),
|
| 121 |
+
("gemini", "GEMINI_API_KEYS"),
|
| 122 |
+
("sambanova", "SAMBANOVA_API_KEYS"),
|
| 123 |
+
("github", "GITHUB_LLM_API_KEYS"),
|
| 124 |
+
("openrouter", "OPENROUTER_API_KEYS"),
|
| 125 |
+
]
|
| 126 |
+
for provider, env_name in mapping:
|
| 127 |
+
keys = _split_env(env_name)
|
| 128 |
+
if keys:
|
| 129 |
+
self._registry[provider] = [KeyState(k, provider) for k in keys]
|
| 130 |
+
self._rr_index[provider] = 0
|
| 131 |
+
self._provider_priority.append(provider)
|
| 132 |
+
|
| 133 |
+
if not self._registry:
|
| 134 |
+
logger.warning("LLMRouter: no provider keys configured.")
|
| 135 |
+
|
| 136 |
+
def telemetry(self) -> Dict[str, Any]:
|
| 137 |
+
out: Dict[str, Any] = {}
|
| 138 |
+
with self._lock:
|
| 139 |
+
for provider, states in self._registry.items():
|
| 140 |
+
out[provider] = {
|
| 141 |
+
"keys": len(states),
|
| 142 |
+
"healthy_keys": sum(1 for s in states if s.healthy()),
|
| 143 |
+
"total_requests": sum(s.requests for s in states),
|
| 144 |
+
"failures": sum(s.failures for s in states),
|
| 145 |
+
"last_error": next((s.last_error for s in states if s.last_error), ""),
|
| 146 |
+
}
|
| 147 |
+
return out
|
| 148 |
+
|
| 149 |
+
def _pick_key(self, provider: str) -> Optional[KeyState]:
|
| 150 |
+
states = self._registry.get(provider, [])
|
| 151 |
+
if not states:
|
| 152 |
+
return None
|
| 153 |
+
idx = self._rr_index.get(provider, 0)
|
| 154 |
+
n = len(states)
|
| 155 |
+
for i in range(n):
|
| 156 |
+
s = states[(idx + i) % n]
|
| 157 |
+
if s.healthy():
|
| 158 |
+
self._rr_index[provider] = (idx + i + 1) % n
|
| 159 |
+
return s
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
def _cooldown(self, state: KeyState, seconds: float, error: str = "") -> None:
|
| 163 |
+
with self._lock:
|
| 164 |
+
state.cooldown_until = time.time() + seconds
|
| 165 |
+
state.failures += 1
|
| 166 |
+
state.last_error = error[:300]
|
| 167 |
+
|
| 168 |
+
# -------------------- public API --------------------
|
| 169 |
+
def chat(
|
| 170 |
+
self,
|
| 171 |
+
messages: List[Dict[str, str]],
|
| 172 |
+
model: Optional[str] = None,
|
| 173 |
+
temperature: float = 0.2,
|
| 174 |
+
max_tokens: int = 1500,
|
| 175 |
+
timeout: float = 60.0,
|
| 176 |
+
preferred_provider: Optional[str] = None,
|
| 177 |
+
) -> str:
|
| 178 |
+
chunks: List[str] = []
|
| 179 |
+
for chunk in self.stream(
|
| 180 |
+
messages,
|
| 181 |
+
model=model,
|
| 182 |
+
temperature=temperature,
|
| 183 |
+
max_tokens=max_tokens,
|
| 184 |
+
timeout=timeout,
|
| 185 |
+
preferred_provider=preferred_provider,
|
| 186 |
+
):
|
| 187 |
+
chunks.append(chunk)
|
| 188 |
+
return "".join(chunks)
|
| 189 |
+
|
| 190 |
+
def stream(
|
| 191 |
+
self,
|
| 192 |
+
messages: List[Dict[str, str]],
|
| 193 |
+
model: Optional[str] = None,
|
| 194 |
+
temperature: float = 0.2,
|
| 195 |
+
max_tokens: int = 1500,
|
| 196 |
+
timeout: float = 60.0,
|
| 197 |
+
preferred_provider: Optional[str] = None,
|
| 198 |
+
) -> Generator[str, None, None]:
|
| 199 |
+
"""Try providers in priority order, with key rotation per provider."""
|
| 200 |
+
order = list(self._provider_priority)
|
| 201 |
+
if preferred_provider and preferred_provider in order:
|
| 202 |
+
order.remove(preferred_provider)
|
| 203 |
+
order.insert(0, preferred_provider)
|
| 204 |
+
|
| 205 |
+
if not order:
|
| 206 |
+
yield "[LLMRouter] No providers configured. Returning placeholder.\n"
|
| 207 |
+
yield self._offline_placeholder(messages)
|
| 208 |
+
return
|
| 209 |
+
|
| 210 |
+
last_error = "no providers tried"
|
| 211 |
+
for provider in order:
|
| 212 |
+
tried = 0
|
| 213 |
+
max_keys = len(self._registry.get(provider, []))
|
| 214 |
+
while tried < max_keys:
|
| 215 |
+
key_state = self._pick_key(provider)
|
| 216 |
+
if key_state is None:
|
| 217 |
+
break
|
| 218 |
+
tried += 1
|
| 219 |
+
try:
|
| 220 |
+
started = time.time()
|
| 221 |
+
yielded_any = False
|
| 222 |
+
for chunk in self._stream_provider(
|
| 223 |
+
provider,
|
| 224 |
+
key_state,
|
| 225 |
+
messages,
|
| 226 |
+
model=model,
|
| 227 |
+
temperature=temperature,
|
| 228 |
+
max_tokens=max_tokens,
|
| 229 |
+
timeout=timeout,
|
| 230 |
+
):
|
| 231 |
+
yielded_any = True
|
| 232 |
+
yield chunk
|
| 233 |
+
if yielded_any:
|
| 234 |
+
with self._lock:
|
| 235 |
+
key_state.requests += 1
|
| 236 |
+
key_state.last_latency_ms = (time.time() - started) * 1000
|
| 237 |
+
return
|
| 238 |
+
else:
|
| 239 |
+
last_error = f"{provider}: empty response"
|
| 240 |
+
self._cooldown(key_state, 15, last_error)
|
| 241 |
+
except httpx.HTTPStatusError as e:
|
| 242 |
+
code = e.response.status_code if e.response else 0
|
| 243 |
+
body = ""
|
| 244 |
+
try:
|
| 245 |
+
body = e.response.text[:300] if e.response else ""
|
| 246 |
+
except Exception:
|
| 247 |
+
body = ""
|
| 248 |
+
last_error = f"{provider}/{code}: {body}"
|
| 249 |
+
logger.warning(last_error)
|
| 250 |
+
if code == 429:
|
| 251 |
+
self._cooldown(key_state, 60, last_error)
|
| 252 |
+
elif 500 <= code < 600:
|
| 253 |
+
self._cooldown(key_state, 30, last_error)
|
| 254 |
+
elif code in (401, 403):
|
| 255 |
+
# bad key – cool it down for a long time
|
| 256 |
+
self._cooldown(key_state, 3600, last_error)
|
| 257 |
+
else:
|
| 258 |
+
self._cooldown(key_state, 10, last_error)
|
| 259 |
+
except Exception as e:
|
| 260 |
+
last_error = f"{provider} error: {e}"
|
| 261 |
+
logger.exception("Provider error")
|
| 262 |
+
self._cooldown(key_state, 20, last_error)
|
| 263 |
+
|
| 264 |
+
yield f"[LLMRouter] All providers failed. Last error: {last_error}\n"
|
| 265 |
+
yield self._offline_placeholder(messages)
|
| 266 |
+
|
| 267 |
+
def _offline_placeholder(self, messages: List[Dict[str, str]]) -> str:
|
| 268 |
+
last_user = next((m["content"] for m in reversed(messages) if m.get("role") == "user"), "")
|
| 269 |
+
return (
|
| 270 |
+
"OFFLINE_PLAN_FALLBACK\n"
|
| 271 |
+
"I could not reach any LLM provider. Producing a heuristic response.\n"
|
| 272 |
+
f"User intent: {last_user[:200]}\n"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
# -------------------- provider impls --------------------
|
| 276 |
+
def _stream_provider(
|
| 277 |
+
self,
|
| 278 |
+
provider: str,
|
| 279 |
+
key_state: KeyState,
|
| 280 |
+
messages: List[Dict[str, str]],
|
| 281 |
+
model: Optional[str],
|
| 282 |
+
temperature: float,
|
| 283 |
+
max_tokens: int,
|
| 284 |
+
timeout: float,
|
| 285 |
+
) -> Generator[str, None, None]:
|
| 286 |
+
cfg = PROVIDER_CONFIGS[provider]
|
| 287 |
+
if cfg.api_style == "openai":
|
| 288 |
+
yield from self._stream_openai_compatible(cfg, key_state, messages, model, temperature, max_tokens, timeout)
|
| 289 |
+
elif cfg.api_style == "gemini":
|
| 290 |
+
yield from self._stream_gemini(cfg, key_state, messages, model, temperature, max_tokens, timeout)
|
| 291 |
+
else:
|
| 292 |
+
raise RuntimeError(f"Unknown api_style: {cfg.api_style}")
|
| 293 |
+
|
| 294 |
+
def _stream_openai_compatible(
|
| 295 |
+
self,
|
| 296 |
+
cfg: ProviderConfig,
|
| 297 |
+
key_state: KeyState,
|
| 298 |
+
messages: List[Dict[str, str]],
|
| 299 |
+
model: Optional[str],
|
| 300 |
+
temperature: float,
|
| 301 |
+
max_tokens: int,
|
| 302 |
+
timeout: float,
|
| 303 |
+
) -> Generator[str, None, None]:
|
| 304 |
+
url = f"{cfg.base_url}/chat/completions"
|
| 305 |
+
headers = {
|
| 306 |
+
"Authorization": f"Bearer {key_state.key}",
|
| 307 |
+
"Content-Type": "application/json",
|
| 308 |
+
}
|
| 309 |
+
headers.update(cfg.extra_headers)
|
| 310 |
+
body = {
|
| 311 |
+
"model": model or cfg.default_model,
|
| 312 |
+
"messages": messages,
|
| 313 |
+
"temperature": temperature,
|
| 314 |
+
"max_tokens": max_tokens,
|
| 315 |
+
"stream": True,
|
| 316 |
+
}
|
| 317 |
+
with httpx.Client(timeout=timeout) as client:
|
| 318 |
+
with client.stream("POST", url, json=body, headers=headers) as r:
|
| 319 |
+
if r.status_code >= 400:
|
| 320 |
+
text = r.read().decode("utf-8", "ignore")
|
| 321 |
+
raise httpx.HTTPStatusError(text[:300], request=r.request, response=r)
|
| 322 |
+
for line in r.iter_lines():
|
| 323 |
+
if not line:
|
| 324 |
+
continue
|
| 325 |
+
if isinstance(line, bytes):
|
| 326 |
+
line = line.decode("utf-8", "ignore")
|
| 327 |
+
if line.startswith("data:"):
|
| 328 |
+
line = line[5:].strip()
|
| 329 |
+
if not line or line == "[DONE]":
|
| 330 |
+
continue
|
| 331 |
+
try:
|
| 332 |
+
obj = json.loads(line)
|
| 333 |
+
delta = obj.get("choices", [{}])[0].get("delta", {})
|
| 334 |
+
content = delta.get("content") or ""
|
| 335 |
+
if content:
|
| 336 |
+
yield content
|
| 337 |
+
except Exception:
|
| 338 |
+
continue
|
| 339 |
+
|
| 340 |
+
def _stream_gemini(
|
| 341 |
+
self,
|
| 342 |
+
cfg: ProviderConfig,
|
| 343 |
+
key_state: KeyState,
|
| 344 |
+
messages: List[Dict[str, str]],
|
| 345 |
+
model: Optional[str],
|
| 346 |
+
temperature: float,
|
| 347 |
+
max_tokens: int,
|
| 348 |
+
timeout: float,
|
| 349 |
+
) -> Generator[str, None, None]:
|
| 350 |
+
model_name = model or cfg.default_model
|
| 351 |
+
# Use streamGenerateContent
|
| 352 |
+
url = f"{cfg.base_url}/models/{model_name}:streamGenerateContent?alt=sse&key={key_state.key}"
|
| 353 |
+
contents = []
|
| 354 |
+
sys_prompt = ""
|
| 355 |
+
for m in messages:
|
| 356 |
+
role = m.get("role", "user")
|
| 357 |
+
content = m.get("content", "")
|
| 358 |
+
if role == "system":
|
| 359 |
+
sys_prompt += content + "\n"
|
| 360 |
+
continue
|
| 361 |
+
mapped = "user" if role == "user" else "model"
|
| 362 |
+
contents.append({"role": mapped, "parts": [{"text": content}]})
|
| 363 |
+
body: Dict[str, Any] = {
|
| 364 |
+
"contents": contents,
|
| 365 |
+
"generationConfig": {
|
| 366 |
+
"temperature": temperature,
|
| 367 |
+
"maxOutputTokens": max_tokens,
|
| 368 |
+
},
|
| 369 |
+
}
|
| 370 |
+
if sys_prompt:
|
| 371 |
+
body["systemInstruction"] = {"parts": [{"text": sys_prompt.strip()}]}
|
| 372 |
+
headers = {"Content-Type": "application/json"}
|
| 373 |
+
with httpx.Client(timeout=timeout) as client:
|
| 374 |
+
with client.stream("POST", url, json=body, headers=headers) as r:
|
| 375 |
+
if r.status_code >= 400:
|
| 376 |
+
text = r.read().decode("utf-8", "ignore")
|
| 377 |
+
raise httpx.HTTPStatusError(text[:300], request=r.request, response=r)
|
| 378 |
+
for line in r.iter_lines():
|
| 379 |
+
if not line:
|
| 380 |
+
continue
|
| 381 |
+
if isinstance(line, bytes):
|
| 382 |
+
line = line.decode("utf-8", "ignore")
|
| 383 |
+
if line.startswith("data:"):
|
| 384 |
+
line = line[5:].strip()
|
| 385 |
+
if not line or line == "[DONE]":
|
| 386 |
+
continue
|
| 387 |
+
try:
|
| 388 |
+
obj = json.loads(line)
|
| 389 |
+
for cand in obj.get("candidates", []):
|
| 390 |
+
parts = cand.get("content", {}).get("parts", [])
|
| 391 |
+
for p in parts:
|
| 392 |
+
t = p.get("text")
|
| 393 |
+
if t:
|
| 394 |
+
yield t
|
| 395 |
+
except Exception:
|
| 396 |
+
continue
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# Singleton getter
|
| 400 |
+
_router_singleton: Optional[LLMRouter] = None
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def get_router() -> LLMRouter:
|
| 404 |
+
global _router_singleton
|
| 405 |
+
if _router_singleton is None:
|
| 406 |
+
_router_singleton = LLMRouter()
|
| 407 |
+
return _router_singleton
|
backend/planner.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Autonomous task planner.
|
| 3 |
+
|
| 4 |
+
Produces structured action plans (list of dicts):
|
| 5 |
+
{"type": "shell"|"python"|"browser"|"git"|"deploy"|"note", ...}
|
| 6 |
+
|
| 7 |
+
Strategy:
|
| 8 |
+
1. Try LLM-based planning with strict JSON output.
|
| 9 |
+
2. If LLM fails or returns invalid JSON, use heuristic fallback.
|
| 10 |
+
3. Always produces non-empty plan.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import re
|
| 17 |
+
from typing import Any, Dict, List
|
| 18 |
+
|
| 19 |
+
from .llm_router import get_router
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("planner")
|
| 22 |
+
|
| 23 |
+
PLANNER_SYSTEM = (
|
| 24 |
+
"You are an autonomous AI Developer Agent's planner. "
|
| 25 |
+
"Decompose a user task into a JSON list of concrete actions. "
|
| 26 |
+
"Each action is an object with a 'type' field and supporting fields. "
|
| 27 |
+
"Supported types: shell (cmd), python (code), browser (action,url,...), "
|
| 28 |
+
"git (op,args), deploy (target), note (msg). "
|
| 29 |
+
"Return ONLY a JSON array, no commentary. Keep plan under 12 steps."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def plan_task(title: str, description: str, context: Dict[str, Any] | None = None) -> List[Dict[str, Any]]:
|
| 34 |
+
"""Generate a concrete action plan."""
|
| 35 |
+
router = get_router()
|
| 36 |
+
user_prompt = (
|
| 37 |
+
f"TASK TITLE: {title}\n"
|
| 38 |
+
f"DESCRIPTION:\n{description}\n\n"
|
| 39 |
+
f"CONTEXT:\n{json.dumps(context or {}, indent=2)[:2000]}\n\n"
|
| 40 |
+
"Output a JSON array of action objects. Example:\n"
|
| 41 |
+
'[{"type":"shell","cmd":"echo hi"},{"type":"note","msg":"done"}]'
|
| 42 |
+
)
|
| 43 |
+
messages = [
|
| 44 |
+
{"role": "system", "content": PLANNER_SYSTEM},
|
| 45 |
+
{"role": "user", "content": user_prompt},
|
| 46 |
+
]
|
| 47 |
+
try:
|
| 48 |
+
raw = router.chat(messages, temperature=0.1, max_tokens=1200, timeout=45.0)
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.warning("Planner LLM call failed: %s", e)
|
| 51 |
+
raw = ""
|
| 52 |
+
|
| 53 |
+
plan = _parse_plan_json(raw)
|
| 54 |
+
if plan:
|
| 55 |
+
return plan
|
| 56 |
+
logger.info("Planner falling back to heuristic plan")
|
| 57 |
+
return heuristic_plan(title, description)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _parse_plan_json(raw: str) -> List[Dict[str, Any]]:
|
| 61 |
+
if not raw:
|
| 62 |
+
return []
|
| 63 |
+
# Try direct
|
| 64 |
+
try:
|
| 65 |
+
obj = json.loads(raw)
|
| 66 |
+
if isinstance(obj, list):
|
| 67 |
+
return [a for a in obj if isinstance(a, dict) and "type" in a]
|
| 68 |
+
except Exception:
|
| 69 |
+
pass
|
| 70 |
+
# Find first JSON array in text
|
| 71 |
+
m = re.search(r"\[[\s\S]*\]", raw)
|
| 72 |
+
if m:
|
| 73 |
+
try:
|
| 74 |
+
obj = json.loads(m.group(0))
|
| 75 |
+
if isinstance(obj, list):
|
| 76 |
+
return [a for a in obj if isinstance(a, dict) and "type" in a]
|
| 77 |
+
except Exception:
|
| 78 |
+
return []
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def heuristic_plan(title: str, description: str) -> List[Dict[str, Any]]:
|
| 83 |
+
"""Always-valid fallback plan."""
|
| 84 |
+
text = (title + "\n" + description).lower()
|
| 85 |
+
plan: List[Dict[str, Any]] = []
|
| 86 |
+
|
| 87 |
+
if any(k in text for k in ["deploy", "deployment", "huggingface", "hf space", "vercel"]):
|
| 88 |
+
plan.append({"type": "note", "msg": "Deployment task detected"})
|
| 89 |
+
if "vercel" in text:
|
| 90 |
+
plan.append({"type": "deploy", "target": "vercel"})
|
| 91 |
+
if "huggingface" in text or "hf" in text:
|
| 92 |
+
plan.append({"type": "deploy", "target": "huggingface"})
|
| 93 |
+
return plan
|
| 94 |
+
|
| 95 |
+
if any(k in text for k in ["git", "github", "commit", "push", "pr ", "pull request"]):
|
| 96 |
+
plan.append({"type": "git", "op": "status"})
|
| 97 |
+
plan.append({"type": "note", "msg": "GitHub task detected"})
|
| 98 |
+
return plan
|
| 99 |
+
|
| 100 |
+
if any(k in text for k in ["browser", "scrape", "navigate", "click", "open url", "http://", "https://"]):
|
| 101 |
+
url_match = re.search(r"https?://[\w\.\-/?=&%#]+", description)
|
| 102 |
+
url = url_match.group(0) if url_match else "https://example.com"
|
| 103 |
+
plan.append({"type": "browser", "action": "navigate", "url": url})
|
| 104 |
+
plan.append({"type": "browser", "action": "screenshot"})
|
| 105 |
+
return plan
|
| 106 |
+
|
| 107 |
+
if any(k in text for k in ["run python", "python script", "execute python"]):
|
| 108 |
+
plan.append({"type": "python", "code": "print('Hello from AI Developer Agent')"})
|
| 109 |
+
return plan
|
| 110 |
+
|
| 111 |
+
if any(k in text for k in ["install", "pip ", "npm "]):
|
| 112 |
+
# Try to extract a package name
|
| 113 |
+
m = re.search(r"(?:install|add)\s+([\w\.\-]+)", text)
|
| 114 |
+
pkg = m.group(1) if m else ""
|
| 115 |
+
if "npm" in text:
|
| 116 |
+
plan.append({"type": "shell", "cmd": f"npm install {pkg}".strip()})
|
| 117 |
+
else:
|
| 118 |
+
plan.append({"type": "shell", "cmd": f"pip install {pkg}".strip()})
|
| 119 |
+
return plan
|
| 120 |
+
|
| 121 |
+
# Generic fallback: echo the task back as an inspection action.
|
| 122 |
+
plan.append({"type": "note", "msg": f"Plan-fallback for: {title}"})
|
| 123 |
+
plan.append({"type": "shell", "cmd": "uname -a && python3 --version && node --version 2>/dev/null || true"})
|
| 124 |
+
return plan
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def repair_plan(error_category: str, detail: str = "") -> List[Dict[str, Any]]:
|
| 128 |
+
from .classifier import ErrorClass
|
| 129 |
+
from .repair import repair_actions
|
| 130 |
+
return repair_actions(ErrorClass(category=error_category, detail=detail, suggested_fix=""))
|
backend/repair.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Self-repair engine: converts an ErrorClass into actionable shell commands.
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from typing import List
|
| 7 |
+
from .classifier import ErrorClass
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def repair_actions(err: ErrorClass) -> List[dict]:
|
| 11 |
+
"""Return list of structured actions (type=shell|git|python)."""
|
| 12 |
+
actions: List[dict] = []
|
| 13 |
+
c = err.category
|
| 14 |
+
|
| 15 |
+
if c == "missing_python_module":
|
| 16 |
+
# err.detail might be 'ModuleNotFoundError: No module named "x"'
|
| 17 |
+
import re
|
| 18 |
+
m = re.search(r"['\"]([\w\.\-]+)['\"]", err.detail)
|
| 19 |
+
pkg = m.group(1) if m else err.detail.split()[-1].strip("'\"")
|
| 20 |
+
actions.append({"type": "shell", "cmd": f"pip install --no-cache-dir {pkg}"})
|
| 21 |
+
|
| 22 |
+
elif c == "missing_pip":
|
| 23 |
+
actions.append({"type": "shell", "cmd": "python -m ensurepip --upgrade || apt-get install -y python3-pip"})
|
| 24 |
+
|
| 25 |
+
elif c == "missing_node":
|
| 26 |
+
actions.append({"type": "shell", "cmd": "apt-get update && apt-get install -y nodejs npm"})
|
| 27 |
+
|
| 28 |
+
elif c == "npm_failure":
|
| 29 |
+
actions.append({"type": "shell", "cmd": "rm -rf node_modules package-lock.json && npm install"})
|
| 30 |
+
|
| 31 |
+
elif c == "missing_playwright":
|
| 32 |
+
actions.append({"type": "shell", "cmd": "pip install playwright && python -m playwright install --with-deps chromium"})
|
| 33 |
+
|
| 34 |
+
elif c == "playwright_browsers_missing":
|
| 35 |
+
actions.append({"type": "shell", "cmd": "python -m playwright install chromium"})
|
| 36 |
+
|
| 37 |
+
elif c == "playwright_missing_deps":
|
| 38 |
+
actions.append({"type": "shell", "cmd": "python -m playwright install-deps chromium"})
|
| 39 |
+
|
| 40 |
+
elif c == "git_identity_missing":
|
| 41 |
+
actions.append({"type": "shell", "cmd": "git config --global user.email 'ai-developer@genspark.ai' && git config --global user.name 'AI Developer Agent'"})
|
| 42 |
+
|
| 43 |
+
elif c == "not_a_git_repo":
|
| 44 |
+
actions.append({"type": "shell", "cmd": "git init"})
|
| 45 |
+
|
| 46 |
+
elif c == "git_auth_failed":
|
| 47 |
+
actions.append({"type": "note", "msg": "ensure GITHUB_TOKEN is set and remote uses token URL"})
|
| 48 |
+
|
| 49 |
+
elif c == "greenlet_build_failure":
|
| 50 |
+
actions.append({"type": "shell", "cmd": "pip install --upgrade pip setuptools wheel && pip install greenlet --only-binary :all:"})
|
| 51 |
+
|
| 52 |
+
elif c == "python_build_failure":
|
| 53 |
+
actions.append({"type": "shell", "cmd": "apt-get install -y build-essential python3-dev && pip install --upgrade pip setuptools wheel"})
|
| 54 |
+
|
| 55 |
+
elif c == "python_version_mismatch":
|
| 56 |
+
actions.append({"type": "note", "msg": "Need Python 3.11 - check Dockerfile base image"})
|
| 57 |
+
|
| 58 |
+
elif c == "network_failure":
|
| 59 |
+
actions.append({"type": "sleep", "seconds": 5})
|
| 60 |
+
|
| 61 |
+
elif c == "rate_limited":
|
| 62 |
+
actions.append({"type": "sleep", "seconds": 30})
|
| 63 |
+
|
| 64 |
+
elif c == "auth_failure":
|
| 65 |
+
actions.append({"type": "note", "msg": "rotate API key"})
|
| 66 |
+
|
| 67 |
+
elif c == "python_exception":
|
| 68 |
+
actions.append({"type": "note", "msg": "no automatic repair; will retry once"})
|
| 69 |
+
|
| 70 |
+
return actions
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
pydantic==2.9.2
|
| 4 |
+
httpx==0.27.2
|
| 5 |
+
python-multipart==0.0.9
|
| 6 |
+
sse-starlette==2.1.3
|
| 7 |
+
e2b-code-interpreter==1.0.4
|
| 8 |
+
playwright==1.47.0
|
| 9 |
+
greenlet==3.0.3
|
backend/retry.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Retry wrapper - executes a callable with exponential backoff and
|
| 3 |
+
optional classification-based repair callback.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Callable, Optional, Any
|
| 10 |
+
|
| 11 |
+
from .classifier import classify
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger("retry")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def retry_call(
|
| 17 |
+
fn: Callable[[], Any],
|
| 18 |
+
max_attempts: int = 3,
|
| 19 |
+
base_delay: float = 1.5,
|
| 20 |
+
on_error: Optional[Callable[[Exception, int], None]] = None,
|
| 21 |
+
repair_cb: Optional[Callable[[str], None]] = None,
|
| 22 |
+
) -> Any:
|
| 23 |
+
last_exc: Optional[Exception] = None
|
| 24 |
+
for attempt in range(1, max_attempts + 1):
|
| 25 |
+
try:
|
| 26 |
+
return fn()
|
| 27 |
+
except Exception as e:
|
| 28 |
+
last_exc = e
|
| 29 |
+
logger.warning("retry attempt %s failed: %s", attempt, e)
|
| 30 |
+
if on_error:
|
| 31 |
+
try:
|
| 32 |
+
on_error(e, attempt)
|
| 33 |
+
except Exception:
|
| 34 |
+
pass
|
| 35 |
+
if repair_cb:
|
| 36 |
+
try:
|
| 37 |
+
err_class = classify(str(e))
|
| 38 |
+
if err_class:
|
| 39 |
+
repair_cb(err_class.category)
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
if attempt < max_attempts:
|
| 43 |
+
time.sleep(base_delay * (2 ** (attempt - 1)))
|
| 44 |
+
assert last_exc is not None
|
| 45 |
+
raise last_exc
|
backend/tasks.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SQLite task persistence.
|
| 3 |
+
|
| 4 |
+
Tables:
|
| 5 |
+
tasks(id, title, description, state, created_at, updated_at, payload_json)
|
| 6 |
+
task_events(id, task_id, ts, kind, message)
|
| 7 |
+
retries(id, task_id, attempt, error, ts)
|
| 8 |
+
deployments(id, task_id, target, url, status, ts)
|
| 9 |
+
sandboxes(id, task_id, sandbox_id, status, ts)
|
| 10 |
+
provider_usage(id, provider, ts, ok, latency_ms, error)
|
| 11 |
+
checkpoints(id, task_id, step_index, state_json, ts)
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import sqlite3
|
| 18 |
+
import threading
|
| 19 |
+
import time
|
| 20 |
+
import uuid
|
| 21 |
+
from typing import Any, Dict, List, Optional
|
| 22 |
+
|
| 23 |
+
DB_PATH = os.getenv("TASKS_DB_PATH", os.path.join(os.path.dirname(__file__), "tasks.db"))
|
| 24 |
+
_LOCK = threading.RLock()
|
| 25 |
+
|
| 26 |
+
SCHEMA = """
|
| 27 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 28 |
+
id TEXT PRIMARY KEY,
|
| 29 |
+
title TEXT,
|
| 30 |
+
description TEXT,
|
| 31 |
+
state TEXT,
|
| 32 |
+
created_at REAL,
|
| 33 |
+
updated_at REAL,
|
| 34 |
+
payload_json TEXT
|
| 35 |
+
);
|
| 36 |
+
CREATE TABLE IF NOT EXISTS task_events (
|
| 37 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 38 |
+
task_id TEXT,
|
| 39 |
+
ts REAL,
|
| 40 |
+
kind TEXT,
|
| 41 |
+
message TEXT
|
| 42 |
+
);
|
| 43 |
+
CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id);
|
| 44 |
+
CREATE TABLE IF NOT EXISTS retries (
|
| 45 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 46 |
+
task_id TEXT,
|
| 47 |
+
attempt INTEGER,
|
| 48 |
+
error TEXT,
|
| 49 |
+
ts REAL
|
| 50 |
+
);
|
| 51 |
+
CREATE TABLE IF NOT EXISTS deployments (
|
| 52 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 53 |
+
task_id TEXT,
|
| 54 |
+
target TEXT,
|
| 55 |
+
url TEXT,
|
| 56 |
+
status TEXT,
|
| 57 |
+
ts REAL
|
| 58 |
+
);
|
| 59 |
+
CREATE TABLE IF NOT EXISTS sandboxes (
|
| 60 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 61 |
+
task_id TEXT,
|
| 62 |
+
sandbox_id TEXT,
|
| 63 |
+
status TEXT,
|
| 64 |
+
ts REAL
|
| 65 |
+
);
|
| 66 |
+
CREATE TABLE IF NOT EXISTS provider_usage (
|
| 67 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 68 |
+
provider TEXT,
|
| 69 |
+
ts REAL,
|
| 70 |
+
ok INTEGER,
|
| 71 |
+
latency_ms REAL,
|
| 72 |
+
error TEXT
|
| 73 |
+
);
|
| 74 |
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
| 75 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 76 |
+
task_id TEXT,
|
| 77 |
+
step_index INTEGER,
|
| 78 |
+
state_json TEXT,
|
| 79 |
+
ts REAL
|
| 80 |
+
);
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
# Valid task states
|
| 84 |
+
TASK_STATES = [
|
| 85 |
+
"queued", "planning", "thinking", "executing",
|
| 86 |
+
"repairing", "retrying", "deploying", "completed", "failed",
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _conn() -> sqlite3.Connection:
|
| 91 |
+
c = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30.0)
|
| 92 |
+
c.row_factory = sqlite3.Row
|
| 93 |
+
c.execute("PRAGMA journal_mode=WAL")
|
| 94 |
+
return c
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def init_db() -> None:
|
| 98 |
+
with _LOCK:
|
| 99 |
+
c = _conn()
|
| 100 |
+
try:
|
| 101 |
+
c.executescript(SCHEMA)
|
| 102 |
+
c.commit()
|
| 103 |
+
finally:
|
| 104 |
+
c.close()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# Task CRUD
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
def create_task(title: str, description: str, payload: Optional[Dict[str, Any]] = None) -> str:
|
| 111 |
+
task_id = uuid.uuid4().hex[:12]
|
| 112 |
+
now = time.time()
|
| 113 |
+
with _LOCK:
|
| 114 |
+
c = _conn()
|
| 115 |
+
try:
|
| 116 |
+
c.execute(
|
| 117 |
+
"INSERT INTO tasks(id, title, description, state, created_at, updated_at, payload_json) VALUES (?,?,?,?,?,?,?)",
|
| 118 |
+
(task_id, title, description, "queued", now, now, json.dumps(payload or {})),
|
| 119 |
+
)
|
| 120 |
+
c.commit()
|
| 121 |
+
finally:
|
| 122 |
+
c.close()
|
| 123 |
+
log_event(task_id, "create", f"Task created: {title}")
|
| 124 |
+
return task_id
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def update_state(task_id: str, state: str) -> None:
|
| 128 |
+
with _LOCK:
|
| 129 |
+
c = _conn()
|
| 130 |
+
try:
|
| 131 |
+
c.execute("UPDATE tasks SET state=?, updated_at=? WHERE id=?", (state, time.time(), task_id))
|
| 132 |
+
c.commit()
|
| 133 |
+
finally:
|
| 134 |
+
c.close()
|
| 135 |
+
log_event(task_id, "state", state)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
| 139 |
+
with _LOCK:
|
| 140 |
+
c = _conn()
|
| 141 |
+
try:
|
| 142 |
+
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
| 143 |
+
return dict(row) if row else None
|
| 144 |
+
finally:
|
| 145 |
+
c.close()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def list_tasks(limit: int = 50) -> List[Dict[str, Any]]:
|
| 149 |
+
with _LOCK:
|
| 150 |
+
c = _conn()
|
| 151 |
+
try:
|
| 152 |
+
rows = c.execute(
|
| 153 |
+
"SELECT * FROM tasks ORDER BY updated_at DESC LIMIT ?", (limit,)
|
| 154 |
+
).fetchall()
|
| 155 |
+
return [dict(r) for r in rows]
|
| 156 |
+
finally:
|
| 157 |
+
c.close()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def log_event(task_id: str, kind: str, message: str) -> None:
|
| 161 |
+
with _LOCK:
|
| 162 |
+
c = _conn()
|
| 163 |
+
try:
|
| 164 |
+
c.execute(
|
| 165 |
+
"INSERT INTO task_events(task_id, ts, kind, message) VALUES (?,?,?,?)",
|
| 166 |
+
(task_id, time.time(), kind, message[:8000]),
|
| 167 |
+
)
|
| 168 |
+
c.commit()
|
| 169 |
+
finally:
|
| 170 |
+
c.close()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def get_events(task_id: str, since_id: int = 0, limit: int = 1000) -> List[Dict[str, Any]]:
|
| 174 |
+
with _LOCK:
|
| 175 |
+
c = _conn()
|
| 176 |
+
try:
|
| 177 |
+
rows = c.execute(
|
| 178 |
+
"SELECT * FROM task_events WHERE task_id=? AND id>? ORDER BY id ASC LIMIT ?",
|
| 179 |
+
(task_id, since_id, limit),
|
| 180 |
+
).fetchall()
|
| 181 |
+
return [dict(r) for r in rows]
|
| 182 |
+
finally:
|
| 183 |
+
c.close()
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def record_retry(task_id: str, attempt: int, error: str) -> None:
|
| 187 |
+
with _LOCK:
|
| 188 |
+
c = _conn()
|
| 189 |
+
try:
|
| 190 |
+
c.execute(
|
| 191 |
+
"INSERT INTO retries(task_id, attempt, error, ts) VALUES (?,?,?,?)",
|
| 192 |
+
(task_id, attempt, error[:4000], time.time()),
|
| 193 |
+
)
|
| 194 |
+
c.commit()
|
| 195 |
+
finally:
|
| 196 |
+
c.close()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def record_deployment(task_id: str, target: str, url: str, status: str) -> None:
|
| 200 |
+
with _LOCK:
|
| 201 |
+
c = _conn()
|
| 202 |
+
try:
|
| 203 |
+
c.execute(
|
| 204 |
+
"INSERT INTO deployments(task_id, target, url, status, ts) VALUES (?,?,?,?,?)",
|
| 205 |
+
(task_id, target, url, status, time.time()),
|
| 206 |
+
)
|
| 207 |
+
c.commit()
|
| 208 |
+
finally:
|
| 209 |
+
c.close()
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def save_checkpoint(task_id: str, step_index: int, state: Dict[str, Any]) -> None:
|
| 213 |
+
with _LOCK:
|
| 214 |
+
c = _conn()
|
| 215 |
+
try:
|
| 216 |
+
c.execute(
|
| 217 |
+
"INSERT INTO checkpoints(task_id, step_index, state_json, ts) VALUES (?,?,?,?)",
|
| 218 |
+
(task_id, step_index, json.dumps(state)[:64000], time.time()),
|
| 219 |
+
)
|
| 220 |
+
c.commit()
|
| 221 |
+
finally:
|
| 222 |
+
c.close()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def latest_checkpoint(task_id: str) -> Optional[Dict[str, Any]]:
|
| 226 |
+
with _LOCK:
|
| 227 |
+
c = _conn()
|
| 228 |
+
try:
|
| 229 |
+
row = c.execute(
|
| 230 |
+
"SELECT * FROM checkpoints WHERE task_id=? ORDER BY id DESC LIMIT 1", (task_id,)
|
| 231 |
+
).fetchone()
|
| 232 |
+
if not row:
|
| 233 |
+
return None
|
| 234 |
+
d = dict(row)
|
| 235 |
+
d["state"] = json.loads(d["state_json"] or "{}")
|
| 236 |
+
return d
|
| 237 |
+
finally:
|
| 238 |
+
c.close()
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def record_provider_usage(provider: str, ok: bool, latency_ms: float, error: str = "") -> None:
|
| 242 |
+
with _LOCK:
|
| 243 |
+
c = _conn()
|
| 244 |
+
try:
|
| 245 |
+
c.execute(
|
| 246 |
+
"INSERT INTO provider_usage(provider, ts, ok, latency_ms, error) VALUES (?,?,?,?,?)",
|
| 247 |
+
(provider, time.time(), 1 if ok else 0, latency_ms, error[:1000]),
|
| 248 |
+
)
|
| 249 |
+
c.commit()
|
| 250 |
+
finally:
|
| 251 |
+
c.close()
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
init_db()
|