359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""FastAPI: HTTP-API + UI für die Agent Platform.
|
|
|
|
Agents leben in der DB. CRUD über UI und API.
|
|
"""
|
|
import json
|
|
import logging
|
|
from typing import Optional, Dict, Any, List
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel, Field
|
|
import aiosqlite
|
|
|
|
from config import settings
|
|
from db import (
|
|
init_db,
|
|
list_agents, get_agent, upsert_agent, delete_agent, set_agent_enabled,
|
|
create_conversation, list_conversations, get_messages,
|
|
list_audit, log_audit,
|
|
)
|
|
from auth import get_current_user, require_admin
|
|
from mcp_client import get_mcp_client, close_mcp_client
|
|
from agent import sync_code_agents, run_agent_turn
|
|
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("api")
|
|
|
|
|
|
# === Pydantic Models ===
|
|
|
|
class AgentCreate(BaseModel):
|
|
id: str = Field(..., pattern=r'^[a-z0-9_-]+$', min_length=1, max_length=64)
|
|
name: str = Field(..., min_length=1)
|
|
description: str = ""
|
|
system_prompt: str = Field(..., min_length=1)
|
|
allowed_tools: List[str] = []
|
|
model: Optional[str] = None
|
|
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
|
max_tokens: int = Field(default=2000, ge=100, le=32000)
|
|
enabled: bool = True
|
|
|
|
|
|
class AgentUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
system_prompt: Optional[str] = None
|
|
allowed_tools: Optional[List[str]] = None
|
|
model: Optional[str] = None
|
|
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
|
max_tokens: Optional[int] = Field(default=None, ge=100, le=32000)
|
|
enabled: Optional[bool] = None
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
message: str = Field(..., min_length=1, max_length=32000)
|
|
|
|
|
|
# === App ===
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# B1 Boot Guard: refuse to start with default or weak AUTH_TOKEN
|
|
if settings.AUTH_TOKEN == "change-me-in-production" or len(settings.AUTH_TOKEN) < 32:
|
|
raise RuntimeError(
|
|
"AUTH_TOKEN env var is unset or weak (must be >=32 chars and not the default "
|
|
"'change-me-in-production'). Set a strong AUTH_TOKEN before starting the service."
|
|
)
|
|
Path(settings.DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
|
db = await aiosqlite.connect(settings.DB_PATH)
|
|
try:
|
|
await init_db()
|
|
await sync_code_agents(db)
|
|
finally:
|
|
await db.close()
|
|
_count_db = await aiosqlite.connect(settings.DB_PATH)
|
|
try:
|
|
agents = await list_agents(_count_db)
|
|
finally:
|
|
await _count_db.close()
|
|
logger.info(f"DB initialisiert. {len(agents)} Agent(s) verfügbar: {[a['id'] for a in agents]}")
|
|
yield
|
|
await close_mcp_client()
|
|
|
|
|
|
app = FastAPI(title="Agent Platform", version="0.3.0", lifespan=lifespan)
|
|
|
|
|
|
# === Static + Templates ===
|
|
|
|
BASE_DIR = Path(__file__).parent
|
|
TEMPLATES_DIR = BASE_DIR / "templates"
|
|
STATIC_DIR = BASE_DIR / "static"
|
|
|
|
if STATIC_DIR.exists():
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) if TEMPLATES_DIR.exists() else None
|
|
|
|
|
|
# === DB Dependency ===
|
|
|
|
async def get_db():
|
|
db = await aiosqlite.connect(settings.DB_PATH)
|
|
try:
|
|
yield db
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
# === Health ===
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
db = await aiosqlite.connect(settings.DB_PATH)
|
|
try:
|
|
agents = await list_agents(db)
|
|
finally:
|
|
await db.close()
|
|
mcp_ok = False
|
|
try:
|
|
mcp_ok = await get_mcp_client().health()
|
|
except BaseException:
|
|
pass
|
|
return {
|
|
"status": "ok",
|
|
"agents": len(agents),
|
|
"agent_ids": [a["id"] for a in agents],
|
|
"mcp_server": "reachable" if mcp_ok else "unreachable",
|
|
"llm_model": settings.LLM_MODEL,
|
|
}
|
|
|
|
|
|
# === Agents API ===
|
|
|
|
@app.get("/api/agents")
|
|
async def api_list_agents(db: aiosqlite.Connection = Depends(get_db), _: str = Depends(get_current_user)):
|
|
return await list_agents(db)
|
|
|
|
|
|
@app.post("/api/agents", status_code=201)
|
|
async def api_create_agent(
|
|
agent: AgentCreate,
|
|
user_id: str = Depends(require_admin),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
existing = await get_agent(db, agent.id)
|
|
if existing:
|
|
raise HTTPException(409, f"Agent '{agent.id}' existiert bereits")
|
|
await upsert_agent(db, agent.model_dump(), source="db")
|
|
await log_audit(db, agent_id=agent.id, user_id=user_id, action="agent_created", target=agent.id, args=agent.model_dump())
|
|
return await get_agent(db, agent.id)
|
|
|
|
|
|
@app.get("/api/agents/{agent_id}")
|
|
async def api_get_agent(agent_id: str, db: aiosqlite.Connection = Depends(get_db), _: str = Depends(get_current_user)):
|
|
agent = await get_agent(db, agent_id)
|
|
if not agent:
|
|
raise HTTPException(404, f"Agent '{agent_id}' nicht gefunden")
|
|
return agent
|
|
|
|
|
|
@app.put("/api/agents/{agent_id}")
|
|
async def api_update_agent(
|
|
agent_id: str,
|
|
update: AgentUpdate,
|
|
user_id: str = Depends(require_admin),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
existing = await get_agent(db, agent_id)
|
|
if not existing:
|
|
raise HTTPException(404)
|
|
merged = {**existing}
|
|
for k, v in update.model_dump(exclude_unset=True).items():
|
|
merged[k] = v
|
|
await upsert_agent(db, merged, source=existing.get("source", "db"))
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_updated", target=agent_id, args=update.model_dump(exclude_unset=True))
|
|
return await get_agent(db, agent_id)
|
|
|
|
|
|
@app.delete("/api/agents/{agent_id}", status_code=204)
|
|
async def api_delete_agent(
|
|
agent_id: str,
|
|
user_id: str = Depends(require_admin),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
agent = await get_agent(db, agent_id)
|
|
if not agent:
|
|
raise HTTPException(404)
|
|
if agent.get("source") == "code":
|
|
# Code-Agents: nur deaktivieren, damit sie beim nächsten Sync wieder da sind
|
|
await set_agent_enabled(db, agent_id, False)
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_disabled_via_delete", target=agent_id)
|
|
return {"status": "disabled", "note": "Code-Agents werden nicht gelöscht, nur deaktiviert"}
|
|
await delete_agent(db, agent_id)
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_deleted", target=agent_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@app.post("/api/agents/{agent_id}/enable")
|
|
async def api_enable_agent(agent_id: str, user_id: str = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)):
|
|
if not await get_agent(db, agent_id):
|
|
raise HTTPException(404)
|
|
await set_agent_enabled(db, agent_id, True)
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_enabled", target=agent_id)
|
|
return {"status": "enabled"}
|
|
|
|
|
|
@app.post("/api/agents/{agent_id}/disable")
|
|
async def api_disable_agent(agent_id: str, user_id: str = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)):
|
|
if not await get_agent(db, agent_id):
|
|
raise HTTPException(404)
|
|
await set_agent_enabled(db, agent_id, False)
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_disabled", target=agent_id)
|
|
return {"status": "disabled"}
|
|
|
|
|
|
# === Chat API ===
|
|
|
|
@app.post("/api/chat/{agent_id}")
|
|
async def api_new_chat(
|
|
agent_id: str,
|
|
msg: ChatMessage,
|
|
user_id: str = Depends(get_current_user),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
agent = await get_agent(db, agent_id)
|
|
if not agent:
|
|
raise HTTPException(404)
|
|
if not agent["enabled"]:
|
|
raise HTTPException(403, f"Agent '{agent_id}' ist deaktiviert")
|
|
conv_id = await create_conversation(db, agent_id, user_id=user_id, title=msg.message[:50])
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="chat_started", target=agent_id)
|
|
try:
|
|
result = await run_agent_turn(db, agent_id, user_id, msg.message, conv_id)
|
|
except Exception as e:
|
|
logger.exception("Agent run failed")
|
|
await log_audit(db, agent_id=agent_id, user_id=user_id, action="chat_failed", target=agent_id, result={"error": str(e)})
|
|
raise HTTPException(500, f"Agent-Fehler: {e}")
|
|
return {"conversation_id": conv_id, **result}
|
|
|
|
|
|
@app.post("/api/chat/{agent_id}/{conv_id}")
|
|
async def api_continue_chat(
|
|
agent_id: str,
|
|
conv_id: str,
|
|
msg: ChatMessage,
|
|
user_id: str = Depends(get_current_user),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
return await run_agent_turn(db, agent_id, user_id, msg.message, conv_id)
|
|
|
|
|
|
@app.get("/api/chat/{conv_id}/messages")
|
|
async def api_get_messages(conv_id: str, _: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
|
rows = await get_messages(db, conv_id)
|
|
return [{"role": r[0], "content": r[1], "tool_calls": r[2]} for r in rows]
|
|
|
|
|
|
@app.get("/api/conversations")
|
|
async def api_list_conversations(
|
|
agent_id: Optional[str] = None,
|
|
_: str = Depends(get_current_user),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
rows = await list_conversations(db, agent_id=agent_id)
|
|
return [{"id": r[0], "agent_id": r[1], "title": r[2], "created_at": r[3]} for r in rows]
|
|
|
|
|
|
# === Tools API ===
|
|
|
|
@app.get("/api/tools")
|
|
async def api_list_tools(_: str = Depends(get_current_user)):
|
|
try:
|
|
tools = await get_mcp_client().list_tools()
|
|
return {"mcp_reachable": True, "tools": tools}
|
|
except Exception as e:
|
|
return {"mcp_reachable": False, "error": str(e), "tools": []}
|
|
|
|
|
|
# === Audit API ===
|
|
|
|
@app.get("/api/audit")
|
|
async def api_audit(
|
|
agent_id: Optional[str] = None,
|
|
limit: int = 100,
|
|
_: str = Depends(require_admin),
|
|
db: aiosqlite.Connection = Depends(get_db),
|
|
):
|
|
rows = await list_audit(db, agent_id=agent_id, limit=limit)
|
|
return [{"timestamp": r[0], "agent_id": r[1], "action": r[2], "target": r[3], "duration_ms": r[4]} for r in rows]
|
|
|
|
|
|
# === UI Routes ===
|
|
|
|
def _ctx(request: Request, user_id: str, **extra) -> dict:
|
|
return {"request": request, "user_id": user_id, **extra}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def ui_dashboard(request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
|
agents = await list_agents(db)
|
|
return templates.TemplateResponse("dashboard.html", _ctx(request, user_id, agents=agents))
|
|
|
|
|
|
@app.get("/agents", response_class=HTMLResponse)
|
|
async def ui_agents(request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
|
agents = await list_agents(db)
|
|
return templates.TemplateResponse("agents.html", _ctx(request, user_id, agents=agents))
|
|
|
|
|
|
@app.get("/agents/new", response_class=HTMLResponse)
|
|
async def ui_agent_new(request: Request, user_id: str = Depends(require_admin)):
|
|
return templates.TemplateResponse("agent_form.html", _ctx(request, user_id, agent=None))
|
|
|
|
|
|
@app.get("/agents/{agent_id}", response_class=HTMLResponse)
|
|
async def ui_agent_detail(agent_id: str, request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
|
agent = await get_agent(db, agent_id)
|
|
if not agent:
|
|
raise HTTPException(404)
|
|
# verfügbare Tools vom MCP-Server
|
|
available_tools = []
|
|
try:
|
|
available_tools = await get_mcp_client().list_tools()
|
|
except Exception:
|
|
pass
|
|
return templates.TemplateResponse("agent_form.html", _ctx(request, user_id, agent=agent, available_tools=available_tools))
|
|
|
|
|
|
@app.get("/chat/{conv_id}", response_class=HTMLResponse)
|
|
async def ui_chat(conv_id: str, request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
|
rows = await get_messages(db, conv_id)
|
|
messages = [{"role": r[0], "content": r[1]} for r in rows]
|
|
return templates.TemplateResponse("chat.html", _ctx(request, user_id, conv_id=conv_id, messages=messages))
|
|
|
|
|
|
@app.get("/audit", response_class=HTMLResponse)
|
|
async def ui_audit(request: Request, user_id: str = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)):
|
|
rows = await list_audit(db, limit=200)
|
|
entries = [{"timestamp": r[0], "agent_id": r[1], "action": r[2], "target": r[3], "duration_ms": r[4]} for r in rows]
|
|
return templates.TemplateResponse("audit.html", _ctx(request, user_id, entries=entries))
|
|
|
|
|
|
@app.get("/tools", response_class=HTMLResponse)
|
|
async def ui_tools(request: Request, user_id: str = Depends(get_current_user)):
|
|
try:
|
|
tools = await get_mcp_client().list_tools()
|
|
return templates.TemplateResponse("tools.html", _ctx(request, user_id, tools=tools, reachable=True))
|
|
except Exception as e:
|
|
return templates.TemplateResponse("tools.html", _ctx(request, user_id, tools=[], reachable=False, error=str(e)))
|