Merge pull request 'func_improvments' (#18) from func_improvments into main

Reviewed-on: meulilivio/AISE1_Project#18
This commit is contained in:
Livio Meuli 2026-05-26 15:02:32 +02:00
commit ff61ef5d16
27 changed files with 2929 additions and 494 deletions

3
.gitignore vendored
View File

@ -50,3 +50,6 @@ data/raw/
# Workspace
workspace/
# Logs
logs/

46
DEBUG_LOGGER_USAGE.md Normal file
View File

@ -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']}")
```

201
README.md
View File

@ -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
```

392
READMEnew.md Normal file
View File

@ -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_<modulname>.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 │
└─────────────────────────────────────────────────────────────────┘
```

View File

@ -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
``"- <tool_name>: <description>"``.
"""
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 <replan> 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.</replan>"
)
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.</replan>"
),
})
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

View File

@ -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:")

View File

@ -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": {

View File

@ -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="<agent_code>", 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"

View File

@ -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 ───────────────────────────────────────────────────────────

View File

@ -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 ───────────────────────────────────────────────────────────

View File

@ -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())

View File

@ -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 [

View File

@ -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)

View File

@ -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,
}

View File

@ -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()

View File

@ -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"

View File

@ -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():
"""
<style>
.block-container { padding-top: 1rem; }
[data-testid="stSidebarContent"] { padding-top: 0rem; margin-top: -2rem; }
[data-testid="stSidebarContent"] { padding-top: 1rem; }
</style>
""",
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()

View File

@ -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 ───────────────────────────────────────────────────────────────

View File

@ -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"""<script>
(function() {{
setTimeout(function() {{
const tabs = window.parent.document
.querySelectorAll('button[data-baseweb="tab"]');
if (tabs[{idx}]) tabs[{idx}].click();
}}, 100);
}})();
</script>""",
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.")

View File

@ -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

View File

@ -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

View File

@ -25,5 +25,6 @@ python-dotenv>=1.0.0
#For code editor functionality
streamlit-ace>=0.1.0
#Whitelisted Imports from Agent-Sandbox
pygame
#MCP-Code execution tools
pyflakes>=0.1.0
pygame>=0.1.0

View File

@ -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.")

View File

@ -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 ──────────────────────────────────────────────────────────────────

View File

@ -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 (110)
# =========================================================
# ---------------------------------------------------------
# 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 (1120)
# =========================================================
# ---------------------------------------------------------
# 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

View File

@ -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 (110)
# =========================================================
# ---------------------------------------------------------
# 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 (1120)
# =========================================================
# ---------------------------------------------------------
# 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

View File

@ -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 = "<html><body><h1>Hello World</h1></body></html>"
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 = (
"<html><head><style>body{color:red}</style></head>"
"<body><script>alert('x')</script><p>Content</p></body></html>"
)
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"<html><body>{long_text}</body></html>"
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 = "<html><body><p>Short</p></body></html>"
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")