Files
agent-platform/agent_platform/api.py
T
implementation_engineer fcc9b22ec0
tests / pytest (push) Has been cancelled
chore: C2 DELETE 204+body, C4 cached_tokens, C5 utcnow + LICENSE (T011)
2026-07-06 07:33:38 +02:00

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, 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)))