feat: agent platform initial commit
- FastAPI + HTMX UI: dashboard, agents CRUD, chat, audit, tools - SQLite schema: agents, conversations, messages, audit, sessions - Code-agent sync (agents/*.py -> DB on startup) - MCP client with health check - Token auth (Bearer + session) - LiteLLM integration via llm.py - Docker Compose: agent-platform + mcp-tools services - Smoke tests pass: /health 200, /api/agents 200, / 401
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# === App ===
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
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)))
|
||||
Reference in New Issue
Block a user