Merge pull request 'func_improvments' (#18) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#18
This commit is contained in:
commit
ff61ef5d16
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,3 +50,6 @@ data/raw/
|
||||
|
||||
# Workspace
|
||||
workspace/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
46
DEBUG_LOGGER_USAGE.md
Normal file
46
DEBUG_LOGGER_USAGE.md
Normal 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
201
README.md
@ -2,106 +2,102 @@
|
||||
|
||||
AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026)
|
||||
|
||||
## Project Structure
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
AISE_AIAgent/
|
||||
├── frontend/ # Streamlit UI Components
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # Main Streamlit application entry point
|
||||
│ ├── sidebar.py # File navigation sidebar component
|
||||
│ ├── editor.py # Code editor pane component
|
||||
│ └── chat.py # Chat interface component
|
||||
├── frontend/ # Streamlit UI-Komponenten
|
||||
│ ├── app.py # Haupteinstiegspunkt der Streamlit-App
|
||||
│ ├── state.py # Session-State-Verwaltung
|
||||
│ ├── sidebar.py # Datei-Navigation (Sidebar)
|
||||
│ ├── editor.py # Code-Editor-Pane
|
||||
│ └── chat.py # Chat-Interface
|
||||
│
|
||||
├── backend/ # Backend Logic Modules
|
||||
│ ├── __init__.py
|
||||
│ ├── managers/ # Business logic for UI operations
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── file_manager.py # File I/O operations for UI (read, write, list files)
|
||||
│ │ ├── chat_manager.py # AI chat management and history
|
||||
│ │ ├── system_prompter.py # System prompts and context injection
|
||||
│ │ ├── search_manager.py # Internet search functionality
|
||||
│ │ ├── execution_engine.py # Code execution and sandboxing
|
||||
│ │ └── debug_logger.py # Logging, error handling, debug messages
|
||||
├── backend/ # Backend-Logik
|
||||
│ ├── managers/ # Business-Logik für UI-Operationen
|
||||
│ │ ├── file_manager.py # Datei-CRUD (lesen, schreiben, listen)
|
||||
│ │ ├── chat_manager.py # AI-Chat-Verwaltung und -History
|
||||
│ │ ├── system_prompter.py # System-Prompts und Kontext-Injektion
|
||||
│ │ ├── search_manager.py # Web-Suche (DuckDuckGo)
|
||||
│ │ ├── execution_engine.py # Code-Ausführung und Sandboxing
|
||||
│ │ └── debug_logger.py # Logging, Fehlerbehandlung, Debug-Ausgaben
|
||||
│ │
|
||||
│ ├── agents/ # AI Agent System
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── coding_agent.py # Main agent loop (plan-act-observe cycle)
|
||||
│ │ └── tools.py # Tools available to agent (7 functions + dispatcher)
|
||||
│ │
|
||||
│ └── utils/ # Helper Utilities
|
||||
│ ├── __init__.py
|
||||
│ └── server_utils.py # LLM client init, chat functions, formatters
|
||||
│ └── agent/ # Autonomes AI-Agent-System (MCP-basiert)
|
||||
│ ├── coding_agent.py # Haupt-Agent-Loop (Plan-Act-Observe)
|
||||
│ ├── mcp_server_adapter.py # MCP-Adapter: verbindet Agent mit MCP-Servern
|
||||
│ ├── mcp_server_adapter_RAG.py # MCP-Adapter mit RAG-basierter Tool-Auswahl
|
||||
│ ├── mcp_server_config.json # Konfiguration der MCP-Server (Startbefehle)
|
||||
│ └── servers/ # MCP-Server-Implementierungen
|
||||
│ ├── mcp_server_code_execution.py # Tool: Python-Code ausführen
|
||||
│ ├── mcp_server_file_search.py # Tool: Dateien suchen und lesen
|
||||
│ └── mcp_server_web_search.py # Tool: Web-Suche via DuckDuckGo
|
||||
│
|
||||
├── tests/ # Unit Tests
|
||||
│ ├── __init__.py
|
||||
│ ├── test_file_manager.py # Tests for file operations
|
||||
│ ├── test_chat_manager.py # Tests for chat functionality
|
||||
│ ├── test_execution_engine.py # Tests for code execution
|
||||
│ └── test_main.py # Integration tests
|
||||
├── tests/ # Unit-Tests (pytest)
|
||||
│ ├── conftest.py # Globale Test-Fixtures und MCP-Mocks
|
||||
│ ├── test_file_manager.py
|
||||
│ ├── test_chat_manager.py
|
||||
│ ├── test_execution_engine.py
|
||||
│ ├── test_coding_agent.py
|
||||
│ ├── test_debug_logger.py
|
||||
│ ├── test_system_prompter.py
|
||||
│ ├── test_mcp_server_code_execution.py
|
||||
│ ├── test_mcp_server_file_search.py
|
||||
│ └── test_mcp_server_web_search.py
|
||||
│
|
||||
├── workspace/ # Agent Sandbox Directory
|
||||
│ └── .gitkeep # Placeholder for agent to work safely in isolation
|
||||
│
|
||||
├── .gitignore # Git exclusions (venv, .env, __pycache__, etc.)
|
||||
├── .env # Local environment variables (NOT committed)
|
||||
├── .env.example # Template for environment variables (IS committed)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── README.md # This file
|
||||
└── project_exercise.pdf # Project specification
|
||||
├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis)
|
||||
├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent
|
||||
└── .env.example # Vorlage für Umgebungsvariablen
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
## Komponenten
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
- **app.py**: Main Streamlit application, layout orchestration
|
||||
- **sidebar.py**: File browser and project navigation
|
||||
- **editor.py**: Code editing interface with syntax highlighting
|
||||
- **chat.py**: AI assistant chat interface
|
||||
- **app.py**: Streamlit-Applikation, Layout-Orchestrierung
|
||||
- **state.py**: Zentralisierte Session-State-Verwaltung
|
||||
- **sidebar.py**: Datei-Browser und Projekt-Navigation
|
||||
- **editor.py**: Code-Editor mit Syntax-Highlighting
|
||||
- **chat.py**: AI-Assistent Chat-Interface
|
||||
|
||||
### Backend Managers (`backend/managers/`)
|
||||
Used directly by Frontend for UI operations:
|
||||
- **file_manager.py**: CRUD operations on project files
|
||||
- **chat_manager.py**: Chat history, message management
|
||||
- **system_prompter.py**: System prompt generation and file context
|
||||
- **execution_engine.py**: Safe code execution with output capture
|
||||
- **debug_logger.py**: Error tracking and log formatting
|
||||
- **search_manager.py**: Web search integration
|
||||
### Backend Manager (`backend/managers/`)
|
||||
Werden direkt vom Frontend für UI-Operationen genutzt:
|
||||
- **file_manager.py**: CRUD-Operationen auf Projektdateien (`get_file_tree()` liefert die verschachtelte Baumstruktur für den File Explorer; eine flache `list_files()` wurde bewusst nicht implementiert, da das Frontend die Baumstruktur benötigt — für den Agent Mode übernimmt `mcp_server_file_search.py` die Dateisuche)
|
||||
- **chat_manager.py**: Chat-History, Nachrichten-Verwaltung (Fehler aus der Code-Ausführung werden im normalen Chat bewusst per "Debug with AI"-Button manuell an den Chat übergeben — der User entscheidet selbst wann die AI eingeschaltet wird; im Agent Mode geschieht dies automatisch über den Plan-Act-Observe-Loop)
|
||||
- **system_prompter.py**: System-Prompt-Generierung und Datei-Kontext
|
||||
- **execution_engine.py**: Sichere Code-Ausführung mit Output-Capture
|
||||
- **debug_logger.py**: Fehler-Tracking und Log-Formatierung (`format_debug_output()` formatiert Execution-Output für UI und AI-Chat; `log_error()` wurde bewusst nicht als separate Methode implementiert — Python's Standard-`logging`-Modul mit `logger.error()` deckt diese Funktionalität bereits vollständig ab und wird konsequent im gesamten Code verwendet)
|
||||
- **search_manager.py**: Web-Suche via DuckDuckGo (`ddgs`-Bibliothek)
|
||||
|
||||
### Backend Agents (`backend/agents/`)
|
||||
Independent AI agent system for complex tasks:
|
||||
- **coding_agent.py**: Agent loop (Plan → Act → Observe → Repeat)
|
||||
- **tools.py**: 7 tools agent can use (read/write/run/search/validate/grep/done)
|
||||
|
||||
### Backend Utils (`backend/utils/`)
|
||||
- **server_utils.py**: LLM client initialization, chat helpers, message formatters
|
||||
### Backend Agent (`backend/agent/`)
|
||||
Autonomes AI-Agent-System für komplexe Coding-Aufgaben:
|
||||
- **coding_agent.py**: Agent-Loop (Plan → Act → Observe → Wiederholen)
|
||||
- **mcp_server_adapter.py**: Verbindet den Agent mit MCP-Servern via Konfigurationsdatei
|
||||
- **mcp_server_adapter_RAG.py**: Erweiterter Adapter mit semantischer Tool-Auswahl (RAG)
|
||||
- **mcp_server_config.json**: Definiert welche MCP-Server gestartet werden und mit welchen Argumenten
|
||||
- **servers/**: Die eigentlichen MCP-Tool-Server (Code-Ausführung, Datei-Suche, Web-Suche)
|
||||
|
||||
### Workspace (`workspace/`)
|
||||
- Sandbox directory where agent executes and stores files
|
||||
- Prevents agent from accessing files outside this directory
|
||||
- Sandbox-Verzeichnis, in dem der Agent Dateien erstellt und ausführt
|
||||
- Verhindert, dass der Agent auf Dateien ausserhalb dieses Verzeichnisses zugreift
|
||||
|
||||
## Features
|
||||
|
||||
- **File Display & Management**: Browse and edit code files
|
||||
- **Chat Interface**: AI-powered code assistant
|
||||
- **Code Execution**: Run Python code with debugging
|
||||
- **Internet Search**: Fetch documentation and examples
|
||||
- **System Prompts**: Context-aware AI interactions
|
||||
- **Datei-Verwaltung**: Dateien im Workspace durchsuchen und bearbeiten
|
||||
- **Chat-Interface**: KI-gestützter Code-Assistent
|
||||
- **Code-Ausführung**: Python-Code sicher ausführen mit Debug-Output
|
||||
- **Web-Suche**: Dokumentation und Beispiele via DuckDuckGo abrufen
|
||||
- **Autonomer Agent**: MCP-basierter Coding-Agent mit Plan-Act-Observe-Loop
|
||||
- **RAG Tool-Auswahl**: Semantische Tool-Selektion via Sentence Transformers
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Project Clonen
|
||||
### 1. Repository klonen
|
||||
|
||||
1. In den Zielordner wechseln
|
||||
cd /pfad/zum/zielordner
|
||||
|
||||
2. Repository klonen
|
||||
```bash
|
||||
git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git
|
||||
|
||||
3. In das Projekt wechseln
|
||||
cd AISE1_Project
|
||||
```
|
||||
|
||||
### 2. Activate Virtual Environment
|
||||
### 2. Virtuelle Umgebung aktivieren
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
@ -111,43 +107,62 @@ cd AISE1_Project
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
### 3. Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Run Application
|
||||
### 4. Umgebungsvariablen konfigurieren
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env mit API-Keys befüllen
|
||||
```
|
||||
|
||||
### 5. Applikation starten
|
||||
|
||||
```bash
|
||||
streamlit run frontend/app.py
|
||||
```
|
||||
|
||||
### 5. Run Tests
|
||||
### 6. Tests ausführen
|
||||
|
||||
```bash
|
||||
pytest tests/
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Konfiguration: `mcp_server_config.json`
|
||||
|
||||
The application follows a frontend-backend split:
|
||||
Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Agent starten soll. Jeder Eintrag enthält den Servernamen, den Startbefehl (`command`) und optionale Argumente (`args`) sowie Umgebungsvariablen (`env`):
|
||||
|
||||
- **Frontend**: Streamlit UI components (sidebar, editor, chat)
|
||||
- **Backend**: Specialized manager modules
|
||||
- FileManager: File operations
|
||||
- ChatManager: AI interaction
|
||||
- SystemPrompter: Prompt management
|
||||
- SearchManager: Internet search
|
||||
- ExecutionEngine: Code execution
|
||||
- DebugLogger: Error handling & logging
|
||||
```json
|
||||
{
|
||||
"FileSearchServer": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_file_search.py"]
|
||||
},
|
||||
"WebSearchServer": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_web_search.py"],
|
||||
"env": { "DDGS_API_KEY": "your_key_here" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
## Architektur
|
||||
|
||||
Use Git to track changes:
|
||||
```
|
||||
Frontend (Streamlit) ──► Backend Manager ──► AI API
|
||||
│
|
||||
└──► Coding Agent ──► MCP-Adapter ──► MCP-Server
|
||||
(Code / File / Web)
|
||||
```
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Your message"
|
||||
git push origin sturcture
|
||||
git commit -m "Deine Nachricht"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
392
READMEnew.md
Normal file
392
READMEnew.md
Normal 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 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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:")
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
71
backend/managers/_debug_logger.py
Normal file
71
backend/managers/_debug_logger.py
Normal 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())
|
||||
@ -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 [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
257
frontend/chat.py
257
frontend/chat.py
@ -1,19 +1,40 @@
|
||||
"""Chat view — renders both the normal chat interface and the Coding Agent mode."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
from backend.managers.chat_manager import ChatManager
|
||||
from backend.managers.system_prompter import SystemPrompter
|
||||
from backend.managers.search_manager import SearchManager
|
||||
from backend.agent.coding_agent import CodingAgent
|
||||
from backend.managers.debug_logger import get_logger
|
||||
|
||||
import asyncio
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
||||
def _run_async(coro):
|
||||
"""Hilfsfunktion um async Code in sync Streamlit auszuführen"""
|
||||
"""Execute an async coroutine from synchronous Streamlit code.
|
||||
|
||||
Streamlit runs in a synchronous context, but the CodingAgent uses async
|
||||
methods (for MCP tool calls). This helper bridges the gap by reusing an
|
||||
already-running event loop when one exists, or creating a new one otherwise.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run.
|
||||
|
||||
Returns:
|
||||
The return value of the coroutine.
|
||||
"""
|
||||
# REVIEW: asyncio.get_running_loop() always raises RuntimeError in a Streamlit context;
|
||||
# the try branch is dead code. The except branch always runs.
|
||||
try:
|
||||
# Reuse the loop that is already running (e.g. inside pytest-asyncio).
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop in this thread — create a fresh one.
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
@ -23,7 +44,7 @@ def _start_agent(task: str):
|
||||
Stores the agent and its state in session_state so Streamlit can reference
|
||||
them across reruns without losing progress.
|
||||
"""
|
||||
from backend.agent.coding_agent import CodingAgent
|
||||
logger.info("Starting coding agent.")
|
||||
agent = CodingAgent()
|
||||
agent.start_task(task)
|
||||
action = _run_async(agent.propose_next_action())
|
||||
@ -39,6 +60,7 @@ def _approve_action():
|
||||
pending = st.session_state.agent_pending_action
|
||||
|
||||
result = _run_async(agent.approve())
|
||||
logger.info("Approve action and propose next step.")
|
||||
|
||||
# Append a record to the log so the user can review every completed step.
|
||||
st.session_state.agent_log.append({
|
||||
@ -63,6 +85,7 @@ def _reject_action(feedback: str):
|
||||
The pending action is discarded; the agent receives the user's feedback and
|
||||
proposes a different approach on the next call to propose_next_action().
|
||||
"""
|
||||
logger.info("Rejecting proposed action.")
|
||||
agent = st.session_state.coding_agent
|
||||
agent.reject(feedback or "Please try a different approach.")
|
||||
next_action = _run_async(agent.propose_next_action())
|
||||
@ -72,6 +95,7 @@ def _reject_action(feedback: str):
|
||||
|
||||
def _followup_agent(question: str):
|
||||
"""Continue a finished task by injecting a follow-up question and resuming the loop."""
|
||||
logger.info("Asking follow up question")
|
||||
agent = st.session_state.coding_agent
|
||||
agent.follow_up(question)
|
||||
action = _run_async(agent.propose_next_action())
|
||||
@ -81,6 +105,7 @@ def _followup_agent(question: str):
|
||||
|
||||
def _reset_agent():
|
||||
"""Clear all agent state and return to the idle (task input) screen."""
|
||||
logger.info("Resetting Agent")
|
||||
st.session_state.coding_agent = None
|
||||
st.session_state.agent_status = "idle"
|
||||
st.session_state.agent_log = []
|
||||
@ -89,6 +114,62 @@ def _reset_agent():
|
||||
|
||||
# ── Agent Mode UI ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_arguments(args: dict):
|
||||
if not args:
|
||||
return
|
||||
|
||||
with st.expander("📦 Arguments", expanded=False):
|
||||
|
||||
if args.get("path"):
|
||||
st.markdown("##### 📁 Path")
|
||||
st.code(args["path"])
|
||||
|
||||
if args.get("dir_path"):
|
||||
st.markdown("##### 🌳 Directory")
|
||||
st.code(args["dir_path"])
|
||||
|
||||
if args.get("query"):
|
||||
st.markdown("##### 🔎 Query")
|
||||
st.code(args["query"])
|
||||
|
||||
if args.get("url"):
|
||||
st.markdown("##### 🌐 URL")
|
||||
st.code(args["url"])
|
||||
|
||||
if args.get("content"):
|
||||
st.markdown("##### 📝 Content")
|
||||
st.code(args["content"])
|
||||
|
||||
if args.get("code"):
|
||||
st.markdown("##### 🐍 Python Code")
|
||||
st.code(args["code"], language="python")
|
||||
|
||||
if args.get("max_results") is not None:
|
||||
st.markdown("##### 📊 Max Results")
|
||||
st.code(str(args["max_results"]))
|
||||
|
||||
known_keys = {
|
||||
"path",
|
||||
"dir_path",
|
||||
"query",
|
||||
"content",
|
||||
"url",
|
||||
"code",
|
||||
"max_results",
|
||||
}
|
||||
|
||||
extra_args = {
|
||||
k: v for k, v in args.items()
|
||||
if k not in known_keys
|
||||
}
|
||||
|
||||
if extra_args:
|
||||
st.markdown("##### ⚙️ Other")
|
||||
st.code(
|
||||
json.dumps(extra_args, indent=2),
|
||||
language="json"
|
||||
)
|
||||
|
||||
def render_agent_mode():
|
||||
"""Render the step-by-step agent UI.
|
||||
|
||||
@ -97,6 +178,7 @@ def render_agent_mode():
|
||||
- "waiting_approval" → show proposed action, Approve / Reject / Abort
|
||||
- "done" → success message, follow-up input, New Task button
|
||||
"""
|
||||
logger.info("Agent mode.")
|
||||
# The toggle must always render so Streamlit keeps agent_mode=True in session_state.
|
||||
st.toggle("Agent Mode", key="agent_mode")
|
||||
|
||||
@ -111,8 +193,8 @@ def render_agent_mode():
|
||||
with st.chat_message("assistant"):
|
||||
st.markdown(f"**Step {i + 1} — `{step['tool']}`**")
|
||||
st.caption(f"Thought: {step['thought']}")
|
||||
if step.get("arguments"):
|
||||
st.json(step["arguments"])
|
||||
if step.get("arguments"):
|
||||
_render_arguments(step["arguments"])
|
||||
result_text = step.get("result", "")
|
||||
# Colour the result based on whether the tool succeeded or failed.
|
||||
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
|
||||
@ -131,8 +213,6 @@ def render_agent_mode():
|
||||
placeholder="e.g. Write a function that sorts a list and saves it to sorted.py",
|
||||
)
|
||||
if st.button("Start Agent", type="primary", use_container_width=True):
|
||||
#loop = asyncio.new_event_loop()
|
||||
#asyncio.set_event_loop(loop)
|
||||
if task.strip():
|
||||
with st.spinner("Agent is thinking..."):
|
||||
_start_agent(task.strip())
|
||||
@ -150,15 +230,7 @@ def render_agent_mode():
|
||||
|
||||
args = pending.get("arguments", {})
|
||||
if args:
|
||||
# Show file content separately as a code block for readability;
|
||||
# other arguments are displayed as JSON.
|
||||
if "content" in args:
|
||||
display_args = {k: v for k, v in args.items() if k != "content"}
|
||||
if display_args:
|
||||
st.json(display_args)
|
||||
st.code(args["content"], language="python")
|
||||
else:
|
||||
st.json(args)
|
||||
_render_arguments(args)
|
||||
|
||||
feedback = st.text_input(
|
||||
"Rejection feedback (optional):",
|
||||
@ -211,6 +283,79 @@ def render_agent_mode():
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ── Normal Chat helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _detect_task_type(user_input: str) -> str:
|
||||
"""Infer the task type from keywords in the user message."""
|
||||
lower = user_input.lower()
|
||||
if any(kw in lower for kw in ("error", "bug", "fix", "crash", "exception", "debug")):
|
||||
return "debug"
|
||||
if any(kw in lower for kw in ("explain", "what does", "how does", "why")):
|
||||
return "explain"
|
||||
if any(kw in lower for kw in ("optimize", "improve", "faster", "refactor", "clean")):
|
||||
return "optimize"
|
||||
return "default"
|
||||
|
||||
def _set_system_prompt(chat_manager: ChatManager, user_input: str) -> None:
|
||||
"""Compute and inject the system prompt before every message.
|
||||
|
||||
Uses the custom prompt from Settings if set; otherwise generates one based
|
||||
on the detected task type and active file context. Updates the existing
|
||||
system message in-place so the history stays a single-system-message list.
|
||||
"""
|
||||
custom = st.session_state.get("custom_system_prompt", "").strip()
|
||||
if custom:
|
||||
prompt = custom
|
||||
else:
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
user_message=user_input,
|
||||
file_context=_build_file_context(),
|
||||
task_type=_detect_task_type(user_input),
|
||||
)
|
||||
|
||||
if chat_manager.chat_history and chat_manager.chat_history[0]["role"] == "system":
|
||||
chat_manager.chat_history[0]["content"] = prompt
|
||||
else:
|
||||
chat_manager.chat_history.insert(0, {"role": "system", "content": prompt})
|
||||
|
||||
|
||||
def _build_file_context() -> dict | None:
|
||||
"""Return file context for the system prompt if a file is open and context is enabled.
|
||||
|
||||
Reads from files_content cache first; falls back to FileManager if the file
|
||||
has not been loaded into the editor yet.
|
||||
"""
|
||||
if not st.session_state.get("include_file_context", True):
|
||||
return None
|
||||
active_file = st.session_state.get("active_file")
|
||||
if not active_file:
|
||||
return None
|
||||
content = st.session_state.get("files_content", {}).get(active_file, "")
|
||||
if not content:
|
||||
try:
|
||||
from backend.managers.file_manager import FileManager
|
||||
fm = FileManager()
|
||||
content = fm.read_file(Path(active_file)) or ""
|
||||
except Exception:
|
||||
return None
|
||||
return {"name": Path(active_file).name, "content": content}
|
||||
|
||||
|
||||
@st.dialog("Clear Chat")
|
||||
def _clear_chat_dialog():
|
||||
"""Confirmation dialog before wiping the full conversation history."""
|
||||
st.warning("All messages will be deleted. This cannot be undone.")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("Clear", type="primary", use_container_width=True):
|
||||
st.session_state.chat_manager.clear_history()
|
||||
st.session_state.chat_history = []
|
||||
st.rerun()
|
||||
with col2:
|
||||
if st.button("Cancel", use_container_width=True):
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ── Normal Chat ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_search_panel():
|
||||
@ -264,14 +409,36 @@ def render_normal_chat():
|
||||
full context throughout the session. If search results are active when the
|
||||
user sends a message, they are prepended to that message as a context block.
|
||||
"""
|
||||
_render_search_panel()
|
||||
logger.info("Chat mode")
|
||||
chat_manager: ChatManager = st.session_state.chat_manager
|
||||
|
||||
# Apply model/token overrides from the Settings panel before any API call.
|
||||
if st.session_state.get("selected_model"):
|
||||
chat_manager.model = st.session_state.selected_model
|
||||
if "chat_max_tokens" in st.session_state:
|
||||
chat_manager.max_tokens = st.session_state.chat_max_tokens
|
||||
|
||||
# Consume a debug message forwarded from the editor's "Debug with AI" button.
|
||||
pending_debug = st.session_state.pop("pending_debug_message", None)
|
||||
if pending_debug:
|
||||
_set_system_prompt(chat_manager, pending_debug)
|
||||
with st.spinner("Sending debug info to AI..."):
|
||||
try:
|
||||
ai_response = chat_manager.send_message(pending_debug)
|
||||
except Exception as e:
|
||||
ai_response = f"Error: {e}"
|
||||
st.session_state.chat_history.append({"role": "user", "content": pending_debug})
|
||||
st.session_state.chat_history.append(
|
||||
{"role": "assistant", "content": ai_response}
|
||||
)
|
||||
st.rerun()
|
||||
return
|
||||
|
||||
# Replay the conversation history as chat bubbles (skip system messages).
|
||||
for message in st.session_state.chat_history:
|
||||
role = message["role"]
|
||||
if role == "system":
|
||||
if message["role"] == "system":
|
||||
continue
|
||||
with st.chat_message(role):
|
||||
with st.chat_message(message["role"]):
|
||||
st.markdown(message["content"])
|
||||
|
||||
# Chat input — Enter to send, no extra button needed.
|
||||
@ -320,14 +487,10 @@ def render_normal_chat():
|
||||
return
|
||||
|
||||
# ── Normal chat message ───────────────────────────────────────────────
|
||||
chat_manager = st.session_state.chat_manager
|
||||
search_results = st.session_state.get("search_results", [])
|
||||
|
||||
# On the very first user message, prepend the system prompt so the AI
|
||||
# knows it is a code assistant embedded in an editor.
|
||||
if not chat_manager.get_history():
|
||||
system_prompt = SystemPrompter.generate_prompt()
|
||||
chat_manager.add_message("system", system_prompt)
|
||||
# 5g — System-prompt logic: inject on first message, update on file change.
|
||||
_set_system_prompt(chat_manager, user_input)
|
||||
|
||||
# If search results are active, prepend them as a context block so the
|
||||
# AI can reference them regardless of where in the conversation we are.
|
||||
@ -348,7 +511,6 @@ def render_normal_chat():
|
||||
with st.chat_message("user"):
|
||||
st.markdown(user_input)
|
||||
|
||||
# Call the AI and show its response with a spinner while waiting.
|
||||
with st.chat_message("assistant"):
|
||||
with st.spinner("Thinking..."):
|
||||
try:
|
||||
@ -357,15 +519,46 @@ def render_normal_chat():
|
||||
ai_response = f"Error: {e}"
|
||||
st.markdown(ai_response)
|
||||
|
||||
st.session_state.chat_history.append({"role": "user", "content": user_input})
|
||||
st.session_state.chat_history.append({"role": "user", "content": user_input})
|
||||
st.session_state.chat_history.append({"role": "assistant", "content": ai_response})
|
||||
st.rerun()
|
||||
|
||||
# Rendered in the normal flow; JS above clones them to fixed positions
|
||||
# and hides these originals.
|
||||
# 5d — Clear Chat opens a confirmation dialog instead of deleting immediately.
|
||||
if st.button("🗑️ Clear Chat"):
|
||||
_clear_chat_dialog()
|
||||
|
||||
# Toggle to switch to Agent Mode (render_normal_chat and render_agent_mode are
|
||||
# mutually exclusive, so the same key here causes no DuplicateWidgetID conflict).
|
||||
st.toggle("Agent Mode", key="agent_mode")
|
||||
with st.expander("Settings", expanded=False):
|
||||
st.toggle("Use debug system prompt", key="use_system_prompt", value=True)
|
||||
|
||||
# 5h — Settings expander: file context toggle, model, token limit, custom prompt.
|
||||
with st.expander("⚙️ Settings", expanded=False):
|
||||
st.toggle("Include current file as context", key="include_file_context", value=True)
|
||||
|
||||
st.divider()
|
||||
|
||||
default_model = chat_manager.model or ""
|
||||
model_options = [default_model] if default_model else []
|
||||
for m in ["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307", "gpt-4o", "gpt-4o-mini"]:
|
||||
if m not in model_options:
|
||||
model_options.append(m)
|
||||
st.selectbox("Model", model_options, key="selected_model")
|
||||
|
||||
st.slider(
|
||||
"Max Response Tokens",
|
||||
min_value=256, max_value=8000,
|
||||
value=chat_manager.max_tokens,
|
||||
step=256, key="chat_max_tokens",
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
st.text_area(
|
||||
"Custom System Prompt (overrides default if set)",
|
||||
key="custom_system_prompt",
|
||||
height=120,
|
||||
placeholder="Leave empty to use the default assistant prompt with optional file context.",
|
||||
)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
78
run_agent.py
78
run_agent.py
@ -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.")
|
||||
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -0,0 +1,428 @@
|
||||
import pytest
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from backend.managers.execution_engine import ExecutionEngine
|
||||
|
||||
|
||||
# =========================================================
|
||||
# FIXTURE
|
||||
# =========================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def engine():
|
||||
return ExecutionEngine()
|
||||
|
||||
|
||||
# =========================================================
|
||||
# BASIC TESTS (1–10)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 1. Python-Datei wird korrekt ausgeführt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_python_file_success(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "test.py"
|
||||
file.write_text("print('hello')")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="hello\n",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 0
|
||||
assert "hello" in result["stdout"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. Python-Datei mit Fehler
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_python_file_error(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "broken.py"
|
||||
file.write_text("1/0")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="ZeroDivisionError",
|
||||
returncode=1
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 1
|
||||
assert "ZeroDivisionError" in result["stderr"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 3. LaTeX-Datei wird kompiliert
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_tex_file_success(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "doc.tex"
|
||||
file.write_text("\\documentclass{article}")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="PDF created",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 0
|
||||
assert "PDF created" in result["stdout"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 4. Unsupported File Type
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_unsupported_file(engine, tmp_path):
|
||||
file = tmp_path / "test.js"
|
||||
file.write_text("console.log('x')")
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 1
|
||||
assert "Unsupported file type" in result["stderr"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. Timeout wird behandelt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_timeout(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "slow.py"
|
||||
file.write_text("while True: pass")
|
||||
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["py"],
|
||||
timeout=30
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == -1
|
||||
assert "Timed out" in result["stderr"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 6. Fehlender Interpreter
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_missing_interpreter(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "test.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.side_effect = FileNotFoundError("py not found")
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == -1
|
||||
assert "py not found" in result["stderr"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 7. Allgemeine Exception
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_general_exception(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "test.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.side_effect = RuntimeError("unexpected")
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == -1
|
||||
assert "unexpected" in result["stderr"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 8. subprocess.run wird mit cwd ausgeführt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_uses_correct_cwd(mock_run, engine, tmp_path):
|
||||
folder = tmp_path / "project"
|
||||
folder.mkdir()
|
||||
|
||||
file = folder / "main.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
engine.run_code(file)
|
||||
|
||||
_, kwargs = mock_run.call_args
|
||||
|
||||
assert kwargs["cwd"] == folder.resolve()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 9. subprocess.run nutzt capture_output
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_capture_output_enabled(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "test.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
engine.run_code(file)
|
||||
|
||||
_, kwargs = mock_run.call_args
|
||||
|
||||
assert kwargs["capture_output"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 10. subprocess.run nutzt text=True
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_text_mode_enabled(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "test.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
engine.run_code(file)
|
||||
|
||||
_, kwargs = mock_run.call_args
|
||||
|
||||
assert kwargs["text"] is True
|
||||
|
||||
|
||||
# =========================================================
|
||||
# EDGE CASE TESTS (11–20)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 11. Unicode Output
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_unicode_output(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "unicode.py"
|
||||
file.write_text("print('🔥 Grüezi 世界')", encoding="utf-8")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="🔥 Grüezi 世界\n",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert "🔥 Grüezi 世界" in result["stdout"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 12. Leerer stdout/stderr
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_empty_output(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "empty.py"
|
||||
file.write_text("x = 1")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["stderr"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 13. Sehr langer stdout
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_large_output(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "large.py"
|
||||
file.write_text("print('A')")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="A" * 100000,
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert len(result["stdout"]) == 100000
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 14. Dateiname mit Leerzeichen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_filename_with_spaces(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "my script.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
engine.run_code(file)
|
||||
|
||||
args, _ = mock_run.call_args
|
||||
|
||||
assert "my script.py" in args[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 15. Dateiname mit Unicode
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_unicode_filename(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "🔥_test.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 16. .tex nutzt pdflatex
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_tex_uses_pdflatex(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "doc.tex"
|
||||
file.write_text("x")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
engine.run_code(file)
|
||||
|
||||
args, _ = mock_run.call_args
|
||||
|
||||
assert args[0][0] == "pdflatex"
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 17. .py nutzt py Interpreter
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_python_uses_py_interpreter(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "main.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
engine.run_code(file)
|
||||
|
||||
args, _ = mock_run.call_args
|
||||
|
||||
assert args[0][0] == "py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 18. Relative Pfade funktionieren
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_relative_paths(mock_run, engine, tmp_path):
|
||||
sub = tmp_path / "src"
|
||||
sub.mkdir()
|
||||
|
||||
file = sub / "main.py"
|
||||
file.write_text("print(1)")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 19. Großgeschriebenes Suffix blockiert
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_uppercase_suffix_not_supported(engine, tmp_path):
|
||||
file = tmp_path / "SCRIPT.PY"
|
||||
file.write_text("print(1)")
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 20. Leere Datei ausführen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_empty_file(mock_run, engine, tmp_path):
|
||||
file = tmp_path / "empty.py"
|
||||
file.write_text("")
|
||||
|
||||
mock_run.return_value = Mock(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
result = engine.run_code(file)
|
||||
|
||||
assert result["rc"] == 0
|
||||
@ -0,0 +1,269 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import subprocess
|
||||
|
||||
from backend.agent.servers import mcp_server_code_execution as server
|
||||
|
||||
|
||||
# =========================================================
|
||||
# BASIC TESTS (1–10)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 1. Erlaubter Code besteht Safety Check
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_valid():
|
||||
code = "print('hello')"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. Blockierter Import wird erkannt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_blocked_import():
|
||||
code = "import os"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "Blocked import" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 3. Blockierter Builtin wird erkannt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_blocked_builtin():
|
||||
code = "eval('2+2')"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "Blocked builtin" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 4. analyse_structure erkennt Funktionen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_analyse_structure_function():
|
||||
code = """
|
||||
def hello(name):
|
||||
return name
|
||||
"""
|
||||
|
||||
result = server.analyse_structure(code)
|
||||
|
||||
assert "def hello(name)" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. analyse_structure erkennt Klassen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_analyse_structure_class():
|
||||
code = """
|
||||
class User:
|
||||
def login(self):
|
||||
pass
|
||||
"""
|
||||
|
||||
result = server.analyse_structure(code)
|
||||
|
||||
assert "class User" in result
|
||||
assert "method: login" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 6. lint_code erkennt Undefined Variable
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_lint_code_undefined_variable():
|
||||
code = "print(x)"
|
||||
|
||||
result = server.lint_code(code)
|
||||
|
||||
assert "undefined name 'x'" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 7. lint_code erkennt sauberen Code
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_lint_code_clean():
|
||||
code = """
|
||||
x = 1
|
||||
print(x)
|
||||
"""
|
||||
|
||||
result = server.lint_code(code)
|
||||
|
||||
assert "No issues found" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 8. python_code_validation validiert sicheren Code
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_python_code_validation_safe():
|
||||
code = "print('safe')"
|
||||
|
||||
result = server.python_code_validation(code)
|
||||
|
||||
assert "can be executed" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 9. run_python_sandboxed führt Code aus
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_python_sandboxed_success():
|
||||
code = "print('hello world')"
|
||||
|
||||
result = server.run_python_sandboxed(code)
|
||||
|
||||
assert "hello world" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 10. run_python_sandboxed ohne Output
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_python_sandboxed_no_output():
|
||||
code = "x = 5"
|
||||
|
||||
result = server.run_python_sandboxed(code)
|
||||
|
||||
assert "no output" in result.lower()
|
||||
|
||||
|
||||
# =========================================================
|
||||
# EDGE CASE TESTS (11–20)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 11. Syntaxfehler erkennen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_syntax_error():
|
||||
code = "def broken("
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "SyntaxError" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 12. ImportFrom blockieren
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_import_from():
|
||||
code = "from os import path"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "Blocked import" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 13. Gefährliche Path-Sequenzen erkennen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_path_traversal():
|
||||
code = "print('../etc/passwd')"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "Suspect path sequence" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 14. __import__ erkennen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_import_escape():
|
||||
code = "__import__('os')"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "Blocked" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 15. subprocess Escape erkennen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_check_code_safety_subprocess_escape():
|
||||
code = "subprocess.run(['ls'])"
|
||||
|
||||
result = server.check_code_safety(code)
|
||||
|
||||
assert "Suspect path sequence" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 16. Endlosschleife Timeout
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_python_sandboxed_timeout():
|
||||
code = """
|
||||
while True:
|
||||
pass
|
||||
"""
|
||||
|
||||
result = server.run_python_sandboxed(code)
|
||||
|
||||
assert "time limit" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 17. Sehr großer Output wird gekürzt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_python_sandboxed_large_output():
|
||||
code = "print('A' * 10000)"
|
||||
|
||||
result = server.run_python_sandboxed(code)
|
||||
|
||||
assert "truncated" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 18. Unicode Output funktioniert
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_python_sandboxed_unicode():
|
||||
code = "print('🔥 Grüezi 世界')"
|
||||
|
||||
result = server.run_python_sandboxed(code)
|
||||
|
||||
assert "🔥 Grüezi 世界" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 19. analyse_structure bei leerem Code
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_analyse_structure_empty():
|
||||
code = ""
|
||||
|
||||
result = server.analyse_structure(code)
|
||||
|
||||
assert "No top-level imports" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 20. Sandbox behandelt Runtime Errors
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_run_python_sandboxed_runtime_error():
|
||||
code = "1 / 0"
|
||||
|
||||
result = server.run_python_sandboxed(code)
|
||||
|
||||
assert "ZeroDivisionError" in result
|
||||
362
tests/test_search_manager.py
Normal file
362
tests/test_search_manager.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user