agent-control-center/backend/app/main.py

252 lines
8 KiB
Python

"""Agent Control Center — Control Plane API (FastAPI)."""
from __future__ import annotations
import json
import os
import subprocess
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
app = FastAPI(title="Agent Control Center", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
MEMORY_DIR = HERMES_HOME / "acc-memory"
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
WORKER_PROFILES = ["worker-code", "worker-fast", "worker-research", "worker-review"]
runs_db: dict[str, dict[str, Any]] = {}
projects_db: list[dict[str, Any]] = [
{"id": "proj-1", "key": "ACC", "name": "Agent Control Center", "status": "active"},
{"id": "proj-2", "key": "OPS", "name": "Operations", "status": "active"},
]
UI_BUILD_DIR = Path(__file__).resolve().parents[2] / "frontend" / "dist"
if UI_BUILD_DIR.exists():
from fastapi.staticfiles import StaticFiles
app.mount("/ui", StaticFiles(directory=UI_BUILD_DIR, html=True), name="ui")
# ── Helpers ──────────────────────────────────────────────
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def hermes_memory_path() -> Path:
return MEMORY_DIR / "MEMORY.md"
def read_memory() -> str:
path = hermes_memory_path()
if path.exists():
return path.read_text(encoding="utf-8")
return ""
def write_memory(content: str) -> None:
hermes_memory_path().write_text(content, encoding="utf-8")
# ── Models ───────────────────────────────────────────────
class RunCreate(BaseModel):
profile: str = Field(..., description="Worker profile, e.g. worker-code")
prompt: str = Field(..., min_length=1)
context: str | None = None
class RunOut(BaseModel):
id: str
profile: str
prompt: str
status: str
created_at: str
updated_at: str
output: str | None = None
error: str | None = None
class HealthOut(BaseModel):
profile: str
current_model: str | None
provider: str | None
status: str
latency_ms: int | None = None
class MemoryPut(BaseModel):
content: str
# ── Endpoints: runs ──────────────────────────────────────
@app.post("/api/v1/runs", response_model=RunOut)
def create_run(payload: RunCreate) -> dict[str, Any]:
if payload.profile not in WORKER_PROFILES:
raise HTTPException(status_code=422, detail=f"Unknown profile: {payload.profile}")
run_id = str(uuid.uuid4())
run = {
"id": run_id,
"profile": payload.profile,
"prompt": payload.prompt,
"status": "queued",
"created_at": now_iso(),
"updated_at": now_iso(),
"output": None,
"error": None,
}
runs_db[run_id] = run
# Dispatch via hermes chat -q (fire-and-forget background-friendly sync here)
command = ["hermes", "-p", payload.profile, "chat", "-q", payload.prompt]
try:
run["status"] = "running"
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=300,
encoding="utf-8",
errors="replace",
)
run["output"] = result.stdout.strip() or "(empty response)"
if result.returncode != 0:
run["status"] = "failed"
run["error"] = result.stderr.strip() or "hermes exit non-zero"
else:
run["status"] = "succeeded"
except subprocess.TimeoutExpired:
run["status"] = "failed"
run["error"] = "hermes timeout after 300s"
except Exception as exc:
run["status"] = "failed"
run["error"] = f"dispatch error: {exc}"
run["updated_at"] = now_iso()
return run
@app.get("/api/v1/runs")
def list_runs() -> dict[str, Any]:
return {"data": list(runs_db.values()), "meta": {"request_id": str(uuid.uuid4())}}
@app.get("/api/v1/runs/{run_id}")
def get_run(run_id: str) -> dict[str, Any]:
run = runs_db.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
return {"data": run, "meta": {"request_id": str(uuid.uuid4())}}
@app.delete("/api/v1/runs/{run_id}")
def cancel_run(run_id: str) -> dict[str, Any]:
run = runs_db.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
if run["status"] in ("succeeded", "failed", "cancelled"):
raise HTTPException(status_code=409, detail=f"Run already terminal: {run['status']}")
run["status"] = "cancelled"
run["updated_at"] = now_iso()
return {"data": run, "meta": {"request_id": str(uuid.uuid4())}}
# ── Endpoints: agents / health ───────────────────────────
@app.post("/api/v1/agents/health")
def agents_health() -> dict[str, Any]:
health = []
for profile in WORKER_PROFILES:
current_model = None
try:
r = subprocess.run(
["hermes", "config", "get", "model.default", "-p", profile],
capture_output=True,
text=True,
timeout=10,
)
current_model = r.stdout.strip() or None
except Exception:
current_model = None
provider = current_model.split("/")[0] if current_model else None
status = "unknown"
latency_ms = None
try:
start = datetime.now(timezone.utc)
r = subprocess.run(
["hermes", "-p", profile, "chat", "-q", "hi"],
capture_output=True,
text=True,
timeout=15,
)
latency_ms = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
status = "ok" if r.returncode == 0 else "error"
except subprocess.TimeoutExpired:
status = "timeout"
latency_ms = 15000
except Exception:
status = "offline"
health.append(
{
"profile": profile,
"current_model": current_model,
"provider": provider,
"status": status,
"latency_ms": latency_ms,
}
)
return {"data": health, "meta": {"request_id": str(uuid.uuid4())}}
# ── Endpoints: projects ──────────────────────────────────
@app.get("/api/v1/projects")
def list_projects() -> dict[str, Any]:
return {"data": projects_db, "meta": {"request_id": str(uuid.uuid4())}}
# ── Endpoints: memory ────────────────────────────────────
@app.get("/api/v1/memory/{key}")
def get_memory(key: str) -> dict[str, Any]:
if key != "global":
raise HTTPException(status_code=404, detail="Only key='global' is supported in MVP")
return {
"data": {"key": key, "content": read_memory()},
"meta": {"request_id": str(uuid.uuid4())},
}
@app.put("/api/v1/memory/{key}")
def put_memory(key: str, payload: MemoryPut) -> dict[str, Any]:
if key != "global":
raise HTTPException(status_code=404, detail="Only key='global' is supported in MVP")
write_memory(payload.content)
return {
"data": {"key": key, "content": payload.content, "updated_at": now_iso()},
"meta": {"request_id": str(uuid.uuid4())},
}
# ── Root / docs ──────────────────────────────────────────
@app.get("/api/v1/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}