From 64b840c94c23a887bd164c619614272ab1a2d5ff Mon Sep 17 00:00:00 2001 From: Leopold Date: Sun, 5 Jul 2026 22:18:00 +0200 Subject: [PATCH] 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 --- .env.example | 17 + .gitignore | 7 + README.md | 93 +++++ agent_platform/Dockerfile | 20 ++ agent_platform/agent.py | 183 ++++++++++ agent_platform/agents/__init__.py | 54 +++ agent_platform/agents/base.py | 37 ++ agent_platform/agents/example.py | 26 ++ agent_platform/api.py | 352 +++++++++++++++++++ agent_platform/audit.py | 53 +++ agent_platform/auth.py | 36 ++ agent_platform/config.py | 33 ++ agent_platform/db.py | 289 ++++++++++++++++ agent_platform/llm.py | 86 +++++ agent_platform/mcp_client.py | 98 ++++++ agent_platform/pyproject.toml | 27 ++ agent_platform/static/style.css | 249 ++++++++++++++ agent_platform/templates/agent_detail.html | 60 ++++ agent_platform/templates/agent_form.html | 95 +++++ agent_platform/templates/agents.html | 59 ++++ agent_platform/templates/audit.html | 39 +++ agent_platform/templates/base.html | 30 ++ agent_platform/templates/chat.html | 36 ++ agent_platform/templates/dashboard.html | 69 ++++ agent_platform/templates/tools.html | 46 +++ docker-compose.yml | 53 +++ docs/PLAN.md | 381 +++++++++++++++++++++ mcp_tools/Dockerfile | 16 + mcp_tools/requirements.txt | 3 + mcp_tools/server.py | 143 ++++++++ 30 files changed, 2690 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent_platform/Dockerfile create mode 100644 agent_platform/agent.py create mode 100644 agent_platform/agents/__init__.py create mode 100644 agent_platform/agents/base.py create mode 100644 agent_platform/agents/example.py create mode 100644 agent_platform/api.py create mode 100644 agent_platform/audit.py create mode 100644 agent_platform/auth.py create mode 100644 agent_platform/config.py create mode 100644 agent_platform/db.py create mode 100644 agent_platform/llm.py create mode 100644 agent_platform/mcp_client.py create mode 100644 agent_platform/pyproject.toml create mode 100644 agent_platform/static/style.css create mode 100644 agent_platform/templates/agent_detail.html create mode 100644 agent_platform/templates/agent_form.html create mode 100644 agent_platform/templates/agents.html create mode 100644 agent_platform/templates/audit.html create mode 100644 agent_platform/templates/base.html create mode 100644 agent_platform/templates/chat.html create mode 100644 agent_platform/templates/dashboard.html create mode 100644 agent_platform/templates/tools.html create mode 100644 docker-compose.yml create mode 100644 docs/PLAN.md create mode 100644 mcp_tools/Dockerfile create mode 100644 mcp_tools/requirements.txt create mode 100644 mcp_tools/server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..30cc10b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..399cc0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.db +.venv/ +.env +*.log +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ba4ce3 --- /dev/null +++ b/README.md @@ -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 diff --git a/agent_platform/Dockerfile b/agent_platform/Dockerfile new file mode 100644 index 0000000..afbf4ff --- /dev/null +++ b/agent_platform/Dockerfile @@ -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"] diff --git a/agent_platform/agent.py b/agent_platform/agent.py new file mode 100644 index 0000000..ab2d1d2 --- /dev/null +++ b/agent_platform/agent.py @@ -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, + } diff --git a/agent_platform/agents/__init__.py b/agent_platform/agents/__init__.py new file mode 100644 index 0000000..9780b32 --- /dev/null +++ b/agent_platform/agents/__init__.py @@ -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()] diff --git a/agent_platform/agents/base.py b/agent_platform/agents/base.py new file mode 100644 index 0000000..ba18777 --- /dev/null +++ b/agent_platform/agents/base.py @@ -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 diff --git a/agent_platform/agents/example.py b/agent_platform/agents/example.py new file mode 100644 index 0000000..80c8286 --- /dev/null +++ b/agent_platform/agents/example.py @@ -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, + ) diff --git a/agent_platform/api.py b/agent_platform/api.py new file mode 100644 index 0000000..59dc6ad --- /dev/null +++ b/agent_platform/api.py @@ -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))) diff --git a/agent_platform/audit.py b/agent_platform/audit.py new file mode 100644 index 0000000..e5554aa --- /dev/null +++ b/agent_platform/audit.py @@ -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) diff --git a/agent_platform/auth.py b/agent_platform/auth.py new file mode 100644 index 0000000..c9fd3ae --- /dev/null +++ b/agent_platform/auth.py @@ -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 diff --git a/agent_platform/config.py b/agent_platform/config.py new file mode 100644 index 0000000..576a2d3 --- /dev/null +++ b/agent_platform/config.py @@ -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() diff --git a/agent_platform/db.py b/agent_platform/db.py new file mode 100644 index 0000000..a3a48d0 --- /dev/null +++ b/agent_platform/db.py @@ -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] diff --git a/agent_platform/llm.py b/agent_platform/llm.py new file mode 100644 index 0000000..e8eaf90 --- /dev/null +++ b/agent_platform/llm.py @@ -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, + ) diff --git a/agent_platform/mcp_client.py b/agent_platform/mcp_client.py new file mode 100644 index 0000000..1e382bf --- /dev/null +++ b/agent_platform/mcp_client.py @@ -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 diff --git a/agent_platform/pyproject.toml b/agent_platform/pyproject.toml new file mode 100644 index 0000000..fb7b9e6 --- /dev/null +++ b/agent_platform/pyproject.toml @@ -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*"] diff --git a/agent_platform/static/style.css b/agent_platform/static/style.css new file mode 100644 index 0000000..fe76492 --- /dev/null +++ b/agent_platform/static/style.css @@ -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%; } +} diff --git a/agent_platform/templates/agent_detail.html b/agent_platform/templates/agent_detail.html new file mode 100644 index 0000000..32a158d --- /dev/null +++ b/agent_platform/templates/agent_detail.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}{{ agent.name }} – Agent Platform{% endblock %} + +{% block content %} + + +
+

