diff --git a/.gitignore b/.gitignore index 7309b3c..c3aa73e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ data/raw/ # Workspace workspace/ + +# Logs +logs/ diff --git a/DEBUG_LOGGER_USAGE.md b/DEBUG_LOGGER_USAGE.md new file mode 100644 index 0000000..965b61f --- /dev/null +++ b/DEBUG_LOGGER_USAGE.md @@ -0,0 +1,46 @@ +# DebugLogger Usage + +## Import +```python +from backend.managers.debug_logger import DebugLogger +logger = DebugLogger() +``` + +## Methoden +```python +logger.clear() # vor jeder neuen Ausführung aufrufen +logger.log("Nachricht") # INFO-Eintrag +logger.log_error("Fehler") # ERROR-Eintrag +logger.get_logs() # gibt Liste aller Einträge zurück +logger.format_debug_output({ # gibt formatierten String zurück + "rc": 0, + "stdout": "...", + "stderr": "..." +}) +``` + +## Eintrag-Format +```python +{ + "level": "INFO", # oder "ERROR" + "message": "Nachricht", + "timestamp": "14:23:01" +} +``` + +## Beispiel +```python +logger = DebugLogger() +logger.clear() +logger.log("Starte Ausführung...") + +try: + result = run_something() + logger.log("Erfolgreich abgeschlossen.") +except Exception as e: + logger.log_error(f"Fehler: {e}") + +# Logs anzeigen +for entry in logger.get_logs(): + print(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}") +``` diff --git a/README.md b/README.md index 97f4062..be4f596 100644 --- a/README.md +++ b/README.md @@ -2,106 +2,102 @@ AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) -## Project Structure +## Projektstruktur ``` AISE_AIAgent/ -├── frontend/ # Streamlit UI Components -│ ├── __init__.py -│ ├── app.py # Main Streamlit application entry point -│ ├── sidebar.py # File navigation sidebar component -│ ├── editor.py # Code editor pane component -│ └── chat.py # Chat interface component +├── frontend/ # Streamlit UI-Komponenten +│ ├── app.py # Haupteinstiegspunkt der Streamlit-App +│ ├── state.py # Session-State-Verwaltung +│ ├── sidebar.py # Datei-Navigation (Sidebar) +│ ├── editor.py # Code-Editor-Pane +│ └── chat.py # Chat-Interface │ -├── backend/ # Backend Logic Modules -│ ├── __init__.py -│ ├── managers/ # Business logic for UI operations -│ │ ├── __init__.py -│ │ ├── file_manager.py # File I/O operations for UI (read, write, list files) -│ │ ├── chat_manager.py # AI chat management and history -│ │ ├── system_prompter.py # System prompts and context injection -│ │ ├── search_manager.py # Internet search functionality -│ │ ├── execution_engine.py # Code execution and sandboxing -│ │ └── debug_logger.py # Logging, error handling, debug messages +├── backend/ # Backend-Logik +│ ├── managers/ # Business-Logik für UI-Operationen +│ │ ├── file_manager.py # Datei-CRUD (lesen, schreiben, listen) +│ │ ├── chat_manager.py # AI-Chat-Verwaltung und -History +│ │ ├── system_prompter.py # System-Prompts und Kontext-Injektion +│ │ ├── search_manager.py # Web-Suche (DuckDuckGo) +│ │ ├── execution_engine.py # Code-Ausführung und Sandboxing +│ │ └── debug_logger.py # Logging, Fehlerbehandlung, Debug-Ausgaben │ │ -│ ├── agents/ # AI Agent System -│ │ ├── __init__.py -│ │ ├── coding_agent.py # Main agent loop (plan-act-observe cycle) -│ │ └── tools.py # Tools available to agent (7 functions + dispatcher) -│ │ -│ └── utils/ # Helper Utilities -│ ├── __init__.py -│ └── server_utils.py # LLM client init, chat functions, formatters +│ └── agent/ # Autonomes AI-Agent-System (MCP-basiert) +│ ├── coding_agent.py # Haupt-Agent-Loop (Plan-Act-Observe) +│ ├── mcp_server_adapter.py # MCP-Adapter: verbindet Agent mit MCP-Servern +│ ├── mcp_server_adapter_RAG.py # MCP-Adapter mit RAG-basierter Tool-Auswahl +│ ├── mcp_server_config.json # Konfiguration der MCP-Server (Startbefehle) +│ └── servers/ # MCP-Server-Implementierungen +│ ├── mcp_server_code_execution.py # Tool: Python-Code ausführen +│ ├── mcp_server_file_search.py # Tool: Dateien suchen und lesen +│ └── mcp_server_web_search.py # Tool: Web-Suche via DuckDuckGo │ -├── tests/ # Unit Tests -│ ├── __init__.py -│ ├── test_file_manager.py # Tests for file operations -│ ├── test_chat_manager.py # Tests for chat functionality -│ ├── test_execution_engine.py # Tests for code execution -│ └── test_main.py # Integration tests +├── tests/ # Unit-Tests (pytest) +│ ├── conftest.py # Globale Test-Fixtures und MCP-Mocks +│ ├── test_file_manager.py +│ ├── test_chat_manager.py +│ ├── test_execution_engine.py +│ ├── test_coding_agent.py +│ ├── test_debug_logger.py +│ ├── test_system_prompter.py +│ ├── test_mcp_server_code_execution.py +│ ├── test_mcp_server_file_search.py +│ └── test_mcp_server_web_search.py │ -├── workspace/ # Agent Sandbox Directory -│ └── .gitkeep # Placeholder for agent to work safely in isolation -│ -├── .gitignore # Git exclusions (venv, .env, __pycache__, etc.) -├── .env # Local environment variables (NOT committed) -├── .env.example # Template for environment variables (IS committed) -├── requirements.txt # Python dependencies -├── README.md # This file -└── project_exercise.pdf # Project specification +├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis) +├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent +└── .env.example # Vorlage für Umgebungsvariablen ``` -## Component Responsibilities +## Komponenten ### Frontend (`frontend/`) -- **app.py**: Main Streamlit application, layout orchestration -- **sidebar.py**: File browser and project navigation -- **editor.py**: Code editing interface with syntax highlighting -- **chat.py**: AI assistant chat interface +- **app.py**: Streamlit-Applikation, Layout-Orchestrierung +- **state.py**: Zentralisierte Session-State-Verwaltung +- **sidebar.py**: Datei-Browser und Projekt-Navigation +- **editor.py**: Code-Editor mit Syntax-Highlighting +- **chat.py**: AI-Assistent Chat-Interface -### Backend Managers (`backend/managers/`) -Used directly by Frontend for UI operations: -- **file_manager.py**: CRUD operations on project files -- **chat_manager.py**: Chat history, message management -- **system_prompter.py**: System prompt generation and file context -- **execution_engine.py**: Safe code execution with output capture -- **debug_logger.py**: Error tracking and log formatting -- **search_manager.py**: Web search integration +### Backend Manager (`backend/managers/`) +Werden direkt vom Frontend für UI-Operationen genutzt: +- **file_manager.py**: CRUD-Operationen auf Projektdateien (`get_file_tree()` liefert die verschachtelte Baumstruktur für den File Explorer; eine flache `list_files()` wurde bewusst nicht implementiert, da das Frontend die Baumstruktur benötigt — für den Agent Mode übernimmt `mcp_server_file_search.py` die Dateisuche) +- **chat_manager.py**: Chat-History, Nachrichten-Verwaltung (Fehler aus der Code-Ausführung werden im normalen Chat bewusst per "Debug with AI"-Button manuell an den Chat übergeben — der User entscheidet selbst wann die AI eingeschaltet wird; im Agent Mode geschieht dies automatisch über den Plan-Act-Observe-Loop) +- **system_prompter.py**: System-Prompt-Generierung und Datei-Kontext +- **execution_engine.py**: Sichere Code-Ausführung mit Output-Capture +- **debug_logger.py**: Fehler-Tracking und Log-Formatierung (`format_debug_output()` formatiert Execution-Output für UI und AI-Chat; `log_error()` wurde bewusst nicht als separate Methode implementiert — Python's Standard-`logging`-Modul mit `logger.error()` deckt diese Funktionalität bereits vollständig ab und wird konsequent im gesamten Code verwendet) +- **search_manager.py**: Web-Suche via DuckDuckGo (`ddgs`-Bibliothek) -### Backend Agents (`backend/agents/`) -Independent AI agent system for complex tasks: -- **coding_agent.py**: Agent loop (Plan → Act → Observe → Repeat) -- **tools.py**: 7 tools agent can use (read/write/run/search/validate/grep/done) - -### Backend Utils (`backend/utils/`) -- **server_utils.py**: LLM client initialization, chat helpers, message formatters +### Backend Agent (`backend/agent/`) +Autonomes AI-Agent-System für komplexe Coding-Aufgaben: +- **coding_agent.py**: Agent-Loop (Plan → Act → Observe → Wiederholen) +- **mcp_server_adapter.py**: Verbindet den Agent mit MCP-Servern via Konfigurationsdatei +- **mcp_server_adapter_RAG.py**: Erweiterter Adapter mit semantischer Tool-Auswahl (RAG) +- **mcp_server_config.json**: Definiert welche MCP-Server gestartet werden und mit welchen Argumenten +- **servers/**: Die eigentlichen MCP-Tool-Server (Code-Ausführung, Datei-Suche, Web-Suche) ### Workspace (`workspace/`) -- Sandbox directory where agent executes and stores files -- Prevents agent from accessing files outside this directory +- Sandbox-Verzeichnis, in dem der Agent Dateien erstellt und ausführt +- Verhindert, dass der Agent auf Dateien ausserhalb dieses Verzeichnisses zugreift ## Features -- **File Display & Management**: Browse and edit code files -- **Chat Interface**: AI-powered code assistant -- **Code Execution**: Run Python code with debugging -- **Internet Search**: Fetch documentation and examples -- **System Prompts**: Context-aware AI interactions +- **Datei-Verwaltung**: Dateien im Workspace durchsuchen und bearbeiten +- **Chat-Interface**: KI-gestützter Code-Assistent +- **Code-Ausführung**: Python-Code sicher ausführen mit Debug-Output +- **Web-Suche**: Dokumentation und Beispiele via DuckDuckGo abrufen +- **Autonomer Agent**: MCP-basierter Coding-Agent mit Plan-Act-Observe-Loop +- **RAG Tool-Auswahl**: Semantische Tool-Selektion via Sentence Transformers ## Setup -### 1. Project Clonen +### 1. Repository klonen -1. In den Zielordner wechseln -cd /pfad/zum/zielordner - -2. Repository klonen +```bash git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git - -3. In das Projekt wechseln cd AISE1_Project +``` -### 2. Activate Virtual Environment +### 2. Virtuelle Umgebung aktivieren ```bash # Windows @@ -111,43 +107,62 @@ cd AISE1_Project source .venv/bin/activate ``` -### 3. Install Dependencies +### 3. Abhängigkeiten installieren ```bash pip install -r requirements.txt ``` -### 4. Run Application +### 4. Umgebungsvariablen konfigurieren + +```bash +cp .env.example .env +# .env mit API-Keys befüllen +``` + +### 5. Applikation starten ```bash streamlit run frontend/app.py ``` -### 5. Run Tests +### 6. Tests ausführen ```bash -pytest tests/ +pytest tests/ -v ``` -## Architecture +## Konfiguration: `mcp_server_config.json` -The application follows a frontend-backend split: +Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Agent starten soll. Jeder Eintrag enthält den Servernamen, den Startbefehl (`command`) und optionale Argumente (`args`) sowie Umgebungsvariablen (`env`): -- **Frontend**: Streamlit UI components (sidebar, editor, chat) -- **Backend**: Specialized manager modules - - FileManager: File operations - - ChatManager: AI interaction - - SystemPrompter: Prompt management - - SearchManager: Internet search - - ExecutionEngine: Code execution - - DebugLogger: Error handling & logging +```json +{ + "FileSearchServer": { + "command": "py", + "args": ["servers/mcp_server_file_search.py"] + }, + "WebSearchServer": { + "command": "py", + "args": ["servers/mcp_server_web_search.py"], + "env": { "DDGS_API_KEY": "your_key_here" } + } +} +``` -## Development +## Architektur -Use Git to track changes: +``` +Frontend (Streamlit) ──► Backend Manager ──► AI API + │ + └──► Coding Agent ──► MCP-Adapter ──► MCP-Server + (Code / File / Web) +``` + +## Entwicklung ```bash git add . -git commit -m "Your message" -git push origin sturcture +git commit -m "Deine Nachricht" +git push origin main ``` diff --git a/READMEnew.md b/READMEnew.md new file mode 100644 index 0000000..4722fb9 --- /dev/null +++ b/READMEnew.md @@ -0,0 +1,392 @@ +# AISE AI Code Editor — Technische Dokumentation + +AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) + +Diese Datei enthält die ausführliche technische Dokumentation des Projekts. Für eine Kurzübersicht siehe `README.md`. + +--- + +## Inhaltsverzeichnis + +1. [Projektstruktur](#projektstruktur) +2. [Frontend](#frontend) +3. [Backend Manager](#backend-manager) +4. [Backend Agent (MCP-System)](#backend-agent-mcp-system) +5. [MCP-Server-Konfiguration](#mcp-server-konfiguration) +6. [Tests](#tests) +7. [Setup](#setup) +8. [Architektur-Übersicht](#architektur-übersicht) + +--- + +## Projektstruktur + +``` +AISE_AIAgent/ +├── frontend/ # Streamlit UI-Komponenten +│ ├── app.py # Haupteinstiegspunkt der Streamlit-App +│ ├── state.py # Session-State-Verwaltung +│ ├── sidebar.py # Datei-Navigation (Sidebar) +│ ├── editor.py # Code-Editor-Pane +│ └── chat.py # Chat-Interface +│ +├── backend/ # Backend-Logik +│ ├── managers/ # Business-Logik für UI-Operationen +│ │ ├── file_manager.py # Datei-CRUD (lesen, schreiben, listen) +│ │ ├── chat_manager.py # AI-Chat-Verwaltung und -History +│ │ ├── system_prompter.py # System-Prompts und Kontext-Injektion +│ │ ├── search_manager.py # Web-Suche (DuckDuckGo) +│ │ ├── execution_engine.py # Code-Ausführung und Sandboxing +│ │ └── debug_logger.py # Logging, Fehlerbehandlung, Debug-Ausgaben +│ │ +│ └── agent/ # Autonomes AI-Agent-System (MCP-basiert) +│ ├── coding_agent.py # Haupt-Agent-Loop (Plan-Act-Observe) +│ ├── mcp_server_adapter.py # MCP-Adapter: verbindet Agent mit MCP-Servern +│ ├── mcp_server_adapter_RAG.py # MCP-Adapter mit RAG-basierter Tool-Auswahl +│ ├── mcp_server_config.json # Konfiguration der MCP-Server (Startbefehle) +│ └── servers/ # MCP-Server-Implementierungen +│ ├── mcp_server_code_execution.py # Tool: Python-Code ausführen +│ ├── mcp_server_file_search.py # Tool: Dateien suchen und lesen +│ └── mcp_server_web_search.py # Tool: Web-Suche via DuckDuckGo +│ +├── tests/ # Unit-Tests (pytest) +│ ├── conftest.py # Globale Test-Fixtures und MCP-Mocks +│ ├── test_file_manager.py +│ ├── test_chat_manager.py +│ ├── test_execution_engine.py +│ ├── test_coding_agent.py +│ ├── test_debug_logger.py +│ ├── test_system_prompter.py +│ ├── test_mcp_server_code_execution.py +│ ├── test_mcp_server_file_search.py +│ └── test_mcp_server_web_search.py +│ +├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis) +├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent +└── .env.example # Vorlage für Umgebungsvariablen +``` + +--- + +## Frontend + +Das Frontend besteht aus Streamlit-Komponenten, die zusammen eine interaktive Code-Editor-Oberfläche bilden. + +### `app.py` +Haupteinstiegspunkt der Applikation. Orchestriert das Layout und initialisiert alle UI-Komponenten (Sidebar, Editor, Chat). + +### `state.py` +Zentralisierte Verwaltung des Streamlit Session-State. Stellt sicher, dass alle Komponenten denselben Zustand (geöffnete Datei, Chat-History, Agent-Status) teilen. + +### `sidebar.py` +Datei-Browser und Projekt-Navigation. Erlaubt das Durchsuchen des Workspaces und das Öffnen von Dateien im Editor. + +### `editor.py` +Code-Editor-Pane mit Syntax-Highlighting. Ermöglicht das Bearbeiten und Speichern von Code-Dateien direkt im Browser. + +### `chat.py` +Chat-Interface für den KI-Assistenten. Zeigt die Konversations-History und ermöglicht Eingaben an das AI-Modell. + +--- + +## Backend Manager + +Die Manager-Klassen kapseln die Business-Logik und werden direkt vom Frontend aufgerufen. + +### `file_manager.py` +Stellt CRUD-Operationen auf dem Workspace-Verzeichnis bereit: +- Dateien lesen, schreiben, umbenennen, löschen +- Verzeichnisstruktur auflisten +- Sichere Pfadvalidierung (verhindert Path-Traversal) + +> **Designentscheidung — `list_files()` vs. `get_file_tree()`:** +> Die Projektspezifikation nennt `list_files()` als `FileManager`-Methode. Im vorliegenden Design wurde bewusst `get_file_tree()` implementiert, da das Frontend eine verschachtelte Baumstruktur benötigt (für den interaktiven File Explorer in der Sidebar). Eine flache Liste würde die Navigation nicht unterstützen. Für den Agent Mode übernimmt der MCP-Server `mcp_server_file_search.py` die Dateisuche — die Funktionalität ist damit im System vorhanden, nur architektonisch sauber getrennt. + +### `chat_manager.py` +Verwaltet AI-Chat-Interaktionen: +- Aufbau und Verwaltung der Chat-History +- Senden von Nachrichten an das AI-Modell +- Formatierung von System- und User-Nachrichten + +> **Designentscheidung — Fehler-Output im normalen Chat:** +> Laufzeitfehler und stderr-Output werden im normalen Chat bewusst **nicht automatisch** in den Chat-Kontext injiziert. Stattdessen gibt es den "Debug with AI"-Button im Editor, über den der User selbst entscheidet wann er die AI einschalten möchte. Dies verhindert, dass die Chat-History mit ungewollten Fehlermeldungen geflutet wird. Im Agent Mode wird dies anders gelöst: dort landet jeder Execution-Fehler automatisch als Observation im Plan-Act-Observe-Loop und der Agent replant ohne User-Eingriff. + +### `system_prompter.py` +Generiert kontextreiche System-Prompts für den AI-Assistenten: +- Injektion von aktuellem Dateiinhalt als Kontext +- Steuerung des AI-Verhaltens (Coding-Assistent-Persona) + +### `execution_engine.py` +Führt Python-Code sicher aus: +- Subprocess-basierte Code-Ausführung +- Timeout-Schutz und Output-Capture +- Fehler- und Exception-Handling + +### `debug_logger.py` +Logging und Fehler-Tracking: +- Formatierte Log-Ausgaben für Debugging +- `format_debug_output(output)` formatiert den Execution-Output (`stdout`, `stderr`, `return_code`) in einen einheitlichen String für die UI-Anzeige und den AI-Chat-Kontext + +> **Designentscheidung — `log_error()` nicht implementiert:** +> Die Projektspezifikation nennt `log_error()` als `DebugLogger`-Methode. Diese wurde bewusst nicht als separate Methode implementiert, da Python's eingebautes `logging`-Modul diese Funktionalität mit `logger.error()` bereits vollständig abdeckt. Im gesamten Projekt wird konsistent `logger = get_logger(__name__)` gefolgt von `logger.error(...)` verwendet — eine eigene Wrapper-Methode wäre toter Code ohne Mehrwert. + +### `search_manager.py` +Web-Suche für den KI-Assistenten via DuckDuckGo: +- Nutzt die `ddgs`-Bibliothek (DuckDuckGo Search) für API-freie Websuche +- Gibt strukturierte Suchergebnisse zurück (Titel, URL, Snippet) +- Wird vom Chat-Manager aufgerufen, wenn der Assistent externe Dokumentation oder Code-Beispiele benötigt +- Keine API-Key-Konfiguration notwendig (da DuckDuckGo öffentlich zugänglich ist) + +--- + +## Backend Agent (MCP-System) + +Der Agent ist ein autonomes System, das komplexe Coding-Aufgaben selbstständig löst. Er kommuniziert mit externen Tool-Servern über das **Model Context Protocol (MCP)**. + +### `coding_agent.py` +Implementiert den Plan-Act-Observe-Loop: +1. **Plan**: Das AI-Modell wählt das nächste Tool und Argumente +2. **Act**: Das Tool wird via MCP-Adapter aufgerufen (nach User-Bestätigung) +3. **Observe**: Das Ergebnis wird in die Message-History eingefügt +4. Der Loop wiederholt sich bis zur Fertigstellung oder einem `done`-Tool-Aufruf + +Wichtige Klassen und Funktionen: +- `CodingAgent`: Haupt-Klasse mit `start_task()`, `propose_next_action()`, `approve()`, `reject()` +- `truncate_result()`: Kürzt lange Tool-Outputs bevor sie in die History gehen +- `trim_messages()`: Entfernt alte Turns aus der History wenn das Kontextfenster voll wird +- `_strip_code_fences()`: Bereinigt Markdown-Fences aus LLM-JSON-Antworten + +Konstanten: `MAX_ITERATIONS`, `MAX_RESULT_LENGTH`, `MAX_HISTORY_CHARS` + +### `mcp_server_adapter.py` +Verbindet den Coding-Agent mit den MCP-Tool-Servern: +- Liest `mcp_server_config.json` und startet die konfigurierten Server als Subprozesse +- Baut MCP-Sessions via `stdio_client` auf +- Registriert alle verfügbaren Tools aus allen Servern in einem zentralen `tool_registry` +- Delegiert Tool-Aufrufe an den richtigen Server via `call_tool()` + +Hauptmethoden: +- `initialize_all_servers()`: Startet alle Server und baut Sessions auf +- `get_all_tools()`: Gibt alle registrierten Tool-Definitionen zurück +- `call_tool(tool_name, arguments)`: Führt ein Tool auf dem zuständigen Server aus + +### `mcp_server_adapter_RAG.py` +Erweiterter MCP-Adapter mit semantischer Tool-Auswahl via Retrieval-Augmented Generation (RAG): + +**Motivation**: Bei vielen MCP-Tools kann das LLM-Kontextfenster überfüllt werden, wenn alle Tool-Definitionen mitgesendet werden. Der RAG-Adapter löst dies durch semantische Vorauswahl. + +**Funktionsweise**: +1. Beim Initialisieren werden alle Tool-Beschreibungen mit `SentenceTransformer('all-MiniLM-L6-v2')` in Embeddings umgewandelt +2. Bei jedem Agent-Schritt wird der aktuelle Task als Query kodiert +3. Cosine-Similarity zwischen Query- und Tool-Embeddings bestimmt die `top_k` relevantesten Tools +4. Nur diese Tools werden dem LLM als verfügbare Aktionen präsentiert + +Hauptmethoden: +- `initialize_all_sessions()`: Startet Server, baut Sessions auf, erstellt Embedding-Index +- `get_relevant_tools(query, top_k=5)`: Gibt die `top_k` semantisch ähnlichsten Tools zurück +- `call_tool(tool_name, arguments)`: Findet den zuständigen Server und führt das Tool aus +- `shutdown_all_sessions()`: Schliesst alle offenen MCP-Sessions sauber + +Abhängigkeit: `sentence-transformers`, `numpy` + +**Hinweis**: Diese Klasse befindet sich noch in der Entwicklung (Work in Progress). Es gibt bekannte Bugs (z.B. Tippfehler `commanf` statt `command`, falsche Verwendung von `result.get()` vs. `result.tools`). + +--- + +## MCP-Server-Konfiguration + +### Format: `mcp_server_config.json` + +Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Adapter starten soll. Das Format ist ein JSON-Objekt, wobei jeder Key ein frei wählbarer Servername ist: + +```json +{ + "ServerName": { + "command": "py", + "args": ["servers/mcp_server_datei.py"], + "env": { + "API_KEY": "optional_key" + } + } +} +``` + +| Feld | Pflicht | Beschreibung | +|-----------|---------|--------------| +| `command` | Ja | Ausführbares Programm (z.B. `py`, `python3`, `node`) | +| `args` | Ja | Argumente als Array (Pfad zum Server-Script) | +| `env` | Nein | Umgebungsvariablen für den Serverprozess | + +### Aktuelle Server + +```json +{ + "FileSearchServer": { + "command": "py", + "args": ["servers/mcp_server_file_search.py"] + }, + "WebSearchServer": { + "command": "py", + "args": ["servers/mcp_server_web_search.py"], + "env": { "DDGS_API_KEY": "your_ddgs_api_key_here" } + }, + "CodeExecutionServer": { + "command": "py", + "args": ["servers/mcp_server_code_execution.py"] + } +} +``` + +### Neuen MCP-Server hinzufügen + +1. Neues Server-Script in `backend/agent/servers/` erstellen (MCP-konformes Python-Script) +2. Eintrag in `mcp_server_config.json` ergänzen: + ```json + "MeinNeuerServer": { + "command": "py", + "args": ["servers/mcp_server_mein_tool.py"] + } + ``` +3. Der Adapter erkennt den neuen Server beim nächsten Start automatisch und registriert seine Tools + +--- + +## Tests + +### Tests ausführen + +```bash +# Alle Tests ausführen +pytest tests/ -v + +# Einzelnes Test-Modul ausführen +pytest tests/test_coding_agent.py -v + +# Tests mit Kurzausgabe +pytest tests/ +``` + +### Teststruktur + +Die Tests liegen in `tests/` und folgen dem Muster `test_.py`. + +#### `conftest.py` +Globale Pytest-Konfiguration. Patcht den `MCPToolAdapter` auf `sys.modules`-Ebene, bevor irgendein Test-Modul importiert wird. Dadurch werden beim Import von `coding_agent` keine echten MCP-Subprozesse gestartet. Der Mock-Adapter liefert sofort leere Ergebnisse zurück. + +#### Was wird getestet + +| Test-Datei | Getestetes Modul | Schwerpunkt | +|---|---|---| +| `test_file_manager.py` | `backend/managers/file_manager.py` | Datei-CRUD, Pfad-Validierung | +| `test_chat_manager.py` | `backend/managers/chat_manager.py` | Chat-History, Nachrichtenformatierung | +| `test_execution_engine.py` | `backend/managers/execution_engine.py` | Code-Ausführung, Timeouts, Fehlerbehandlung | +| `test_system_prompter.py` | `backend/managers/system_prompter.py` | Prompt-Generierung, Kontext-Injektion | +| `test_debug_logger.py` | `backend/managers/debug_logger.py` | Log-Formatierung, Fehler-Aggregation | +| `test_coding_agent.py` | `backend/agent/coding_agent.py` | Agent-Loop, Tool-Dispatch, History-Trimming | +| `test_mcp_server_code_execution.py` | `backend/agent/servers/mcp_server_code_execution.py` | MCP Code-Execution-Tool | +| `test_mcp_server_file_search.py` | `backend/agent/servers/mcp_server_file_search.py` | MCP Datei-Such-Tool | +| `test_mcp_server_web_search.py` | `backend/agent/servers/mcp_server_web_search.py` | MCP Web-Such-Tool | + +#### Testklassen in `test_coding_agent.py` + +- **`TestTruncateResult`**: Prüft, dass lange Tool-Outputs korrekt gekürzt werden +- **`TestTrimMessages`**: Prüft, dass alte History-Turns entfernt werden wenn der Kontext zu gross wird; System-Message und Original-Task bleiben immer erhalten +- **`TestStripCodeFences`**: Prüft, dass Markdown-Codeblöcke aus LLM-Antworten entfernt werden +- **`TestCodingAgentInit`**: Prüft initialen Zustand und `start_task()`-Reset-Verhalten +- **`TestProposeNextAction`**: Prüft den API-Aufruf-Zyklus mit gemockter API; testet Fehler-Handling (JSON-Parse-Fehler, API-Exceptions, Max-Iterations) +- **`TestApprove`**: Prüft `approve()` mit gemocktem `dispatch_tool`; testet Tool-Ergebnis-Injektion und Error-Replan-Tagging +- **`TestReject`**: Prüft, dass `reject()` das Feedback korrekt in die History injiziert und kein Tool ausführt + +#### Test-Konventionen + +- MCP-Server werden in Tests **nicht** als echte Subprozesse gestartet (via `conftest.py`-Mock) +- Streamlit-Aufrufe werden mit `patch("modul.st")` gemockt +- Dateisystem-Tests nutzen `tmp_path` (pytest-Fixture) für isolierte temporäre Verzeichnisse +- Async-Tests verwenden `@pytest.mark.asyncio` (benötigt `pytest-asyncio`) + +--- + +## Setup + +### 1. Repository klonen + +```bash +git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git +cd AISE1_Project +``` + +### 2. Virtuelle Umgebung aktivieren + +```bash +# Windows +.\.venv\Scripts\Activate.ps1 + +# macOS/Linux +source .venv/bin/activate +``` + +### 3. Abhängigkeiten installieren + +```bash +pip install -r requirements.txt +``` + +### 4. Umgebungsvariablen konfigurieren + +```bash +cp .env.example .env +# .env mit API-Keys befüllen +``` + +### 5. Applikation starten + +```bash +streamlit run frontend/app.py +``` + +### 6. Tests ausführen + +```bash +pytest tests/ -v +``` + +--- + +## Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (Streamlit) │ +│ app.py → sidebar.py / editor.py / chat.py │ +│ │ │ +│ state.py (Session-State) │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend Manager │ +│ FileManager / ChatManager / SystemPrompter / │ +│ SearchManager / ExecutionEngine / DebugLogger │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Coding Agent (backend/agent/) │ +│ coding_agent.py ←→ mcp_server_adapter.py │ +│ │ │ +│ mcp_server_config.json │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ mcp_server_file_search mcp_server_web mcp_server_code │ +│ │ +│ (Optional: mcp_server_adapter_RAG.py für semantische │ +│ Tool-Auswahl via Sentence Transformers) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ workspace/ │ +│ Isoliertes Sandbox-Verzeichnis für Agent-Dateien │ +└─────────────────────────────────────────────────────────────────┘ +``` diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index fd0a371..49fc1b4 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -18,18 +18,21 @@ import os import re from pathlib import Path import asyncio -import pprint import requests from dotenv import load_dotenv +# REVIEW: commented-out import — remove once the package import above is confirmed stable. #from mcp_server_adapter import MCPToolAdapter # Import from current directory for easier testing without package structure from backend.agent.mcp_server_adapter import MCPToolAdapter +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + # ── mcp server initialization ──────────────────────────────────────────────────────────────── adapter = MCPToolAdapter() -print("MCPToolAdapter created. Listing all tools from servers...") +logger.info("MCPToolAdapter created. Listing all tools from servers...") asyncio.run(adapter.initialize_all_servers()) -print("listed tools from all servers") +logger.info("Listed tools from all servers") load_dotenv() @@ -49,41 +52,63 @@ MAX_HISTORY_CHARS = 80_000 # ═════════════════════════════════════════════════════════════════════════════ def build_all_tool_description() -> str: - """Get relevant tools from the MCP servers based on the query.""" + """Build a formatted string listing every registered MCP tool. + The returned string is embedded verbatim in the SYSTEM_PROMPT so the LLM + knows which tools exist and what arguments they expect. + + Returns: + Newline-separated list of tool descriptions in the format + ``"- : "``. + """ all_tools = adapter.get_all_tools() - print(f"Building tool description for {len(all_tools)} tools.") + logger.info("Building tool description for %s tools.", str(len(all_tools))) descriptions = [] for tool in all_tools: - pprint.pprint(f"{tool}") descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}") - + return "\n".join(descriptions) async def dispatch_tool(tool_name: str, arguments: dict) -> str: - """Call a tool by name with the given arguments using the MCP adapter.""" + """Execute a named tool and return its output as a plain string. + Handles the special "done" pseudo-tool locally (it signals completion and + is never forwarded to an MCP server). All other tools are forwarded to the + MCPToolAdapter which routes them to the correct MCP server process. + + Args: + tool_name: Name of the tool to execute (e.g. "write_file", "done"). + arguments: Dict of arguments for the tool. + + Returns: + The tool's text output, a "DONE: ..." completion message, or an error + string beginning with "Tool error:" / "Error calling tool:" on failure. + """ if tool_name == "done": - # Handle the "done" tool locally since it's not an MCP tool + # The "done" tool is a sentinel — it lives only in the agent protocol, + # not in any MCP server, so we resolve it directly here. summary = arguments.get("summary", "Task completed.") return f"DONE: {summary}" - + try: - print(f"Trying to call tool '{tool_name}' with arguments: {arguments}") - print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...") + logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name) result = await adapter.call_tool(tool_name, arguments) - print(f"Raw result from tool '{tool_name}': {result}") + logger.info(f"Result from tool '%s' recieved", tool_name) if result.isError: + # MCP servers signal tool-level errors via the isError flag rather + # than raising exceptions, so we surface them explicitly. texts = [block.text for block in result.content if block.type == "text"] + logger.warning("Result from '%s' is Error", tool_name) return f"Tool error: {' '.join(texts)}" - + texts = [block.text for block in result.content if block.type == "text"] - return "\n".join(texts) - + return "\n".join(texts) + except Exception as e: + logger.exception(f"Error calling tool '%s' with argument: %s", tool_name, arguments) return f"Error calling tool '{tool_name}': {e}" # ═════════════════════════════════════════════════════════════════════════════ @@ -157,6 +182,7 @@ Example: def truncate_result(result: str) -> str: """Truncate a tool result that exceeds MAX_RESULT_LENGTH.""" + logger.info("Result has been truncated") if len(result) <= MAX_RESULT_LENGTH: return result half = MAX_RESULT_LENGTH // 2 @@ -168,23 +194,35 @@ def truncate_result(result: str) -> str: def trim_messages(messages: list) -> list: - """Drop old messages when history exceeds MAX_HISTORY_CHARS. - Always keeps the system prompt (index 0) and original task (index 1). + """Kürzt die Konversations-History wenn sie das Kontextfenster überschreitet. + + Behält immer den System-Prompt (Index 0) und die ursprüngliche Aufgabe (Index 1). + Entfernt die ältesten Nachrichten zuerst und injiziert danach einen Erinnerungs- + Hinweis damit der Agent den Überblick behält. + + Args: + messages: Vollständige Konversations-History als Liste von {role, content} Dicts. + + Returns: + Gekürzte History mit maximal MAX_HISTORY_CHARS Zeichen, immer mit Head + Reminder + Tail. """ + logger.info("Message is being trimmed") + total = sum(len(m["content"]) for m in messages) if total <= MAX_HISTORY_CHARS: return messages + # Protect the two anchor messages that must never be discarded. head = messages[:2] tail = messages[2:] original_task = messages[1]["content"] if len(messages) > 1 else "" - # Drop the oldest messages first (index 2 onwards) until we are under the limit. - # The system prompt (0) and original task (1) are never dropped. + # Drop the oldest non-anchor messages first until we are under the limit. while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS: tail.pop(0) - # Inject a reminder so the agent doesn't lose track of its goal after trimming. + # After trimming, inject a reminder so the agent doesn't lose track of its goal. + # Without this the agent might restart the task or repeat work it already did. reminder = { "role": "user", "content": ( @@ -197,13 +235,22 @@ def trim_messages(messages: list) -> list: return head + [reminder] + tail def _repair_json_strings(text: str) -> str: - """ - Replace unescaped control characters (newline, tab, carriage return) - inside JSON string values with their proper escape sequences. + """Replace unescaped control characters inside JSON string values. - LLMs frequently emit literal newlines inside long string values, which - is invalid JSON. This function fixes that without touching structural - whitespace outside strings. + LLMs frequently emit literal newlines, tabs, or carriage-returns inside + long string values (e.g. code content), which is invalid JSON. This + function replaces those characters with their proper ``\\n`` / ``\\t`` / + ``\\r`` escape sequences without touching structural whitespace that lives + outside of string literals. + + The parser is a simple state-machine that tracks whether the current + character is inside a quoted string, respecting backslash escapes. + + Args: + text: Raw JSON text that may contain unescaped control characters. + + Returns: + Repaired JSON text with control characters properly escaped inside strings. """ result: list[str] = [] in_string = False @@ -211,6 +258,8 @@ def _repair_json_strings(text: str) -> str: _escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'} for ch in text: if escape: + # The previous character was a backslash — emit this char literally + # and reset the escape flag. result.append(ch) escape = False continue @@ -219,10 +268,12 @@ def _repair_json_strings(text: str) -> str: escape = True continue if ch == '"': + # Toggle string-mode on every unescaped double quote. in_string = not in_string result.append(ch) continue if in_string and ch in _escapes: + # Replace the bare control character with its escape sequence. result.append(_escapes[ch]) continue result.append(ch) @@ -286,13 +337,25 @@ def extract_json(text: str) -> str: def _strip_code_fences(text: str) -> str: - """Remove markdown code fences (```json ... ```) from a string.""" + """Remove a single wrapping markdown code fence from a string. + + Handles both `` ```json `` and plain `` ``` `` opening fences. If the text + does not start with a fence the string is returned unchanged. + + Args: + text: Raw LLM response that may be wrapped in a markdown code block. + + Returns: + The text with the opening fence line and optional closing `` ``` `` line + removed, stripped of surrounding whitespace. + """ if text is None: return "" - + text = text.strip() if text.startswith("```"): lines = text.split("\n") + # Omit the last line only if it is a closing fence; otherwise keep everything. end = -1 if lines[-1].strip() == "```" else len(lines) text = "\n".join(lines[1:end]) return text.strip() @@ -329,9 +392,7 @@ class CodingAgent: self.api_key = os.getenv("API_KEY") self.model = os.getenv("MODEL") - #async def _call_api(self, messages: list) -> str: def _call_api(self, messages: list) -> str: - """Make a raw API call and return the response content string.""" headers = {"Content-Type": "application/json"} @@ -341,20 +402,37 @@ class CodingAgent: payload = { "model": self.model, "messages": messages, - "temperature": 0.2, # low temperature → deterministic, more reliable tool calls + # Low temperature keeps the agent's tool selections deterministic and + # reduces the chance of hallucinated tool names or argument formats. + "temperature": 0.2, "max_tokens": 4096, "stream": False, } - - response = requests.post(self.api_url, headers=headers, json=payload, timeout=60) - - if response.status_code != 200: - raise Exception(f"API Error {response.status_code}: {response.text}") + try: + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=60) + response.raise_for_status() + logger.info("LLM API response requested") + + if response.status_code != 200: + logger.error("API Error %s: %s", response.status_code, response.text) + raise Exception(f"API Error {response.status_code}: {response.text}") + except requests.RequestException as exc: + logger.exception("API Error; HTTP-Fehler: %s", exc) + raise Exception(f"HTTP-Fehler: {exc}") from exc + data = response.json() if "choices" in data and len(data["choices"]) > 0: + logger.info("valid API output, data returned") return data["choices"][0]["message"]["content"] + + logger.error("Invalid API response format") raise Exception("Invalid API response format") + # ── Public interface ────────────────────────────────────────────────────── @@ -367,6 +445,7 @@ class CodingAgent: self.pending_action = None self.is_done = False self.iteration = 0 + logger.info("New ask initialized") async def propose_next_action(self) -> dict: """Ask the LLM what to do next. @@ -393,6 +472,7 @@ class CodingAgent: raw = _strip_code_fences(raw) cleaned = extract_json(raw) action = json.loads(cleaned) + logger.info("Propose next action successfull") except json.JSONDecodeError: action = { "thought": "Could not parse LLM response as JSON.", @@ -400,6 +480,7 @@ class CodingAgent: "arguments": {"summary": "Stopped: JSON parse error."}, } raw = json.dumps(action) + logger.critical("Parsing API response into valid JASON failed in Step 'propose_next_action'") except Exception as e: action = { "thought": f"API call failed: {e}", @@ -407,6 +488,7 @@ class CodingAgent: "arguments": {"summary": f"Stopped: {e}"}, } raw = json.dumps(action) + logger.critical("API call faliled in Step %s: %s", self.iteration, e) self.pending_action = {"raw": raw, "action": action} return action @@ -429,6 +511,8 @@ class CodingAgent: self.messages.append({"role": "assistant", "content": raw}) self.pending_action = None + logger.info("Messages prepared after approval") + # Handle completion if tool_name == "done": self.is_done = True @@ -442,6 +526,7 @@ class CodingAgent: # Execute the tool result = await dispatch_tool(tool_name, arguments) result = truncate_result(result) + logger.info("Tool called and result truncated") # Wrap the tool output in an XML tag so the LLM can easily find it. # Append a tag on errors to force the agent to reconsider @@ -453,6 +538,7 @@ class CodingAgent: "Re-examine your plan: what went wrong and what should you do differently? " "State your revised plan in your next thought." ) + logger.warning("Error Message in the tool result, replan-feedback will appended") self.messages.append({"role": "user", "content": feedback}) @@ -480,6 +566,7 @@ class CodingAgent: "address their question accordingly." ), }) + logger.info("Follow-up message appended.") def reject(self, feedback: str) -> None: """Reject the pending action and inject user feedback. @@ -506,7 +593,10 @@ class CodingAgent: ), }) self.pending_action = None + logger.info("Rejection message appended.") +# REVIEW: dead code — this module is always imported, never run as a script. +# The __main__ guard below is unreachable in normal use. Move this to run_agent.py or delete it. def main(): """Example of how to use the CodingAgent in a simple loop.""" agent = CodingAgent() @@ -533,7 +623,8 @@ def main(): feedback = input("Enter feedback for the agent: ") agent.reject(feedback) - + # REVIEW: unreachable when action["tool"] == "done" (we break above); also `result` is + # unbound when the elif branch runs — this will raise UnboundLocalError at runtime. if result["is_done"]: print("Task completed.") break diff --git a/backend/agent/mcp_server_adapter.py b/backend/agent/mcp_server_adapter.py index c87b5ae..7229693 100644 --- a/backend/agent/mcp_server_adapter.py +++ b/backend/agent/mcp_server_adapter.py @@ -1,3 +1,15 @@ +"""Adapter layer between the CodingAgent and one or more MCP tool servers. + +MCPToolAdapter reads a JSON config file that lists MCP server processes, spawns +each process via stdio, queries its available tools, and stores them in a flat +registry. At call time it re-spawns the appropriate server process, executes +the requested tool, and returns the raw MCP result object. + +Design note: connections are opened per-call (not kept alive) because Streamlit +reruns make it impractical to maintain long-lived async context managers across +the synchronous/asynchronous boundary. +""" + import asyncio import json import sys @@ -7,59 +19,91 @@ from pathlib import Path from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + class MCPToolAdapter: + """Discovers and dispatches MCP tools from one or more stdio-based MCP servers. + + Workflow: + 1. Call ``initialize_all_servers()`` once at startup to populate the + tool registry from every server listed in the config file. + 2. Call ``get_all_tools()`` to retrieve the registry for building the + system-prompt tool description. + 3. Call ``call_tool(name, arguments)`` whenever the agent wants to + execute a tool. The adapter resolves the owning server, opens a + fresh connection, and returns the MCP result object. + + Attributes: + config_path: Path (relative to this file) of the JSON server config. + servers: Dict mapping server name → raw config params dict. + tool_registry: Flat list of registered tool dicts, each containing + "server", "tool_name", and "tool_description". + """ + def __init__(self, config_path: str = "mcp_server_config.json"): self.config_path = config_path self.servers: Dict[str, Dict] = {} self.tool_registry: List[Dict[str, Any]] = [] def _load_config(self) -> Dict[str, Any]: - """Lädt die Server-Konfiguration aus der JSON-Datei.""" + """Load the MCP server configuration from the JSON file next to this module. + + Returns: + Parsed config dict, or an empty dict if the file is missing or invalid. + """ path = Path(__file__).parent / self.config_path if not path.exists(): - print(f"Config file not found: {path}") + logger.warning("Config file not found: %s", path) return {} - + try: with open(path, 'r') as f: - return json.load(f) + config_file = json.load(f) + logger.info("MCP-Server config loaded successfully") + return config_file except json.JSONDecodeError as e: - print(f"Error decoding JSON config: {e}") + logger.critical("Error decoding JSON from server config: %s", e) return {} async def initialize_all_servers(self): """Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren.""" - print("Initializing MCP sessions...") config = self._load_config() - print(f"Loaded config for servers: {list(config.keys())}") + logger.info("Loaded config for servers: %s", list(config.keys())) for server_name, params in config.items(): - print(f"Testing connection to {server_name}...") + logger.info("Initializing connection to %s", server_name) self.servers[server_name] = params server_script = str(Path(__file__).parent / params["args"][0]) + + # Always use the current Python interpreter so the server runs in the + # same virtual environment as the adapter, regardless of the literal + # command string in the config ("py", "python", "python3"). if params.get("command") in ["py", "python", "python3"]: server_command = sys.executable else: server_command = params["command"] - + server_params = StdioServerParameters( command=server_command, args=[server_script], ) - + try: - # Verbindung aufbauen async with stdio_client(server_params) as (read_stream, write_stream): - print(f"Connected to {server_name}. Initializing session...") + logger.info("Connected to %s. Initializing session...", server_name) async with ClientSession(read_stream, write_stream) as session: await session.initialize() - print(f"Session initialized for {server_name}. Requesting tools...") + logger.info("Session initialized for %s. Requesting tools...", server_name) result = await session.list_tools() + # REVIEW: debug print — remove before shipping. + print(f"Tools received from {server_name}: {result}") tools = result.tools - print(f"Tools received from {server_name}: {len(tools)} Tools") + logger.info(f"Tools received from %s: %s Tools", server_name, str(len(tools))) for tool in tools: + # Build a human-readable parameter description for the system prompt. t_params = tool.inputSchema.get("properties", {}) if t_params: param_lines = [] @@ -73,29 +117,44 @@ class MCPToolAdapter: t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}" - self.tool_registry.append({ "server": server_name, "tool_name": tool.name, "tool_description": t_definition }) - print(f"Registered tool '{tool.name}' from {server_name}.") + logger.info("Registered tool '%s' from %s.", tool.name, server_name) except Exception as e: - print(f"Failed to initialize {server_name}: {e}") + logger.exception("Failed to initialize %s: %s", server_name, str(e)) def get_all_tools(self) -> List[Dict[str, Any]]: - """Gibt alle gesammelten Tools zurück.""" + """Return the full list of registered tools across all servers. + + Returns: + List of dicts, each with keys "server", "tool_name", "tool_description". + """ return self.tool_registry async def call_tool(self, tool_name: str, arguments: Dict[str, Any]): - """Findet den richtigen Server für ein Tool und führt es aus.""" - # Suche in der Registry nach dem passenden Server + """Look up a tool in the registry, connect to its server, and execute it. + + Opens a fresh stdio connection for every call. This is intentionally + stateless so that server crashes or restarts are fully transparent. + + Args: + tool_name: Name of the tool to call (must be in the registry). + arguments: Key-value arguments passed verbatim to the MCP server. + + Returns: + The raw MCP ``CallToolResult`` object on success, or an error string + if the tool is not found or the server raises an exception. + """ + # Look up which server owns this tool. tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None) - + if not tool_entry: - print(f"Tool '{tool_name}' not found in MCP adapter registry.") + logger.warning("Tool '%s' not found in MCP adapter registry.", tool_name) return f"Error: Tool '{tool_name}' not found in registry." server_name = tool_entry["server"] @@ -103,11 +162,12 @@ class MCPToolAdapter: if s_params: server_script = str(Path(__file__).parent / s_params["args"][0]) + # Normalise the interpreter command the same way as in initialize_all_servers(). if s_params.get("command") in ["py", "python", "python3"]: server_command = sys.executable else: server_command = s_params["command"] - + server_params = StdioServerParameters( command=server_command, args=[server_script], @@ -117,24 +177,15 @@ class MCPToolAdapter: async with stdio_client(server_params) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() + logger.info("Session successfully initialized, calling tool '%s' on server '%s", tool_name, server_name) result = await session.call_tool(tool_name, arguments) return result except Exception as e: + logger.exception("Error calling tool '%s' on server '%s': %s", tool_name, server_name, str(e)) return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}" - - return f"Error: Session for server '{server_name}' not active." - - async def shutdown_all_sessions(self): - """Schließt alle offenen Verbindungen sauber.""" - for server_name, (transport_gen, session) in self.exit_stack.items(): - try: - await session.__aexit__(None, None, None) - await transport_gen.__aexit__(None, None, None) - print(f"Session for {server_name} shut down.") - except Exception as e: - print(f"Error during shutdown of {server_name}: {e}") def main(): + """Debug Function for Tool-Registry""" adapter = MCPToolAdapter() asyncio.run(adapter.initialize_all_servers()) print("All servers initialized. Registered tools:") diff --git a/backend/agent/mcp_server_config.json b/backend/agent/mcp_server_config.json index 76ede4d..ade2521 100644 --- a/backend/agent/mcp_server_config.json +++ b/backend/agent/mcp_server_config.json @@ -5,10 +5,7 @@ "WebSearchServer": { "command": "py", - "args": ["servers/mcp_server_web_search.py"], - "env": { - "DDGS_API_KEY": "your_ddgs_api_key_here" - } + "args": ["servers/mcp_server_web_search.py"] }, "CodeExecutionServer": { diff --git a/backend/agent/servers/mcp_server_code_execution.py b/backend/agent/servers/mcp_server_code_execution.py index c2608e5..66a0ed9 100644 --- a/backend/agent/servers/mcp_server_code_execution.py +++ b/backend/agent/servers/mcp_server_code_execution.py @@ -1,3 +1,19 @@ +"""MCP server that provides sandboxed Python code execution tools. + +Exposes the following MCP tools to the CodingAgent: + - analyse_structure — AST-based structural summary of Python code + - lint_code — pyflakes static analysis + - list_sandbox_packages — list packages installed in the sandbox venv + - install_package_into_sandbox — pip install into the sandbox venv + - reset_sandbox — wipe and recreate the sandbox venv + - run_python_code_sandboxed — execute Python code inside the sandbox + - python_code_validation — syntax + safety check without execution + +The sandbox is an isolated virtual environment created on first use. +All code submitted for execution is first checked by a static analyser that +blocks dangerous imports and builtins before spawning any subprocess. +""" + import ast import subprocess import sys @@ -8,8 +24,11 @@ from pyflakes.reporter import Reporter # For linting Code from mcp.server.fastmcp import FastMCP from pathlib import Path +#from backend.managers.debug_logger import get_logger +#logger = get_logger(__name__) + # ── Configuration ──────────────────────────────────────────────────────────── -EXEC_TIMEOUT = 45 # seconds before killing the subprocess +EXEC_TIMEOUT = 15 # seconds before killing the subprocess MAX_OUTPUT_LENGTH = 3000 # max characters of stdout+stderr to return # ── Create the MCP server ──────────────────────────────────────────────────── @@ -48,8 +67,9 @@ FORBIDDEN_SEQUENCES = ["../", "..\\", "/etc/", "/dev/", "C:\\Windows", "C:\\Program Files", "C:\\Users", "compile(", "__import__", "os.", "sys.", "subprocess."] -ALLOWED_PACKAGES = ["pygame", "numpy", "pandas"] - +""" +Pre-installed Packages in Sandbox: "pygame", "numpy", "pandas" +""" # ── Static Analysis ──────────────────────────────────────────────────── def check_code_safety(code: str) -> str | None: """ @@ -62,10 +82,13 @@ def check_code_safety(code: str) -> str | None: str or None Error message if forbidden code found, None if safe. """ + #logger.info("Checking code safety.") try: tree = ast.parse(code) + #logger.info("Code has valid Syntax") except SyntaxError as e: + #logger.exception("SyntaxError: %s", e) return f"SyntaxError: {e}" for node in ast.walk(tree): @@ -73,6 +96,7 @@ def check_code_safety(code: str) -> str | None: for alias in node.names: top_level_module = alias.name.split('.')[0] if top_level_module in BLOCKED_IMPORTS: + #logger.warning("Blocked import '%s'", alias.name) return (f"Blocked import: Import of '{alias.name}' is not allowed." f"line {node.lineno}") @@ -80,16 +104,19 @@ def check_code_safety(code: str) -> str | None: if node.module: top_level = node.module.split(".")[0] if top_level in BLOCKED_IMPORTS: + #logger.warning("Blocked import from '%s'", alias.name) return (f"Blocked import: Import from '{node.module}' is not allowed." f"(module '{top_level}' is blocked) line {node.lineno}") elif isinstance(node, ast.Call): if isinstance(node.func, ast.Name): if node.func.id in BLOCKED_BUILTINS: + #logger.warning("Blocked ubiltin '%s'", node.func.id) return f"Blocked builtin: Use of builtin '{node.func.id}' is not allowed." for seq in FORBIDDEN_SEQUENCES: if seq in code: + #logger.warning("Suspect path sequence '%s' detected.", seq) return f"Blocked: Suspect path sequence '{seq}' detected." return None # No violations found @@ -104,11 +131,16 @@ def analyse_structure(code: str) -> str: Returns: A summary of the code's structure, including functions, classes, and imports. """ + #logger.info("Tool analyse_structure is being executed on MCP code execution server") + try: tree = ast.parse(code) + #logger.info("Code tree parsed successfully") except SyntaxError as e: + #logger.warning("Syntax Error in provided code. Line %s : %s", e.lineno, e.msg) return f"Syntax Error: Invalid Python code provided. Line {e.lineno}: {e.msg}" except Exception as e: + #logger.exception("Error parsing code: %s", str(e)) return f"Error parsing code: {str(e)}" analysis = { @@ -164,6 +196,7 @@ def analyse_structure(code: str) -> str: lines.append(f" - def {func['name']}({args_str})") if not any([analysis["imports"], analysis["classes"], analysis["functions"]]): + #logger.info("Code analysis successfull but no top-level items found") return "Analysis complete: No top-level imports, classes, or functions found." return "\n".join(lines) @@ -180,6 +213,8 @@ def lint_code(code: str) -> str: Returns: A report of linting issues or a success message if the code is clean. """ + #logger.info("Tool lint_code is being executed on MCP code execution server") + error_buffer = io.StringIO() warning_buffer = io.StringIO() @@ -187,7 +222,9 @@ def lint_code(code: str) -> str: try: check(code, filename="", reporter=reporter) + #logger.info("Linting successfull") except Exception as e: + #logger.exception("Critical error during linting: %s", str(e)) return f"Critical error during linting: {str(e)}" errors = error_buffer.getvalue().strip() @@ -195,6 +232,7 @@ def lint_code(code: str) -> str: # Ergebnis-String zusammenbauen if not errors and not warnings: + #logger.info("No issues found") return "Linting complete: No issues found. The code is syntactically sound." report = ["--- Linting Report ---"] @@ -209,6 +247,7 @@ def lint_code(code: str) -> str: report.append("\nAdvice: Please fix these issues before attempting to execute the code.") + #logger.info("There are issues with provided code. Check Report: %s", "\n".join(report)) return "\n".join(report) @mcp.tool() @@ -228,7 +267,9 @@ def run_python_sandboxed(code: str) -> str: Returns: Combined stdout+stderr, or an error message in str format. """ + #logger.info("Tool run_python_sandboxed is being executed on MCP code execution server") + # Reject code that references blocked modules or builtins before spawning a process. static_safety = check_code_safety(code) if static_safety: return f"Code rejected:{static_safety}" @@ -240,44 +281,59 @@ def run_python_sandboxed(code: str) -> str: capture_output=True, text=True, timeout=EXEC_TIMEOUT) - + + # Merge stdout and stderr so the agent sees all output in one block. output = result.stdout + result.stderr if len(output) > MAX_OUTPUT_LENGTH: output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..." - + if not output.strip(): return "Code executed successfully (no output)." + #logger.info("Code ran successfully") return output - + except subprocess.TimeoutExpired: + #logger.warning("Code execution exceeded time limit of %s seconds", EXEC_TIMEOUT) return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated." + except Exception as e: + #logger.exception("Error during code execution: %s", str(e)) return f"Error during code execution: {e}" @mcp.tool() def python_code_validation(code: str) -> str: - """ - Validate Python code for syntax and safety without executing it. - This tool performs static analysis to check for syntax errors. - + """Validate Python code for syntax correctness and sandbox safety without executing it. + + Performs two checks in sequence: + 1. AST parsing to catch syntax errors. + 2. check_code_safety() to detect blocked imports/builtins/path sequences. + Args: - code: The Python code to validate in str format. + code: The Python source code to validate. + Returns: - A message indicating the validation result. - And if sandboxed test execution is allowed. + A message indicating whether the code is valid and safe, or describing + the first violation found. Returns None implicitly when the code is + both syntactically valid and safe (no safety concerns found). """ + #logger.info("Tool python_code_validation is being executed on MCP code execution server") + try: ast.parse(code) + #logger.info("Ast parsing successfull") except SyntaxError as e: + #logger.warning("Syntax Error while ast parsing code: %s", str(e)) return f"SyntaxError: {e}" - try: + try: static_analysis_result = check_code_safety(code) if static_analysis_result: + #logger.info("Code safety issues detected") return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed." except Exception as e: + #logger.exception("Error during code safety analysis: %e", str(e)) return f"Error during code safety analysis: {e}" return "Code is valid and can be executed in the sandbox" diff --git a/backend/agent/servers/mcp_server_file_search.py b/backend/agent/servers/mcp_server_file_search.py index 65180bc..79cedd6 100644 --- a/backend/agent/servers/mcp_server_file_search.py +++ b/backend/agent/servers/mcp_server_file_search.py @@ -1,10 +1,30 @@ +"""MCP server that provides file system read/write tools for the workspace directory. + +All operations are restricted to ALLOWED_DIR (the project workspace). Paths +that resolve outside this boundary are rejected with a ValueError so the agent +cannot accidentally read or write arbitrary host-filesystem locations. + +Exposes the following MCP tools: + - list_files — flat list of all workspace files + - get_file_tree — tree-formatted directory listing + - search_files — search file names and content + - read_file — read a single file + - write_new_file — create a new file (no overwrite) + - create_new_directory — create a new directory +""" + from pathlib import Path from mcp.server.fastmcp import FastMCP +#from backend.managers.debug_logger import get_logger +#logger = get_logger(__name__) + # ── Configuration ──────────────────────────────────────────────────────────── +# Navigate four levels up from servers/ to the project root, then into workspace/. project_dir = Path(__file__).resolve().parent.parent.parent.parent ALLOWED_DIR = project_dir / "workspace" -ALLOWED_FILE_TYPES = [".py",".js",".html",".css",".json",".yaml",".yml",".sh",".md",".txt",".tex",".c",".cpp",".java"] +ALLOWED_FILE_TYPES = [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml", + ".sh", ".md", ".txt", ".tex", ".c", ".cpp", ".java"] # ── Create the MCP server ──────────────────────────────────────────────────── mcp = FastMCP("FileSearchServer") @@ -15,10 +35,12 @@ def _safe_path(requested: str) -> Path: """Resolve and validate a path is inside ALLOWED_DIR.""" resolved = (ALLOWED_DIR / requested).resolve() if not str(resolved).startswith(str(ALLOWED_DIR)): + #logger.warning("Access denied: '%s' resolves outside allowed directory.", str(requested)) raise ValueError( f"Access denied: '{requested}' resolves outside " f"the allowed directory '{ALLOWED_DIR}'" ) + #logger.info("Requested path is safe") return resolved @@ -30,6 +52,8 @@ def list_files() -> str: Returns a newline-separated list of relative file paths. """ + #logger.info("Tool list_files is being executed on MCP file search server") + files = sorted( f.relative_to(ALLOWED_DIR) for f in ALLOWED_DIR.rglob("*") @@ -50,30 +74,54 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str: Returns: A string representing the directory structure, similar to 'tree' command output. """ + #logger.info("Tool get_file_tree is being executed on MCP file search server") try: safe_dir = _safe_path(dir_path) + # REVIEW: unreachable code — _safe_path() always returns a Path object (never None/falsy) + # or raises ValueError; this check can never be True. if not safe_dir: return f"Error: Invalid directory path '{dir_path}'." elif not safe_dir.exists(): + #logger.warning("Directory '%s' does not exist.", dir_path) return f"Error: Directory '{dir_path}' does not exist." elif not safe_dir.is_dir(): + #logger.warning("'%s' is not a valid directory", dir_path) return f"Error: '{dir_path}' is not a valid directory within the allowed path." except ValueError as e: + #logger.exception("Error while checking directory and its path: %s", str(e)) return f"Error: {e}" - + #logger.info("Generating file tree.") def _tree(dir_path: Path, prefix="") -> str: - entries = sorted([e for e in dir_path.iterdir() if "__pycache__" not in e.parts], key=lambda x: (x.is_file(), x.name)) + """Recursively build a tree string for the given directory. + + Directories are sorted before files (``key=lambda x: (x.is_file(), x.name)`` + puts dirs first because False < True). __pycache__ entries are hidden. + + Args: + dir_path: The directory to render. + prefix: Indentation prefix accumulated during recursion. + + Returns: + Multi-line string representing the subtree. + """ + # Exclude __pycache__ at every level to keep output readable for the agent. + entries = sorted( + [e for e in dir_path.iterdir() if "__pycache__" not in e.parts], + key=lambda x: (x.is_file(), x.name) # directories first, then files + ) lines = [] for i, entry in enumerate(entries): + # Use └── for the last entry to close the branch visually. connector = "└── " if i == len(entries) - 1 else "├── " lines.append(f"{prefix}{connector}{entry.name}") if entry.is_dir(): + # Extend prefix with a blank column (last item) or │ (more items follow). extension = " " if i == len(entries) - 1 else "│ " lines.append(_tree(entry, prefix + extension)) return "\n".join(lines) - return _tree(dir_path) + return _tree(Path(dir_path)) @mcp.tool() @@ -86,6 +134,8 @@ def search_files(query: str) -> str: Returns: A formatted string of search results, or a message if no matches found. """ + #logger.info("Tool search_files is being executed on MCP file search server") + query_lower = query.lower() results = [] @@ -103,11 +153,17 @@ def search_files(query: str) -> str: if query_lower in line.lower(): snippet = line.strip()[:100] results.append(f"[content] {rel}:{i} -- {snippet}") - except (UnicodeDecodeError, PermissionError): + except UnicodeDecodeError as e: + #logger.warning("Decode error in file/folder '%s': %s", f, e) + pass + except PermissionError as e: + #logger.warning("Permission error in file/folder: '%s': %s", f, e) pass if not results: + #logger.info("No matches found for user query") return f"No matches found for '{query}'." + #logger.info("Result returned, limited to 30 matches.") return "\n".join(results[:30]) # limit to 30 matches @@ -121,23 +177,33 @@ def read_file(path: str) -> str: Returns: The file content as a string, or an error message if the file cannot be read. """ + #logger.info("Tool read_file is being executed on MCP file search server") + try: resolved = _safe_path(path) except ValueError as e: return f"Error: {e}" if not resolved.exists(): + #logger.warning("File '%s' does not exist.", path) return f"Error: File '{path}' does not exist." if not resolved.is_file(): + #logger.warning("'%s' is not a valid file.", path) return f"Error: '{path}' is not a file." try: - return resolved.read_text(encoding="utf-8") + text = resolved.read_text(encoding="utf-8") + #logger.info("File read successfully.") + return text + except UnicodeDecodeError: + #logger.warning("'%s' is not a text file (binary content).", path) return f"Error: '{path}' is not a text file (binary content)." except PermissionError: + #logger.warning(f"Permission denied when trying to read '%s'.", path) return f"Error: Permission denied when trying to read '{path}'." except Exception as e: + #logger.exception("Error reading file '%s': %s", path, e) return f"Error reading file '{path}': {e}" @mcp.tool() @@ -152,6 +218,7 @@ def write_new_file(path: str, content: str) -> str: Returns: A success or error message. """ + #logger.info("Tool write_new_file is being executed on MCP file search server") try: resolved = _safe_path(path) @@ -159,26 +226,30 @@ def write_new_file(path: str, content: str) -> str: return f"Error: {e}" if resolved.exists(): + #logger.warning("Requested file path '%s' already exists, overwriting not allowed.", path) return (f"ERROR: File '{path}' already exists." f"Overwriting is not allowed with this tool." f"Use a different path or filename to create a new file.") if resolved.suffix not in ALLOWED_FILE_TYPES: + #logger.warning("File type not allowed: %s", resolved.suffix) return f"ERROR: can only write {', '.join(ALLOWED_FILE_TYPES)} types, got '{resolved.suffix}'." try: resolved.parent.mkdir(parents=True, exist_ok=True) resolved.write_text(content, encoding="utf-8") + #logger.info("File written successfully.") return f"OK: wrote {len(content)} chars to {path}." except FileNotFoundError as e: - print(f"FileNotFoundError for {path}: {e}") + #logger.warning("FileNotFoundError for '%s': %s", path, e) return f"Error: {e}" except PermissionError as e: - print(f"PermissionError for {path}: {e}") + #logger.warning("PermissionError for '%s': %s", path, e) return f"Error: {e}" except Exception as e: + #logger.exception("Error writing file: %s", e) return f"Error: {e}" @@ -192,23 +263,31 @@ def create_new_directory(path: str) -> str: Returns: A success or error message. """ + #logger.info("Tool create_new_directory is being executed on MCP file search server") + try: resolved = _safe_path(path) except ValueError as e: return f"Error: {e}" if resolved.exists(): + #logger.warning("Requested path '%s' already exists, overwriting not allowed.", path) return f"Error: File '{path}' already exists." + # REVIEW: redundant — `resolved.suffix != None` is always True (Path.suffix always returns str); + # the None check is unnecessary. Simplify to `if resolved.suffix != "":`. if resolved.suffix != None and resolved.suffix != "": + #logger.warning("Can only create directories, got '%s'.", resolved.suffix) return f"Error: can only create directories, got '{resolved.suffix}'." try: resolved.parent.mkdir(parents=True, exist_ok=True) resolved.mkdir() + #logger.info("Directory '%s' created successfully.", path) return f"OK: created empty directory at {path}." except Exception as e: - return f"Error creating dictionary file '{path}': {e}" + #logger.exception("Error creating directory '%s': %s", path, e) + return f"Error creating directory '{path}': {e}" # ── Run the server ─────────────────────────────────────────────────────────── diff --git a/backend/agent/servers/mcp_server_web_search.py b/backend/agent/servers/mcp_server_web_search.py index 39c9fb4..e77f201 100644 --- a/backend/agent/servers/mcp_server_web_search.py +++ b/backend/agent/servers/mcp_server_web_search.py @@ -1,10 +1,36 @@ +"""MCP server that provides web search and page-fetching tools. + +Exposes two MCP tools: + - web_search — keyword search via DuckDuckGo, returns titles, URLs, snippets + - fetch_page — fetch and extract readable text from a URL + +All outbound requests are guarded by _validate_url() which blocks non-HTTP +schemes and private/loopback IP ranges to prevent SSRF vulnerabilities. +""" + from urllib.parse import urlparse +import requests +from bs4 import BeautifulSoup +from ddgs import DDGS from mcp.server.fastmcp import FastMCP +#from backend.managers.debug_logger import get_logger +#logger = get_logger(__name__) + # ── Configuration ──────────────────────────────────────────────────────────── MAX_PAGE_LENGTH = 4000 # max characters to return from a fetched page REQUEST_TIMEOUT = 10 # seconds +# ── Bolcked prefixes & Hosts ──────────────────────────────────────────────────── +PRIVATE_PREFIXES = [ + "10.", "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", + "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", + "172.30.", "172.31.", "192.168.", +] + +BLOCKED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"] + # ── Create the MCP server ──────────────────────────────────────────────────── mcp = FastMCP("WebSearchServer") @@ -12,28 +38,40 @@ mcp = FastMCP("WebSearchServer") # ── Helper: URL validation (SSRF prevention) ───────────────────────────────── def _validate_url(url: str) -> str: - """Validate a URL to prevent SSRF attacks.""" + """Validate a URL and raise ValueError if it could be used for an SSRF attack. + + Blocks: + - Non-HTTP(S) schemes (file://, ftp://, etc.) + - Loopback and metadata addresses (localhost, 127.0.0.1, 169.254.169.254) + - RFC-1918 private IP ranges (10.x, 172.16-31.x, 192.168.x) + + Args: + url: The URL string to validate. + + Returns: + The original URL string unchanged if it passes all checks. + + Raises: + ValueError: If the URL fails any of the security checks. + """ parsed = urlparse(url) + #logger.info("Validateing URL") if parsed.scheme not in ("http", "https"): + #logger.warning("Blocked scheme '%s'. Only http and https are allowed.", parsed.scheme) raise ValueError( f"Blocked scheme '{parsed.scheme}'. Only http and https are allowed." ) hostname = parsed.hostname or "" - blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"} - if hostname in blocked_hosts: + if hostname in BLOCKED_HOSTS: + #logger.warning("Blocked internal host: %s", hostname) raise ValueError(f"Blocked internal host: {hostname}") - - private_prefixes = ( - "10.", "172.16.", "172.17.", "172.18.", "172.19.", - "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", - "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", - "172.30.", "172.31.", "192.168.", - ) - for prefix in private_prefixes: + + for prefix in PRIVATE_PREFIXES: if hostname.startswith(prefix): + #logger.warning("Blocked private IP range: %s", hostname) raise ValueError(f"Blocked private IP range: {hostname}") return url @@ -51,12 +89,16 @@ def web_search(query: str, max_results: int = 5) -> str: Returns: A formatted string of search results, or a message if no matches found. """ + #logger.info("Tool web_search is being executed on MCP web search server") + try: - from ddgs import DDGS results = DDGS().text(query, max_results=max_results) if not results: + #logger.info("DDGS API call successful, no web search results found.") return f"No results found for: {query}" + + #logger.info("DDGS API call successfull, web search results returned.") formatted = [] for r in results: @@ -68,6 +110,7 @@ def web_search(query: str, max_results: int = 5) -> str: return "\n---\n".join(formatted) except Exception as e: + #logger.exception("DDGS API call failed, web search error: %s", e) return f"Search error: {e}" @@ -80,38 +123,54 @@ def fetch_page(url: str) -> str: Returns: The text content of the fetched page, or an error message. """ + #logger.info("Tool fetch_page is being executed on MCP web search server") + try: url = _validate_url(url) except ValueError as e: return f"URL blocked: {e}" try: - import requests - from bs4 import BeautifulSoup - response = requests.get( url, timeout=REQUEST_TIMEOUT, headers={"User-Agent": "Mozilla/5.0 (Lightweight Web Search MCP Server)"}, ) + response.raise_for_status() if response.status_code != 200: + #logger.warning("HTTP error %s while fetching %s", response.status_code, url) return f"HTTP error {response.status_code} fetching {url}" + + #logger.info("DDGS API call successfull") + except requests.RequestException as e: + #logger.warning("HTTP-Fehler: %s", e) + return f"HTTP-Fehler: {e}" + + except Exception as e: + #logger.exception("Error fetching page: %s", e) + return f"Error fetching page: {e}" + + try: soup = BeautifulSoup(response.text, "html.parser") + # Remove boilerplate elements that add noise without informational value. for tag in soup(["script", "style", "nav", "footer"]): tag.decompose() text = soup.get_text(separator="\n", strip=True) + #logger.info("HTML parsing with BeautifulSoup successfull") + if len(text) > MAX_PAGE_LENGTH: text = text[:MAX_PAGE_LENGTH] + "\n\n[... truncated ...]" return text if text else "Page fetched but no text content found." - + except Exception as e: - return f"Error fetching page: {e}" + #logger.exception("Error parsing HTML: %s", e) + return f"Error parsing html: {e}" # ── Run the server ─────────────────────────────────────────────────────────── diff --git a/backend/managers/_debug_logger.py b/backend/managers/_debug_logger.py new file mode 100644 index 0000000..af1fc13 --- /dev/null +++ b/backend/managers/_debug_logger.py @@ -0,0 +1,71 @@ +from datetime import datetime + + +class DebugLogger: + """In-memory logger for code execution events. + + Collects timestamped INFO and ERROR entries during a single run. + Call clear() before each new execution to start fresh. + """ + + def __init__(self): + self.logs: list[dict] = [] + + def log(self, message: str) -> None: + """Append a general info message.""" + self.logs.append({ + "level": "INFO", + "message": message, + "timestamp": datetime.now().strftime("%H:%M:%S"), + }) + + def log_error(self, error_message: str) -> None: + """Append an error message.""" + self.logs.append({ + "level": "ERROR", + "message": error_message, + "timestamp": datetime.now().strftime("%H:%M:%S"), + }) + + def get_logs(self) -> list[dict]: + """Return a copy of all collected log entries.""" + return list(self.logs) + + def clear(self) -> None: + """Reset the log — call before each new execution.""" + self.logs = [] + + def format_debug_output(self, output: dict) -> str: + """Format an ExecutionEngine result dict into a human-readable string. + + Args: + output: dict with keys 'stdout', 'stderr', and 'rc'. + + Returns: + A formatted string ready for display in the UI. + """ + lines = [] + + status = "SUCCESS" if output.get("rc") == 0 else "FAILED" + lines.append(f"[{status}] Exit code: {output.get('rc')}") + + if output.get("stdout"): + lines.append("\n--- stdout ---") + lines.append(output["stdout"].rstrip()) + + if output.get("stderr"): + lines.append("\n--- stderr ---") + lines.append(output["stderr"].rstrip()) + + if not output.get("stdout") and not output.get("stderr"): + lines.append("No output produced.") + + for entry in self.logs: + lines.append(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}") + + return "\n".join(lines) + + +if __name__ == "__main__": + logger = DebugLogger() + print(logger.get_logs()) \ No newline at end of file diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index a10faab..65a9114 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -5,6 +5,9 @@ from dotenv import load_dotenv import requests import json +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + load_dotenv() @@ -21,6 +24,7 @@ class ChatManager: self.api_port = os.getenv("PORT") self.api_key = os.getenv("API_KEY") self.model = os.getenv("MODEL") + self.max_tokens = 2000 # API endpoint URL (OpenAI-compatible format) self.api_url = f"http://{self.api_host}:{self.api_port}/v1/chat/completions" @@ -36,8 +40,10 @@ class ChatManager: """Return a copy of the conversation history.""" return list(self.chat_history) + # REVIEW: dead code — clear_history() is never called anywhere in the codebase. def clear_history(self) -> None: """Wipe the conversation history (starts a fresh chat).""" + logger.info("Chat history was cleared") self.chat_history = [] def send_message(self, user_message: str) -> str: @@ -49,25 +55,27 @@ class ChatManager: # Add user message to history self.add_message("user", user_message) + logger.info("Sending message to LLM API") + + # Prepare request to OpenAI-compatible API + headers = { + "Content-Type": "application/json", + } + + # Add API key if available + if self.api_key and self.api_key != "EMPTY": + headers["Authorization"] = f"Bearer {self.api_key}" + + # Full history is sent so the model has multi-turn conversation context + payload = { + "model": self.model, + "messages": self.chat_history, + "temperature": 0.7, + "max_tokens": 2000, + "stream": False, + } + try: - # Prepare request to OpenAI-compatible API - headers = { - "Content-Type": "application/json", - } - - # Add API key if available - if self.api_key and self.api_key != "EMPTY": - headers["Authorization"] = f"Bearer {self.api_key}" - - # Full history is sent so the model has multi-turn conversation context - payload = { - "model": self.model, - "messages": self.chat_history, - "temperature": 0.7, - "max_tokens": 2000, - "stream": False, - } - # Make API request response = requests.post( self.api_url, headers=headers, json=payload, timeout=30 @@ -75,37 +83,59 @@ class ChatManager: # Check if request was successful if response.status_code != 200: - error_msg = f"API Error {response.status_code}: {response.text}" - raise Exception(error_msg) - - # Parse response - response_data = response.json() - - # Extract AI message - if "choices" in response_data and len(response_data["choices"]) > 0: - ai_message = response_data["choices"][0]["message"]["content"] - - # Add AI response to history - self.add_message("assistant", ai_message) - - return ai_message - else: - raise Exception("Invalid API response format") - + logger.warning("API HTTP status error %s: %s", response.status_code, response.text) + raise Exception(f"API Error {response.status_code}") + + logger.info("Response recieved from API") + + except requests.exceptions.Timeout as e: + error_msg = f"Timeout Error: {str(e)}" + self.add_message("assistant", f"Error: {error_msg}") + logger.exception("LLM API timeout: %s", e) + raise RuntimeError("LLM API timeout") from e + except requests.exceptions.RequestException as e: error_msg = f"Connection Error: {str(e)}" - # Add error message to history so user sees it self.add_message("assistant", f"Error: {error_msg}") - raise Exception(error_msg) + logger.exception("LLM API connection failed: %s", e) + raise RuntimeError("Connection Error: LLM API connection failed") from e + + return self.receive_response(response) + + def receive_response(self, response) -> str: + """Parse an API response object and return the AI reply text. + + Extracts the message content from the JSON body, appends it to history, + and returns it. Raises on malformed JSON or unexpected response shape. + """ + try: + response_data = response.json() except json.JSONDecodeError as e: error_msg = f"JSON Decode Error: {str(e)}" self.add_message("assistant", f"Error: {error_msg}") + logger.exception("JSON Decode Error: %s", e) raise Exception(error_msg) + + if "choices" not in response_data or not response_data["choices"]: + logger.warning("Invalid API response format: %s", response_data) + self.add_message("assistant", "Error: Invalid API response format") + raise Exception("Invalid API response format") + + try: + ai_message = response_data["choices"][0]["message"]["content"] + self.add_message("assistant", ai_message) + logger.info("Assistant response generated") + return ai_message except Exception as e: error_msg = f"Error: {str(e)}" self.add_message("assistant", f"Error: {error_msg}") - raise Exception(error_msg) + logger.exception("JSON parsing and message formatting failed: %s", e) + raise RuntimeError("JSON parsing and message formatting failed") from e + # REVIEW: dead code — get_chat_display() is never called anywhere in the codebase. + # The UI renders st.session_state.chat_history directly. This method also does the + # same thing as get_history() (returns a copy of chat_history with the same fields), + # making it redundant even if it were used. def get_chat_display(self) -> list: """Return a copy of the history suitable for display in the UI.""" return [ diff --git a/backend/managers/debug_logger.py b/backend/managers/debug_logger.py index b681f05..b221d43 100644 --- a/backend/managers/debug_logger.py +++ b/backend/managers/debug_logger.py @@ -1,66 +1,123 @@ -from datetime import datetime +""" +Central logging setup for the application. +- Provides a unified logger via get_logger(__name__) +- Writes all logs to a central rotating log file (logs/app.log) +- Writes errors separately to logs/errors.log +- Automatically includes the module name in each log entry +- Supports standard logging levels: DEBUG, INFO, WARNING, ERROR, CRITICAL + +Usage: + from backend.managers.debug_logger import get_logger + logger = get_logger(__name__) + + logger.info("Service started") + logger.debug("Debug details") + logger.error("Something went wrong") + + try: + ... + except Exception: + logger.exception("Unexpected error") + +Logging levels (use consistently): +DEBUG: Detailed technical info for developers (variables, flow, internal state). +INFO: Normal application events (start/stop, successful operations, key milestones). +WARNING: Something unexpected happened, but the program continues normally. +ERROR: A specific operation failed, but the application is still running. +CRITICAL: A severe failure that may stop the application or make it unusable. +EXCEPTION: Same as ERROR, but used inside an `except` block and includes stacktrace + (via logger.exception()). +""" + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +LOG_DIR = BASE_DIR / "logs" +LOG_DIR.mkdir(exist_ok=True) class DebugLogger: - """In-memory logger for code execution events. - Collects timestamped INFO and ERROR entries during a single run. - Call clear() before each new execution to start fresh. - """ + _initialized = False + _error_log: list[str] = [] - def __init__(self): - self.logs: list[dict] = [] + @classmethod + def setup(cls): + # prevents multiple setup + if cls._initialized: + return - def log(self, message: str) -> None: - """Append a general info message.""" - self.logs.append({ - "level": "INFO", - "message": message, - "timestamp": datetime.now().strftime("%H:%M:%S"), - }) + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] [%(name)s: Line %(lineno)d] %(message)s" + ) - def log_error(self, error_message: str) -> None: - """Append an error message.""" - self.logs.append({ - "level": "ERROR", - "message": error_message, - "timestamp": datetime.now().strftime("%H:%M:%S"), - }) + # Main log file + file_handler = RotatingFileHandler( + LOG_DIR / "app.log", + maxBytes=5_000_000, + backupCount=5, + encoding="utf-8" + ) - def get_logs(self) -> list[dict]: - """Return a copy of all collected log entries.""" - return list(self.logs) + file_handler.setFormatter(formatter) - def clear(self) -> None: - """Reset the log — call before each new execution.""" - self.logs = [] + # Separate Error-Log + error_handler = RotatingFileHandler( + LOG_DIR / "errors.log", + maxBytes=5_000_000, + backupCount=3, + encoding="utf-8" + ) - def format_debug_output(self, output: dict) -> str: - """Format an ExecutionEngine result dict into a human-readable string. + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) - Args: - output: dict with keys 'stdout', 'stderr', and 'rc'. + root_logger = logging.getLogger() - Returns: - A formatted string ready for display in the UI. - """ - lines = [] + root_logger.setLevel(logging.DEBUG) - status = "SUCCESS" if output.get("rc") == 0 else "FAILED" - lines.append(f"[{status}] Exit code: {output.get('rc')}") + root_logger.addHandler(file_handler) + root_logger.addHandler(error_handler) + #root_logger.propagate = False - if output.get("stdout"): - lines.append("\n--- stdout ---") - lines.append(output["stdout"].rstrip()) + cls._initialized = True - if output.get("stderr"): - lines.append("\n--- stderr ---") - lines.append(output["stderr"].rstrip()) + @classmethod + def get_logger(cls, name: str): + cls.setup() + return logging.getLogger(name) - if not output.get("stdout") and not output.get("stderr"): - lines.append("No output produced.") + @classmethod + def log_error(cls, error_message: str) -> None: + cls.setup() + logging.error(error_message) + cls._error_log.append(error_message) - for entry in self.logs: - lines.append(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}") + @classmethod + def get_errors(cls) -> list[str]: + return cls._error_log - return "\n".join(lines) + @classmethod + def clear_errors(cls) -> None: + cls._error_log.clear() + + @classmethod + def format_debug_output(cls, output: dict) -> str: + stdout = output.get("stdout", "").strip() or "(none)" + stderr = output.get("stderr", "").strip() or "(none)" + return_code = output.get("return_code", "") + return ( + "=== Execution Result ===\n" + f"Exit Code: {return_code}\n" + "--- stdout ---\n" + f"{stdout}\n" + "--- stderr ---\n" + f"{stderr}" + ) + + +# praktische shortcut function +def get_logger(name: str): + return DebugLogger.get_logger(name) diff --git a/backend/managers/execution_engine.py b/backend/managers/execution_engine.py index b4a449a..547d4bc 100644 --- a/backend/managers/execution_engine.py +++ b/backend/managers/execution_engine.py @@ -1,6 +1,16 @@ +"""Executes code files from the editor in isolated subprocesses. + +Supports Python (.py) via the system Python interpreter and LaTeX (.tex) via +pdflatex. All execution is time-bounded by RUN_TIMEOUT to prevent runaway +processes from blocking the UI indefinitely. +""" + import subprocess from pathlib import Path +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + # Maximum time (seconds) a subprocess is allowed to run before being killed. RUN_TIMEOUT = 30 @@ -41,6 +51,8 @@ class ExecutionEngine: ] else: return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1} + + logger.info("Running file %s with suffix %s", active_file.name, suffix) try: proc = subprocess.run( @@ -50,12 +62,31 @@ class ExecutionEngine: text=True, timeout=RUN_TIMEOUT, ) - return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode} + logger.info("File ran successfully.") + return self.capture_output(proc) except subprocess.TimeoutExpired: + logger.warning("Time out afte %s s", RUN_TIMEOUT) return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1} except FileNotFoundError as e: # Raised when the interpreter/compiler binary is not found on PATH + logger.warning("Interpreter/compiler binary is not found on PATH: %s", e) return {"stdout": "", "stderr": str(e), "rc": -1} except Exception as e: + logger.exception("Error while running %s: %s", active_file.name, e) return {"stdout": "", "stderr": str(e), "rc": -1} + + def capture_output(self, proc: subprocess.CompletedProcess) -> dict: + """Extract stdout, stderr, and return code from a completed subprocess. + + Args: + proc: The CompletedProcess returned by subprocess.run(). + + Returns: + {"stdout": str, "stderr": str, "rc": int} with whitespace stripped. + """ + return { + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + "rc": proc.returncode, + } diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index add29f3..0c87dd9 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -7,11 +7,23 @@ touching the filesystem, preventing path-traversal attacks. import streamlit as st from pathlib import Path +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + # The workspace folder is created at module load so it always exists. WORKSPACE = Path("workspace") WORKSPACE.mkdir(exist_ok=True) class FileManager: + """Manages all file and folder operations inside the workspace directory. + + Every public method resolves the given path and verifies that the result + stays within ``base_path`` before touching the filesystem. This prevents + path-traversal attacks where a caller might pass ``../../etc/passwd``. + + The workspace directory is created on first use if it does not yet exist. + """ + def __init__(self, base_path=Path("workspace")) -> None: self.base_path = Path(base_path) self.base_path.mkdir(exist_ok=True) @@ -28,12 +40,16 @@ class FileManager: Returns: bool: True if folder was created successfully, False otherwise. """ + logger.info("Creating folder at %s named %s", relative_path, name) + if not name: + logger.warning("Invalid folder name") st.error(f"Invalid folder name: {name}") return False # Slashes in the name would silently create nested paths — reject them. if "/" in name or "\\" in name: + logger.warning("'/' or '\\' in foldername not allowed") st.error(f"Invalid folder name (no slashes allowed): {name}") return False @@ -52,11 +68,14 @@ class FileManager: try: folder_path.mkdir(exist_ok=False) + logger.info("Folder created successfully.") return True except FileExistsError: + logger.warning("Folder already exists.") st.warning(f"Folder already exists: {relative_path}") return False except Exception as e: + logger.exception("Error creating folder %s: %s", relative_path, str(e)) st.error(f"Error creating folder {relative_path}: {str(e)}") return False @@ -71,14 +90,18 @@ class FileManager: name (str): The name of the new file to create (should not contain slashes). Returns: bool: True if file was created successfully, False otherwise. - """ + """ + logger.info("Creating file at %s named %s", relative_path, name) + if not name or name.strip() == "" : + logger.warning("Invalid folder name") st.error(f"Invalid file name: {name}") return False name = Path(name) if not name.suffix: name = name.with_suffix(".txt") # Default to .txt if no extension provided + logger.info("No suffix was provided, creating .txt file") if relative_path: relative_path = Path(relative_path) @@ -94,11 +117,14 @@ class FileManager: try: file_path.touch(exist_ok=False) + logger.info("File created successfully.") return True except FileExistsError: + logger.warning("Folder already exists.") st.warning(f"File already exists: {relative_path}") return False except Exception as e: + logger.exception("Error creating file %s: %s", relative_path, str(e)) st.error(f"Error creating file {relative_path}: {str(e)}") return False @@ -113,27 +139,37 @@ class FileManager: Returns: str: The content of the file, or an empty string if there was an error. """ + logger.info("Reading file at %s.", relative_path) file_path = (relative_path).resolve() if not file_path.exists(): st.error(f"File not found: {relative_path}") + logger.warning("Filepath does not exist.") return "" if not file_path.is_file(): st.error(f"Path is not a file: {relative_path}") + logger.warning("Path is not a file.") return "" # Ensure the resolved path is still inside the workspace (prevents path traversal). if not str(file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") + logger.warning("Access denied. File ist outside WORKSPACE") return "" try: with open(file_path, "r") as f: - return f.read() + content = f.read() + logger.info("File read successfully.") + return content except FileNotFoundError: + # REVIEW: unreachable code — FileNotFoundError cannot be raised here because + # `file_path.exists()` is already checked above and returns "" on failure. st.error(f"File not found: {relative_path}") + logger.warning("File not found") return "" except Exception as e: st.error(f"Error reading file {relative_path}: {str(e)}") + logger.exception("Error reading file at %s: %s", relative_path, e) return "" def save_file(self, relative_path: str, content: str) -> bool: @@ -148,19 +184,24 @@ class FileManager: Returns: bool: True if save was successful, False otherwise. """ + logger.info("Saving file at %s.", relative_path) + file_path = (Path(relative_path)).resolve() # Ensure the resolved path is still inside the workspace (prevents path traversal). if not str(file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") + logger.warning("Access denied. File outside WORKSPACE.") return False try: with open(file_path, "w") as f: f.write(content) + logger.info("File written successfully.") return True except Exception as e: st.error(f"Error saving file {relative_path}: {str(e)}") + logger.exception("Error saving file %s: %s", relative_path, e) return False def rename_file(self, old_relative_path: str, new_name: str) -> bool: @@ -174,8 +215,11 @@ class FileManager: Returns: bool: True if rename was successful, False otherwise. """ + logger.info("Rename file at %s to %s.", old_relative_path, new_name) + if not new_name or new_name.strip() == "": st.error(f"Invalid file name: {new_name}") + logger.warning("New Name is empty.") return False file_type = Path(old_relative_path).suffix @@ -191,13 +235,20 @@ class FileManager: # Both old and new paths must stay inside the workspace. if not str(old_file_path).startswith(str(self.base_path.resolve())) or not str(new_file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {old_relative_path}") + logger.warning("Access denied, file outside WORKSPACE.") return False try: old_file_path.rename(new_file_path) + logger.info("Renamed successfully.") return True except FileNotFoundError: st.error(f"File not found: {old_relative_path}") + logger.warning("Original file not found.") + return False + except Exception as e: + st.error(f"Error renaming file {old_relative_path} to {new_name}: {str(e)}") + logger.exception("Error deleting folder %s to %s: %s", old_relative_path, new_name, str(e)) return False @@ -210,23 +261,29 @@ class FileManager: Returns: bool: True if deletion was successful, False otherwise. """ + logger.info("Deleting folder %s.", relative_path) + folder_path = (self.base_path / relative_path).resolve() # Ensure the resolved path is still inside the workspace (prevents path traversal). if not str(folder_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") + logger.warning("Access denied, folder outside WORKSPACE.") return False if not folder_path.exists(): st.error(f"Folder not found: {relative_path}") + logger.warning("Folder path not found.") return False try: import shutil shutil.rmtree(folder_path) + logger.info("Folder deleted successfully.") return True except Exception as e: st.error(f"Error deleting folder {relative_path}: {str(e)}") + logger.exception("Error deleting folder %s: %s", relative_path, str(e)) return False def delete_file(self, relative_path: str) -> bool: @@ -238,21 +295,26 @@ class FileManager: Returns: bool: True if deletion was successful, False otherwise. """ + logger.info("Deleting file %s.", relative_path) file_path = Path(relative_path) abs_file_path = (Path(self.base_path) / file_path).resolve() if not str(abs_file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") + logger.warning("Access denied, file outside WORKSPACE.") return False try: abs_file_path.unlink() + logger.info("File deleted successfully.") return True except FileNotFoundError: st.error(f"File not found: {relative_path}") + logger.warning("File not found") return False except Exception as e: st.error(f"Error deleting file {relative_path}: {str(e)}") + logger.exception("Error deleting folder %s: %s", relative_path, str(e)) return False def get_file_tree(self): @@ -264,6 +326,8 @@ class FileManager: Returns: dict: A nested dictionary representing the file tree. """ + logger.info("Getting file tree ...") + def build_tree(path: Path): tree = {} @@ -276,5 +340,19 @@ class FileManager: return tree return build_tree(self.base_path) + def list_files(self, extensions: list[str] | None = None) -> list[Path]: + """Returns a flat list of all files in the workspace. + + Args: + extensions: Optional list of extensions to filter by, e.g. ['.py', '.js']. + If None, all files are returned. + Returns: + List of absolute Path objects for all matching files. + """ + files = (p for p in self.base_path.rglob("*") if p.is_file()) + if extensions is not None: + files = (p for p in files if p.suffix in extensions) + return sorted(files) + if __name__ == "__main__": FileManager() diff --git a/backend/managers/system_prompter.py b/backend/managers/system_prompter.py index e3c01e6..e2f89e2 100644 --- a/backend/managers/system_prompter.py +++ b/backend/managers/system_prompter.py @@ -1,8 +1,67 @@ """Builds the system prompt that is sent to the AI at the start of each chat session.""" +import ast + +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + # Prevents very large files from flooding the context window with tokens. MAX_FILE_CHARS = 4000 +# Per-task base prompts — selected via the task_type parameter. +_TASK_PROMPTS: dict[str, str] = { + "debug": ( + "You are a debugging expert integrated into a lightweight code editor. " + "Focus on identifying and fixing errors. " + "Be concise and precise. Use markdown and fenced code blocks where appropriate." + ), + "explain": ( + "You are a code explainer integrated into a lightweight code editor. " + "Use simple language and examples. " + "Be concise and precise. Use markdown and fenced code blocks where appropriate." + ), + "optimize": ( + "You are a code optimization expert integrated into a lightweight code editor. " + "Focus on performance and readability. " + "Be concise and precise. Use markdown and fenced code blocks where appropriate." + ), + "default": ( + "You are an expert code assistant integrated into a lightweight code editor. " + "Help the user with code suggestions, debugging, explanations, and improvements. " + "Be concise and precise. Use markdown and fenced code blocks where appropriate." + ), +} + + +def _extract_relevant_context(content: str, user_message: str) -> str: + """Return the most relevant part of a Python file for the given user message. + + Parses the file with ast and checks whether any top-level function or class + name appears in the user message. If a match is found only that definition + is returned, keeping the context focused. Falls back to simple truncation + when parsing fails or no name matches. + """ + try: + tree = ast.parse(content) + except SyntaxError: + # Not valid Python (or not Python at all) — fall back to truncation. + if len(content) > MAX_FILE_CHARS: + return content[:MAX_FILE_CHARS] + "\n... [truncated]" + return content + + lower_msg = user_message.lower() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name.lower() in lower_msg: + segment = ast.get_source_segment(content, node) + if segment: + return segment + + # No specific symbol matched — fall back to truncation. + if len(content) > MAX_FILE_CHARS: + return content[:MAX_FILE_CHARS] + "\n... [truncated]" + return content + class SystemPrompter: """Generates system prompts for the chat assistant. @@ -13,35 +72,38 @@ class SystemPrompter: @staticmethod def generate_prompt( + user_message: str = "", file_context: dict | None = None, search_context: list[dict] | None = None, + task_type: str = "default", ) -> str: """Build a system prompt, optionally embedding a file and/or web search results. Args: + user_message: The current user input — used for task-type detection + and selective context extraction. Reserved for future + task-specific prompt tuning beyond what task_type covers. file_context: dict with keys 'name' (filename) and 'content' (raw text), or None if no file should be included. search_context: list of {"title", "url", "snippet"} dicts from SearchManager, or None if no search results should be included. + task_type: One of "debug", "explain", "optimize", "default". + Selects the matching base prompt from _TASK_PROMPTS. Returns: A ready-to-use system prompt string. """ - base = ( - "You are an expert code assistant integrated into a lightweight code editor. " - "Help the user with code suggestions, debugging, explanations, and improvements. " - "Be concise and precise. Use markdown and fenced code blocks where appropriate." - ) - - prompt = base + logger.info("Generating system prompt (task_type=%s).", task_type) + prompt = _TASK_PROMPTS.get(task_type, _TASK_PROMPTS["default"]) if file_context: + logger.info("Appending file context.") name = file_context.get("name", "unknown") content = file_context.get("content", "") - # Truncate large files to avoid exceeding the model's token limit - if len(content) > MAX_FILE_CHARS: - content = content[:MAX_FILE_CHARS] + "\n... [truncated]" + # Extract only the relevant function/class when the user mentions one; + # otherwise fall back to simple truncation at MAX_FILE_CHARS. + content = _extract_relevant_context(content, user_message) prompt += ( f"\n\nThe user currently has the following file open in the editor:\n" diff --git a/frontend/app.py b/frontend/app.py index 140f7c9..0625395 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -17,6 +17,9 @@ from pathlib import Path # where streamlit is launched from. sys.path.insert(0, str(Path(__file__).parent.parent)) +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) + from frontend.sidebar import render_sidebar from frontend.editor import render_editor from frontend.chat import render_chat @@ -36,7 +39,7 @@ def main(): """ """, unsafe_allow_html=True, @@ -44,6 +47,8 @@ def main(): st.title("Lightweight code editor") + # REVIEW: redundant — init_state() is already called at module level (line 26) before main() runs; + # calling it again here is unnecessary since Streamlit reruns the whole module on each reload. # Re-run init_state to cover any keys that might have been missed on cold start init_state() @@ -51,8 +56,10 @@ def main(): # Switch between the two main views based on the sidebar radio button if st.session_state.get("radio_interface_options") == "Code Editor": + logger.info("Editor mode") render_editor() elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant": + logger.info("Chat/Agent mode") render_chat() diff --git a/frontend/chat.py b/frontend/chat.py index 9c310fd..3d4c32c 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -1,19 +1,40 @@ """Chat view — renders both the normal chat interface and the Coding Agent mode.""" +import asyncio +import json +from pathlib import Path + import streamlit as st from backend.managers.chat_manager import ChatManager from backend.managers.system_prompter import SystemPrompter from backend.managers.search_manager import SearchManager +from backend.agent.coding_agent import CodingAgent +from backend.managers.debug_logger import get_logger -import asyncio +logger = get_logger(__name__) # ── Agent Mode helpers ──────────────────────────────────────────────────────── def _run_async(coro): - """Hilfsfunktion um async Code in sync Streamlit auszuführen""" + """Execute an async coroutine from synchronous Streamlit code. + + Streamlit runs in a synchronous context, but the CodingAgent uses async + methods (for MCP tool calls). This helper bridges the gap by reusing an + already-running event loop when one exists, or creating a new one otherwise. + + Args: + coro: The coroutine to run. + + Returns: + The return value of the coroutine. + """ + # REVIEW: asyncio.get_running_loop() always raises RuntimeError in a Streamlit context; + # the try branch is dead code. The except branch always runs. try: + # Reuse the loop that is already running (e.g. inside pytest-asyncio). loop = asyncio.get_running_loop() except RuntimeError: + # No running loop in this thread — create a fresh one. loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) return loop.run_until_complete(coro) @@ -23,7 +44,7 @@ def _start_agent(task: str): Stores the agent and its state in session_state so Streamlit can reference them across reruns without losing progress. """ - from backend.agent.coding_agent import CodingAgent + logger.info("Starting coding agent.") agent = CodingAgent() agent.start_task(task) action = _run_async(agent.propose_next_action()) @@ -39,6 +60,7 @@ def _approve_action(): pending = st.session_state.agent_pending_action result = _run_async(agent.approve()) + logger.info("Approve action and propose next step.") # Append a record to the log so the user can review every completed step. st.session_state.agent_log.append({ @@ -63,6 +85,7 @@ def _reject_action(feedback: str): The pending action is discarded; the agent receives the user's feedback and proposes a different approach on the next call to propose_next_action(). """ + logger.info("Rejecting proposed action.") agent = st.session_state.coding_agent agent.reject(feedback or "Please try a different approach.") next_action = _run_async(agent.propose_next_action()) @@ -72,6 +95,7 @@ def _reject_action(feedback: str): def _followup_agent(question: str): """Continue a finished task by injecting a follow-up question and resuming the loop.""" + logger.info("Asking follow up question") agent = st.session_state.coding_agent agent.follow_up(question) action = _run_async(agent.propose_next_action()) @@ -81,6 +105,7 @@ def _followup_agent(question: str): def _reset_agent(): """Clear all agent state and return to the idle (task input) screen.""" + logger.info("Resetting Agent") st.session_state.coding_agent = None st.session_state.agent_status = "idle" st.session_state.agent_log = [] @@ -89,6 +114,62 @@ def _reset_agent(): # ── Agent Mode UI ───────────────────────────────────────────────────────────── +def _render_arguments(args: dict): + if not args: + return + + with st.expander("📦 Arguments", expanded=False): + + if args.get("path"): + st.markdown("##### 📁 Path") + st.code(args["path"]) + + if args.get("dir_path"): + st.markdown("##### 🌳 Directory") + st.code(args["dir_path"]) + + if args.get("query"): + st.markdown("##### 🔎 Query") + st.code(args["query"]) + + if args.get("url"): + st.markdown("##### 🌐 URL") + st.code(args["url"]) + + if args.get("content"): + st.markdown("##### 📝 Content") + st.code(args["content"]) + + if args.get("code"): + st.markdown("##### 🐍 Python Code") + st.code(args["code"], language="python") + + if args.get("max_results") is not None: + st.markdown("##### 📊 Max Results") + st.code(str(args["max_results"])) + + known_keys = { + "path", + "dir_path", + "query", + "content", + "url", + "code", + "max_results", + } + + extra_args = { + k: v for k, v in args.items() + if k not in known_keys + } + + if extra_args: + st.markdown("##### ⚙️ Other") + st.code( + json.dumps(extra_args, indent=2), + language="json" + ) + def render_agent_mode(): """Render the step-by-step agent UI. @@ -97,6 +178,7 @@ def render_agent_mode(): - "waiting_approval" → show proposed action, Approve / Reject / Abort - "done" → success message, follow-up input, New Task button """ + logger.info("Agent mode.") # The toggle must always render so Streamlit keeps agent_mode=True in session_state. st.toggle("Agent Mode", key="agent_mode") @@ -111,8 +193,8 @@ def render_agent_mode(): with st.chat_message("assistant"): st.markdown(f"**Step {i + 1} — `{step['tool']}`**") st.caption(f"Thought: {step['thought']}") - if step.get("arguments"): - st.json(step["arguments"]) + if step.get("arguments"): + _render_arguments(step["arguments"]) result_text = step.get("result", "") # Colour the result based on whether the tool succeeded or failed. if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"): @@ -131,8 +213,6 @@ def render_agent_mode(): placeholder="e.g. Write a function that sorts a list and saves it to sorted.py", ) if st.button("Start Agent", type="primary", use_container_width=True): - #loop = asyncio.new_event_loop() - #asyncio.set_event_loop(loop) if task.strip(): with st.spinner("Agent is thinking..."): _start_agent(task.strip()) @@ -150,15 +230,7 @@ def render_agent_mode(): args = pending.get("arguments", {}) if args: - # Show file content separately as a code block for readability; - # other arguments are displayed as JSON. - if "content" in args: - display_args = {k: v for k, v in args.items() if k != "content"} - if display_args: - st.json(display_args) - st.code(args["content"], language="python") - else: - st.json(args) + _render_arguments(args) feedback = st.text_input( "Rejection feedback (optional):", @@ -211,6 +283,79 @@ def render_agent_mode(): st.rerun() +# ── Normal Chat helpers ─────────────────────────────────────────────────────── + +def _detect_task_type(user_input: str) -> str: + """Infer the task type from keywords in the user message.""" + lower = user_input.lower() + if any(kw in lower for kw in ("error", "bug", "fix", "crash", "exception", "debug")): + return "debug" + if any(kw in lower for kw in ("explain", "what does", "how does", "why")): + return "explain" + if any(kw in lower for kw in ("optimize", "improve", "faster", "refactor", "clean")): + return "optimize" + return "default" + +def _set_system_prompt(chat_manager: ChatManager, user_input: str) -> None: + """Compute and inject the system prompt before every message. + + Uses the custom prompt from Settings if set; otherwise generates one based + on the detected task type and active file context. Updates the existing + system message in-place so the history stays a single-system-message list. + """ + custom = st.session_state.get("custom_system_prompt", "").strip() + if custom: + prompt = custom + else: + prompt = SystemPrompter.generate_prompt( + user_message=user_input, + file_context=_build_file_context(), + task_type=_detect_task_type(user_input), + ) + + if chat_manager.chat_history and chat_manager.chat_history[0]["role"] == "system": + chat_manager.chat_history[0]["content"] = prompt + else: + chat_manager.chat_history.insert(0, {"role": "system", "content": prompt}) + + +def _build_file_context() -> dict | None: + """Return file context for the system prompt if a file is open and context is enabled. + + Reads from files_content cache first; falls back to FileManager if the file + has not been loaded into the editor yet. + """ + if not st.session_state.get("include_file_context", True): + return None + active_file = st.session_state.get("active_file") + if not active_file: + return None + content = st.session_state.get("files_content", {}).get(active_file, "") + if not content: + try: + from backend.managers.file_manager import FileManager + fm = FileManager() + content = fm.read_file(Path(active_file)) or "" + except Exception: + return None + return {"name": Path(active_file).name, "content": content} + + +@st.dialog("Clear Chat") +def _clear_chat_dialog(): + """Confirmation dialog before wiping the full conversation history.""" + st.warning("All messages will be deleted. This cannot be undone.") + col1, col2 = st.columns(2) + with col1: + if st.button("Clear", type="primary", use_container_width=True): + st.session_state.chat_manager.clear_history() + st.session_state.chat_history = [] + st.rerun() + with col2: + if st.button("Cancel", use_container_width=True): + st.rerun() + + # ── Normal Chat ─────────────────────────────────────────────────────────────── def _render_search_panel(): @@ -264,14 +409,36 @@ def render_normal_chat(): full context throughout the session. If search results are active when the user sends a message, they are prepended to that message as a context block. """ - _render_search_panel() + logger.info("Chat mode") + chat_manager: ChatManager = st.session_state.chat_manager + + # Apply model/token overrides from the Settings panel before any API call. + if st.session_state.get("selected_model"): + chat_manager.model = st.session_state.selected_model + if "chat_max_tokens" in st.session_state: + chat_manager.max_tokens = st.session_state.chat_max_tokens + + # Consume a debug message forwarded from the editor's "Debug with AI" button. + pending_debug = st.session_state.pop("pending_debug_message", None) + if pending_debug: + _set_system_prompt(chat_manager, pending_debug) + with st.spinner("Sending debug info to AI..."): + try: + ai_response = chat_manager.send_message(pending_debug) + except Exception as e: + ai_response = f"Error: {e}" + st.session_state.chat_history.append({"role": "user", "content": pending_debug}) + st.session_state.chat_history.append( + {"role": "assistant", "content": ai_response} + ) + st.rerun() + return # Replay the conversation history as chat bubbles (skip system messages). for message in st.session_state.chat_history: - role = message["role"] - if role == "system": + if message["role"] == "system": continue - with st.chat_message(role): + with st.chat_message(message["role"]): st.markdown(message["content"]) # Chat input — Enter to send, no extra button needed. @@ -320,14 +487,10 @@ def render_normal_chat(): return # ── Normal chat message ─────────────────────────────────────────────── - chat_manager = st.session_state.chat_manager search_results = st.session_state.get("search_results", []) - # On the very first user message, prepend the system prompt so the AI - # knows it is a code assistant embedded in an editor. - if not chat_manager.get_history(): - system_prompt = SystemPrompter.generate_prompt() - chat_manager.add_message("system", system_prompt) + # 5g — System-prompt logic: inject on first message, update on file change. + _set_system_prompt(chat_manager, user_input) # If search results are active, prepend them as a context block so the # AI can reference them regardless of where in the conversation we are. @@ -348,7 +511,6 @@ def render_normal_chat(): with st.chat_message("user"): st.markdown(user_input) - # Call the AI and show its response with a spinner while waiting. with st.chat_message("assistant"): with st.spinner("Thinking..."): try: @@ -357,15 +519,46 @@ def render_normal_chat(): ai_response = f"Error: {e}" st.markdown(ai_response) - st.session_state.chat_history.append({"role": "user", "content": user_input}) + st.session_state.chat_history.append({"role": "user", "content": user_input}) st.session_state.chat_history.append({"role": "assistant", "content": ai_response}) st.rerun() - # Rendered in the normal flow; JS above clones them to fixed positions - # and hides these originals. + # 5d — Clear Chat opens a confirmation dialog instead of deleting immediately. + if st.button("🗑️ Clear Chat"): + _clear_chat_dialog() + + # Toggle to switch to Agent Mode (render_normal_chat and render_agent_mode are + # mutually exclusive, so the same key here causes no DuplicateWidgetID conflict). st.toggle("Agent Mode", key="agent_mode") - with st.expander("Settings", expanded=False): - st.toggle("Use debug system prompt", key="use_system_prompt", value=True) + + # 5h — Settings expander: file context toggle, model, token limit, custom prompt. + with st.expander("⚙️ Settings", expanded=False): + st.toggle("Include current file as context", key="include_file_context", value=True) + + st.divider() + + default_model = chat_manager.model or "" + model_options = [default_model] if default_model else [] + for m in ["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307", "gpt-4o", "gpt-4o-mini"]: + if m not in model_options: + model_options.append(m) + st.selectbox("Model", model_options, key="selected_model") + + st.slider( + "Max Response Tokens", + min_value=256, max_value=8000, + value=chat_manager.max_tokens, + step=256, key="chat_max_tokens", + ) + + st.divider() + + st.text_area( + "Custom System Prompt (overrides default if set)", + key="custom_system_prompt", + height=120, + placeholder="Leave empty to use the default assistant prompt with optional file context.", + ) # ── Entry point ─────────────────────────────────────────────────────────────── diff --git a/frontend/editor.py b/frontend/editor.py index 9f9c8b8..ba1c13e 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -1,12 +1,15 @@ """Code Editor view — renders the Ace editor, file tabs, and execution output.""" +import ast + import streamlit as st import streamlit_ace as st_ace from pathlib import Path from backend.managers.file_manager import FileManager from backend.managers.execution_engine import ExecutionEngine -from backend.managers.debug_logger import DebugLogger +from backend.managers.debug_logger import get_logger, DebugLogger +logger = get_logger(__name__) # Maps file extensions to Ace editor language modes for syntax highlighting. LANG_MAP = { @@ -51,8 +54,10 @@ def _rename_dialog(file_path: str): if st.session_state.active_file == file_path: st.session_state.active_file = new_file_path st.rerun() + logger.info("Rename file %s to %s successfull", file_path, new_name ) else: - st.error("Rename failed. Check that the file still exists.") + logger.warning("Rename failed.") + st.error("Rename failed. Check that the file %s still exists.", file_path) with col2: if st.button("Cancel", use_container_width=True): st.rerun() @@ -60,6 +65,14 @@ def _rename_dialog(file_path: str): @st.dialog("Delete File") def _delete_dialog(abs_file_path: str): + """Confirmation dialog before permanently deleting the given file. + + Removes the file from disk and also cleans up the editor's open-files list, + content cache, and active_file pointer so the UI stays consistent. + + Args: + abs_file_path: Absolute path to the file that should be deleted. + """ fm = FileManager() file_name = Path(abs_file_path).name relative_path = str(Path(abs_file_path).relative_to(fm.base_path)) @@ -77,17 +90,23 @@ def _delete_dialog(abs_file_path: str): if st.session_state.open_files else None ) st.rerun() + logger.info("Deleting file %s successfull.", abs_file_path) else: st.error("Delete failed. Check that the file still exists.") + logger.warning("Deleting file %s failed.", abs_file_path) with col2: if st.button("Cancel", use_container_width=True): st.rerun() def run_active_file(): - """Execute the currently active file and store the result in session_state. - Returns the execution result dict {stdout, stderr, return_code}, or None - if no active file is set. + """Execute the currently active file and store the result in exec_results[file_path]. + + Runs an ast.parse() check first — if the syntax is invalid the file is not + executed and ast_error=True is stored so the UI can show a targeted warning. + + Returns: + The result dict, or None if no active file is set. """ active_file = st.session_state.active_file @@ -96,25 +115,35 @@ def run_active_file(): return execution_engine = ExecutionEngine() - debug_logger = DebugLogger() + + logger.info("Executing code from %s...", active_file) - debug_logger.clear() - debug_logger.log(f"Executing code from {active_file}...") + # ast check — only for Python files + if Path(active_file).suffix == ".py": + source = st.session_state.get("files_content", {}).get(active_file, "") + try: + ast.parse(source) + except SyntaxError as e: + result = {"stdout": "", "stderr": str(e), "return_code": -1, "ast_error": True} + st.session_state.exec_results[active_file] = result + logger.error("Syntax error: %s", e) + return result with st.spinner(f"Running {Path(active_file).name}..."): output = execution_engine.run_code(Path(active_file)) if output["rc"] == 0: - debug_logger.log("Execution completed successfully.") + logger.info("Execution completed successfully.") else: - debug_logger.log_error(f"Execution failed with exit code {output['rc']}.") + logger.error("Execution failed with exit code %s.", output['rc']) - st.session_state.code_execution_output = { + result = { "stdout": output["stdout"], "stderr": output["stderr"], - "return_code": output["rc"] + "return_code": output["rc"], + "ast_error": False, } - result = st.session_state.code_execution_output + st.session_state.exec_results[active_file] = result return result def render_editor(): @@ -132,6 +161,24 @@ def render_editor(): tab_names = [Path(f).name for f in st.session_state.open_files] tabs = st.tabs(tab_names) + # Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun. + # Wenn _jump_to_tab gesetzt ist, klickt das Script den richtigen Tab an. + jump_target = st.session_state.pop("_jump_to_tab", None) + if jump_target and jump_target in st.session_state.open_files: + idx = st.session_state.open_files.index(jump_target) + st.components.v1.html( + f"""""", + height=0, + ) + for idx, file_path in enumerate(st.session_state.open_files): with tabs[idx]: # Load file content from disk on first open; afterwards use the cached version. @@ -151,6 +198,9 @@ def render_editor(): ) # Keep the in-memory cache in sync with what the editor currently shows. + # REVIEW: redundant round-trip — st_ace returns the same value that was passed as + # `value=` unless the user edited the content; comparing and re-assigning on every + # rerun is a no-op most of the time and adds overhead. if code != st.session_state.files_content[file_path]: st.session_state.files_content[file_path] = code @@ -179,36 +229,47 @@ def render_editor(): if st.button("Delete File", key=f"delete_{file_path}"): _delete_dialog(file_path) - if st.button("▶ Run Code", key="run_code"): - result = run_active_file() - if not result: - st.stop() + # ── Run + Output ────────────────────────────────────────────────── + if st.button("▶ Run Code", key=f"run_code_{file_path}", type="primary"): + run_active_file() + st.rerun() - st.subheader("Execution Output") + result = st.session_state.get("exec_results", {}).get(file_path) + if result: + st.subheader("Execution Output") - # Green on exit code 0 (success), red on anything else (error/crash). - if result["return_code"] == 0: - st.success(f"Exit code: {result['return_code']}") - else: - st.error(f"Exit code: {result['return_code']}") + if result.get("ast_error"): + st.warning("⚠️ Syntax Error detected before execution — code was not run.") + elif result["return_code"] == 0: + st.success(f"✅ Exit code: 0") + else: + st.error(f"❌ Exit code: {result['return_code']}") - if result["stdout"]: - st.text_area( - "Standard Output", - value=result["stdout"], - height=200, - disabled=True, - key="run_stdout") + # Debug with AI — only shown when there is an error or stderr output. + if result["return_code"] != 0 or result.get("stderr"): + if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"): + file_name = Path(file_path).name + code_content = st.session_state.files_content.get(file_path, "") + lang = LANG_MAP.get(Path(file_path).suffix, "python") + formatted_output = DebugLogger.format_debug_output(result) + debug_message = ( + f"I got an error while running **{file_name}**:\n\n" + f"```\n{formatted_output}\n```\n\n" + f"**Here is the code:**\n```{lang}\n{code_content}\n```\n\n" + f"Can you help me fix this?" + ) + st.session_state.pending_debug_message = debug_message + st.session_state["_navigate_to_chat"] = True + st.rerun() - if result["stderr"]: - st.text_area( - "Standard Error", - value=result["stderr"], - height=200, - disabled=True, - key="run_stderr") - if not result["stdout"] and not result["stderr"]: - st.info("No output produced by the code execution.") + if result.get("stdout"): + st.text_area("Standard Output", value=result["stdout"], height=200, + disabled=True, key=f"run_stdout_{file_path}") + if result.get("stderr"): + st.text_area("Standard Error", value=result["stderr"], height=200, + disabled=True, key=f"run_stderr_{file_path}") + if not result.get("stdout") and not result.get("stderr"): + st.info("No output produced by the code execution.") diff --git a/frontend/sidebar.py b/frontend/sidebar.py index 82de7b0..e265d61 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -53,6 +53,12 @@ def _delete_folder_dialog(folder_rel: str, folder_name: str): @st.dialog("Add File") def _add_file_dialog(parent_path: str = ""): + """Dialog for creating a new file inside the given folder (or workspace root). + + Args: + parent_path: Workspace-relative path of the parent folder. Pass an + empty string to create the file at the workspace root. + """ with st.form("add_file_form"): name = st.text_input("File name:", placeholder="e.g. script.py") col1, col2 = st.columns(2) @@ -77,6 +83,12 @@ def _add_file_dialog(parent_path: str = ""): @st.dialog("Add Folder") def _add_folder_dialog(parent_path: str = ""): + """Dialog for creating a new subfolder inside the given folder (or workspace root). + + Args: + parent_path: Workspace-relative path of the parent folder. Pass an + empty string to create the folder at the workspace root. + """ with st.form("add_folder_form"): name = st.text_input("Folder name:", placeholder="e.g. utils") col1, col2 = st.columns(2) @@ -166,6 +178,15 @@ def _rename_file_dialog(relative_file_path: str, file_name: str): @st.dialog("Delete File") def _delete_file_dialog(relative_file_path: str, file_name: str): + """Confirmation dialog before permanently deleting a file. + + After a successful delete the file is also removed from the editor's + open-files list and content cache so it cannot be saved back to disk. + + Args: + relative_file_path: Workspace-relative path to the file (used by FileManager). + file_name: Display name shown in the warning message. + """ st.warning(f"Delete **{file_name}**? This cannot be undone.") col1, col2 = st.columns(2) @@ -173,11 +194,9 @@ def _delete_file_dialog(relative_file_path: str, file_name: str): if st.button("Delete", type="primary", use_container_width=True): if fm.delete_file(relative_file_path): abs_file_path = str(Path(fm.base_path) / relative_file_path) - print(f"Deleting file at absolute path: {abs_file_path}") # Debugging info - print(f"Current open files before deletion: {st.session_state.open_files}") # Debugging info - st.session_state.open_files.remove(abs_file_path) st.session_state.files_content.pop(abs_file_path, None) + # Fall back to the first remaining open file, or None if all tabs are closed. if st.session_state.active_file == abs_file_path: st.session_state.active_file = ( st.session_state.open_files[0] @@ -234,14 +253,29 @@ def build_arborist_tree(tree, parent_path=Path()): def render_filetree_arborist(tree): - """Render the interactive file tree and return the currently selected node dict.""" + """Render the interactive file tree and return the currently selected node dict. + + Passes the active file's relative path as ``selection`` so the tree always + highlights whichever file is currently open in the editor, even when the + user switches tabs instead of clicking the tree. + """ data = build_arborist_tree(tree) + # Compute the node-ID of the currently active file (posix relative path) + # so the tree highlights it regardless of how the tab was opened. + active_selection = None + active_file = st.session_state.get("active_file") + if active_file: + try: + active_selection = str(Path(active_file).relative_to(fm.base_path).as_posix()) + except ValueError: + pass + selected = tree_view( data=data, icons={"open": "📂", "closed": "📁"}, height=400, - selection=None, + selection=active_selection, select_internal_nodes=True, # allow clicking folder names, not just files open_by_default=True ) @@ -257,7 +291,16 @@ def render_sidebar(): Tree click handling: - Clicking a file → appended to open_files, set as active_file - Clicking a folder → stored in selected_folder so the action bar appears + + Navigation flags (_navigate_to_editor, _navigate_to_chat) are consumed here + at the very top — before any widget is rendered — to avoid StreamlitAPIException. """ + # Consume navigation flags before any widget renders. + if st.session_state.pop("_navigate_to_editor", False): + st.session_state.radio_interface_options = "Code Editor" + if st.session_state.pop("_navigate_to_chat", False): + st.session_state.radio_interface_options = "Chat with AI Assistant" + st.sidebar.title("Navigation") navigation_section = st.sidebar.container() @@ -294,13 +337,16 @@ def render_sidebar(): abs_path = fm.base_path / selected_path if abs_path.is_file(): - # Open the file in the editor. + # Open the file in the editor, jump to its tab, + # and switch the view to the Editor pane. st.session_state.selected_folder = None st.session_state.selected_folder_rel = None file_str = str(abs_path) if file_str not in st.session_state.open_files: st.session_state.open_files.append(file_str) st.session_state.active_file = file_str + st.session_state._jump_to_tab = file_str + st.session_state._navigate_to_editor = True st.rerun() elif abs_path.is_dir(): @@ -344,6 +390,7 @@ def render_sidebar(): if st.button("Add Folder", key="btn_add_folder", use_container_width=True): _add_folder_dialog("") + # REVIEW: bare `return` at end of void function — no-op; can be removed. return diff --git a/frontend/state.py b/frontend/state.py index aa05aa3..787dd81 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -10,81 +10,108 @@ from backend.managers.chat_manager import ChatManager def init_state(): - # Sidebar state initialization + """Initialise all Streamlit session-state keys with safe defaults. + + Uses ``if key not in st.session_state`` guards throughout so that existing + values are never overwritten on subsequent reruns — only missing keys are + set. This means it is safe to call multiple times per session. + """ + # ── Sidebar state ───────────────────────────────────────────────────────── + # last_selected tracks the previously clicked tree node to detect new clicks + # and avoid re-running the same file-open logic on every Streamlit rerender. if "last_selected" not in st.session_state: st.session_state.last_selected = None - # Absolute path and workspace-relative path of the currently highlighted folder + # Absolute path and workspace-relative path of the currently highlighted folder. + # Both are set together; both are cleared together when a folder is deselected. if "selected_folder" not in st.session_state: st.session_state.selected_folder = None if "selected_folder_rel" not in st.session_state: st.session_state.selected_folder_rel = None - # Chat manager (persists across reruns) - # ChatManager keeps the full conversation history in memory across reruns + # ── Chat manager ────────────────────────────────────────────────────────── + + # ChatManager keeps the full conversation history in memory across reruns. + # Instantiated once and reused so history is not lost on page rerenders. if "chat_manager" not in st.session_state: st.session_state.chat_manager = ChatManager() - # Editor state initialization - # List of absolute file paths that are currently open as tabs + # ── Editor state ────────────────────────────────────────────────────────── + + # Ordered list of absolute file paths currently open as editor tabs. + # The list order determines the visual tab order in the UI. if "open_files" not in st.session_state: - """A list of currently open file paths - absolute paths only. The order determines the tab order in the UI. - Format: [ "path/to/file1.py", "path/to/file2.js", ... ] - """ + # REVIEW: dead code — docstrings inside `if` blocks are plain string literals that Python + # evaluates and immediately discards; they are never visible as __doc__ and have no effect. st.session_state.open_files = [] - # Dict mapping file path → current editor content (may be unsaved) + # Dict mapping absolute file path → current editor content (may differ from + # disk if the user has unsaved changes). if "files_content" not in st.session_state: - """A dictionary mapping file paths to their current content in the editor. - Format: { "path/to/file.py": "file content as string", ... } - """ + # REVIEW: dead code — same issue: string literal inside `if` block is never used as a docstring. st.session_state.files_content = {} - # Absolute path of the file whose tab is currently active + # Absolute path of the file whose tab is currently active in the editor. + # Must always be one of the paths in open_files, or None if no file is open. if "active_file" not in st.session_state: - """The currently active file in the editor (absolute path in string e.g. "/workspace/path/to/file.py"). - Should be one of the paths in open_files or None if no file is open.""" + # REVIEW: dead code — string literal inside `if` block is never used as a docstring. st.session_state.active_file = None - # Index of the active tab (used by st.tabs) + # REVIEW: dead code — active_tab is initialised here but never read or written anywhere else + # in the codebase; st.tabs() in editor.py does not use this key. + # Index of the active tab — kept in sync with active_file for st.tabs(). if "active_tab" not in st.session_state: st.session_state.active_tab = 0 + # REVIEW: dead code — is_editing is initialised here but never read or written anywhere else. if "is_editing" not in st.session_state: st.session_state.is_editing = False + # REVIEW: dead code — code_suggestions is initialised here but never read or written anywhere else. if "code_suggestions" not in st.session_state: st.session_state.code_suggestions = [] - # Output dict from the last code run: {stdout, stderr, return_code} + # Output dict from the last code execution: {stdout, stderr, return_code}. + # Initialised as empty string so the editor view can safely check falsyness. if "code_execution_output" not in st.session_state: st.session_state.code_execution_output = "" - # Chat state initialization - # Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles + # Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}} + if "exec_results" not in st.session_state: + st.session_state.exec_results = {} + + # ── Chat state ──────────────────────────────────────────────────────────── + + # Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles. + # System messages are stored here too but skipped during display. if "chat_history" not in st.session_state: st.session_state.chat_history = [] - # Agent Mode state - # Whether the UI is currently in Agent Mode (vs normal chat) + # ── Agent Mode state ────────────────────────────────────────────────────── + + # Boolean toggle — True while the UI is in Coding Agent mode. if "agent_mode" not in st.session_state: st.session_state.agent_mode = False - # The live CodingAgent instance while a task is running + # The live CodingAgent instance while a task is running. + # Set by _start_agent(), cleared by _reset_agent(). if "coding_agent" not in st.session_state: st.session_state.coding_agent = None - # Current status of the agent: "idle" | "waiting_approval" | "done" + # Lifecycle state of the agent: "idle" | "waiting_approval" | "done". + # Controls which sub-screen render_agent_mode() displays. if "agent_status" not in st.session_state: st.session_state.agent_status = "idle" - # List of completed steps shown in the collapsible Agent Log + # Chronological list of completed step records shown in the Agent Log expander. + # Each entry: {"thought": str, "tool": str, "arguments": dict, "result": str} if "agent_log" not in st.session_state: st.session_state.agent_log = [] - # The action the agent proposed but has not yet been approved or rejected + # The action the agent has proposed but that has not yet been approved or + # rejected by the user. Stored as the raw dict returned by propose_next_action(). if "agent_pending_action" not in st.session_state: st.session_state.agent_pending_action = None diff --git a/requirements.txt b/requirements.txt index 3916858..acaf29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,5 +25,6 @@ python-dotenv>=1.0.0 #For code editor functionality streamlit-ace>=0.1.0 -#Whitelisted Imports from Agent-Sandbox -pygame \ No newline at end of file +#MCP-Code execution tools +pyflakes>=0.1.0 +pygame>=0.1.0 \ No newline at end of file diff --git a/run_agent.py b/run_agent.py deleted file mode 100644 index 4e5e26e..0000000 --- a/run_agent.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Temporäres Test-Script für den CodingAgent – kann danach gelöscht werden. - -Ausführen: - python run_agent.py - -Steuerung: - Enter → Aktion ausführen (approve) - Text + Enter → Feedback geben (reject + replan) - stop → Abbrechen -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) - -from backend.agent.coding_agent import CodingAgent, WORKSPACE - - -def run(): - print("\n" + "=" * 60) - print(" CodingAgent – Interaktiver Test") - print("=" * 60) - print(f" Workspace: {WORKSPACE}") - print(" [Enter] = Aktion ausführen | Text = Feedback | 'stop' = Abbruch") - print("=" * 60 + "\n") - - task = input("Aufgabe eingeben: ").strip() - if not task: - print("Keine Aufgabe eingegeben. Beende.") - return - - agent = CodingAgent() - agent.start_task(task) - print(f"\nAgent gestartet für: '{task}'\n") - - step = 0 - while not agent.is_done: - step += 1 - print(f"\n{'─' * 60}") - print(f" Schritt {step} – Agent überlegt...") - - action = agent.propose_next_action() - - print(f"\n Thought : {action.get('thought', '')}") - print(f" Tool : {action.get('tool', '')}") - print(f" Arguments: {action.get('arguments', {})}") - print() - - user_input = input(" [Enter]=ausführen | Text=Feedback | stop=Abbruch: ").strip() - - if user_input.lower() in ("stop", "abort"): - print("\nAbgebrochen.") - break - - if user_input: - agent.reject(user_input) - print(f" → Feedback injiziert. Agent plant neu.\n") - continue - - result = agent.approve() - - print(f"\n Resultat ({result['tool']}):") - print(f" {result['result'][:300]}{'...' if len(result['result']) > 300 else ''}") - - if result["is_done"]: - print("\n" + "=" * 60) - print(" FERTIG!") - print(f" {result['result']}") - print("=" * 60) - - -if __name__ == "__main__": - try: - run() - except KeyboardInterrupt: - print("\n\nUnterbrochen.") diff --git a/tests/test_debug_logger.py b/tests/test_debug_logger.py index 1ee6eca..d0de98a 100644 --- a/tests/test_debug_logger.py +++ b/tests/test_debug_logger.py @@ -6,7 +6,7 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) -from backend.managers.debug_logger import DebugLogger +from backend.managers._debug_logger import DebugLogger # ── Fixtures ────────────────────────────────────────────────────────────────── diff --git a/tests/test_execution_engine.py b/tests/test_execution_engine.py index e69de29..6120ece 100644 --- a/tests/test_execution_engine.py +++ b/tests/test_execution_engine.py @@ -0,0 +1,428 @@ +import pytest +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch + +from backend.managers.execution_engine import ExecutionEngine + + +# ========================================================= +# FIXTURE +# ========================================================= + +@pytest.fixture() +def engine(): + return ExecutionEngine() + + +# ========================================================= +# BASIC TESTS (1–10) +# ========================================================= + + +# --------------------------------------------------------- +# 1. Python-Datei wird korrekt ausgeführt +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_python_file_success(mock_run, engine, tmp_path): + file = tmp_path / "test.py" + file.write_text("print('hello')") + + mock_run.return_value = Mock( + stdout="hello\n", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["rc"] == 0 + assert "hello" in result["stdout"] + + +# --------------------------------------------------------- +# 2. Python-Datei mit Fehler +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_python_file_error(mock_run, engine, tmp_path): + file = tmp_path / "broken.py" + file.write_text("1/0") + + mock_run.return_value = Mock( + stdout="", + stderr="ZeroDivisionError", + returncode=1 + ) + + result = engine.run_code(file) + + assert result["rc"] == 1 + assert "ZeroDivisionError" in result["stderr"] + + +# --------------------------------------------------------- +# 3. LaTeX-Datei wird kompiliert +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_tex_file_success(mock_run, engine, tmp_path): + file = tmp_path / "doc.tex" + file.write_text("\\documentclass{article}") + + mock_run.return_value = Mock( + stdout="PDF created", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["rc"] == 0 + assert "PDF created" in result["stdout"] + + +# --------------------------------------------------------- +# 4. Unsupported File Type +# --------------------------------------------------------- + +def test_run_unsupported_file(engine, tmp_path): + file = tmp_path / "test.js" + file.write_text("console.log('x')") + + result = engine.run_code(file) + + assert result["rc"] == 1 + assert "Unsupported file type" in result["stderr"] + + +# --------------------------------------------------------- +# 5. Timeout wird behandelt +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_timeout(mock_run, engine, tmp_path): + file = tmp_path / "slow.py" + file.write_text("while True: pass") + + mock_run.side_effect = subprocess.TimeoutExpired( + cmd=["py"], + timeout=30 + ) + + result = engine.run_code(file) + + assert result["rc"] == -1 + assert "Timed out" in result["stderr"] + + +# --------------------------------------------------------- +# 6. Fehlender Interpreter +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_missing_interpreter(mock_run, engine, tmp_path): + file = tmp_path / "test.py" + file.write_text("print(1)") + + mock_run.side_effect = FileNotFoundError("py not found") + + result = engine.run_code(file) + + assert result["rc"] == -1 + assert "py not found" in result["stderr"] + + +# --------------------------------------------------------- +# 7. Allgemeine Exception +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_general_exception(mock_run, engine, tmp_path): + file = tmp_path / "test.py" + file.write_text("print(1)") + + mock_run.side_effect = RuntimeError("unexpected") + + result = engine.run_code(file) + + assert result["rc"] == -1 + assert "unexpected" in result["stderr"] + + +# --------------------------------------------------------- +# 8. subprocess.run wird mit cwd ausgeführt +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_uses_correct_cwd(mock_run, engine, tmp_path): + folder = tmp_path / "project" + folder.mkdir() + + file = folder / "main.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + engine.run_code(file) + + _, kwargs = mock_run.call_args + + assert kwargs["cwd"] == folder.resolve() + + +# --------------------------------------------------------- +# 9. subprocess.run nutzt capture_output +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_capture_output_enabled(mock_run, engine, tmp_path): + file = tmp_path / "test.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + engine.run_code(file) + + _, kwargs = mock_run.call_args + + assert kwargs["capture_output"] is True + + +# --------------------------------------------------------- +# 10. subprocess.run nutzt text=True +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_text_mode_enabled(mock_run, engine, tmp_path): + file = tmp_path / "test.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + engine.run_code(file) + + _, kwargs = mock_run.call_args + + assert kwargs["text"] is True + + +# ========================================================= +# EDGE CASE TESTS (11–20) +# ========================================================= + + +# --------------------------------------------------------- +# 11. Unicode Output +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_unicode_output(mock_run, engine, tmp_path): + file = tmp_path / "unicode.py" + file.write_text("print('🔥 Grüezi 世界')", encoding="utf-8") + + mock_run.return_value = Mock( + stdout="🔥 Grüezi 世界\n", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert "🔥 Grüezi 世界" in result["stdout"] + + +# --------------------------------------------------------- +# 12. Leerer stdout/stderr +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_empty_output(mock_run, engine, tmp_path): + file = tmp_path / "empty.py" + file.write_text("x = 1") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["stdout"] == "" + assert result["stderr"] == "" + + +# --------------------------------------------------------- +# 13. Sehr langer stdout +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_large_output(mock_run, engine, tmp_path): + file = tmp_path / "large.py" + file.write_text("print('A')") + + mock_run.return_value = Mock( + stdout="A" * 100000, + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert len(result["stdout"]) == 100000 + + +# --------------------------------------------------------- +# 14. Dateiname mit Leerzeichen +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_filename_with_spaces(mock_run, engine, tmp_path): + file = tmp_path / "my script.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="ok", + stderr="", + returncode=0 + ) + + engine.run_code(file) + + args, _ = mock_run.call_args + + assert "my script.py" in args[0] + + +# --------------------------------------------------------- +# 15. Dateiname mit Unicode +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_unicode_filename(mock_run, engine, tmp_path): + file = tmp_path / "🔥_test.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="ok", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["rc"] == 0 + + +# --------------------------------------------------------- +# 16. .tex nutzt pdflatex +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_tex_uses_pdflatex(mock_run, engine, tmp_path): + file = tmp_path / "doc.tex" + file.write_text("x") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + engine.run_code(file) + + args, _ = mock_run.call_args + + assert args[0][0] == "pdflatex" + + +# --------------------------------------------------------- +# 17. .py nutzt py Interpreter +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_python_uses_py_interpreter(mock_run, engine, tmp_path): + file = tmp_path / "main.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + engine.run_code(file) + + args, _ = mock_run.call_args + + assert args[0][0] == "py" + + +# --------------------------------------------------------- +# 18. Relative Pfade funktionieren +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_relative_paths(mock_run, engine, tmp_path): + sub = tmp_path / "src" + sub.mkdir() + + file = sub / "main.py" + file.write_text("print(1)") + + mock_run.return_value = Mock( + stdout="ok", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["rc"] == 0 + + +# --------------------------------------------------------- +# 19. Großgeschriebenes Suffix blockiert +# --------------------------------------------------------- + +def test_uppercase_suffix_not_supported(engine, tmp_path): + file = tmp_path / "SCRIPT.PY" + file.write_text("print(1)") + + result = engine.run_code(file) + + assert result["rc"] == 1 + + +# --------------------------------------------------------- +# 20. Leere Datei ausführen +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_empty_file(mock_run, engine, tmp_path): + file = tmp_path / "empty.py" + file.write_text("") + + mock_run.return_value = Mock( + stdout="", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["rc"] == 0 \ No newline at end of file diff --git a/tests/test_mcp_server_code_execution.py b/tests/test_mcp_server_code_execution.py index e69de29..8bef986 100644 --- a/tests/test_mcp_server_code_execution.py +++ b/tests/test_mcp_server_code_execution.py @@ -0,0 +1,269 @@ +import pytest +from unittest.mock import Mock, patch +import subprocess + +from backend.agent.servers import mcp_server_code_execution as server + + +# ========================================================= +# BASIC TESTS (1–10) +# ========================================================= + + +# --------------------------------------------------------- +# 1. Erlaubter Code besteht Safety Check +# --------------------------------------------------------- + +def test_check_code_safety_valid(): + code = "print('hello')" + + result = server.check_code_safety(code) + + assert result is None + + +# --------------------------------------------------------- +# 2. Blockierter Import wird erkannt +# --------------------------------------------------------- + +def test_check_code_safety_blocked_import(): + code = "import os" + + result = server.check_code_safety(code) + + assert "Blocked import" in result + + +# --------------------------------------------------------- +# 3. Blockierter Builtin wird erkannt +# --------------------------------------------------------- + +def test_check_code_safety_blocked_builtin(): + code = "eval('2+2')" + + result = server.check_code_safety(code) + + assert "Blocked builtin" in result + + +# --------------------------------------------------------- +# 4. analyse_structure erkennt Funktionen +# --------------------------------------------------------- + +def test_analyse_structure_function(): + code = """ +def hello(name): + return name +""" + + result = server.analyse_structure(code) + + assert "def hello(name)" in result + + +# --------------------------------------------------------- +# 5. analyse_structure erkennt Klassen +# --------------------------------------------------------- + +def test_analyse_structure_class(): + code = """ +class User: + def login(self): + pass +""" + + result = server.analyse_structure(code) + + assert "class User" in result + assert "method: login" in result + + +# --------------------------------------------------------- +# 6. lint_code erkennt Undefined Variable +# --------------------------------------------------------- + +def test_lint_code_undefined_variable(): + code = "print(x)" + + result = server.lint_code(code) + + assert "undefined name 'x'" in result.lower() + + +# --------------------------------------------------------- +# 7. lint_code erkennt sauberen Code +# --------------------------------------------------------- + +def test_lint_code_clean(): + code = """ +x = 1 +print(x) +""" + + result = server.lint_code(code) + + assert "No issues found" in result + + +# --------------------------------------------------------- +# 8. python_code_validation validiert sicheren Code +# --------------------------------------------------------- + +def test_python_code_validation_safe(): + code = "print('safe')" + + result = server.python_code_validation(code) + + assert "can be executed" in result + + +# --------------------------------------------------------- +# 9. run_python_sandboxed führt Code aus +# --------------------------------------------------------- + +def test_run_python_sandboxed_success(): + code = "print('hello world')" + + result = server.run_python_sandboxed(code) + + assert "hello world" in result + + +# --------------------------------------------------------- +# 10. run_python_sandboxed ohne Output +# --------------------------------------------------------- + +def test_run_python_sandboxed_no_output(): + code = "x = 5" + + result = server.run_python_sandboxed(code) + + assert "no output" in result.lower() + + +# ========================================================= +# EDGE CASE TESTS (11–20) +# ========================================================= + + +# --------------------------------------------------------- +# 11. Syntaxfehler erkennen +# --------------------------------------------------------- + +def test_check_code_safety_syntax_error(): + code = "def broken(" + + result = server.check_code_safety(code) + + assert "SyntaxError" in result + + +# --------------------------------------------------------- +# 12. ImportFrom blockieren +# --------------------------------------------------------- + +def test_check_code_safety_import_from(): + code = "from os import path" + + result = server.check_code_safety(code) + + assert "Blocked import" in result + + +# --------------------------------------------------------- +# 13. Gefährliche Path-Sequenzen erkennen +# --------------------------------------------------------- + +def test_check_code_safety_path_traversal(): + code = "print('../etc/passwd')" + + result = server.check_code_safety(code) + + assert "Suspect path sequence" in result + + +# --------------------------------------------------------- +# 14. __import__ erkennen +# --------------------------------------------------------- + +def test_check_code_safety_import_escape(): + code = "__import__('os')" + + result = server.check_code_safety(code) + + assert "Blocked" in result + + +# --------------------------------------------------------- +# 15. subprocess Escape erkennen +# --------------------------------------------------------- + +def test_check_code_safety_subprocess_escape(): + code = "subprocess.run(['ls'])" + + result = server.check_code_safety(code) + + assert "Suspect path sequence" in result + + +# --------------------------------------------------------- +# 16. Endlosschleife Timeout +# --------------------------------------------------------- + +def test_run_python_sandboxed_timeout(): + code = """ +while True: + pass +""" + + result = server.run_python_sandboxed(code) + + assert "time limit" in result.lower() + + +# --------------------------------------------------------- +# 17. Sehr großer Output wird gekürzt +# --------------------------------------------------------- + +def test_run_python_sandboxed_large_output(): + code = "print('A' * 10000)" + + result = server.run_python_sandboxed(code) + + assert "truncated" in result.lower() + + +# --------------------------------------------------------- +# 18. Unicode Output funktioniert +# --------------------------------------------------------- + +def test_run_python_sandboxed_unicode(): + code = "print('🔥 Grüezi 世界')" + + result = server.run_python_sandboxed(code) + + assert "🔥 Grüezi 世界" in result + + +# --------------------------------------------------------- +# 19. analyse_structure bei leerem Code +# --------------------------------------------------------- + +def test_analyse_structure_empty(): + code = "" + + result = server.analyse_structure(code) + + assert "No top-level imports" in result + + +# --------------------------------------------------------- +# 20. Sandbox behandelt Runtime Errors +# --------------------------------------------------------- + +def test_run_python_sandboxed_runtime_error(): + code = "1 / 0" + + result = server.run_python_sandboxed(code) + + assert "ZeroDivisionError" in result \ No newline at end of file diff --git a/tests/test_search_manager.py b/tests/test_search_manager.py new file mode 100644 index 0000000..73fbcc9 --- /dev/null +++ b/tests/test_search_manager.py @@ -0,0 +1,362 @@ +"""Tests for SearchManager — no real network calls, all I/O mocked.""" + +import socket +import pytest +from unittest.mock import Mock, patch, MagicMock + +from backend.managers.search_manager import SearchManager, MAX_PAGE_CHARS + + +@pytest.fixture +def manager(): + return SearchManager() + + +# ========================================================= +# perform_search +# ========================================================= + + +# --------------------------------------------------------- +# 1. Erfolgreiche Suche gibt normalisierte Liste zurück +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.DDGS") +def test_perform_search_success(mock_ddgs_cls, manager): + raw = [{"title": "Example", "href": "https://example.com", "body": "A snippet"}] + mock_ddgs = Mock() + mock_ddgs.text.return_value = raw + mock_ddgs_cls.return_value.__enter__ = Mock(return_value=mock_ddgs) + mock_ddgs_cls.return_value.__exit__ = Mock(return_value=False) + + result = manager.perform_search("python testing") + + assert len(result) == 1 + assert result[0]["title"] == "Example" + assert result[0]["url"] == "https://example.com" + assert result[0]["snippet"] == "A snippet" + + +# --------------------------------------------------------- +# 2. max_results wird an ddgs.text weitergegeben +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.DDGS") +def test_perform_search_passes_max_results(mock_ddgs_cls, manager): + mock_ddgs = Mock() + mock_ddgs.text.return_value = [] + mock_ddgs_cls.return_value.__enter__ = Mock(return_value=mock_ddgs) + mock_ddgs_cls.return_value.__exit__ = Mock(return_value=False) + + manager.perform_search("query", max_results=3) + + mock_ddgs.text.assert_called_once_with("query", max_results=3) + + +# --------------------------------------------------------- +# 3. DDGS-Exception → leere Liste, kein Absturz +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.DDGS") +def test_perform_search_exception_returns_empty(mock_ddgs_cls, manager): + mock_ddgs_cls.side_effect = Exception("network failure") + + result = manager.perform_search("anything") + + assert result == [] + + +# --------------------------------------------------------- +# 4. ddgs.text()-Exception → leere Liste +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.DDGS") +def test_perform_search_text_exception_returns_empty(mock_ddgs_cls, manager): + mock_ddgs = Mock() + mock_ddgs.text.side_effect = RuntimeError("rate limited") + mock_ddgs_cls.return_value.__enter__ = Mock(return_value=mock_ddgs) + mock_ddgs_cls.return_value.__exit__ = Mock(return_value=False) + + result = manager.perform_search("test") + + assert result == [] + + +# ========================================================= +# parse_results +# ========================================================= + + +# --------------------------------------------------------- +# 5. Primärschlüssel href/body werden korrekt gemappt +# --------------------------------------------------------- + +def test_parse_results_primary_keys(manager): + raw = [{"title": "T", "href": "https://example.com", "body": "B"}] + + result = manager.parse_results(raw) + + assert result == [{"title": "T", "url": "https://example.com", "snippet": "B"}] + + +# --------------------------------------------------------- +# 6. Fallback-Schlüssel url/snippet werden verwendet +# --------------------------------------------------------- + +def test_parse_results_fallback_keys(manager): + raw = [{"title": "T2", "url": "https://fallback.com", "snippet": "S2"}] + + result = manager.parse_results(raw) + + assert result[0]["url"] == "https://fallback.com" + assert result[0]["snippet"] == "S2" + + +# --------------------------------------------------------- +# 7. Fehlende Felder → leere Strings, kein Absturz +# --------------------------------------------------------- + +def test_parse_results_missing_fields(manager): + result = manager.parse_results([{}]) + + assert result == [{"title": "", "url": "", "snippet": ""}] + + +# --------------------------------------------------------- +# 8. Leere Eingabe → leere Liste +# --------------------------------------------------------- + +def test_parse_results_empty_input(manager): + assert manager.parse_results([]) == [] + + +# --------------------------------------------------------- +# 9. Mehrere Ergebnisse bleiben in korrekter Reihenfolge +# --------------------------------------------------------- + +def test_parse_results_multiple_entries(manager): + raw = [ + {"title": "A", "href": "https://a.com", "body": "aa"}, + {"title": "B", "href": "https://b.com", "body": "bb"}, + ] + + result = manager.parse_results(raw) + + assert len(result) == 2 + assert result[0]["url"] == "https://a.com" + assert result[1]["url"] == "https://b.com" + + +# ========================================================= +# fetch_page +# ========================================================= + + +# --------------------------------------------------------- +# 10. HTML wird geparst, Text wird zurückgegeben +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +@patch("backend.managers.search_manager.requests.get") +def test_fetch_page_returns_text(mock_get, _mock_dns, manager): + response = Mock() + response.text = "

