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:
Leopold
2026-07-05 22:18:00 +02:00
commit 64b840c94c
30 changed files with 2690 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# LLM Configuration
LLM_PROVIDER=openrouter
LLM_API_KEY=sk-or-v1-xxx
LLM_MODEL=anthropic/claude-3.5-sonnet
LLM_API_BASE=
# Auth (CHANGE THIS!)
AUTH_TOKEN=change-me-to-something-secure
# Logging
LOG_LEVEL=info
# Limits
MAX_HISTORY_MESSAGES=10
MAX_PARALLEL_AGENTS=5
AGENT_IDLE_TIMEOUT_SEC=300
MCP_TIMEOUT=10
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.db
.venv/
.env
*.log
.pytest_cache/
+93
View File
@@ -0,0 +1,93 @@
# Agent Platform
Eine schlanke, business-taugliche Agent-Plattform mit LiteLLM, Pydantic AI und MCP.
## Architektur
```
┌─────────────────────────────────────────────┐
│ agent-platform (Port 8000) │
│ - FastAPI + LiteLLM + Pydantic AI │
│ - HTMX-UI (kein JS-Build) │
│ - SQLite │
│ - Audit-Log, Auth │
└──────────────────┬──────────────────────────┘
│ MCP (HTTP)
┌─────────────────────────────────────────────┐
│ mcp-tools (Port 8501, intern) │
│ - fastmcp │
│ - 6 generische Tools │
└─────────────────────────────────────────────┘
```
## Quickstart
```bash
# 1. Env-Datei anlegen
cp .env.example .env
# .env editieren: LLM_API_KEY und AUTH_TOKEN setzen
# 2. Starten
docker compose up -d --build
# 3. UI öffnen
http://localhost:8000
# 4. Ersten Agent anlegen
# Im UI: Agents → "Neuen Agent erstellen"
# System-Prompt: "Du bist ein hilfreicher Assistent."
# Erlaubte Tools: "echo, calculate, get_time"
```
## API
```bash
# Health
curl http://localhost:8000/health
# Agents
curl -H "Authorization: Bearer $AUTH_TOKEN" http://localhost:8000/api/agents
# Chat
curl -X POST -H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "Hallo!"}' \
http://localhost:8000/api/chat/general_assistant
# MCP-Tools
curl http://localhost:8000/api/tools
```
## Verfügbare MCP-Tools
- `echo(text)` - Echo-Tool
- `get_time(timezone_name)` - Aktuelle Zeit
- `calculate(expression)` - Mathe-Ausdruck (sicher)
- `http_get(url)` - HTTP-Request (SSRF-Schutz)
- `list_env(prefix)` - Umgebungsvariablen
- `json_format(data)` - JSON formatieren
## Konfiguration
Alle Env-Vars sind in `.env.example` dokumentiert.
## Entwicklung
```bash
# Backend lokal starten (ohne Docker)
cd agent_platform
pip install -e .
LLM_API_KEY=sk-xxx uvicorn api:app --reload
# MCP-Server lokal
cd mcp_tools
pip install -r requirements.txt
python server.py
```
## Ressourcen
- agent-platform: 256 MB RAM, 0.5 CPU
- mcp-tools: 128 MB RAM, 0.25 CPU
- SQLite: 5-10 MB
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir .
COPY . .
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
+183
View File
@@ -0,0 +1,183 @@
"""Agent-Runner: führt einen Agent-Turn aus.
Agents leben in der DB (Tabelle `agents`).
Beim Start werden Code-Agents aus agents/*.py in DB gesynct (source='code').
"""
import json
import time
import logging
from typing import Dict, Any, List
import aiosqlite
from db import get_agent, add_message, get_messages, touch_conversation
from llm import chat
from mcp_client import get_mcp_client
logger = logging.getLogger("agent")
async def sync_code_agents(db: aiosqlite.Connection):
"""Lädt Code-Agents aus agents/*.py und synct sie in die DB."""
from agents import discover_agents
for instance in discover_agents().values():
cfg = instance.config
existing = await get_agent(db, cfg.id)
if existing:
# Update nur Code-Felder, behalte User-Overrides (enabled, custom)
merged = {
"id": cfg.id,
"name": cfg.name,
"description": cfg.description,
"system_prompt": cfg.system_prompt,
"allowed_tools": cfg.allowed_tools,
"model": cfg.model,
"temperature": cfg.temperature,
"max_tokens": cfg.max_tokens,
"enabled": existing.get("enabled", cfg.enabled),
}
await db.execute(
"""UPDATE agents SET
name=?, description=?, system_prompt=?, allowed_tools=?,
model=?, temperature=?, max_tokens=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
merged["name"], merged["description"], merged["system_prompt"],
json.dumps(merged["allowed_tools"]),
merged["model"], merged["temperature"], merged["max_tokens"],
cfg.id,
)
)
else:
await db.execute(
"""INSERT INTO agents (id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'code')""",
(
cfg.id, cfg.name, cfg.description, cfg.system_prompt,
json.dumps(cfg.allowed_tools),
cfg.model, cfg.temperature, cfg.max_tokens,
int(cfg.enabled),
)
)
await db.commit()
async def run_agent_turn(
db: aiosqlite.Connection,
agent_id: str,
user_id: str,
user_message: str,
conversation_id: str,
) -> Dict[str, Any]:
"""Führt einen Agent-Turn aus: User-Message → LLM → optional Tool-Calls → Antwort."""
agent = await get_agent(db, agent_id)
if not agent:
raise ValueError(f"Agent '{agent_id}' nicht gefunden")
if not agent["enabled"]:
raise ValueError(f"Agent '{agent_id}' ist deaktiviert")
# User-Message speichern
await add_message(db, conversation_id, "user", user_message)
# History laden (letzte 20 Messages)
history = await get_messages(db, conversation_id, limit=20)
messages: List[dict] = [{"role": "system", "content": agent["system_prompt"]}]
for row in history[:-1]: # ohne die gerade gespeicherte user-Message
role, content, tool_calls_json, _, _ = row
if role == "tool":
continue
msg = {"role": role, "content": content or ""}
if tool_calls_json:
try:
msg["tool_calls"] = json.loads(tool_calls_json)
except Exception:
pass
messages.append(msg)
# MCP-Tools laden (gefiltert nach allowed_tools)
mcp_client = get_mcp_client()
tools = []
try:
all_tools = await mcp_client.get_tools_for_llm()
if agent["allowed_tools"]:
tools = [t for t in all_tools if t.get("function", {}).get("name") in agent["allowed_tools"]]
else:
tools = all_tools
except Exception as e:
logger.warning(f"MCP-Tools konnten nicht geladen werden: {e}")
# LLM-Loop
MAX_ITER = 10
iterations = 0
total_input = 0
total_output = 0
final_content = ""
while iterations < MAX_ITER:
iterations += 1
kwargs = {}
if agent["model"]:
kwargs["model"] = agent["model"]
start = time.time()
response = await chat(
messages=messages,
tools=tools or None,
temperature=agent["temperature"],
max_tokens=agent["max_tokens"],
**kwargs,
)
duration_ms = int((time.time() - start) * 1000)
total_input += response.input_tokens
total_output += response.output_tokens
assistant_msg = {"role": "assistant", "content": response.content}
if response.tool_calls:
assistant_msg["tool_calls"] = [
{
"id": tc["id"],
"type": "function",
"function": {"name": tc["name"], "arguments": tc["arguments"]},
}
for tc in response.tool_calls
]
messages.append(assistant_msg)
if not response.tool_calls:
final_content = response.content
break
for tc in response.tool_calls:
if agent["allowed_tools"] and tc["name"] not in agent["allowed_tools"]:
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": f"Tool '{tc['name']}' ist nicht erlaubt",
})
continue
try:
args = json.loads(tc["arguments"]) if isinstance(tc["arguments"], str) else tc["arguments"]
except Exception:
args = {}
try:
result = await mcp_client.call_tool(tc["name"], args)
content_str = json.dumps(result, default=str)[:8000]
except Exception as e:
content_str = f"Tool-Fehler: {e}"
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": content_str})
# Assistant-Message speichern
await add_message(
db, conversation_id, "assistant", final_content,
tool_calls=response.tool_calls if (response and response.tool_calls) else None,
token_count=total_input + total_output,
)
await touch_conversation(db, conversation_id)
return {
"content": final_content,
"iterations": iterations,
"input_tokens": total_input,
"output_tokens": total_output,
"duration_ms": duration_ms,
}
+54
View File
@@ -0,0 +1,54 @@
"""Agent-Loader: scannt agents/ Ordner und lädt alle BaseAgent-Subklassen."""
import importlib
import inspect
import logging
from pathlib import Path
from typing import Dict, List
from agents.base import BaseAgent, AgentConfig
logger = logging.getLogger("agents.loader")
def discover_agents() -> Dict[str, BaseAgent]:
"""Scannt das agents/ Verzeichnis und gibt alle Agent-Instanzen zurück."""
agents: Dict[str, BaseAgent] = {}
agents_dir = Path(__file__).parent
for py_file in agents_dir.glob("*.py"):
if py_file.name.startswith("_") or py_file.name in ("base.py", "__init__.py"):
continue
module_name = f"agents.{py_file.stem}"
try:
module = importlib.import_module(module_name)
except Exception as e:
logger.warning(f"Konnte {module_name} nicht laden: {e}")
continue
for name, obj in inspect.getmembers(module, inspect.isclass):
if obj is BaseAgent:
continue
if issubclass(obj, BaseAgent) and obj.__module__ == module_name:
try:
instance = obj()
if hasattr(instance, "config"):
agents[instance.config.id] = instance
logger.info(f"Agent geladen: {instance.config.id} ({instance.config.name})")
except Exception as e:
logger.warning(f"Konnte Agent {name} aus {module_name} nicht instanziieren: {e}")
return agents
def get_agent(agent_id: str) -> BaseAgent:
"""Holt einen Agent by ID."""
agents = discover_agents()
if agent_id not in agents:
raise KeyError(f"Agent '{agent_id}' nicht gefunden. Verfügbar: {list(agents.keys())}")
return agents[agent_id]
def list_agents() -> List[AgentConfig]:
"""Gibt Configs aller Agents zurück."""
return [a.config for a in discover_agents().values()]
+37
View File
@@ -0,0 +1,37 @@
"""Pydantic-Schema für hardcoded Agents.
Beispiel:
# agents/recherche.py
from agents.base import BaseAgent, AgentConfig
class RechercheAgent(BaseAgent):
config = AgentConfig(
id="recherche",
name="Recherche-Assistent",
description="Recherchiert Themen und fasst zusammen.",
system_prompt="Du bist ein Recherche-Assistent...",
allowed_tools=["http_get"],
)
"""
from abc import ABC
from typing import List, Optional
from pydantic import BaseModel, Field
class AgentConfig(BaseModel):
"""Type-safe Definition eines hardcoded Agents."""
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] = []
enabled: bool = True
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)
class BaseAgent(ABC):
"""Marker-Base für Code-Agents. Setze `config` als Class-Variable."""
config: AgentConfig
+26
View File
@@ -0,0 +1,26 @@
"""Beispiel-Agent: zeigt wie ein Agent definiert wird."""
from agents.base import BaseAgent, AgentConfig
class ExampleAgent(BaseAgent):
"""Generischer Beispiel-Agent mit Tool-Zugriff."""
config = AgentConfig(
id="example",
name="Beispiel-Agent",
description="Demonstriert einen hardcoded Agent mit Tool-Zugriff.",
system_prompt="""Du bist ein hilfreicher KI-Assistent.
Du hast Zugriff auf folgende Tools:
- echo: Gibt einen Text zurück
- get_time: Aktuelle Zeit
- calculate: Mathematische Berechnungen
- http_get: HTTP-Requests
- list_env: Umgebungsvariablen
- json_format: JSON formatieren
Antworte immer auf Deutsch, präzise und freundlich.
Wenn du etwas nicht weißt, sage es ehrlich.""",
allowed_tools=["echo", "get_time", "calculate", "http_get", "list_env", "json_format"],
enabled=True,
)
+352
View File
@@ -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)))
+53
View File
@@ -0,0 +1,53 @@
"""Audit-Helper: strukturiertes Logging aller Aktionen."""
import logging
import json
from datetime import datetime
from typing import Optional, Dict, Any
import aiosqlite
from config import settings
from db import log_audit, get_db
logger = logging.getLogger("audit")
async def record(
db: aiosqlite.Connection,
agent_id: Optional[str],
user_id: str,
action: str,
target: Optional[str] = None,
args: Optional[Dict[str, Any]] = None,
result: Optional[Dict[str, Any]] = None,
duration_ms: Optional[int] = None,
) -> None:
"""Schreibt einen Audit-Eintrag in DB + Logger."""
await log_audit(
db=db,
agent_id=agent_id,
user_id=user_id,
action=action,
target=target,
args=args,
result=result,
duration_ms=duration_ms,
)
log_entry = {
"ts": datetime.utcnow().isoformat(),
"agent_id": agent_id,
"user_id": user_id,
"action": action,
"target": target,
"args": args,
"result": result,
"duration_ms": duration_ms,
}
logger.info(json.dumps(log_entry, default=str))
async def get_recent(db: aiosqlite.Connection, limit: int = 100, agent_id: Optional[str] = None):
"""Holt die letzten Audit-Einträge."""
from db import list_audit
return await list_audit(db, agent_id=agent_id, limit=limit)
+36
View File
@@ -0,0 +1,36 @@
"""Token-basierte Auth."""
from fastapi import Header, HTTPException, Depends
from typing import Optional
import aiosqlite
from config import settings
from db import validate_session, get_db
async def get_current_user(
authorization: Optional[str] = Header(None),
db: aiosqlite.Connection = Depends(get_db),
) -> str:
"""Validiert Token und gibt User-ID zurück."""
if not authorization:
raise HTTPException(status_code=401, detail="Missing Authorization header")
token = authorization.replace("Bearer ", "").strip()
# Static-Auth-Token als Master-Key (für Setup/Bootstrap)
if token == settings.AUTH_TOKEN:
return "admin"
# Session-Token aus DB
user_id = await validate_session(db, token)
if not user_id:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user_id
async def require_admin(user_id: str = Depends(get_current_user)) -> str:
"""Erfordert Admin-User."""
if user_id != "admin":
raise HTTPException(status_code=403, detail="Admin required")
return user_id
+33
View File
@@ -0,0 +1,33 @@
"""Settings via Environment-Variablen."""
import os
class Settings:
# LLM
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "openrouter")
LLM_API_KEY: str = os.getenv("LLM_API_KEY", "")
LLM_MODEL: str = os.getenv("LLM_MODEL", "anthropic/claude-3.5-sonnet")
LLM_API_BASE: str = os.getenv("LLM_API_BASE", "")
# MCP Tool Server
MCP_SERVER_URL: str = os.getenv("MCP_SERVER_URL", "http://mcp-tools:8501/mcp")
MCP_TIMEOUT: int = int(os.getenv("MCP_TIMEOUT", "10"))
# Auth
AUTH_TOKEN: str = os.getenv("AUTH_TOKEN", "change-me-in-production")
# Storage
DB_PATH: str = os.getenv("DB_PATH", "/data/agent-platform.db")
# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "info")
# Limits
MAX_HISTORY_MESSAGES: int = int(os.getenv("MAX_HISTORY_MESSAGES", "10"))
MAX_PARALLEL_AGENTS: int = int(os.getenv("MAX_PARALLEL_AGENTS", "5"))
AGENT_IDLE_TIMEOUT_SEC: int = int(os.getenv("AGENT_IDLE_TIMEOUT_SEC", "300"))
settings = Settings()
+289
View File
@@ -0,0 +1,289 @@
"""SQLite-Layer: Chats, Messages, Audit, Sessions, Agents.
Agents können sein:
- source='code': aus agents/*.py, DB ist Override
- source='db': nur in DB, via API/UI angelegt
"""
import aiosqlite
import json
import uuid
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from config import settings
SCHEMA = """
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
system_prompt TEXT NOT NULL,
allowed_tools TEXT DEFAULT '[]',
model TEXT,
temperature REAL DEFAULT 0.7,
max_tokens INTEGER DEFAULT 2000,
enabled INTEGER DEFAULT 1,
source TEXT DEFAULT 'db',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
user_id TEXT,
title TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_calls TEXT,
tool_results TEXT,
token_count INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
agent_id TEXT,
user_id TEXT,
action TEXT,
target TEXT,
args TEXT,
result TEXT,
duration_ms INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit(agent_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit(timestamp);
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
"""
async def init_db():
"""Erstellt DB und Schema."""
Path(settings.DB_PATH).parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(settings.DB_PATH) as db:
await db.executescript(SCHEMA)
await db.commit()
async def get_db_connection():
"""Gibt eine aiosqlite-Connection zurück."""
return await aiosqlite.connect(settings.DB_PATH)
async def get_db():
"""FastAPI-Dependency: aiosqlite-Connection pro Request, mit automatischem Cleanup."""
db = await aiosqlite.connect(settings.DB_PATH)
try:
yield db
finally:
await db.close()
# === Agents ===
def _row_to_agent(row):
return {
"id": row[0],
"name": row[1],
"description": row[2] or "",
"system_prompt": row[3],
"allowed_tools": json.loads(row[4]) if row[4] else [],
"model": row[5],
"temperature": row[6] if row[6] is not None else 0.7,
"max_tokens": row[7] if row[7] is not None else 2000,
"enabled": bool(row[8]),
"source": row[9] or "db",
"created_at": row[10],
"updated_at": row[11],
}
async def upsert_agent(db, agent: dict, source: str = "db"):
"""Insert oder Update. Code-Agents werden beim ersten Start hier gesynct."""
await db.execute(
"""INSERT INTO agents (id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
description=excluded.description,
system_prompt=excluded.system_prompt,
allowed_tools=excluded.allowed_tools,
model=excluded.model,
temperature=excluded.temperature,
max_tokens=excluded.max_tokens,
enabled=excluded.enabled,
updated_at=CURRENT_TIMESTAMP""",
(
agent["id"],
agent["name"],
agent.get("description", ""),
agent["system_prompt"],
json.dumps(agent.get("allowed_tools", [])),
agent.get("model"),
agent.get("temperature", 0.7),
agent.get("max_tokens", 2000),
int(agent.get("enabled", True)),
source,
)
)
await db.commit()
async def get_agent(db, agent_id: str):
async with db.execute(
"SELECT id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source, created_at, updated_at FROM agents WHERE id = ?",
(agent_id,)
) as cur:
row = await cur.fetchone()
return _row_to_agent(row) if row else None
async def list_agents(db, enabled_only=False):
query = "SELECT id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source, created_at, updated_at FROM agents"
params = []
if enabled_only:
query += " WHERE enabled = 1"
query += " ORDER BY id ASC"
async with db.execute(query, params) as cur:
return [_row_to_agent(row) for row in await cur.fetchall()]
async def delete_agent(db, agent_id: str) -> bool:
"""Löscht einen Agent. Code-Agents können gelöscht werden, sind dann bis zum nächsten Sync weg."""
async with db.execute("DELETE FROM agents WHERE id = ?", (agent_id,)) as cur:
await db.commit()
return cur.rowcount > 0
async def set_agent_enabled(db, agent_id: str, enabled: bool):
await db.execute(
"UPDATE agents SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(int(enabled), agent_id)
)
await db.commit()
# === Conversations ===
async def create_conversation(db, agent_id, user_id="default", title="Neuer Chat"):
conv_id = str(uuid.uuid4())
await db.execute(
"INSERT INTO conversations (id, agent_id, user_id, title) VALUES (?, ?, ?, ?)",
(conv_id, agent_id, user_id, title)
)
await db.commit()
return conv_id
async def list_conversations(db, agent_id=None, user_id="default", limit=20):
query = "SELECT id, agent_id, title, created_at FROM conversations WHERE user_id = ?"
params = [user_id]
if agent_id:
query += " AND agent_id = ?"
params.append(agent_id)
query += " ORDER BY updated_at DESC LIMIT ?"
params.append(limit)
async with db.execute(query, params) as cur:
return await cur.fetchall()
async def touch_conversation(db, conv_id):
await db.execute(
"UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(conv_id,)
)
await db.commit()
# === Messages ===
async def add_message(db, conversation_id, role, content, tool_calls=None, tool_results=None, token_count=0):
await db.execute(
"""INSERT INTO messages (conversation_id, role, content, tool_calls, tool_results, token_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(conversation_id, role, content,
json.dumps(tool_calls) if tool_calls else None,
json.dumps(tool_results) if tool_results else None,
token_count)
)
await db.commit()
async def get_messages(db, conversation_id, limit=None):
query = "SELECT role, content, tool_calls, tool_results, token_count FROM messages WHERE conversation_id = ? ORDER BY id ASC"
params = [conversation_id]
if limit:
query += " LIMIT ?"
params.append(limit)
async with db.execute(query, params) as cur:
return await cur.fetchall()
# === Audit ===
async def log_audit(db, agent_id, user_id, action, target=None, args=None, result=None, duration_ms=None):
await db.execute(
"""INSERT INTO audit (agent_id, user_id, action, target, args, result, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(agent_id, user_id, action, target,
json.dumps(args) if args else None,
json.dumps(result) if result else None,
duration_ms)
)
await db.commit()
async def list_audit(db, agent_id=None, limit=100):
query = "SELECT timestamp, agent_id, action, target, duration_ms FROM audit"
params = []
if agent_id:
query += " WHERE agent_id = ?"
params.append(agent_id)
query += " ORDER BY id DESC LIMIT ?"
params.append(limit)
async with db.execute(query, params) as cur:
return await cur.fetchall()
# === Sessions ===
async def create_session(db, user_id="default", ttl_hours=24):
token = secrets.token_urlsafe(32)
expires = (datetime.utcnow() + timedelta(hours=ttl_hours)).isoformat()
await db.execute(
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
(token, user_id, expires)
)
await db.commit()
return token
async def validate_session(db, token):
async with db.execute(
"SELECT user_id, expires_at FROM sessions WHERE token = ?",
(token,)
) as cur:
row = await cur.fetchone()
if not row:
return None
if row[1] and datetime.fromisoformat(row[1]) < datetime.utcnow():
return None
return row[0]
+86
View File
@@ -0,0 +1,86 @@
"""LiteLLM-Wrapper mit Token-Tracking."""
import litellm
from typing import List, Dict, Optional
from config import settings
if settings.LLM_API_KEY:
litellm.api_key = settings.LLM_API_KEY
if settings.LLM_API_BASE:
litellm.api_base = settings.LLM_API_BASE
class LLMResponse:
def __init__(self, content: str, tool_calls: Optional[List[Dict]] = None, usage: Optional[Dict] = None):
self.content = content
self.tool_calls = tool_calls or []
self.usage = usage or {}
@property
def input_tokens(self) -> int:
return self.usage.get("prompt_tokens", 0)
@property
def output_tokens(self) -> int:
return self.usage.get("completion_tokens", 0)
@property
def total_tokens(self) -> int:
return self.input_tokens + self.output_tokens
@property
def finish_reason(self) -> str:
return self.usage.get("finish_reason", "stop")
async def chat(
messages: List[Dict[str, str]],
tools: Optional[List[Dict]] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> LLMResponse:
"""LLM-Call via LiteLLM.
messages: Liste von {role, content} Dicts.
tools: Optional, MCP-Tool-Definitionen für Function-Calling.
"""
model = model or settings.LLM_MODEL
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if tools:
kwargs["tools"] = tools
response = await litellm.acompletion(**kwargs)
message = response.choices[0].message
tool_calls = []
if hasattr(message, "tool_calls") and message.tool_calls:
for tc in message.tool_calls:
tool_calls.append({
"id": tc.id,
"name": tc.function.name,
"arguments": tc.function.arguments,
})
usage = {}
if hasattr(response, "usage") and response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens or 0,
"completion_tokens": response.usage.completion_tokens or 0,
"total_tokens": response.usage.total_tokens or 0,
"finish_reason": response.choices[0].finish_reason or "stop",
}
return LLMResponse(
content=message.content or "",
tool_calls=tool_calls,
usage=usage,
)
+98
View File
@@ -0,0 +1,98 @@
"""MCP-Client: Verbindung zum Tool-Server."""
from typing import List, Dict, Any, Optional
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from config import settings
class MCPClient:
def __init__(self, server_url: str = None):
self.server_url = server_url or settings.MCP_SERVER_URL
self._session: Optional[ClientSession] = None
self._streams = None
self._ctx = None
async def connect(self):
"""Stellt Verbindung zum MCP-Server her (lazy + reconnect-fähig)."""
if self._session:
return
self._ctx = streamablehttp_client(url=self.server_url)
read, write, _ = await self._ctx.__aenter__()
self._session = ClientSession(read, write)
await self._session.__aenter__()
await self._session.initialize()
async def disconnect(self):
if self._session:
try:
await self._session.__aexit__(None, None, None)
except Exception:
pass
if self._ctx:
try:
await self._ctx.__aexit__(None, None, None)
except Exception:
pass
self._session = None
self._ctx = None
async def list_tools(self) -> List[Dict[str, Any]]:
await self.connect()
result = await self._session.list_tools()
tools = []
for tool in result.tools:
t = tool.model_dump() if hasattr(tool, "model_dump") else tool
tools.append(t)
return tools
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
await self.connect()
result = await self._session.call_tool(name, arguments)
return result.model_dump() if hasattr(result, "model_dump") else result
async def get_tools_for_llm(self, allowed: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Konvertiert MCP-Tools in LiteLLM-Tool-Format.
Optional: Filter auf erlaubte Tool-Namen.
"""
tools = await self.list_tools()
result = []
for tool in tools:
name = tool.get("name")
if allowed and name not in allowed:
continue
result.append({
"type": "function",
"function": {
"name": name,
"description": tool.get("description", ""),
"parameters": tool.get("inputSchema", {"type": "object", "properties": {}}),
},
})
return result
async def health(self) -> bool:
"""Prüft ob MCP-Server erreichbar ist."""
try:
await self.connect()
return True
except Exception:
return False
_client: Optional[MCPClient] = None
def get_mcp_client() -> MCPClient:
global _client
if _client is None:
_client = MCPClient()
return _client
async def close_mcp_client():
global _client
if _client:
await _client.disconnect()
_client = None
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "agent-platform"
version = "0.1.0"
description = "Business-taugliche Agent-Plattform mit LiteLLM + Pydantic AI + MCP"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"litellm>=1.50",
"pydantic-ai>=0.4",
"jinja2>=3.1",
"python-multipart>=0.0.12",
"aiosqlite>=0.20",
"httpx>=0.27",
"mcp>=1.0",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
py-modules = ["agent", "llm", "db", "audit", "auth", "config", "api", "mcp_client"]
[tool.setuptools.packages.find]
where = ["."]
include = ["agents*"]
+249
View File
@@ -0,0 +1,249 @@
/* Agent Platform Stylesheet */
:root {
--color-bg: #f7f8fa;
--color-surface: #ffffff;
--color-border: #e1e4e8;
--color-text: #1f2328;
--color-text-muted: #57606a;
--color-primary: #0969da;
--color-primary-hover: #0860c7;
--color-success: #1a7f37;
--color-danger: #cf222e;
--color-warning: #bf8700;
--radius: 6px;
--shadow: 0 1px 3px rgba(0,0,0,0.05);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.08);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
color: var(--color-text);
background: var(--color-bg);
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* === Navbar === */
.navbar {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 24px;
box-shadow: var(--shadow);
}
.nav-brand { font-weight: 700; font-size: 16px; color: var(--color-primary); }
.nav-links { display: flex; gap: 16px; flex: 1; }
.nav-link {
color: var(--color-text-muted);
text-decoration: none;
padding: 6px 12px;
border-radius: var(--radius);
transition: background 0.15s;
}
.nav-link:hover { background: var(--color-bg); color: var(--color-text); }
.nav-user { color: var(--color-text-muted); font-size: 13px; }
/* === Container === */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
width: 100%;
flex: 1;
}
/* === Page Header === */
.page-header { margin-bottom: 24px; }
.page-header h1 { font-size: 28px; font-weight: 600; margin-bottom: 4px; }
.subtitle { color: var(--color-text-muted); }
/* === Card === */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.card h2 { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
/* === Table === */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.table th { background: var(--color-bg); font-weight: 600; font-size: 12px; text-transform: uppercase; color: var(--color-text-muted); }
.table tbody tr:hover { background: var(--color-bg); }
/* === Form === */
.form { display: flex; flex-direction: column; gap: 12px; }
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 13px; font-weight: 500; color: var(--color-text-muted); }
input, select, textarea {
font-family: inherit;
font-size: 14px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
width: 100%;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(9,105,218,0.15);
}
textarea { font-family: inherit; resize: vertical; }
/* === Button === */
.btn {
display: inline-block;
padding: 8px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s;
}
.btn:hover { background: var(--color-bg); border-color: var(--color-text-muted); }
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover { background: var(--color-primary-hover); }
.btn-danger {
background: var(--color-surface);
color: var(--color-danger);
border-color: var(--color-border);
}
.btn-danger:hover { background: var(--color-danger); color: white; border-color: var(--color-danger); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* === Badge === */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.badge-green { background: #dafbe1; color: var(--color-success); border-color: #1a7f3730; }
.badge-red { background: #ffebe9; color: var(--color-danger); border-color: #cf222e30; }
/* === Empty State === */
.empty-state {
text-align: center;
padding: 32px;
color: var(--color-text-muted);
}
/* === Code === */
code {
font-family: var(--mono);
font-size: 12.5px;
background: var(--color-bg);
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: var(--color-bg);
padding: 12px;
border-radius: var(--radius);
overflow-x: auto;
font-size: 12px;
margin-top: 8px;
}
/* === Chat === */
.chat-container { min-height: 400px; }
.messages { display: flex; flex-direction: column; gap: 12px; }
.message {
padding: 10px 14px;
border-radius: var(--radius);
max-width: 80%;
}
.message-user {
background: var(--color-primary);
color: white;
align-self: flex-end;
}
.message-assistant {
background: var(--color-bg);
border: 1px solid var(--color-border);
align-self: flex-start;
}
.message-tool {
background: #fff8c5;
color: var(--color-warning);
border: 1px solid #d4a72c30;
align-self: center;
font-family: var(--mono);
font-size: 12px;
max-width: 90%;
}
.message-role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 4px;
opacity: 0.7;
}
.message-content { white-space: pre-wrap; word-wrap: break-word; }
/* === Tools Grid === */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.tool-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 12px;
}
.tool-card h3 { margin-bottom: 6px; font-size: 14px; }
.tool-card p { color: var(--color-text-muted); font-size: 13px; }
/* === Footer === */
.footer {
text-align: center;
padding: 16px;
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
/* === Responsive === */
@media (max-width: 640px) {
.navbar { flex-wrap: wrap; gap: 12px; padding: 12px; }
.nav-links { order: 3; width: 100%; }
.container { padding: 12px; }
.form-row { flex-direction: column; }
.message { max-width: 95%; }
}
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ agent.name }} Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ agent.name }}</h1>
<p class="subtitle"><code>{{ agent.id }}</code> · {{ agent.description }}</p>
</div>
<section class="card">
<h2>Definition (aus Code)</h2>
<p><small>Diese Definition liegt in <code>agents/*.py</code> und ist read-only im UI. Änderungen am Code erfordern Server-Neustart.</small></p>
<div class="form-group">
<label>System-Prompt</label>
<textarea readonly rows="10">{{ agent.system_prompt }}</textarea>
</div>
<div class="form-group">
<label>Erlaubte Tools</label>
<input type="text" value="{{ agent.allowed_tools|join(', ') }}" readonly>
</div>
{% if agent.model %}
<div class="form-group">
<label>LLM-Override</label>
<input type="text" value="{{ agent.model }}" readonly>
</div>
{% endif %}
</section>
<section class="card">
<h2>Runtime-Settings</h2>
<form hx-put="/api/agents/{{ agent.id }}" hx-target="#settings-result" class="form">
<div class="form-group">
<label>
<input type="checkbox" name="enabled" {% if agent.enabled %}checked{% endif %} value="true">
Aktiv (Agent darf verwendet werden)
</label>
</div>
<div class="form-group">
<label>Custom-Settings (JSON, optional)</label>
<textarea name="custom_settings" rows="4" placeholder='{"temperature": 0.5, "max_tokens": 1000}'>{{ agent.custom_settings | tojson(indent=2) if agent.custom_settings else "{}" }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Settings speichern</button>
<a href="/agents" class="btn">Zurück</a>
</form>
<div id="settings-result"></div>
</section>
<section class="card">
<h2>Chat starten</h2>
<form hx-post="/api/chat/{{ agent.id }}" hx-target="#chat-result" hx-swap="innerHTML" class="form">
<div class="form-group">
<label>Erste Nachricht</label>
<textarea name="message" rows="3" required placeholder="Hallo!"></textarea>
</div>
<button type="submit" class="btn btn-primary">Chat starten</button>
</form>
<div id="chat-result"></div>
</section>
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}{% if agent %}Agent bearbeiten{% else %}Neuer Agent{% endif %} Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>{% if agent %}{{ agent.name }}{% else %}Neuer Agent{% endif %}</h1>
<a href="/agents" class="btn">Zurück</a>
</div>
<form {% if agent %}hx-put="/api/agents/{{ agent.id }}"{% else %}hx-post="/api/agents"{% endif %} hx-target="#result" hx-swap="innerHTML" class="card form">
<div class="form-group">
<label>ID (nur Kleinbuchstaben, Zahlen, _ und -)</label>
{% if agent %}
<input type="text" value="{{ agent.id }}" readonly>
{% else %}
<input type="text" name="id" required pattern="^[a-z0-9_-]+$" placeholder="mein_agent">
{% endif %}
</div>
<div class="form-group">
<label>Name (Anzeigename)</label>
<input type="text" name="name" required value="{{ agent.name if agent else '' }}">
</div>
<div class="form-group">
<label>Beschreibung</label>
<input type="text" name="description" value="{{ agent.description if agent else '' }}">
</div>
<div class="form-group">
<label>System-Prompt</label>
<textarea name="system_prompt" rows="6" required>{{ agent.system_prompt if agent else '' }}</textarea>
</div>
<div class="form-group">
<label>Erlaubte Tools (Mehrfachauswahl)</label>
<div class="checkbox-group">
{% set selected = agent.allowed_tools if agent else [] %}
{% if available_tools %}
{% for tool in available_tools %}
<label class="checkbox-label">
<input type="checkbox" name="allowed_tools" value="{{ tool.name }}" {% if tool.name in selected %}checked{% endif %}>
<code>{{ tool.name }}</code>
</label>
{% endfor %}
{% else %}
<input type="text" name="allowed_tools_csv" placeholder="tool1,tool2 (MCP nicht erreichbar)" value="{{ selected|join(',') }}">
{% endif %}
</div>
<small>Verfügbare Tools vom MCP-Server. Falls nicht erreichbar, manuell als CSV.</small>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>LLM-Modell (leer = Default aus Config)</label>
<input type="text" name="model" placeholder="z.B. anthropic/claude-sonnet-4.5" value="{{ agent.model if agent and agent.model else '' }}">
</div>
<div class="form-group" style="width:120px">
<label>Temperature</label>
<input type="number" name="temperature" step="0.1" min="0" max="2" value="{{ agent.temperature if agent else 0.7 }}">
</div>
<div class="form-group" style="width:140px">
<label>Max Tokens</label>
<input type="number" name="max_tokens" min="100" max="32000" value="{{ agent.max_tokens if agent else 2000 }}">
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="enabled" value="true" {% if not agent or agent.enabled %}checked{% endif %}>
Aktiv (Agent darf verwendet werden)
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{% if agent %}Speichern{% else %}Anlegen{% endif %}</button>
<a href="/agents" class="btn">Abbrechen</a>
</div>
<div id="result"></div>
</form>
{% if agent %}
<section class="card">
<h2>Chat testen</h2>
<form hx-post="/api/chat/{{ agent.id }}" hx-target="#chat-result" hx-swap="innerHTML" class="form">
<textarea name="message" rows="3" required placeholder="Hallo, was kannst du?"></textarea>
<button type="submit" class="btn">Senden</button>
</form>
<div id="chat-result"></div>
</section>
{% endif %}
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Agents Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>Agents</h1>
<a href="/agents/new" class="btn btn-primary">+ Neuer Agent</a>
</div>
<section class="card">
<p><small><strong>Hinweis:</strong> Code-Agents (definiert in <code>agents/*.py</code>) werden beim Start in die DB synchronisiert. Du kannst sie hier anpassen oder neue Agents rein in der DB anlegen.</small></p>
</section>
<section class="card">
<h2>{{ agents|length }} Agent(s)</h2>
{% if agents %}
<table class="table">
<thead>
<tr><th>ID</th><th>Name</th><th>Quelle</th><th>Tools</th><th>Status</th><th>Aktionen</th></tr>
</thead>
<tbody>
{% for agent in agents %}
<tr>
<td><code>{{ agent.id }}</code></td>
<td>{{ agent.name }}</td>
<td>
{% if agent.source == "code" %}
<span class="badge badge-blue">Code</span>
{% else %}
<span class="badge">DB</span>
{% endif %}
</td>
<td><small>{{ agent.allowed_tools|length }} Tool(s)</small></td>
<td>
{% if agent.enabled %}
<span class="badge badge-green">aktiv</span>
{% else %}
<span class="badge badge-red">inaktiv</span>
{% endif %}
</td>
<td>
<a href="/agents/{{ agent.id }}" class="btn btn-sm">Edit</a>
{% if agent.enabled %}
<button hx-post="/api/agents/{{ agent.id }}/disable" hx-swap="none" hx-on::after-request="location.reload()" class="btn btn-sm">Disable</button>
{% else %}
<button hx-post="/api/agents/{{ agent.id }}/enable" hx-swap="none" hx-on::after-request="location.reload()" class="btn btn-sm">Enable</button>
{% endif %}
<button hx-delete="/api/agents/{{ agent.id }}" hx-confirm="Wirklich löschen?" hx-swap="none" hx-on::after-request="location.reload()" class="btn btn-sm btn-danger">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">Keine Agents vorhanden. <a href="/agents/new">Ersten Agent anlegen</a></p>
{% endif %}
</section>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Audit-Log Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>Audit-Log</h1>
<p class="subtitle">{{ entries|length }} Einträge (neueste zuerst)</p>
</div>
<section class="card">
{% if entries %}
<table class="table">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Agent</th>
<th>Aktion</th>
<th>Target</th>
<th>Dauer (ms)</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td><small>{{ entry.timestamp }}</small></td>
<td><code>{{ entry.agent_id or '-' }}</code></td>
<td><span class="badge">{{ entry.action }}</span></td>
<td><code>{{ entry.target or '-' }}</code></td>
<td>{{ entry.duration_ms or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">Noch keine Audit-Einträge.</p>
{% endif %}
</section>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Agent Platform{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<nav class="navbar">
<div class="nav-brand">⚡ Agent Platform</div>
<div class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/agents" class="nav-link">Agents</a>
<a href="/tools" class="nav-link">Tools</a>
<a href="/audit" class="nav-link">Audit</a>
</div>
<div class="nav-user">👤 {{ user_id }}</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<small>Agent Platform v0.1.0 · LiteLLM · Pydantic AI · MCP</small>
</footer>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Chat Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>Chat</h1>
<p class="subtitle">Konversation: <code>{{ conv_id }}</code></p>
</div>
<section class="card chat-container">
<div id="messages" class="messages">
{% for msg in messages %}
<div class="message message-{{ msg.role }}">
<div class="message-role">{{ msg.role }}</div>
<div class="message-content">{{ msg.content }}</div>
</div>
{% endfor %}
{% if not messages %}
<p class="empty-state">Noch keine Nachrichten.</p>
{% endif %}
</div>
</section>
<section class="card">
<form hx-post="" hx-target="#messages" hx-swap="beforeend" hx-on::after-request="this.reset()" class="form" id="chat-form">
<input type="hidden" name="conv_id" value="{{ conv_id }}">
<div class="form-row">
<div class="form-group" style="flex: 1;">
<textarea name="message" rows="2" required placeholder="Nachricht eingeben..." autofocus></textarea>
</div>
<button type="submit" class="btn btn-primary">Senden</button>
</div>
</form>
</section>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Dashboard Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>Dashboard</h1>
<p class="subtitle">Übersicht aller Agents und recent Aktivitäten</p>
</div>
<section class="card">
<h2>Agents ({{ agents|length }})</h2>
{% if agents %}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Beschreibung</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for agent in agents %}
<tr>
<td><code>{{ agent.id }}</code></td>
<td>{{ agent.name }}</td>
<td>{{ agent.description }}</td>
<td>
{% if agent.enabled %}
<span class="badge badge-green">aktiv</span>
{% else %}
<span class="badge badge-red">inaktiv</span>
{% endif %}
</td>
<td><a href="/agents/{{ agent.id }}" class="btn btn-sm">Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Noch keine Agents angelegt.</p>
<a href="/agents" class="btn btn-primary">Ersten Agent erstellen</a>
</div>
{% endif %}
</section>
<section class="card">
<h2>Schnellstart</h2>
<form hx-post="/api/chat" hx-target="#chat-result" class="form">
<div class="form-group">
<label for="agent_id">Agent wählen</label>
<select name="agent_id" id="agent_id" required>
{% for agent in agents %}
<option value="{{ agent.id }}">{{ agent.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="message">Nachricht</label>
<textarea name="message" id="message" rows="3" required placeholder="Deine Nachricht an den Agent..."></textarea>
</div>
<button type="submit" class="btn btn-primary">Senden</button>
</form>
<div id="chat-result"></div>
</section>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}MCP Tools Agent Platform{% endblock %}
{% block content %}
<div class="page-header">
<h1>MCP Tool-Server</h1>
<p class="subtitle">
Status:
{% if reachable %}
<span class="badge badge-green">erreichbar</span>
{% else %}
<span class="badge badge-red">nicht erreichbar</span>
{% if error %}<br><small>{{ error }}</small>{% endif %}
{% endif %}
</p>
</div>
<section class="card">
<h2>Verfügbare Tools ({{ tools|length }})</h2>
{% if tools %}
<div class="tools-grid">
{% for tool in tools %}
<div class="tool-card">
<h3><code>{{ tool.name }}</code></h3>
<p>{{ tool.description }}</p>
{% if tool.inputSchema %}
<details>
<summary>Schema</summary>
<pre><code>{{ tool.inputSchema | tojson(indent=2) }}</code></pre>
</details>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">
{% if reachable %}
MCP-Server erreichbar, aber keine Tools registriert.
{% else %}
MCP-Server nicht erreichbar. Prüfe die Konfiguration.
{% endif %}
</p>
{% endif %}
</section>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
version: "3.9"
services:
agent-platform:
build: ./agent_platform
container_name: agent-platform
ports:
- "8000:8000"
environment:
- LLM_PROVIDER=${LLM_PROVIDER:-openrouter}
- LLM_API_KEY=${LLM_API_KEY}
- LLM_MODEL=${LLM_MODEL:-anthropic/claude-3.5-sonnet}
- LLM_API_BASE=${LLM_API_BASE:-}
- MCP_SERVER_URL=http://mcp-tools:8501
- AUTH_TOKEN=${AUTH_TOKEN}
- DB_PATH=/data/agent-platform.db
- LOG_LEVEL=${LOG_LEVEL:-info}
volumes:
- agent-data:/data
depends_on:
- mcp-tools
restart: unless-stopped
networks:
- agent-net
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
mcp-tools:
build: ./mcp_tools
container_name: mcp-tools
expose:
- "8501"
environment:
- MCP_HOST=0.0.0.0
- MCP_PORT=8501
restart: unless-stopped
networks:
- agent-net
deploy:
resources:
limits:
memory: 128M
cpus: '0.25'
volumes:
agent-data:
networks:
agent-net:
driver: bridge
+381
View File
@@ -0,0 +1,381 @@
# Agent-Platform — Projekt-Plan
> Erstellt: 2026-07-05
> Status: Draft v1
> Ziel: Business-taugliche Agent-Plattform auf Coolify, 2 Container, MCP-Tools
---
## 1. Projekt-Übersicht
**Was wir bauen:**
Eine kleine, schlanke Multi-Agent-Plattform für interne Business-Use-Cases.
**Problem das wir lösen:**
- Agent Zero ist zu mächtig/komplex für Business-Agents
- Tool-Kapselung in Agent Zero erfordert Bastelei
- Wir brauchen eine schlanke Alternative mit klarer MCP-Tool-Architektur
**Outcome:**
Zwei Docker-Container auf coolify-01, deploybar via Coolify, mit:
- Web-UI für Agent-Verwaltung und Chat
- Audit-Trail aller Aktionen
- MCP-Tool-Container mit ersten Business-Tools
- Skaliert auf 50+ User / 5+ parallel Agents
---
## 2. Architektur
### 2-Container-Setup
```
┌─────────────────────────────────────────────┐
│ Container 1: agent-platform │
│ - FastAPI + LiteLLM + Pydantic AI │
│ - HTMX-UI (kein JS-Build) │
│ - SQLite (lokal) │
│ - Audit-Log, Auth │
│ Port: 8000 │
│ RAM: 256 MB, CPU: 0.5 Core │
└──────────────────┬──────────────────────────┘
│ MCP-Protokoll (HTTP)
┌─────────────────────────────────────────────┐
│ Container 2: mcp-tools │
│ - fastmcp Server │
│ - Business-Tools (KB, Email, HTTP, Files) │
│ - Restart-Policy: unless-stopped │
│ Port: 8501 │
│ RAM: 128 MB, CPU: 0.25 Core │
└─────────────────────────────────────────────┘
```
### Komponenten-Verantwortlichkeiten
**agent-platform:**
- LLM-Loop (LiteLLM)
- Agent-Definitionen (hardcoded Python)
- HTTP-API (FastAPI)
- Web-UI (HTMX)
- Persistenz (SQLite)
- Audit-Log
- Auth (API-Token)
**mcp-tools:**
- Tool-Implementierungen (Python)
- MCP-Protokoll (HTTP via fastmcp)
- Keine Business-Logik
- Keine Agent-KI
- Stateless (jeder Call unabhängig)
---
## 3. Tech-Stack
| Schicht | Technologie | Begründung |
|---|---|---|
| LLM-Aufrufe | LiteLLM 1.40+ | 100+ Provider, einheitliche API |
| Agent-Loop | Pydantic AI 0.4+ | Type-safe, MCP eingebaut |
| Backend | FastAPI 0.115+ | Async, OpenAPI auto |
| UI | HTMX 2.0 + Jinja2 + Tailwind CDN | Kein Build-Step, klein |
| DB | SQLite 3.45+ | Zero-Config, embedded |
| Tool-Container | fastmcp 0.4+ | MCP-Standard |
| Validation | Pydantic v2 | Type-Safety überall |
| Container | python:3.12-slim | Klein, aktuell |
| Deploy | Docker + Coolify | Auf coolify-01 |
**Was wir NICHT nutzen:**
- ❌ LangChain / LangGraph (zu schwer)
- ❌ React / Next.js (zu viel RAM)
- ❌ Postgres (overkill)
- ❌ Redis / Celery (overkill)
---
## 4. Datenmodell (SQLite)
```sql
-- Agent-Definitionen (zur Laufzeit aus agents/*.py geladen, hier gecached)
CREATE TABLE agents (
id TEXT PRIMARY KEY, -- z.B. "kunden_email"
name TEXT NOT NULL,
description TEXT,
system_prompt TEXT,
allowed_tools TEXT, -- JSON-Array
enabled BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Konversationen
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents(id),
user_id TEXT,
title TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Messages
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT REFERENCES conversations(id),
role TEXT NOT NULL, -- "user" | "assistant" | "tool"
content TEXT,
tool_calls TEXT, -- JSON
tool_results TEXT, -- JSON
token_count INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Audit-Log
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
agent_id TEXT,
user_id TEXT,
action TEXT, -- "tool_call" | "llm_call" | "auth" | "error"
target TEXT, -- Tool-Name oder LLM-Model
args TEXT, -- JSON
result TEXT, -- JSON oder Status
duration_ms INTEGER
);
-- Sessions / Auth
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
);
```
---
## 5. Komponenten mit Zeilen-Schätzung
### agent-platform (~1840 Zeilen)
| Datei | Zeilen | Zweck |
|---|---|---|
| `config.py` | 40 | Settings, Env-Loading |
| `db.py` | 130 | SQLite + Models |
| `llm.py` | 70 | LiteLLM-Wrapper |
| `mcp_client.py` | 120 | MCP-Client für Tool-Container |
| `agent.py` | 180 | Agent-Loop |
| `audit.py` | 60 | Audit-Helper |
| `auth.py` | 70 | Token-Auth |
| `api.py` | 250 | FastAPI-Endpoints |
| `agents/base.py` | 80 | Abstract Agent |
| `agents/kunden_email.py` | 90 | Erster Agent |
| `agents/recherche.py` | 70 | Zweiter Agent |
| `templates/base.html` | 60 | Layout |
| `templates/agents.html` | 80 | Liste |
| `templates/chat.html` | 120 | Chat |
| `templates/tools.html` | 60 | MCP-Status |
| `templates/audit.html` | 80 | Audit-Viewer |
| `static/style.css` | 200 | Minimal-CSS |
| `pyproject.toml` | 25 | Dependencies |
| `Dockerfile` | 25 | Container |
| **Gesamt** | **~1810** | |
### mcp-tools (~250 Zeilen)
| Datei | Zeilen | Zweck |
|---|---|---|
| `server.py` | 60 | fastmcp Setup |
| `tools/search_kb.py` | 40 | KB-Suche |
| `tools/fetch_url.py` | 30 | HTTP-Request |
| `tools/list_files.py` | 30 | Datei-Listing |
| `tools/save_note.py` | 40 | Notiz speichern |
| `tools/send_email.py` | 50 | E-Mail (Draft) |
| `requirements.txt` | 4 | fastmcp, httpx, pydantic |
| `Dockerfile` | 10 | Container |
| **Gesamt** | **~264** | |
### Deployment
| Datei | Zeilen | Zweck |
|---|---|---|
| `docker-compose.yml` | 40 | Beide Services |
| `.env.example` | 15 | Env-Vorlage |
| `deploy/coolify.json` | 30 | Coolify-Metadata |
| **Gesamt** | **~85** | |
---
## 6. Task-Graph (Reihenfolge)
```
T01: pyproject.toml + Dockerfile (agent-platform)
T02: config.py + db.py + Models
T03: llm.py (LiteLLM-Wrapper + Token-Tracking)
T04: audit.py + auth.py
T05: mcp_client.py (MCP-Client)
T06: agent.py (Agent-Loop — Herzstück)
T07: agents/base.py + agents/kunden_email.py (erster Agent)
T08: api.py (FastAPI-Endpoints)
T09: templates/* + static/* (HTMX-UI)
T10: Lokal testen (curl + Browser)
--- Pause, Freigabe ---
T11: mcp-tools/server.py + 5 Beispiel-Tools
T12: Dockerfile mcp-tools
T13: docker-compose.yml (beide Container)
T14: Lokal starten (docker compose up)
T15: Smoke-Tests (API + UI + Tools)
--- Pause, Freigabe ---
T16: Coolify-Deployment vorbereiten
T17: Auf coolify-01 deployen
T18: Smoke-Test Production
T19: Dokumentation + Runbook
```
**Geschätzter Aufwand:**
- T01-T10: 6-8 Stunden (MVP lokal)
- T11-T15: 2-3 Stunden (MCP-Container lokal)
- T16-T19: 2-3 Stunden (Deployment + Doku)
**Gesamt: 10-14 Stunden, verteilt auf 2-3 Tage**
---
## 7. Ressourcen-Budget
### Container-Limits
| Container | RAM Min | RAM Max | CPU Min | CPU Max |
|---|---|---|---|---|
| agent-platform | 64 MB | 256 MB | 0.1 | 0.5 |
| mcp-tools | 32 MB | 128 MB | 0.05 | 0.25 |
### Storage
| Was | Größe |
|---|---|
| Image agent-platform | ~150 MB |
| Image mcp-tools | ~100 MB |
| SQLite DB (1000 Konversationen) | ~5-10 MB |
| Audit-Log (10000 Einträge) | ~2-5 MB |
### LLM-Kosten (Schätzung)
Bei GPT-4o-mini:
- Input: ~$0.15 / 1M Token
- Output: ~$0.60 / 1M Token
Bei 100 User × 10 Messages/Tag × ~500 Token avg:
- Input: ~500k Token/Tag = ~$0.08
- Output: ~100k Token/Tag = ~$0.06
- **Gesamt: ~$0.14/Tag = ~$4/Monat**
---
## 8. Deployment-Plan
### Coolify-Konfiguration
1. Neues Projekt in Coolify: `agent-platform`
2. Service 1: `agent-platform`
- Source: Git-Repo oder lokales Dockerfile
- Port: 8000
- Domain: `agents.media-on.de`
- Env-Vars: LLM_API_KEY, MCP_SERVER_URL
3. Service 2: `mcp-tools`
- Source: lokales Dockerfile
- Port: 8501 (intern, nicht öffentlich)
- Domain: nur intern erreichbar
4. Docker-Netzwerk: beide Services im selben Coolify-Network
### Env-Vars (agent-platform)
```bash
LLM_PROVIDER=openrouter # oder openai, anthropic, ollama
LLM_API_KEY=*** # via Coolify-Secret
LLM_MODEL=anthropic/claude-3.5-sonnet
MCP_SERVER_URL=http://mcp-tools:8501/mcp
AUTH_TOKEN=*** # via Coolify-Secret
DB_PATH=/data/agent-platform.db
LOG_LEVEL=info
```
### Env-Vars (mcp-tools)
```bash
KB_PATH=/data/knowledge
EMAIL_DRAFT_PATH=/data/drafts
HTTP_TIMEOUT=10
LOG_LEVEL=info
```
---
## 9. Risiken
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|---|---|---|---|
| MCP-Protokoll bricht bei Updates | Mittel | Mittel | Version pinnen, Tests |
| LLM-API-Änderungen | Niedrig | Mittel | LiteLLM abstrahiert |
| SQLite-Performance bei vielen Usern | Niedrig | Niedrig | Bei >10k Messages → Postgres |
| Container startet nicht auf Coolify | Niedrig | Hoch | Erst lokal voll testen |
| Token-Kosten explodieren | Mittel | Mittel | Hard-Limit pro User |
| Tool-Sicherheit (SSRF, Path-Traversal) | Mittel | Hoch | Input-Validation, Sandboxing |
---
## 10. Freigabe-Punkte
### Gate 1 — Vor Implementation
- [ ] Plan-Architektur genehmigt
- [ ] Stack-Entscheidungen bestätigt
- [ ] Datenmodell ok
### Gate 2 — Vor MCP-Container
- [ ] agent-platform läuft lokal
- [ ] Erste Tool-Calls funktionieren
- [ ] UI ist nutzbar
### Gate 3 — Vor Deployment
- [ ] Beide Container laufen lokal
- [ ] Smoke-Tests grün
- [ ] Audit-Log zeigt alle Aktionen
### Gate 4 — Vor Production
- [ ] Coolify-Deployment getestet
- [ ] Doku vorhanden
- [ ] User hat final freigegeben
---
## 11. Was wir NICHT in V1 machen
- Multi-Tenant (mehrere Firmen)
- Multi-LLM pro Agent
- Voice-Interface
- Bildgenerierung
- Vector-DB / echte RAG (kommt in V2)
- 10+ Beispiel-Agents
- E-Mail-Versand (nur Draft in V1)
- SSO / OAuth (nur Token-Auth)
- Tests / CI (kommt nach V1)
---
## 12. Nächste Schritte
1. **JETZT:** Freigabe vom User für Gate 1
2. **Dann:** T01-T10 (MVP lokal)
3. **Pause:** Smoke-Test + User-Freigabe
4. **Dann:** T11-T15 (MCP-Container)
5. **Pause:** Integration-Test
6. **Dann:** T16-T19 (Coolify-Deployment)
---
**Stand:** 2026-07-05 20:52
**Author:** Agent Zero (a0_software_orchestrator)
**Repo:** `/a0/usr/workdir/agent-platform/`
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY server.py .
RUN useradd -m -u 1000 mcpuser && chown -R mcpuser:mcpuser /app
USER mcpuser
EXPOSE 8501
CMD ["python", "server.py"]
+3
View File
@@ -0,0 +1,3 @@
fastmcp>=0.4.0
httpx>=0.27.0
pydantic>=2.6.0
+143
View File
@@ -0,0 +1,143 @@
"""MCP-Tool-Server für die Agent Platform."""
import os
import ast
import operator
import ipaddress
import socket
from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse
import httpx
from fastmcp import FastMCP
mcp = FastMCP("Agent Platform Tools")
@mcp.tool()
def echo(text: str) -> dict:
"""Echo-Tool zum Testen der MCP-Verbindung."""
return {"input": text, "timestamp": datetime.utcnow().isoformat()}
@mcp.tool()
def get_time(timezone_name: str = "UTC") -> dict:
"""Gibt aktuelle Zeit in angegebener Zeitzone zurück."""
tz_offsets = {"UTC": 0, "CET": 1, "CEST": 2, "EST": -5, "PST": -8, "JST": 9}
offset = tz_offsets.get(timezone_name.upper(), 0)
tz = timezone(timedelta(hours=offset))
return {"timezone": timezone_name, "time": datetime.now(tz).isoformat()}
@mcp.tool()
def calculate(expression: str) -> dict:
"""Berechnet einen mathematischen Ausdruck (sicher, nur Zahlen).
Erlaubt: +, -, *, /, **, %.
Beispiel: calculate("2 + 3 * 4") -> 14
"""
ops = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.Mod: operator.mod,
}
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError:
return {"error": "Ungültiger Ausdruck"}
def _eval(node):
if isinstance(node, ast.Constant):
if not isinstance(node.value, (int, float)):
raise ValueError("Nur Zahlen erlaubt")
return node.value
if isinstance(node, ast.BinOp):
if type(node.op) not in ops:
raise ValueError("Operation nicht erlaubt")
return ops[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp):
if isinstance(node.op, ast.USub):
return -_eval(node.operand)
if isinstance(node.op, ast.UAdd):
return _eval(node.operand)
raise ValueError("Ungültiger Knoten")
try:
result = _eval(tree.body)
return {"expression": expression, "result": result}
except Exception as e:
return {"expression": expression, "error": str(e)}
@mcp.tool()
def http_get(url: str, timeout: int = 10) -> dict:
"""Holt eine URL via HTTP. SSRF-Schutz aktiv.
Nur http/https, keine Auth, keine privaten IPs.
Beispiel: http_get("https://example.com")
"""
if not url.startswith(("http://", "https://")):
return {"error": "Nur http/https URLs erlaubt"}
parsed = urlparse(url)
try:
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved:
return {"error": f"IP {ip} blockiert (privat/loopback/reserved)"}
except Exception:
pass
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
r = client.get(url)
return {
"status": r.status_code,
"url": str(r.url),
"content_type": r.headers.get("content-type", ""),
"length": len(r.text),
"text_preview": r.text[:2000],
}
except Exception as e:
return {"error": str(e)}
@mcp.tool()
def list_env(prefix: str = "") -> dict:
"""Listet Umgebungsvariablen mit optionalem Prefix-Filter.
Sensitive Werte (KEY/SECRET/TOKEN/PASSWORD) werden maskiert.
"""
env = {}
for k, v in os.environ.items():
if prefix and not k.startswith(prefix.upper()):
continue
upper = k.upper()
if any(s in upper for s in ["KEY", "SECRET", "TOKEN", "PASSWORD"]):
env[k] = "***MASKED***"
else:
env[k] = v[:200]
return {"count": len(env), "vars": env}
@mcp.tool()
def json_format(data: str, indent: int = 2) -> dict:
"""Formatiert einen JSON-String lesbar.
Beispiel: json_format('{"a":1}')
"""
try:
parsed = json.loads(data)
return {"formatted": json.dumps(parsed, indent=indent, ensure_ascii=False)}
except Exception as e:
return {"error": str(e)}
if __name__ == "__main__":
host = os.getenv("MCP_HOST", "0.0.0.0")
port = int(os.getenv("MCP_PORT", "8501"))
mcp.run(transport="streamable-http", host=host, port=port)