agentic-os/server.py

556 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Agentic OS — FastAPI Backend
Multi-agent orchestration server for opencode, Hermes, Gemini CLI
"""
import argparse
import json
import os
import shutil
import subprocess
import tarfile
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
app = FastAPI(title="Agentic OS", version="1.0.0")
# CORS for local dev
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
BASE_DIR = Path(__file__).parent.resolve()
# ─── Models ───────────────────────────────────────────────────────
class BrainUpdate(BaseModel):
content: str
class SkillRunRequest(BaseModel):
input: Optional[str] = ""
agent: Optional[str] = "auto"
class ScheduleJobRequest(BaseModel):
name: str
skill: str
cron: str
enabled: bool = True
class SettingsUpdate(BaseModel):
settings: dict
class BackupRestoreRequest(BaseModel):
file: str
class ChatRequest(BaseModel):
agent: str
message: str
# ─── Helper Functions ─────────────────────────────────────────────
def read_file(path: Path):
if not path.exists():
return ""
return path.read_text(encoding="utf-8")
def write_file(path: Path, content: str):
path.write_text(content, encoding="utf-8")
return True
def list_dir(path: Path):
if not path.exists():
return []
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".")])
def get_timestamp():
return datetime.now(timezone.utc).isoformat()
def append_audit(entry: dict):
audit_file = BASE_DIR / "audit" / "audit.log"
entry["timestamp"] = get_timestamp()
entry["id"] = str(uuid.uuid4())[:8]
with open(audit_file, "a") as f:
f.write(json.dumps(entry) + "\n")
# ─── Agent Discovery (with cache) ─────────────────────────────────
_agent_cache = {}
_agent_cache_time = 0
def check_agent(name: str) -> dict:
global _agent_cache, _agent_cache_time
now = time.time()
if now - _agent_cache_time < 15 and name in _agent_cache:
return _agent_cache[name]
try:
if name == "opencode":
r = subprocess.run(["opencode", "--version"], capture_output=True, text=True, timeout=3)
status = "online" if r.returncode == 0 else "error"
elif name == "hermes":
r = subprocess.run(["hermes", "--version"], capture_output=True, text=True, timeout=3)
status = "online" if r.returncode == 0 else "error"
elif name == "gemini":
r = subprocess.run(["gemini", "--version"], capture_output=True, text=True, timeout=3)
status = "online" if r.returncode == 0 else "error"
else:
status = "offline"
except Exception:
status = "offline"
result = {"name": name, "status": status}
_agent_cache[name] = result
_agent_cache_time = now
return result
# ─── Routes: Status ───────────────────────────────────────────────
@app.get("/api/status")
def get_status():
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
skills = list_dir(BASE_DIR / "skills")
return {
"status": "healthy",
"agents": agents,
"skills_count": len(skills),
"uptime": time.time(),
}
# ─── Routes: Brain ────────────────────────────────────────────────
@app.get("/api/brain")
def list_brain():
files = list_dir(BASE_DIR / "brain")
brain_data = {}
for f in files:
path = BASE_DIR / "brain" / f
brain_data[f] = read_file(path)
return brain_data
@app.get("/api/brain/{file_name}")
def get_brain_file(file_name: str):
path = BASE_DIR / "brain" / file_name
if not path.exists() or path.is_dir():
raise HTTPException(404, "File not found")
return {"name": file_name, "content": read_file(path)}
@app.put("/api/brain/{file_name}")
def update_brain_file(file_name: str, data: BrainUpdate):
path = BASE_DIR / "brain" / file_name
write_file(path, data.content)
append_audit({"action": "brain_update", "file": file_name})
return {"status": "ok", "file": file_name}
# ─── Routes: Skills ───────────────────────────────────────────────
@app.get("/api/skills")
def list_skills():
skills = []
for d in sorted((BASE_DIR / "skills").iterdir()):
if d.is_dir() and not d.name.startswith("_"):
skill_md = read_file(d / "SKILL.md")
learnings = read_file(d / "learnings.md")
eval_data = {}
eval_path = d / "eval.json"
if eval_path.exists():
eval_data = json.loads(eval_path.read_text())
score_history = []
score_path = d / "score-history.json"
if score_path.exists():
score_history = json.loads(score_path.read_text())
skills.append({
"name": d.name,
"description": skill_md[:200] if skill_md else "",
"has_learnings": bool(learnings),
"eval_criteria": eval_data.get("criteria", []),
"scores": score_history,
})
return skills
@app.get("/api/skills/{name}")
def get_skill(name: str):
path = BASE_DIR / "skills" / name
if not path.exists():
raise HTTPException(404, "Skill not found")
return {
"name": name,
"skill": read_file(path / "SKILL.md"),
"learnings": read_file(path / "learnings.md"),
"eval": json.loads((path / "eval.json").read_text()) if (path / "eval.json").exists() else {},
"score_history": json.loads((path / "score-history.json").read_text()) if (path / "score-history.json").exists() else [],
"context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [],
}
@app.post("/api/skills/{name}/run")
def run_skill(name: str, req: Optional[SkillRunRequest] = None):
path = BASE_DIR / "skills" / name
if not path.exists():
raise HTTPException(404, "Skill not found")
agent_choice = req.agent if req else "auto"
skill_input = req.input if req else ""
# Determine which agent based on skill type
if agent_choice == "auto":
devops_keywords = ["devops", "audit", "deploy", "k8s", "gcp", "infra", "terraform"]
research_keywords = ["research", "synthesis", "analyze", "search", "compare"]
if any(k in name for k in devops_keywords):
agent_choice = "opencode"
elif any(k in name for k in research_keywords):
agent_choice = "gemini"
else:
agent_choice = "opencode"
# Log execution
run_id = str(uuid.uuid4())[:8]
append_audit({
"action": "skill_run",
"skill": name,
"agent": agent_choice,
"run_id": run_id,
})
return {
"status": "started",
"run_id": run_id,
"skill": name,
"agent": agent_choice,
"message": f"Skill '{name}' dispatched to {agent_choice}",
}
@app.get("/api/skills/{name}/eval")
def get_skill_eval(name: str):
path = BASE_DIR / "skills" / name / "score-history.json"
if not path.exists():
return {"scores": []}
return {"scores": json.loads(path.read_text())}
# ─── Routes: Scheduler ────────────────────────────────────────────
@app.get("/api/scheduler/jobs")
def list_jobs():
jobs_dir = BASE_DIR / "scheduler" / "jobs"
jobs = []
for f in sorted(jobs_dir.glob("*.json")):
jobs.append(json.loads(f.read_text()))
return jobs
@app.post("/api/scheduler/jobs")
def create_job(job: ScheduleJobRequest):
jobs_dir = BASE_DIR / "scheduler" / "jobs"
jobs_dir.mkdir(parents=True, exist_ok=True)
job_data = {
"id": str(uuid.uuid4())[:8],
"name": job.name,
"skill": job.skill,
"cron": job.cron,
"enabled": job.enabled,
"created": get_timestamp(),
"last_run": None,
"next_run": None,
}
(jobs_dir / f"{job.name.replace(' ', '_')}.json").write_text(
json.dumps(job_data, indent=2)
)
append_audit({"action": "job_created", "job": job.name})
return job_data
@app.delete("/api/scheduler/jobs/{job_id}")
def delete_job(job_id: str):
jobs_dir = BASE_DIR / "scheduler" / "jobs"
for f in jobs_dir.glob("*.json"):
data = json.loads(f.read_text())
if data.get("id") == job_id:
f.unlink()
append_audit({"action": "job_deleted", "job_id": job_id})
return {"status": "deleted"}
raise HTTPException(404, "Job not found")
# ─── Routes: Audit ────────────────────────────────────────────────
@app.get("/api/audit")
def get_audit(limit: int = Query(100, le=500)):
audit_file = BASE_DIR / "audit" / "audit.log"
if not audit_file.exists():
return {"entries": []}
lines = audit_file.read_text().strip().split("\n")
entries = [json.loads(l) for l in lines if l.strip()]
return {"entries": entries[-limit:]}
# ─── Routes: Cost Analytics ───────────────────────────────────────
@app.get("/api/cost")
def get_cost():
cost_file = BASE_DIR / "data" / "cost-history.json"
if not cost_file.exists():
return {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}
return json.loads(cost_file.read_text())
@app.post("/api/cost/record")
def record_cost(data: dict):
cost_file = BASE_DIR / "data" / "cost-history.json"
cost_data = json.loads(cost_file.read_text()) if cost_file.exists() else \
{"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}
cost_data["entries"].append({
"timestamp": get_timestamp(),
"agent": data.get("agent", "unknown"),
"tokens": data.get("tokens", 0),
"cost": data.get("cost", 0.0),
"model": data.get("model", "unknown"),
})
cost_file.write_text(json.dumps(cost_data, indent=2))
return {"status": "recorded"}
# ─── Routes: Registry/Plugins ─────────────────────────────────────
@app.get("/api/plugins")
def list_plugins():
reg_file = BASE_DIR / "registry" / "plugins.json"
if not reg_file.exists():
return {"plugins": []}
return json.loads(reg_file.read_text())
@app.post("/api/plugins/install")
def install_plugin(data: dict):
name = data.get("name", "").strip()
if not name:
raise HTTPException(400, "Plugin name required")
reg_file = BASE_DIR / "registry" / "plugins.json"
reg = json.loads(reg_file.read_text()) if reg_file.exists() else {"plugins": []}
if any(p["name"] == name for p in reg["plugins"]):
return {"status": "already_installed"}
reg["plugins"].append({
"name": name,
"installed": get_timestamp(),
"version": "1.0.0",
})
reg_file.write_text(json.dumps(reg, indent=2))
append_audit({"action": "plugin_installed", "plugin": name})
return {"status": "installed", "plugin": name}
# ─── Routes: Backup ───────────────────────────────────────────────
@app.get("/api/backups")
def list_backups():
backup_dir = BASE_DIR / "backups"
backups = []
for f in sorted(backup_dir.glob("*.tar.gz"), reverse=True):
backups.append({
"name": f.name,
"size": f.stat().st_size,
"created": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
return backups
@app.post("/api/backup")
def create_backup():
backup_dir = BASE_DIR / "backups"
backup_dir.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = backup_dir / f"agentic-os-{ts}.tar.gz"
with tarfile.open(backup_file, "w:gz") as tar:
for dir_name in ["brain", "skills", "agents", "registry", "standards", "prompts"]:
d = BASE_DIR / dir_name
if d.exists():
tar.add(d, arcname=dir_name)
append_audit({"action": "backup_created", "file": backup_file.name})
return {"status": "ok", "file": backup_file.name, "size": backup_file.stat().st_size}
@app.post("/api/backup/restore")
def restore_backup(data: BackupRestoreRequest):
backup_file = BASE_DIR / "backups" / data.file
if not backup_file.exists():
raise HTTPException(404, "Backup file not found")
with tarfile.open(backup_file, "r:gz") as tar:
tar.extractall(path=BASE_DIR)
append_audit({"action": "backup_restored", "file": data.file})
return {"status": "restored"}
# ─── Routes: Prompts ──────────────────────────────────────────────
@app.get("/api/prompts")
def list_prompts():
prompts_dir = BASE_DIR / "prompts"
prompts = {}
for f in sorted(prompts_dir.glob("*.md")):
prompts[f.stem] = read_file(f)
return prompts
# ─── Routes: Settings ─────────────────────────────────────────────
@app.get("/api/settings")
def get_settings():
sf = BASE_DIR / "data" / "settings.json"
if not sf.exists():
return {}
return json.loads(sf.read_text())
@app.put("/api/settings")
def update_settings(data: SettingsUpdate):
sf = BASE_DIR / "data" / "settings.json"
# Merge with existing
existing = json.loads(sf.read_text()) if sf.exists() else {}
existing.update(data.settings)
sf.write_text(json.dumps(existing, indent=2))
append_audit({"action": "settings_updated"})
return {"status": "ok"}
# ─── Routes: Standards ────────────────────────────────────────────
@app.get("/api/standards")
def list_standards():
std_dir = BASE_DIR / "standards"
if not std_dir.exists():
return {"standards": []}
standards = []
index_file = std_dir / "index.yml"
index_content = read_file(index_file)
for f in std_dir.glob("*.md"):
standards.append({
"name": f.stem,
"content": read_file(f),
})
return {"standards": standards, "index": index_content}
@app.post("/api/standards/discover")
def discover_standards():
# Stub: scans codebase for patterns
append_audit({"action": "standards_discovery_run"})
return {"status": "discovery_started", "message": "Scanning codebase for patterns..."}
# ─── Routes: Chat ─────────────────────────────────────────────────
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
def load_chat_history():
if CHAT_HISTORY_FILE.exists():
return json.loads(CHAT_HISTORY_FILE.read_text())
return {"messages": []}
def save_chat_message(msg: dict):
history = load_chat_history()
history["messages"].append(msg)
if len(history["messages"]) > 200:
history["messages"] = history["messages"][-200:]
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
def execute_agent(agent: str, message: str) -> str:
try:
if agent == "opencode":
r = subprocess.run(
["opencode", "run", message],
capture_output=True, text=True, timeout=12
)
out = (r.stdout or "") + (r.stderr or "")
if r.returncode == 0:
return out if out.strip() else f"✓ opencode received your message.\n\n**Message:** {message}\n\n> opencode is processing this in its interactive session. For a full response, run `opencode run \"{message[:60]}\"` in your terminal."
return out or f"opencode returned exit code {r.returncode}"
elif agent == "hermes":
r = subprocess.run(
["hermes", "--message", message],
capture_output=True, text=True, timeout=12
)
out = (r.stdout or "") + (r.stderr or "")
return out if out.strip() else f"✓ Dispatched to Hermes.\n\n**Message:** {message}"
elif agent == "gemini":
r = subprocess.run(
["gemini", "ask", message],
capture_output=True, text=True, timeout=30
)
out = (r.stdout or "") + (r.stderr or "")
return out if out.strip() else f"✓ Sent to Gemini CLI.\n\n**Message:** {message}"
else:
return f"Unknown agent: {agent}"
except subprocess.TimeoutExpired:
return f"⏱ Agent '{agent}' took too long to respond.\n\nYour message has been dispatched. For a full response, run the agent CLI directly in your terminal.\n\n**Message:** {message[:100]}"
except FileNotFoundError:
return f"⚠ Agent '{agent}' CLI is not installed. Run `pip install {agent}` or install it manually."
except Exception as e:
return f"⚠ Error communicating with {agent}: {str(e)}"
@app.post("/api/chat")
def chat(req: ChatRequest):
agent = req.agent.lower().strip()
if agent not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
user_msg = {
"id": str(uuid.uuid4())[:8],
"role": "user",
"agent": agent,
"content": req.message,
"timestamp": get_timestamp(),
}
save_chat_message(user_msg)
response_text = execute_agent(agent, req.message)
agent_msg = {
"id": str(uuid.uuid4())[:8],
"role": "assistant",
"agent": agent,
"content": response_text,
"timestamp": get_timestamp(),
}
save_chat_message(agent_msg)
append_audit({"action": "chat_message", "agent": agent, "msg_preview": req.message[:50]})
return {"status": "ok", "response": agent_msg}
@app.get("/api/chat/history")
def get_chat_history():
return load_chat_history()
# ─── Routes: Dashboard Static Files ──────────────────────────────
dashboard_dir = BASE_DIR / "dashboard"
if dashboard_dir.exists():
app.mount("/dashboard", StaticFiles(directory=str(dashboard_dir)), name="dashboard")
@app.get("/", response_class=HTMLResponse)
def index():
html_file = BASE_DIR / "dashboard" / "index.html"
if html_file.exists():
content = html_file.read_text()
content = content.replace('href="styles.css"', 'href="/dashboard/styles.css"')
content = content.replace('src="utils.js"', 'src="/dashboard/utils.js"')
content = content.replace('src="api.js"', 'src="/dashboard/api.js"')
content = content.replace('src="app.js"', 'src="/dashboard/app.js"')
content = content.replace('pages/', '/dashboard/pages/')
return HTMLResponse(content=content)
return HTMLResponse("<h1>Agentic OS</h1><p>Dashboard not built yet. Run <code>./install.sh</code> first.</p>")
# ─── Favicon ──────────────────────────────────────────────────────
FAVICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#6c5ce7"/><stop offset="100%" stop-color="#fd79a8"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><polygon points="16,6 24,11 24,21 16,26 8,21 8,11" fill="none" stroke="white" stroke-width="2" stroke-linejoin="round"/><circle cx="16" cy="16" r="3" fill="white"/></svg>'
@app.get("/favicon.ico")
def favicon():
return Response(content=FAVICON_SVG, media_type="image/svg+xml")
@app.get("/favicon.svg")
def favicon_svg():
return Response(content=FAVICON_SVG, media_type="image/svg+xml")
# ─── Main ─────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--host", type=str, default="127.0.0.1")
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)