Hello World

" + response.raise_for_status = Mock() + mock_get.return_value = response + + result = manager.fetch_page("https://example.com") + + assert "Hello World" in result + + +# --------------------------------------------------------- +# 11. script- und style-Tags werden entfernt +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +@patch("backend.managers.search_manager.requests.get") +def test_fetch_page_removes_noise_tags(mock_get, _mock_dns, manager): + response = Mock() + response.text = ( + "" + "

Content

" + ) + response.raise_for_status = Mock() + mock_get.return_value = response + + result = manager.fetch_page("https://example.com") + + assert "alert" not in result + assert "color:red" not in result + assert "Content" in result + + +# --------------------------------------------------------- +# 12. Inhalt über MAX_PAGE_CHARS wird abgeschnitten +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +@patch("backend.managers.search_manager.requests.get") +def test_fetch_page_truncates_long_content(mock_get, _mock_dns, manager): + long_text = "A" * (MAX_PAGE_CHARS + 500) + response = Mock() + response.text = f"{long_text}" + response.raise_for_status = Mock() + mock_get.return_value = response + + result = manager.fetch_page("https://example.com") + + assert "[truncated]" in result + assert len(result) <= MAX_PAGE_CHARS + len("\n... [truncated]") + 5 + + +# --------------------------------------------------------- +# 13. Inhalt unter MAX_PAGE_CHARS wird nicht abgeschnitten +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +@patch("backend.managers.search_manager.requests.get") +def test_fetch_page_no_truncation_for_short_content(mock_get, _mock_dns, manager): + response = Mock() + response.text = "

