feat: agent platform initial commit
- FastAPI + HTMX UI: dashboard, agents CRUD, chat, audit, tools - SQLite schema: agents, conversations, messages, audit, sessions - Code-agent sync (agents/*.py -> DB on startup) - MCP client with health check - Token auth (Bearer + session) - LiteLLM integration via llm.py - Docker Compose: agent-platform + mcp-tools services - Smoke tests pass: /health 200, /api/agents 200, / 401
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.venv/
|
||||
.env
|
||||
*.log
|
||||
.pytest_cache/
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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*"]
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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/`
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fastmcp>=0.4.0
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.6.0
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user