252 lines
8 KiB
Python
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"}
|