"""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, Response 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 Response(status_code=204) 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 Response(status_code=204) @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)))