Definition (aus Code)

+

Diese Definition liegt in agents/*.py und ist read-only im UI. Änderungen am Code erfordern Server-Neustart.

+
+ + +
+
+ + +
+ {% if agent.model %} +
+ + +
+ {% endif %} +
+ +
+

Runtime-Settings

+
+
+ +
+
+ + +
+ + Zurück +
+
+
+ +
+

Chat starten

+
+
+ + +
+ +
+
+
+{% endblock %} diff --git a/agent_platform/templates/agent_form.html b/agent_platform/templates/agent_form.html new file mode 100644 index 0000000..a916c1b --- /dev/null +++ b/agent_platform/templates/agent_form.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} + +{% block title %}{% if agent %}Agent bearbeiten{% else %}Neuer Agent{% endif %} – Agent Platform{% endblock %} + +{% block content %} + + +
+ +
+ + {% if agent %} + + {% else %} + + {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {% set selected = agent.allowed_tools if agent else [] %} + {% if available_tools %} + {% for tool in available_tools %} + + {% endfor %} + {% else %} + + {% endif %} +
+ Verfügbare Tools vom MCP-Server. Falls nicht erreichbar, manuell als CSV. +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ + Abbrechen +
+ +
+
+ +{% if agent %} +
+

Chat testen

+
+ + +
+
+
+{% endif %} +{% endblock %} diff --git a/agent_platform/templates/agents.html b/agent_platform/templates/agents.html new file mode 100644 index 0000000..39bb8f7 --- /dev/null +++ b/agent_platform/templates/agents.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Agents – Agent Platform{% endblock %} + +{% block content %} + + +
+

Hinweis: Code-Agents (definiert in agents/*.py) werden beim Start in die DB synchronisiert. Du kannst sie hier anpassen oder neue Agents rein in der DB anlegen.

+
+ +
+

{{ agents|length }} Agent(s)

+ {% if agents %} + + + + + + {% for agent in agents %} + + + + + + + + + {% endfor %} + +
IDNameQuelleToolsStatusAktionen
{{ agent.id }}{{ agent.name }} + {% if agent.source == "code" %} + Code + {% else %} + DB + {% endif %} + {{ agent.allowed_tools|length }} Tool(s) + {% if agent.enabled %} + aktiv + {% else %} + inaktiv + {% endif %} + + Edit + {% if agent.enabled %} + + {% else %} + + {% endif %} + +
+ {% else %} +

Keine Agents vorhanden. Ersten Agent anlegen

+ {% endif %} +
+{% endblock %} diff --git a/agent_platform/templates/audit.html b/agent_platform/templates/audit.html new file mode 100644 index 0000000..21dfb10 --- /dev/null +++ b/agent_platform/templates/audit.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Audit-Log – Agent Platform{% endblock %} + +{% block content %} + + +
+ {% if entries %} + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + {% endfor %} + +
ZeitstempelAgentAktionTargetDauer (ms)
{{ entry.timestamp }}{{ entry.agent_id or '-' }}{{ entry.action }}{{ entry.target or '-' }}{{ entry.duration_ms or '-' }}
+ {% else %} +

Noch keine Audit-Einträge.

+ {% endif %} +
+{% endblock %} diff --git a/agent_platform/templates/base.html b/agent_platform/templates/base.html new file mode 100644 index 0000000..5e2e447 --- /dev/null +++ b/agent_platform/templates/base.html @@ -0,0 +1,30 @@ + + + + + + {% block title %}Agent Platform{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/agent_platform/templates/chat.html b/agent_platform/templates/chat.html new file mode 100644 index 0000000..836763f --- /dev/null +++ b/agent_platform/templates/chat.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Chat – Agent Platform{% endblock %} + +{% block content %} + + +
+
+ {% for msg in messages %} +
+
{{ msg.role }}
+
{{ msg.content }}
+
+ {% endfor %} + {% if not messages %} +

Noch keine Nachrichten.

+ {% endif %} +
+
+ +
+
+ +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/agent_platform/templates/dashboard.html b/agent_platform/templates/dashboard.html new file mode 100644 index 0000000..11ac405 --- /dev/null +++ b/agent_platform/templates/dashboard.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}Dashboard – Agent Platform{% endblock %} + +{% block content %} + + +
+

Agents ({{ agents|length }})

+ {% if agents %} + + + + + + + + + + + + {% for agent in agents %} + + + + + + + + {% endfor %} + +
IDNameBeschreibungStatusAktion
{{ agent.id }}{{ agent.name }}{{ agent.description }} + {% if agent.enabled %} + aktiv + {% else %} + inaktiv + {% endif %} + Details
+ {% else %} +
+

Noch keine Agents angelegt.

+ Ersten Agent erstellen +
+ {% endif %} +
+ +
+

Schnellstart

+
+
+ + +
+
+ + +
+ +
+
+
+{% endblock %} diff --git a/agent_platform/templates/tools.html b/agent_platform/templates/tools.html new file mode 100644 index 0000000..add6f4c --- /dev/null +++ b/agent_platform/templates/tools.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}MCP Tools – Agent Platform{% endblock %} + +{% block content %} + + +
+

Verfügbare Tools ({{ tools|length }})

+ {% if tools %} +
+ {% for tool in tools %} +
+

{{ tool.name }}

+

{{ tool.description }}

+ {% if tool.inputSchema %} +
+ Schema +
{{ tool.inputSchema | tojson(indent=2) }}
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

+ {% if reachable %} + MCP-Server erreichbar, aber keine Tools registriert. + {% else %} + MCP-Server nicht erreichbar. Prüfe die Konfiguration. + {% endif %} +

+ {% endif %} +
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..04d03b6 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..4291419 --- /dev/null +++ b/docs/PLAN.md @@ -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/` diff --git a/mcp_tools/Dockerfile b/mcp_tools/Dockerfile new file mode 100644 index 0000000..1fa4e84 --- /dev/null +++ b/mcp_tools/Dockerfile @@ -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"] diff --git a/mcp_tools/requirements.txt b/mcp_tools/requirements.txt new file mode 100644 index 0000000..f3bf4f0 --- /dev/null +++ b/mcp_tools/requirements.txt @@ -0,0 +1,3 @@ +fastmcp>=0.4.0 +httpx>=0.27.0 +pydantic>=2.6.0 diff --git a/mcp_tools/server.py b/mcp_tools/server.py new file mode 100644 index 0000000..a199413 --- /dev/null +++ b/mcp_tools/server.py @@ -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)