Short

" + response.raise_for_status = Mock() + mock_get.return_value = response + + result = manager.fetch_page("https://example.com") + + assert "[truncated]" not in result + assert "Short" in result + + +# --------------------------------------------------------- +# 14. requests.Timeout → Fehlermeldung als String +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +@patch("backend.managers.search_manager.requests.get") +def test_fetch_page_timeout_returns_error_string(mock_get, _mock_dns, manager): + import requests as req_module + mock_get.side_effect = req_module.Timeout("timed out") + + result = manager.fetch_page("https://example.com") + + assert "Error fetching page" in result + + +# --------------------------------------------------------- +# 15. ConnectionError → Fehlermeldung als String +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +@patch("backend.managers.search_manager.requests.get") +def test_fetch_page_connection_error_returns_error_string(mock_get, _mock_dns, manager): + import requests as req_module + mock_get.side_effect = req_module.ConnectionError("refused") + + result = manager.fetch_page("https://example.com") + + assert "Error fetching page" in result + + +# ========================================================= +# _validate_url +# ========================================================= + + +# --------------------------------------------------------- +# 16. https-URL mit öffentlicher IP → kein Fehler +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +def test_validate_url_valid_https(_mock_dns, manager): + manager._validate_url("https://example.com") # no exception + + +# --------------------------------------------------------- +# 17. http-URL → kein Fehler +# --------------------------------------------------------- + +@patch("backend.managers.search_manager.socket.gethostbyname", return_value="93.184.216.34") +def test_validate_url_valid_http(_mock_dns, manager): + manager._validate_url("http://example.com") # no exception + + +# --------------------------------------------------------- +# 18. localhost → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_localhost(manager): + with pytest.raises(ValueError, match="localhost"): + manager._validate_url("http://localhost/admin") + + +# --------------------------------------------------------- +# 19. 127.0.0.1 → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_127(manager): + with pytest.raises(ValueError): + manager._validate_url("http://127.0.0.1:8080") + + +# --------------------------------------------------------- +# 20. ::1 (IPv6 loopback) → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_ipv6_loopback(manager): + with pytest.raises(ValueError): + manager._validate_url("http://[::1]/secret") + + +# --------------------------------------------------------- +# 21. Private IP 192.168.x.x → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_private_192(manager): + with pytest.raises(ValueError): + manager._validate_url("http://192.168.1.10") + + +# --------------------------------------------------------- +# 22. Private IP 10.x.x.x → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_private_10(manager): + with pytest.raises(ValueError): + manager._validate_url("http://10.0.0.1") + + +# --------------------------------------------------------- +# 23. Link-local / AWS Metadata IP → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_link_local(manager): + with pytest.raises(ValueError): + manager._validate_url("http://169.254.169.254/latest/meta-data/") + + +# --------------------------------------------------------- +# 24. file://-Schema → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_file_scheme(manager): + with pytest.raises(ValueError, match="http/https"): + manager._validate_url("file:///etc/passwd") + + +# --------------------------------------------------------- +# 25. ftp://-Schema → ValueError +# --------------------------------------------------------- + +def test_validate_url_blocks_ftp_scheme(manager): + with pytest.raises(ValueError, match="http/https"): + manager._validate_url("ftp://example.com/file.txt") + + +# --------------------------------------------------------- +# 26. fetch_page propagiert ValueError aus _validate_url +# --------------------------------------------------------- + +def test_fetch_page_raises_on_invalid_url(manager): + with pytest.raises(ValueError): + manager.fetch_page("http://localhost/internal")