Compare commits
No commits in common. "main" and "ui-setup" have entirely different histories.
10
.env.example
10
.env.example
@ -3,9 +3,11 @@
|
||||
# NOTE: Never commit .env with real secrets to version control!
|
||||
|
||||
# Silicon Server Configuration
|
||||
HOST=
|
||||
PORT=
|
||||
HOST=silicon.fhgr.ch
|
||||
PORT=7080
|
||||
API_KEY=EMPTY
|
||||
MODEL=
|
||||
|
||||
MODEL=qwen3.5-35b-a3b
|
||||
|
||||
# Optional: Add more configuration variables as needed
|
||||
# DEBUG=False
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,6 +50,3 @@ data/raw/
|
||||
|
||||
# Workspace
|
||||
workspace/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
702
README.md
702
README.md
@ -1,637 +1,153 @@
|
||||
# AISE AI Code Editor — Technische Dokumentation
|
||||
# AISE AI Code Editor
|
||||
|
||||
KI-unterstützter Lightweight Code Editor auf Basis von Streamlit (AISE501 Spring 2026).
|
||||
AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026)
|
||||
|
||||
---
|
||||
|
||||
## Projektinformationen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Modul** | AI in Software Engineering 1 (AISE501) |
|
||||
| **Autoren** | Irina Rüegg & Livio Meuli |
|
||||
| **Semester** | Spring 2026 |
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Projektstruktur](#projektstruktur)
|
||||
2. [Schnellstart](#schnellstart)
|
||||
3. [Frontend](#frontend)
|
||||
4. [Backend Manager](#backend-manager)
|
||||
5. [Backend Agent (MCP-System)](#backend-agent-mcp-system)
|
||||
6. [MCP-Server-Konfiguration](#mcp-server-konfiguration)
|
||||
7. [Architektur-Übersicht](#architektur-übersicht)
|
||||
8. [Wichtige Designentscheidungen](#wichtige-designentscheidungen)
|
||||
9. [Tests](#tests)
|
||||
10. [Umgebungsvariablen](#umgebungsvariablen)
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
AISE_AIAgent/
|
||||
├── frontend/ # Streamlit UI-Komponenten
|
||||
│ ├── app.py # Einstiegspunkt der App; Seitenkonfiguration + Routing
|
||||
│ ├── state.py # Zentrale Session-State-Initialisierung
|
||||
│ ├── sidebar.py # Datei-Explorer + Navigations-Radio
|
||||
│ ├── editor.py # Ace-Editor-Tabs + Ausführungs-Output
|
||||
│ └── chat.py # Chat-Interface + Agent-Mode-UI
|
||||
├── 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
|
||||
│
|
||||
├── backend/
|
||||
│ ├── managers/ # Business-Logik, direkt vom Frontend aufgerufen
|
||||
│ │ ├── file_manager.py # Workspace-CRUD mit Path-Traversal-Schutz
|
||||
│ │ ├── chat_manager.py # LLM-API-Wrapper + Sliding-Window-History
|
||||
│ │ ├── system_prompter.py # Kontextbewusste System-Prompt-Generierung
|
||||
│ │ ├── search_manager.py # DuckDuckGo-Websuche + Seitenabruf
|
||||
│ │ ├── execution_engine.py # Subprocess-basierte Code-Ausführung (Python, LaTeX)
|
||||
│ │ └── debug_logger.py # Rotierende Logdatei + Fehler-Aggregation
|
||||
├── 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
|
||||
│ │
|
||||
│ └── agent/ # Autonomes KI-Agenten-System (MCP-basiert)
|
||||
│ ├── coding_agent.py # Plan→Aktion→Beobachten-Schleife
|
||||
│ ├── mcp_server_adapter.py # Verbindet den Agenten mit MCP-Tool-Servern
|
||||
│ ├── mcp_server_config.json # Welche MCP-Server gestartet werden (Pfade + Befehle)
|
||||
│ └── servers/ # MCP-Server-Implementierungen (stdio-Transport)
|
||||
│ ├── mcp_server_code_execution.py # Tool: Sandbox-Python-Ausführung + Linting
|
||||
│ ├── mcp_server_file_search.py # Tool: Workspace-Datei lesen/schreiben/suchen
|
||||
│ └── mcp_server_web_search.py # Tool: DuckDuckGo-Suche + Seitenabruf
|
||||
│ ├── 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
|
||||
│
|
||||
├── tests/ # pytest-Unit-Tests
|
||||
│ ├── conftest.py # Globaler MCP-Mock (keine echten Subprozesse in Tests)
|
||||
│ ├── test_chat_manager.py
|
||||
│ ├── test_coding_agent.py
|
||||
│ ├── test_debug_logger.py
|
||||
│ ├── test_execution_engine.py
|
||||
│ ├── test_file_manager.py
|
||||
│ ├── test_mcp_server_code_execution.py
|
||||
│ ├── test_mcp_server_file_search.py
|
||||
│ ├── test_mcp_server_web_search.py
|
||||
│ ├── test_search_manager.py
|
||||
│ └── test_system_prompter.py
|
||||
├── 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
|
||||
│
|
||||
├── workspace/ # Sandbox-Verzeichnis für Agent- und Editor-Dateien
|
||||
├── logs/ # Rotierende Logdateien (app.log, errors.log)
|
||||
├── .env # Lokale Umgebungsvariablen (nicht eingecheckt)
|
||||
└── .env.example # Vorlage für erforderliche Umgebungsvariablen
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
## Component Responsibilities
|
||||
|
||||
## Schnellstart
|
||||
### 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
|
||||
|
||||
> Die folgenden Schritte funktionieren auf **Windows** und **macOS** — abweichende Befehle sind jeweils mit dem Betriebssystem gekennzeichnet.
|
||||
### 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 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)
|
||||
|
||||
### Schritt 1 — Voraussetzungen prüfen
|
||||
### Backend Utils (`backend/utils/`)
|
||||
- **server_utils.py**: LLM client initialization, chat helpers, message formatters
|
||||
|
||||
**Python 3.10 oder neuer** muss installiert sein.
|
||||
### Workspace (`workspace/`)
|
||||
- Sandbox directory where agent executes and stores files
|
||||
- Prevents agent from accessing files outside this directory
|
||||
|
||||
## 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
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Project Clonen
|
||||
|
||||
1. In den Zielordner wechseln
|
||||
cd /pfad/zum/zielordner
|
||||
|
||||
2. Repository klonen
|
||||
git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git
|
||||
|
||||
3. In das Projekt wechseln
|
||||
cd AISE1_Project
|
||||
|
||||
### 2. Activate Virtual Environment
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
python --version
|
||||
|
||||
# macOS (Terminal)
|
||||
python3 --version
|
||||
```
|
||||
|
||||
Falls Python nicht installiert ist:
|
||||
- **Windows:** [python.org/downloads](https://www.python.org/downloads/) herunterladen und installieren. Bei der Installation „Add Python to PATH" aktivieren.
|
||||
- **macOS:** [python.org/downloads](https://www.python.org/downloads/) herunterladen und installieren, **oder** via Homebrew: `brew install python3`
|
||||
|
||||
**Git** muss ebenfalls installiert sein:
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
Falls nicht vorhanden: [git-scm.com](https://git-scm.com/downloads) (Windows) bzw. `brew install git` (macOS).
|
||||
|
||||
---
|
||||
|
||||
### Schritt 2 — Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project_Irina_Livio.git
|
||||
cd AISE1_Project_Irina_Livio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3 — Virtuelle Umgebung erstellen
|
||||
|
||||
Eine virtuelle Umgebung isoliert die Projekt-Abhängigkeiten vom restlichen System. Sie muss einmalig erstellt werden.
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
python -m venv .venv
|
||||
|
||||
# macOS (Terminal)
|
||||
python3 -m venv .venv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4 — Virtuelle Umgebung aktivieren
|
||||
|
||||
Die Umgebung muss **jedes Mal neu aktiviert** werden, wenn ein neues Terminal geöffnet wird.
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
# Windows
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
> Falls PowerShell die Ausführung blockiert, einmalig folgenden Befehl ausführen und danach erneut versuchen:
|
||||
> ```powershell
|
||||
> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
> ```
|
||||
|
||||
```bash
|
||||
# macOS (Terminal)
|
||||
# macOS/Linux
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
Nach erfolgreicher Aktivierung erscheint `(.venv)` am Anfang der Eingabezeile.
|
||||
|
||||
---
|
||||
|
||||
### Schritt 5 — Abhängigkeiten installieren
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Dieser Schritt lädt alle benötigten Pakete herunter (~2–5 Minuten je nach Internetverbindung). Er muss nur einmal ausgeführt werden.
|
||||
|
||||
---
|
||||
|
||||
### Schritt 6 — Umgebungsvariablen konfigurieren
|
||||
### 4. Run Application
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
copy .env.example .env
|
||||
|
||||
# macOS (Terminal)
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Danach die Datei `.env` in einem Texteditor öffnen und die Werte eintragen welche per Mail mitgeteilt wurden (HOST, PORT, API_KEY, MODEL).
|
||||
|
||||
---
|
||||
|
||||
### Schritt 7 — App starten
|
||||
|
||||
```bash
|
||||
# Windows & macOS
|
||||
streamlit run frontend/app.py
|
||||
```
|
||||
|
||||
Streamlit öffnet die App automatisch im Standard-Browser unter `http://localhost:8501`.
|
||||
Falls der Browser nicht automatisch aufgeht, die URL manuell eingeben.
|
||||
|
||||
Zum **Beenden** der App im Terminal `Ctrl + C` drücken.
|
||||
|
||||
---
|
||||
|
||||
### Schritt 8 — Tests ausführen (optional)
|
||||
### 5. Run Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
---
|
||||
## Architecture
|
||||
|
||||
## Frontend
|
||||
The application follows a frontend-backend split:
|
||||
|
||||
Alle Frontend-Module sind reine Streamlit-Komponenten. Sie enthalten keine Business-Logik,
|
||||
sondern delegieren alles an die Backend-Manager.
|
||||
- **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
|
||||
|
||||
### `app.py`
|
||||
Einstiegspunkt der Applikation. Ruft `init_state()` auf Modul-Ebene auf (vor `main()`),
|
||||
damit alle Session-State-Schlüssel existieren, bevor ein Widget gerendert wird. Delegiert
|
||||
an `render_sidebar()`, `render_editor()` und `render_chat()` basierend auf dem Navigations-Radio.
|
||||
## Development
|
||||
|
||||
### `state.py`
|
||||
Zentrale Quelle aller `st.session_state`-Schlüsselnamen und ihrer Standardwerte.
|
||||
Alle Schlüssel nutzen `if key not in st.session_state`-Guards, damit bei Streamlit-Reruns
|
||||
keine bestehenden Werte überschrieben werden. Aktuell verwaltete Schlüssel:
|
||||
|
||||
| Schlüssel | Standard | Zweck |
|
||||
|-----------|----------|-------|
|
||||
| `last_selected` | `None` | Zuletzt angeklickter Baum-Knoten (verhindert erneutes Ausführen bei jedem Rerender) |
|
||||
| `selected_folder` / `selected_folder_rel` | `None` | Aktuell markierter Ordner |
|
||||
| `chat_manager` | `ChatManager()` | Live-ChatManager-Instanz |
|
||||
| `open_files` | `[]` | Geordnete Liste absoluter Pfade als Editor-Tabs |
|
||||
| `files_content` | `{}` | Pfad → aktueller Editor-Inhalt (kann von Disk abweichen) |
|
||||
| `active_file` | `None` | Absoluter Pfad des aktiven Editor-Tabs |
|
||||
| `exec_results` | `{}` | Pfad → letztes Ausführungsergebnis-Dict |
|
||||
| `chat_history` | `[]` | Flache Liste von `{role, content}`-Dicts zur Anzeige |
|
||||
| `agent_mode` | `False` | Ob die Agent-Mode-UI aktiv ist |
|
||||
| `coding_agent` | `None` | Live-`CodingAgent`-Instanz während einer Aufgabe |
|
||||
| `agent_status` | `"idle"` | `"idle"` / `"waiting_approval"` / `"done"` |
|
||||
| `agent_log` | `[]` | Liste abgeschlossener Schritt-Einträge |
|
||||
| `agent_pending_action` | `None` | Vorgeschlagene Aktion, die auf Benutzer-Genehmigung wartet |
|
||||
| `search_results` | `[]` | Aktive Websuchergebnisse für Kontext-Injektion |
|
||||
|
||||
### `sidebar.py`
|
||||
Rendert das Navigations-Radio und den Workspace-Datei-Explorer (basierend auf
|
||||
`streamlit-arborist` für einen interaktiven Baum). Datei-Klicks öffnen einen neuen
|
||||
Editor-Tab; Ordner-Klicks zeigen eine Aktionsleiste mit «Datei hinzufügen» / «Ordner
|
||||
hinzufügen» / «Löschen». Ein Popover am unteren Rand ermöglicht das Erstellen und
|
||||
Hochladen von Dateien (bis 1 MB) im Workspace-Wurzelverzeichnis.
|
||||
|
||||
### `editor.py`
|
||||
Verwendet `streamlit-ace` für syntaxhervorgehobene Bearbeitung. Jede geöffnete Datei
|
||||
erhält einen eigenen Tab via `st.tabs()`. Die aktive Datei steuert die Schaltflächen
|
||||
«Code ausführen», «Herunterladen», «Schliessen», «Umbenennen» und «Löschen». Der Button
|
||||
**Code ausführen** führt für Python-Dateien zuerst `ast.parse()` durch, um Syntaxfehler
|
||||
vor dem Subprocess zu erkennen. Der Button **Mit KI debuggen** (nach einem fehlgeschlagenen
|
||||
Lauf eingeblendet) formatiert die Fehlerausgabe und navigiert zur Chat-Ansicht mit einer
|
||||
vorausgefüllten Debug-Nachricht.
|
||||
|
||||
### `chat.py`
|
||||
Zwei sich gegenseitig ausschliessende Ansichten, umgeschaltet via `st.toggle("Agent Mode")`:
|
||||
|
||||
**Normaler Chat** (`render_normal_chat()`):
|
||||
- Kompakte 4-spaltige Toolbar direkt über dem Chat-Input:
|
||||
`[● Agent Mode]` `[🔍 Search]` `[🗑️ Clear]` `[⚙️ Settings]`
|
||||
— Web-Suche und Einstellungen jeweils als `st.popover`, Clear öffnet einen Bestätigungs-Dialog.
|
||||
- System-Prompt wird vor jeder ausgehenden Nachricht neu generiert (`_set_system_prompt()`).
|
||||
- Unterstützt Slash-Befehle `/search <Abfrage>` und `/search clear`.
|
||||
- Settings-Popover: Dateikontext-Toggle, Modell-Auswahl, Max-Token-Slider,
|
||||
benutzerdefinierter System-Prompt.
|
||||
- «Mit KI debuggen»-Nachrichten vom Editor werden über `pending_debug_message` im
|
||||
Session-State weitergeleitet.
|
||||
|
||||
**Agent Mode** (`render_agent_mode()`):
|
||||
- `idle` → Aufgabeneingabe + Start-Schaltfläche.
|
||||
- `waiting_approval` → zeigt vorgeschlagenen Gedanken + Tool + Argumente; Benutzer kann
|
||||
Genehmigen, Ablehnen (mit Feedback) oder Abbrechen.
|
||||
- `done` → Erfolgsmeldung + Folgefrage-Eingabe zum Weiterführen der Aufgabe.
|
||||
|
||||
---
|
||||
|
||||
## Backend Manager
|
||||
|
||||
### `file_manager.py`
|
||||
Alle öffentlichen Methoden lösen Pfade auf und prüfen, ob sie innerhalb von `workspace/`
|
||||
bleiben, bevor sie das Dateisystem berühren (**Path-Traversal-Schutz**). Je nach Operation
|
||||
werden relative oder absolute Pfade akzeptiert und zurückgegeben:
|
||||
|
||||
| Methode | Pfad-Typ | Hinweise |
|
||||
|---------|----------|----------|
|
||||
| `create_folder(relative_path, name)` | Workspace-relativ | Erstellt eine Ebene |
|
||||
| `create_file(relative_path, name)` | Workspace-relativ | Standard: `.txt` |
|
||||
| `read_file(absolute_path)` | Absolutes `Path`-Objekt | Vom Editor verwendet |
|
||||
| `save_file(absolute_path, content)` | Absoluter String | Überschreibt vorhandenes |
|
||||
| `rename_file(relative_path, new_name)` | Workspace-relativ | Erweiterung bleibt immer erhalten |
|
||||
| `delete_file(relative_path)` | Workspace-relativ | |
|
||||
| `delete_folder(relative_path)` | Workspace-relativ | Rekursiv via `shutil.rmtree` |
|
||||
| `get_file_tree()` | — | Gibt verschachteltes Dict zurück; Verzeichnisse → Dict, Dateien → None |
|
||||
|
||||
Dateien werden in `get_file_tree()` standardmässig auf `CODE_EXTENSIONS` gefiltert.
|
||||
|
||||
### `chat_manager.py`
|
||||
Kapselt einen OpenAI-kompatiblen REST-Endpunkt, konfiguriert via `.env`.
|
||||
|
||||
**Sliding-Window-History:** `_build_payload_messages()` sendet immer zuerst die
|
||||
System-Nachricht (damit sie nie verworfen wird), gefolgt von den letzten
|
||||
`max_history_messages` (20) Nicht-System-Nachrichten. Das begrenzt die Payload-Grösse,
|
||||
ohne den System-Prompt zu verlieren.
|
||||
|
||||
**Fehlerbehandlung:** Verbindungs-Timeouts und HTTP-Fehler werden abgefangen, geloggt und
|
||||
als Assistenten-Nachrichten in der History gespeichert (sodass die UI den Fehler inline
|
||||
anzeigt).
|
||||
|
||||
**API-Key:** Falls `API_KEY` den Wert `"EMPTY"` hat oder fehlt, wird kein
|
||||
`Authorization`-Header gesendet (unterstützt lokale/anonyme Endpunkte).
|
||||
|
||||
### `system_prompter.py`
|
||||
Generiert kontextbewusste System-Prompts. Signatur:
|
||||
```python
|
||||
SystemPrompter.generate_prompt(
|
||||
user_message="",
|
||||
file_context=None, # {"name": str, "content": str}
|
||||
search_context=None, # list[{"title", "url", "snippet"}] — in system_prompter.py implementiert,
|
||||
# aber in chat.py nicht verwendet: dort wird Search-Kontext direkt
|
||||
# als <search_context>-Block vor die Nachricht eingefügt
|
||||
task_type="default", # "debug" | "explain" | "optimize" | "default"
|
||||
)
|
||||
```
|
||||
Der Aufgabentyp wird anhand von Schlüsselwörtern in der Benutzernachricht durch
|
||||
`_detect_task_type()` in `chat.py` ermittelt. Der Dateiinhalt wird wörtlich in einen
|
||||
XML-ähnlichen `<file>...<code>`-Block eingebettet und bei `MAX_FILE_CHARS` Zeichen
|
||||
abgeschnitten. `_extract_relevant_context()` nutzt `ast.parse()`, um nur die spezifische
|
||||
Funktion oder Klasse zurückzugeben, nach der der Benutzer fragt, anstatt die gesamte Datei.
|
||||
|
||||
### `execution_engine.py`
|
||||
Führt Dateien in einem Subprocess mit `capture_output=True`, `text=True` und einem
|
||||
`RUN_TIMEOUT` von 30 Sekunden aus. Aktuell unterstützt:
|
||||
- `.py` — via `sys.executable` (plattformübergreifend; zeigt auf den aktuell aktiven Python-Interpreter)
|
||||
|
||||
Rückgabe: `{"stdout": str, "stderr": str, "rc": int}`.
|
||||
|
||||
### `search_manager.py`
|
||||
DuckDuckGo-basierte Websuche und Seitenabruf für die Chat-Ansicht. SSRF-geschützt:
|
||||
`_validate_url()` blockiert Nicht-HTTP(S)-Schemata, Loopback- und RFC-1918-private
|
||||
IP-Bereiche. `fetch_page()` extrahiert lesbaren Text via BeautifulSoup, entfernt
|
||||
`<script>`-, `<style>`-, `<nav>`- und `<footer>`-Tags und kürzt auf `MAX_PAGE_CHARS`.
|
||||
|
||||
### `debug_logger.py`
|
||||
Richtet einen rotierenden Datei-Handler für `logs/app.log` (5 MB × 5 Backups) und eine
|
||||
separate `logs/errors.log` für `ERROR`/`CRITICAL`-Einträge ein. Verwendung im Code:
|
||||
|
||||
```python
|
||||
from backend.managers.debug_logger import get_logger
|
||||
logger = get_logger(__name__) # Standard-Python-Logger
|
||||
|
||||
logger.info("Dienst gestartet")
|
||||
logger.error("Etwas ist schiefgelaufen")
|
||||
logger.exception("Unerwarteter Fehler") # loggt Stack-Trace
|
||||
```
|
||||
|
||||
Zusätzliche Classmethods zur sitzungsweisen Fehler-Aggregation:
|
||||
```python
|
||||
DebugLogger.log_error("Nachricht") # loggt + hängt an _error_log-Liste an
|
||||
DebugLogger.get_errors() # gibt Liste der Fehlermeldungen dieser Sitzung zurück
|
||||
DebugLogger.clear_errors() # leert die In-Memory-Liste
|
||||
|
||||
DebugLogger.format_debug_output({ # formatiert Ausführungsergebnis für die KI
|
||||
"return_code": 1,
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Agent (MCP-System)
|
||||
|
||||
### `coding_agent.py`
|
||||
Implementiert eine **Plan → Aktion → Beobachten**-Schleife, die schrittweise durch die
|
||||
Streamlit-UI gesteuert wird. Das LLM antwortet immer mit einer strukturierten JSON-Aktion:
|
||||
|
||||
```json
|
||||
{"thought": "...", "tool": "<tool_name>", "arguments": {"key": "value"}}
|
||||
```
|
||||
|
||||
Wichtige Methoden:
|
||||
|
||||
| Methode | Beschreibung |
|
||||
|---------|--------------|
|
||||
| `start_task(task)` | Setzt den gesamten Zustand zurück, befüllt History mit System + Aufgabe |
|
||||
| `propose_next_action()` | Ruft das LLM auf, parst JSON, speichert als `pending_action` |
|
||||
| `approve()` | Führt das ausstehende Tool via `dispatch_tool()` aus, loggt Ergebnis |
|
||||
| `reject(feedback)` | Injiziert Feedback + Replan-Tag in die History; `propose_next_action()` wird danach separat aufgerufen |
|
||||
| `follow_up(question)` | Fügt nach «done» eine Folgefrage ein, setzt Schleife fort |
|
||||
|
||||
Hilfsfunktionen (Modul-Ebene):
|
||||
|
||||
| Funktion | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `truncate_result(text)` | Begrenzt Tool-Output auf `MAX_RESULT_LENGTH` (10 000 Zeichen) |
|
||||
| `trim_messages(msgs)` | Entfernt alte Turns, wenn History `MAX_HISTORY_CHARS` (80 000 Zeichen) überschreitet; System-Nachricht + Original-Aufgabe bleiben immer erhalten |
|
||||
| `_strip_code_fences(text)` | Entfernt ` ```json `- / ` ``` `-Wrapper aus LLM-Antworten |
|
||||
| `dispatch_tool(name, arguments)` | Leitet weiter an `MCPToolAdapter.call_tool()` |
|
||||
| `build_all_tool_description()` | Erstellt eine menschenlesbare Tool-Liste für den System-Prompt |
|
||||
|
||||
### `mcp_server_adapter.py`
|
||||
Liest `mcp_server_config.json`, startet jeden Server als stdio-Subprocess (immer mit
|
||||
`sys.executable`, unabhängig vom literalen Befehl in der Konfiguration) und registriert
|
||||
alle Tools in einem flachen `tool_registry`. Verbindungen werden pro Aufruf geöffnet
|
||||
(nicht dauerhaft gehalten), da Streamlits synchrones Rerun-Modell langlebige
|
||||
async-Kontextmanager unpraktisch macht.
|
||||
|
||||
### MCP-Server (in `servers/`)
|
||||
|
||||
Alle drei Server sind FastMCP-Applikationen, die über stdio kommunizieren.
|
||||
|
||||
**`mcp_server_file_search.py`** — Workspace-Dateioperationen:
|
||||
- `list_files()` — flache rekursive Auflistung
|
||||
- `get_file_tree(dir_path)` — baumförmige Verzeichnisstruktur
|
||||
- `search_files(query)` — Name- und Inhaltssuche (bis 30 Treffer)
|
||||
- `read_file(path)` — Textdatei lesen
|
||||
- `write_new_file(path, content)` — Erstellen (kein Überschreiben)
|
||||
- `create_new_directory(path)` — Verzeichnis erstellen
|
||||
|
||||
**`mcp_server_web_search.py`** — Webzugriff:
|
||||
- `web_search(query, max_results=5)` — DuckDuckGo-Suche
|
||||
- `fetch_page(url)` — Abrufen + Text extrahieren (max. `MAX_PAGE_LENGTH` Zeichen)
|
||||
- Beide Tools nutzen SSRF-Schutz (gleiche URL-Validierung wie `search_manager.py`)
|
||||
|
||||
**`mcp_server_code_execution.py`** — Sandbox-Python-Analyse:
|
||||
- `analyse_structure(code)` — AST-basierte Strukturzusammenfassung
|
||||
- `lint_code(code)` — pyflakes-Analyse
|
||||
- `python_code_validation(code)` — Sicherheits- + Syntaxprüfung ohne Ausführung
|
||||
- `run_python_sandboxed(code)` — Ausführung in einem Subprocess mit `PYTHONIOENCODING=utf-8`,
|
||||
15 s Timeout, Output begrenzt auf `MAX_OUTPUT_LENGTH`
|
||||
|
||||
---
|
||||
|
||||
## MCP-Server-Konfiguration
|
||||
|
||||
### Format: `backend/agent/mcp_server_config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"Servername": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_beispiel.py"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Pflicht | Beschreibung |
|
||||
|------|---------|--------------|
|
||||
| `command` | Ja | Ausführbare Datei (`py`, `python3`, `node`, …) — wird für Python immer durch `sys.executable` ersetzt |
|
||||
| `args` | Ja | Argumente-Array — erstes Element ist der Server-Script-Pfad relativ zu `backend/agent/` |
|
||||
| `env` | Nein | Zusätzliche Umgebungsvariablen für den Serverprozess |
|
||||
|
||||
### Neuen MCP-Server hinzufügen
|
||||
|
||||
1. Neues FastMCP-Script in `backend/agent/servers/` erstellen:
|
||||
```python
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
mcp = FastMCP("MeinServer")
|
||||
|
||||
@mcp.tool()
|
||||
def mein_tool(param: str) -> str:
|
||||
"""Tool-Beschreibung, die dem Agenten angezeigt wird."""
|
||||
return f"Ergebnis: {param}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
```
|
||||
2. Eintrag in `mcp_server_config.json` ergänzen:
|
||||
```json
|
||||
"MeinServer": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_mein_tool.py"]
|
||||
}
|
||||
```
|
||||
3. Der Adapter erkennt den neuen Server beim nächsten App-Start automatisch und
|
||||
bindet seine Tools in den System-Prompt des Agenten ein.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Streamlit) │
|
||||
│ app.py ──► sidebar.py / editor.py / chat.py │
|
||||
│ │ │
|
||||
│ state.py (alle Session-State-Schlüssel) │
|
||||
└──────────────┬──────────────────────────────────────────────────┘
|
||||
│ direkte Python-Aufrufe
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend Manager │
|
||||
│ FileManager ChatManager SystemPrompter │
|
||||
│ SearchManager ExecutionEngine DebugLogger │
|
||||
└──────────────┬──────────────────────────────────────────────────┘
|
||||
│ async-Aufrufe (via _run_async-Bridge)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Coding Agent │
|
||||
│ coding_agent.py ◄──► mcp_server_adapter.py │
|
||||
│ │ stdio (Subprocess pro Aufruf) │
|
||||
│ ┌────────────────┼───────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ mcp_server_file_search mcp_server_web mcp_server_code │
|
||||
└──────────────┬──────────────────────────────────────────────────┘
|
||||
│ lesen / schreiben
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ workspace/ (isoliertes Sandbox-Verzeichnis) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Async-Bridge:** Streamlit läuft synchron. Der `CodingAgent` verwendet async-Methoden
|
||||
(da die MCP-Client-Bibliothek async ist). `_run_async(coro)` in `chat.py` erstellt pro
|
||||
Aufruf eine neue Event-Loop (`asyncio.new_event_loop()`), was im Thread-Modell von
|
||||
Streamlit sicher ist, da auf dem UI-Thread keine Loop läuft.
|
||||
|
||||
**MCP-Verbindungsmodell:** Der Adapter öffnet pro Tool-Aufruf eine neue stdio-Verbindung
|
||||
statt eine persistente Session zu halten. Das vermeidet die Komplexität langlebiger
|
||||
async-Kontextmanager über Streamlit-Reruns hinweg.
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Designentscheidungen
|
||||
|
||||
### Warum `get_file_tree()` statt einer flachen Dateiliste?
|
||||
Der interaktive Datei-Explorer in der Sidebar benötigt eine verschachtelte Struktur,
|
||||
um das Baum-Widget aufzubauen. Eine flache Liste würde die clientseitige Rekonstruktion
|
||||
von Eltern-Kind-Beziehungen erfordern. Der MCP-Server `mcp_server_file_search.py` stellt
|
||||
sein eigenes `list_files()`-Tool für den Agenten bereit, wo eine flache Auflistung
|
||||
für das LLM nützlicher ist.
|
||||
|
||||
### Warum wird der System-Prompt bei jeder Nachricht neu generiert?
|
||||
Die im Editor aktive Datei kann sich zwischen Nachrichten ändern. Durch das Neuerstellen
|
||||
des Prompts hat die KI immer den aktuellen Dateikontext. Der vorhandene System-Nachrichten-Eintrag
|
||||
wird in-place aktualisiert (nicht angehängt), sodass die History stets genau eine
|
||||
System-Nachricht enthält.
|
||||
|
||||
### Warum ist die Chat-History auf ein Sliding-Window begrenzt?
|
||||
`ChatManager._build_payload_messages()` behält die System-Nachricht und die letzten 20 Turns.
|
||||
Das verhindert, dass die Payload während langer Sitzungen das Kontextlimit des Modells
|
||||
überschreitet, während der System-Prompt immer erhalten bleibt. Der Agent hat eine eigene,
|
||||
separate Kürzungslogik (`trim_messages()`), die zusätzlich die ursprüngliche Aufgabenbeschreibung
|
||||
bewahrt.
|
||||
|
||||
### Warum werden MCP-Tool-Verbindungen pro Aufruf geöffnet?
|
||||
Streamlit führt das gesamte Script bei jeder Benutzerinteraktion erneut aus. Eine lebende
|
||||
async-MCP-Session über Reruns hinweg zu erhalten würde entweder einen Hintergrund-Thread
|
||||
oder eine persistente asyncio-Loop erfordern — beides erhöht Komplexität und Fehleranfälligkeit.
|
||||
Verbindungen pro Aufruf sind einfacher und zuverlässiger, auf Kosten eines kleinen
|
||||
Subprocess-Start-Overheads pro Tool-Aufruf.
|
||||
|
||||
### Warum wird Fehler-Output NICHT automatisch in den normalen Chat injiziert?
|
||||
Das automatische Einschleusen jedes Laufzeitfehlers würde die Chat-History schnell mit
|
||||
Rauschen überfluten. Stattdessen entscheidet der Benutzer selbst, wann er die KI über
|
||||
den Button «Mit KI debuggen» im Editor einbezieht. Der Agent-Mode behandelt dies anders:
|
||||
Tool-Fehler werden immer als Beobachtungen an das LLM zurückgegeben und lösen automatisches
|
||||
Replanning aus.
|
||||
|
||||
### Warum setzt `run_python_sandboxed()` `PYTHONIOENCODING=utf-8`?
|
||||
Unter Windows ist die Standard-Konsolen-Kodierung cp1252, die Unicode-Zeichen ausserhalb
|
||||
des Latin-1-Bereichs (z. B. Emoji, CJK) nicht kodieren kann. Das Setzen von
|
||||
`PYTHONIOENCODING=utf-8` in der Subprocess-Umgebung stellt sicher, dass `print()` für
|
||||
beliebige Unicode-Inhalte korrekt funktioniert.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Tests ausführen
|
||||
Use Git to track changes:
|
||||
|
||||
```bash
|
||||
pytest tests/ -v # alle Tests
|
||||
pytest tests/test_chat_manager.py -v # einzelne Datei
|
||||
pytest tests/ -q # Kurzausgabe
|
||||
git add .
|
||||
git commit -m "Your message"
|
||||
git push origin sturcture
|
||||
```
|
||||
|
||||
### Test-Architektur
|
||||
|
||||
`conftest.py` patcht `MCPToolAdapter` auf `sys.modules`-Ebene **vor** dem Import eines
|
||||
Testmoduls. Das verhindert, dass `coding_agent.py`'s Modul-Level-Aufruf
|
||||
`asyncio.run(adapter.initialize_all_servers())` echte MCP-Subprozesse startet.
|
||||
|
||||
| Testdatei | Getestetes Modul | Wichtige Muster |
|
||||
|-----------|-----------------|-----------------|
|
||||
| `test_chat_manager.py` | `ChatManager` | `@patch("requests.post")` für HTTP |
|
||||
| `test_coding_agent.py` | `CodingAgent` | `@pytest.mark.asyncio`, mock `_call_api` |
|
||||
| `test_debug_logger.py` | `DebugLogger` | autouse-Fixture setzt `_error_log`-Klassenvariable zurück |
|
||||
| `test_execution_engine.py` | `ExecutionEngine` | `@patch("subprocess.run")` |
|
||||
| `test_file_manager.py` | `FileManager` | `tmp_path`-Fixture, mock `st` |
|
||||
| `test_mcp_server_code_execution.py` | MCP-Code-Server | führt echten Python-Code aus |
|
||||
| `test_mcp_server_file_search.py` | MCP-Datei-Server | `monkeypatch` tauscht `ALLOWED_DIR` |
|
||||
| `test_mcp_server_web_search.py` | MCP-Web-Server | patcht `DDGS` im Server-Namespace |
|
||||
| `test_search_manager.py` | `SearchManager` | mockt DDGS-Kontextmanager + requests |
|
||||
| `test_system_prompter.py` | `SystemPrompter` | reine Funktion, kein Mocking nötig |
|
||||
|
||||
### Async-Tests
|
||||
Die Tests in `test_coding_agent.py` verwenden `@pytest.mark.asyncio` aus `pytest-asyncio`.
|
||||
Der `MCPToolAdapter` ist vollständig via `conftest.py` gemockt, sodass kein MCP-Subprocess
|
||||
beteiligt ist.
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
`.env.example` nach `.env` kopieren und ausfüllen:
|
||||
|
||||
| Variable | Beschreibung | Beispiel |
|
||||
|----------|--------------|---------|
|
||||
| `HOST` | Hostname des LLM-API-Endpunkts | `localhost` |
|
||||
| `PORT` | Port des LLM-API-Endpunkts | `8000` |
|
||||
| `API_KEY` | Bearer-Token — `EMPTY` für offene Endpunkte verwenden | `sk-...` |
|
||||
| `MODEL` | Modellname, der in API-Payloads gesendet wird | `mistral-7b` |
|
||||
|
||||
Die App funktioniert mit jedem OpenAI-kompatiblen API-Endpunkt (vLLM, Ollama mit
|
||||
OpenAI-Shim, OpenAI selbst usw.).
|
||||
|
||||
---
|
||||
|
||||
## Einsatz von KI-Werkzeugen
|
||||
|
||||
Während der Entwicklung wurden KI-Assistenten (Claude, GitHub Copilot) als
|
||||
Werkzeuge eingesetzt — vergleichbar mit der Nutzung von Dokumentation, Stack Overflow
|
||||
oder einer IDE mit Autocomplete.
|
||||
|
||||
Konkret bedeutet das:
|
||||
- **Eigenständige Konzeption und Architektur**: Die Gesamtarchitektur (Schichtentrennung
|
||||
Frontend / Manager / Agent), die Designentscheidungen und die Aufteilung in Komponenten
|
||||
wurden selbst erarbeitet und geplant.
|
||||
- **Implementierung mit Unterstützung**: Boilerplate-Code, Docstrings und einzelne
|
||||
Hilfsfunktionen wurden teils mit KI-Unterstützung geschrieben, verstanden und
|
||||
anschliessend in das Projekt integriert.
|
||||
- **MCP-Integration und Chat-Logik**: Für das Model Context Protocol und den
|
||||
Chat-Assistenten haben wir uns an den Kursbeispielen des Dozenten orientiert und
|
||||
diese als Ausgangsbasis adaptiert und erweitert.
|
||||
- **Debugging und Refactoring**: KI wurde als Gesprächspartner genutzt, um Fehler zu
|
||||
analysieren und Lösungsansätze zu diskutieren — die Entscheidungen wurden jedoch
|
||||
eigenständig getroffen und umgesetzt.
|
||||
|
||||
Der gesamte Code wurde von uns gelesen, verstanden und bewusst eingesetzt.
|
||||
Unkritisch übernommener oder nicht verstandener Code wurde nicht ins Projekt aufgenommen.
|
||||
|
||||
@ -13,24 +13,16 @@ step-by-step methods so Streamlit can drive the loop via session_state:
|
||||
agent.reject(feedback) # skip action, inject user feedback
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
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()
|
||||
logger.info("MCPToolAdapter created. Listing all tools from servers...")
|
||||
asyncio.run(adapter.initialize_all_servers())
|
||||
logger.info("Listed tools from all servers")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@ -46,94 +38,179 @@ MAX_HISTORY_CHARS = 80_000
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Tool dispatching and result handling
|
||||
# PART A – TOOL FUNCTIONS
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Each tool is a plain Python function decorated with @register_tool.
|
||||
# The decorator adds the function to TOOL_REGISTRY so the dispatcher
|
||||
# can call it by name at runtime.
|
||||
|
||||
def build_all_tool_description() -> str:
|
||||
"""Build a formatted string listing every registered MCP tool.
|
||||
TOOL_REGISTRY: dict[str, callable] = {}
|
||||
|
||||
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()
|
||||
logger.info("Building tool description for %s tools.", str(len(all_tools)))
|
||||
def register_tool(func):
|
||||
"""Decorator – adds a function to the global tool registry."""
|
||||
TOOL_REGISTRY[func.__name__] = func
|
||||
return func
|
||||
|
||||
descriptions = []
|
||||
for tool in all_tools:
|
||||
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
|
||||
|
||||
return "\n".join(descriptions)
|
||||
@register_tool
|
||||
def read_file(path: str) -> str:
|
||||
"""Read a .py or .txt file from the workspace and return its contents."""
|
||||
target = (WORKSPACE / path).resolve()
|
||||
if not str(target).startswith(str(WORKSPACE.resolve())):
|
||||
return "ERROR: path is outside the workspace."
|
||||
if not target.exists():
|
||||
return f"ERROR: file '{path}' not found."
|
||||
if target.suffix not in (".py", ".txt"):
|
||||
return f"ERROR: can only read .py and .txt files, got '{target.suffix}'."
|
||||
return target.read_text()
|
||||
|
||||
async def dispatch_tool(tool_name: str, arguments: dict) -> str:
|
||||
"""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.
|
||||
@register_tool
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Write content to a .py or .txt file in the workspace."""
|
||||
target = (WORKSPACE / path).resolve()
|
||||
if not str(target).startswith(str(WORKSPACE.resolve())):
|
||||
return "ERROR: path is outside the workspace."
|
||||
if target.suffix not in (".py", ".txt"):
|
||||
return f"ERROR: can only write .py and .txt files, got '{target.suffix}'."
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
return f"OK: wrote {len(content)} chars to {path}."
|
||||
|
||||
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":
|
||||
# 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}"
|
||||
@register_tool
|
||||
def list_files(file_glob: str = "*") -> str:
|
||||
"""List files in the workspace matching the glob pattern."""
|
||||
found = sorted(WORKSPACE.glob(file_glob))
|
||||
found = [f.relative_to(WORKSPACE) for f in found if f.is_file()]
|
||||
if not found:
|
||||
return f"No files matching '{file_glob}' in workspace."
|
||||
return "\n".join(str(f) for f in found)
|
||||
|
||||
|
||||
@register_tool
|
||||
def grep_search(pattern: str, file_glob: str = "*.py") -> str:
|
||||
"""Search for a pattern in workspace files and return matching lines with line numbers."""
|
||||
matches = []
|
||||
for filepath in sorted(WORKSPACE.glob(file_glob)):
|
||||
if filepath.suffix not in (".py", ".txt"):
|
||||
continue
|
||||
try:
|
||||
lines = filepath.read_text().splitlines()
|
||||
except Exception:
|
||||
continue
|
||||
for i, line in enumerate(lines, 1):
|
||||
if pattern in line:
|
||||
rel = filepath.relative_to(WORKSPACE)
|
||||
matches.append(f"{rel}:{i}: {line}")
|
||||
if not matches:
|
||||
return f"No matches for '{pattern}' in {file_glob}."
|
||||
return "\n".join(matches)
|
||||
|
||||
|
||||
@register_tool
|
||||
def run_python(path: str) -> str:
|
||||
"""Execute a Python file in the workspace and return stdout and stderr."""
|
||||
target = (WORKSPACE / path).resolve()
|
||||
if not str(target).startswith(str(WORKSPACE.resolve())):
|
||||
return "ERROR: path is outside the workspace."
|
||||
if not target.exists():
|
||||
return f"ERROR: file '{path}' not found."
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(target)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(WORKSPACE),
|
||||
)
|
||||
output = ""
|
||||
if result.stdout:
|
||||
output += f"STDOUT:\n{result.stdout}"
|
||||
if result.stderr:
|
||||
output += f"STDERR:\n{result.stderr}"
|
||||
output += f"\nExit code: {result.returncode}"
|
||||
return output.strip()
|
||||
|
||||
|
||||
@register_tool
|
||||
def validate_python(path: str) -> str:
|
||||
"""Check whether a Python file has valid syntax using ast.parse."""
|
||||
target = (WORKSPACE / path).resolve()
|
||||
if not str(target).startswith(str(WORKSPACE.resolve())):
|
||||
return "ERROR: path is outside the workspace."
|
||||
if not target.exists():
|
||||
return f"ERROR: file '{path}' not found."
|
||||
source = target.read_text()
|
||||
try:
|
||||
logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name)
|
||||
result = await adapter.call_tool(tool_name, arguments)
|
||||
ast.parse(source)
|
||||
return "OK: syntax is valid."
|
||||
except SyntaxError as e:
|
||||
return f"SYNTAX ERROR: {e}"
|
||||
|
||||
logger.info("Result from tool '%s' received", 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)}"
|
||||
@register_tool
|
||||
def done(summary: str) -> str:
|
||||
"""Signal that the agent has finished its task."""
|
||||
return f"DONE: {summary}"
|
||||
|
||||
texts = [block.text for block in result.content if block.type == "text"]
|
||||
return "\n".join(texts)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error calling tool '%s' with argument: %s", tool_name, arguments)
|
||||
return f"Error calling tool '{tool_name}': {e}"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# SYSTEM PROMPT
|
||||
# PART B – TOOL DISPATCHER
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def build_tool_description() -> str:
|
||||
"""Auto-generate tool descriptions from function signatures and docstrings."""
|
||||
lines = []
|
||||
for name, func in TOOL_REGISTRY.items():
|
||||
sig = inspect.signature(func)
|
||||
params = []
|
||||
for pname, param in sig.parameters.items():
|
||||
if param.default is inspect.Parameter.empty:
|
||||
params.append(f'"{pname}": "<value>"')
|
||||
else:
|
||||
params.append(f'"{pname}": "<optional, default={param.default!r}>"')
|
||||
param_str = ", ".join(params)
|
||||
doc = (func.__doc__ or "").strip().split("\n")[0]
|
||||
lines.append(f" - {name}({{{param_str}}}): {doc}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def dispatch_tool(tool_name: str, arguments: dict) -> str:
|
||||
"""Call a tool by name with the given arguments."""
|
||||
if tool_name not in TOOL_REGISTRY:
|
||||
return f"ERROR: unknown tool '{tool_name}'. Available: {list(TOOL_REGISTRY.keys())}"
|
||||
func = TOOL_REGISTRY[tool_name]
|
||||
try:
|
||||
return func(**arguments)
|
||||
except TypeError as e:
|
||||
return f"ERROR calling {tool_name}: {e}"
|
||||
except Exception as e:
|
||||
return f"ERROR in {tool_name}: {type(e).__name__}: {e}"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PART C – SYSTEM PROMPT
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
SYSTEM_PROMPT = f"""\
|
||||
You are a coding agent that helps users with Python programming tasks.
|
||||
You work inside a workspace directory and have access to tools.
|
||||
You solve tasks by interacting with a workspace through a
|
||||
dynamic set of tools via the Model Context Protocol (MCP).
|
||||
|
||||
<capabilities>
|
||||
You have access to a workspace where you can manage files,
|
||||
analyze code, and execute Python scripts.
|
||||
You can call tools to interact with the workspace and get feedback.
|
||||
You can write and read files, list directory contents, search for patterns,
|
||||
validate Python syntax, and run Python code.
|
||||
You can access web search and page fetching tools to gather information from the internet.
|
||||
You can use these capabilities to iteratively work towards completing the user's task.
|
||||
<capabilities>
|
||||
You can:
|
||||
- Read .py and .txt files from the workspace
|
||||
- Write .py and .txt files to the workspace
|
||||
- List files in the workspace
|
||||
- Search for patterns in files using grep
|
||||
- Execute Python files and see their output
|
||||
- Validate Python syntax using ast.parse
|
||||
- Signal completion when the task is done
|
||||
</capabilities>
|
||||
|
||||
<tools>
|
||||
{build_all_tool_description()}
|
||||
{build_tool_description()}
|
||||
</tools>
|
||||
|
||||
|
||||
<workflow>
|
||||
For every user request, follow this workflow:
|
||||
1. PLAN: Think about what steps are needed. List them in "thought".
|
||||
@ -167,7 +244,6 @@ Example:
|
||||
- After validation passes, run it with run_python to verify correctness.
|
||||
- If an error occurs, analyse it and try to fix it (up to 3 retries).
|
||||
- Stay within the workspace directory.
|
||||
- Never use emojis, umlauts (ä, ö, ü, Ä, Ö, Ü, ß), or any non-ASCII characters in string literals or print() calls — the execution environment uses cp1252 encoding which cannot handle them.
|
||||
- When the task is fully complete, call the "done" tool.
|
||||
- If you receive a <human_message>, acknowledge it and adjust your plan.
|
||||
- If you receive a <replan> tag, revise your plan before choosing the next tool.
|
||||
@ -176,12 +252,11 @@ Example:
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# CODING AGENT CLASS
|
||||
# PART D – CODING AGENT CLASS
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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
|
||||
@ -193,35 +268,20 @@ def truncate_result(result: str) -> str:
|
||||
|
||||
|
||||
def trim_messages(messages: list) -> list:
|
||||
"""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.
|
||||
"""Drop old messages when history exceeds MAX_HISTORY_CHARS.
|
||||
Always keeps the system prompt (index 0) and original task (index 1).
|
||||
"""
|
||||
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 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)
|
||||
|
||||
# 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": (
|
||||
@ -233,128 +293,12 @@ def trim_messages(messages: list) -> list:
|
||||
}
|
||||
return head + [reminder] + tail
|
||||
|
||||
def _repair_json_strings(text: str) -> str:
|
||||
"""Replace unescaped control characters inside JSON string values.
|
||||
|
||||
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
|
||||
escape = False
|
||||
_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
|
||||
if ch == '\\' and in_string:
|
||||
result.append(ch)
|
||||
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)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def extract_json(text: str) -> str:
|
||||
"""
|
||||
Extract and repair a JSON object or array from an LLM response that may
|
||||
contain extra prose, markdown code fences, or unescaped control characters.
|
||||
|
||||
Strategy:
|
||||
1. Strip markdown ```json ... ``` or ``` ... ``` fences.
|
||||
2. Find the first '{' or '[' and extract to the matching closing bracket.
|
||||
3. Repair unescaped newlines/tabs inside string values.
|
||||
|
||||
Returns the cleaned JSON string, or the original text as a fallback
|
||||
(so json.loads can raise a meaningful error with context).
|
||||
"""
|
||||
|
||||
if text is None:
|
||||
return ""
|
||||
|
||||
# 1. Strip markdown fences
|
||||
fenced = re.sub(r"```(?:json)?\s*([\s\S]*?)\s*```", r"\1", text.strip())
|
||||
if fenced != text.strip():
|
||||
return _repair_json_strings(fenced.strip())
|
||||
|
||||
# 2. Find first JSON container and extract to matching close
|
||||
extracted = text
|
||||
for start_char, end_char in [('{', '}'), ('[', ']')]:
|
||||
idx = text.find(start_char)
|
||||
if idx == -1:
|
||||
continue
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for i, ch in enumerate(text[idx:], start=idx):
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == start_char:
|
||||
depth += 1
|
||||
elif ch == end_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
extracted = text[idx: i + 1]
|
||||
break
|
||||
break
|
||||
|
||||
# 3. Repair unescaped control characters inside string values
|
||||
return _repair_json_strings(extracted)
|
||||
|
||||
|
||||
def _strip_code_fences(text: str) -> str:
|
||||
"""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 ""
|
||||
|
||||
"""Remove markdown code fences (```json ... ```) from a string."""
|
||||
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()
|
||||
@ -393,7 +337,6 @@ class CodingAgent:
|
||||
|
||||
def _call_api(self, messages: list) -> str:
|
||||
"""Make a raw API call and return the response content string."""
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key and self.api_key != "EMPTY":
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
@ -401,33 +344,20 @@ class CodingAgent:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
# 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,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=60)
|
||||
response.raise_for_status()
|
||||
logger.info("LLM API response requested")
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("API Error; HTTP-Fehler: %s", exc)
|
||||
raise Exception(f"HTTP-Fehler: {exc}") from exc
|
||||
|
||||
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}")
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
@ -440,9 +370,8 @@ 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:
|
||||
def propose_next_action(self) -> dict:
|
||||
"""Ask the LLM what to do next.
|
||||
|
||||
Returns the parsed action dict without executing anything.
|
||||
@ -458,16 +387,14 @@ class CodingAgent:
|
||||
if self.iteration >= MAX_ITERATIONS:
|
||||
return {"thought": "Max iterations reached.", "tool": "done",
|
||||
"arguments": {"summary": "Stopped: max iterations reached."}}
|
||||
|
||||
|
||||
self.iteration += 1
|
||||
self.messages = trim_messages(self.messages)
|
||||
|
||||
try:
|
||||
raw = self._call_api(self.messages)
|
||||
raw = _strip_code_fences(raw)
|
||||
cleaned = extract_json(raw)
|
||||
action = json.loads(cleaned)
|
||||
logger.info("Propose next action successfull")
|
||||
action = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
action = {
|
||||
"thought": "Could not parse LLM response as JSON.",
|
||||
@ -475,7 +402,6 @@ 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}",
|
||||
@ -483,12 +409,11 @@ 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
|
||||
|
||||
async def approve(self) -> dict:
|
||||
def approve(self) -> dict:
|
||||
"""Execute the pending action and return the result.
|
||||
|
||||
Returns:
|
||||
@ -506,8 +431,6 @@ 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
|
||||
@ -519,13 +442,10 @@ class CodingAgent:
|
||||
}
|
||||
|
||||
# Execute the tool
|
||||
result = await dispatch_tool(tool_name, arguments)
|
||||
result = 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
|
||||
# its plan rather than blindly retrying the same failing action.
|
||||
# Build feedback – nudge agent to replan on errors
|
||||
feedback = f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>'
|
||||
if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"):
|
||||
feedback += (
|
||||
@ -533,7 +453,6 @@ 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})
|
||||
|
||||
@ -561,7 +480,6 @@ 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.
|
||||
@ -588,4 +506,3 @@ class CodingAgent:
|
||||
),
|
||||
})
|
||||
self.pending_action = None
|
||||
logger.info("Rejection message appended.")
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
"""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
|
||||
from typing import List, Dict, Any
|
||||
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]:
|
||||
"""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():
|
||||
logger.warning("Config file not found: %s", path)
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
config_file = json.load(f)
|
||||
logger.info("MCP-Server config loaded successfully")
|
||||
return config_file
|
||||
except json.JSONDecodeError as 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."""
|
||||
config = self._load_config()
|
||||
logger.info("Loaded config for servers: %s", list(config.keys()))
|
||||
|
||||
for server_name, params in config.items():
|
||||
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:
|
||||
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||
logger.info("Connected to %s. Initializing session...", server_name)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
logger.info("Session initialized for %s. Requesting tools...", server_name)
|
||||
result = await session.list_tools()
|
||||
tools = result.tools
|
||||
logger.info("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 = []
|
||||
for pname, pinfo in t_params.items():
|
||||
ptype = pinfo.get("type", "any")
|
||||
pdesc = pinfo.get("description", "")
|
||||
param_lines.append(f" - {pname} ({ptype}): {pdesc}")
|
||||
param_str = "\n".join(param_lines)
|
||||
else:
|
||||
param_str = " (none)"
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
logger.info("Registered tool '%s' from %s.", tool.name, server_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to initialize %s: %s", server_name, str(e))
|
||||
|
||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||
"""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]):
|
||||
"""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:
|
||||
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"]
|
||||
s_params = self.servers.get(server_name)
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
try:
|
||||
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)}"
|
||||
|
||||
def main():
|
||||
"""Debug Function for Tool-Registry"""
|
||||
adapter = MCPToolAdapter()
|
||||
asyncio.run(adapter.initialize_all_servers())
|
||||
print("All servers initialized. Registered tools:")
|
||||
for tool in adapter.get_all_tools():
|
||||
print(f"- {tool['tool_name']} (from {tool['server']})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,16 +0,0 @@
|
||||
{"FileSearchServer": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_file_search.py"]
|
||||
},
|
||||
|
||||
"WebSearchServer": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_web_search.py"]
|
||||
},
|
||||
|
||||
"CodeExecutionServer": {
|
||||
"command": "py",
|
||||
"args": ["servers/mcp_server_code_execution.py"]
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,351 +0,0 @@
|
||||
"""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 os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import io
|
||||
from pyflakes.api import check # For linting Code
|
||||
from pyflakes.reporter import Reporter # For linting Code
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
#from backend.managers.debug_logger import get_logger
|
||||
#logger = get_logger(__name__)
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────────────────
|
||||
EXEC_TIMEOUT = 15 # seconds before killing the subprocess
|
||||
MAX_OUTPUT_LENGTH = 3000 # max characters of stdout+stderr to return
|
||||
|
||||
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||
mcp = FastMCP("CodeExecutionServer")
|
||||
|
||||
# ── Blocked Imports and Builtins ────────────────────────────────────────────────────
|
||||
BLOCKED_IMPORTS = {
|
||||
# Filesystem access:
|
||||
"os", "pathlib", "shutil", "glob", "tempfile", "fileinput",
|
||||
# Process execution:
|
||||
"subprocess", "multiprocessing", "threading",
|
||||
# Network access:
|
||||
"socket", "http", "urllib", "requests", "ftplib", "smtplib","xmlrpc", "asyncio",
|
||||
# System internals:
|
||||
"sys", "ctypes", "importlib", "code", "codeop", "compileall",
|
||||
# Serialization exploits:
|
||||
"pickle", "shelve", "marshal",
|
||||
# Other dangerous:
|
||||
"signal", "resource", "pty", "fcntl", "termios", "webbrowser", "antigravity"
|
||||
}
|
||||
|
||||
BLOCKED_BUILTINS = {
|
||||
# Code execution:
|
||||
"exec", "eval", "compile", "__import__",
|
||||
# File access:
|
||||
"open",
|
||||
# Process control:
|
||||
"exit", "quit", "breakpoint",
|
||||
# Attribute manipulation:
|
||||
"getattr", "setattr", "delattr",
|
||||
# Introspection escapes:
|
||||
"globals", "locals", "vars", "memoryview", "type"
|
||||
}
|
||||
|
||||
FORBIDDEN_SEQUENCES = ["../", "..\\", "/etc/", "/dev/",
|
||||
"C:\\Windows", "C:\\Program Files", "C:\\Users",
|
||||
"compile(", "__import__", "os.", "sys.", "subprocess."]
|
||||
|
||||
"""
|
||||
Pre-installed Packages in Sandbox: "pygame", "numpy", "pandas"
|
||||
"""
|
||||
# ── Static Analysis ────────────────────────────────────────────────────
|
||||
def check_code_safety(code: str) -> str | None:
|
||||
"""
|
||||
Statically analyze Python code for forbidden imports and builtins with ast.
|
||||
|
||||
Args:
|
||||
code: The Python code to analyze.
|
||||
|
||||
Returns:
|
||||
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):
|
||||
if isinstance(node, ast.Import):
|
||||
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}")
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
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
|
||||
|
||||
@mcp.tool()
|
||||
def analyse_structure(code: str) -> str:
|
||||
"""
|
||||
Analyze the structure of Python code and return a summary of its components.
|
||||
|
||||
Args:
|
||||
code: The Python code to analyze in str format.
|
||||
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 = {
|
||||
"imports": [],
|
||||
"classes": [],
|
||||
"functions": []
|
||||
}
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
analysis["imports"].append(f"import {alias.name}")
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
for alias in node.names:
|
||||
analysis["imports"].append(f"from {module} import {alias.name}")
|
||||
|
||||
elif isinstance(node, ast.ClassDef):
|
||||
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef)]
|
||||
analysis["classes"].append({
|
||||
"name": node.name,
|
||||
"methods": methods
|
||||
})
|
||||
|
||||
elif isinstance(node, ast.FunctionDef):
|
||||
args = [arg.arg for arg in node.args.args]
|
||||
analysis["functions"].append({
|
||||
"name": node.name,
|
||||
"args": args
|
||||
})
|
||||
|
||||
# Zusammenfassung als String formatieren
|
||||
lines = ["--- Code Structure Analysis ---"]
|
||||
|
||||
if analysis["imports"]:
|
||||
lines.append("\n[Imports]")
|
||||
lines.extend([f" - {imp}" for imp in analysis["imports"]])
|
||||
|
||||
if analysis["classes"]:
|
||||
lines.append("\n[Classes]")
|
||||
for cls in analysis["classes"]:
|
||||
lines.append(f" - class {cls['name']}:")
|
||||
if cls["methods"]:
|
||||
lines.extend([f" * method: {m}" for m in cls["methods"]])
|
||||
else:
|
||||
lines.append(" * (no methods)")
|
||||
|
||||
if analysis["functions"]:
|
||||
lines.append("\n[Top-Level Functions]")
|
||||
for func in analysis["functions"]:
|
||||
args_str = ", ".join(func["args"])
|
||||
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)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def lint_code(code: str) -> str:
|
||||
"""
|
||||
Runs a fast static analysis check to catch syntax errors, unused imports,
|
||||
or undefined variables without executing the code.
|
||||
|
||||
Args:
|
||||
code: The Python code to lint in str format.
|
||||
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()
|
||||
|
||||
reporter = Reporter(warning_buffer, error_buffer)
|
||||
|
||||
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()
|
||||
warnings = warning_buffer.getvalue().strip()
|
||||
|
||||
# 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 ---"]
|
||||
|
||||
if errors:
|
||||
report.append("\n[Syntax Errors / Critical Issues]")
|
||||
report.append(errors)
|
||||
|
||||
if warnings:
|
||||
report.append("\n[Logical Issues (Unused imports, Undefined names, etc.)]")
|
||||
report.append(warnings)
|
||||
|
||||
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()
|
||||
def run_python_sandboxed(code: str) -> str:
|
||||
"""
|
||||
Run Python code in a sandboxed environment.
|
||||
|
||||
The sandbox blocks dangerous operations (filesystem, network, process
|
||||
control). Code is killed after 10 seconds. Use print() to produce
|
||||
output, which is captured and returned (up to 3000 chars). If the code
|
||||
is deemed unsafe by static analysis, it will not be executed and an error
|
||||
message will be returned instead.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute in str format.
|
||||
|
||||
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}"
|
||||
|
||||
try:
|
||||
# Force UTF-8 I/O so the subprocess can print unicode on Windows
|
||||
# (default console encoding is cp1252 which cannot encode emoji).
|
||||
utf8_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=EXEC_TIMEOUT,
|
||||
env=utf8_env,
|
||||
)
|
||||
|
||||
# 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 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 source code to validate.
|
||||
|
||||
Returns:
|
||||
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:
|
||||
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"
|
||||
|
||||
|
||||
# ── Run the server ───────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
@ -1,287 +0,0 @@
|
||||
"""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"]
|
||||
|
||||
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||
mcp = FastMCP("FileSearchServer")
|
||||
|
||||
|
||||
# ── Helper: path validation ──────────────────────────────────────────────────
|
||||
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
|
||||
|
||||
|
||||
# ── MCP Tools ────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def list_files() -> str:
|
||||
"""List all files in the project directory (recursively).
|
||||
|
||||
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("*")
|
||||
if f.is_file() and "__pycache__" not in f.parts
|
||||
)
|
||||
if not files:
|
||||
return "No files found in the project directory."
|
||||
return "\n".join(str(f) for f in files)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_file_tree(dir_path: str="") -> str:
|
||||
"""Get a tree representation of the project directory.
|
||||
|
||||
Args:
|
||||
path: The directory path to display (default is the allowed directory).
|
||||
|
||||
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)
|
||||
if 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:
|
||||
"""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(Path(safe_dir))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_files(query: str) -> str:
|
||||
"""Search for files whose name or content contains the query string.
|
||||
|
||||
Args:
|
||||
query: The search term (case-insensitive).
|
||||
|
||||
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 = []
|
||||
|
||||
for f in sorted(ALLOWED_DIR.rglob("*")):
|
||||
if not f.is_file() or "__pycache__" in f.parts:
|
||||
continue
|
||||
rel = f.relative_to(ALLOWED_DIR)
|
||||
|
||||
if query_lower in str(rel).lower():
|
||||
results.append(f"[name match] {rel}")
|
||||
|
||||
try:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(content.splitlines(), 1):
|
||||
if query_lower in line.lower():
|
||||
snippet = line.strip()[:100]
|
||||
results.append(f"[content] {rel}:{i} -- {snippet}")
|
||||
except (UnicodeDecodeError, PermissionError):
|
||||
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
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def read_file(path: str) -> str:
|
||||
"""Read the contents of a file.
|
||||
|
||||
Args:
|
||||
path: Relative path to the file within the project directory.
|
||||
|
||||
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:
|
||||
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()
|
||||
def write_new_file(path: str, content: str) -> str:
|
||||
"""Write content to a new file in the allowed directory.
|
||||
Existing files cannot be overwritten with this tool.
|
||||
|
||||
Args:
|
||||
path: Relative path to the file within the allowed directory.
|
||||
content: The content to write to the file.
|
||||
|
||||
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)
|
||||
except ValueError as e:
|
||||
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:
|
||||
#logger.warning("FileNotFoundError for '%s': %s", path, e)
|
||||
return f"Error: {e}"
|
||||
except PermissionError as 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}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_new_directory(path: str) -> str:
|
||||
"""Create a new empty directory in the allowed directory.
|
||||
|
||||
Args:
|
||||
path: Relative path to the directory within the allowed directory.
|
||||
|
||||
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."
|
||||
|
||||
if 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:
|
||||
#logger.exception("Error creating directory '%s': %s", path, e)
|
||||
return f"Error creating directory '{path}': {e}"
|
||||
|
||||
|
||||
# ── Run the server ───────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
"""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")
|
||||
|
||||
|
||||
# ── Helper: URL validation (SSRF prevention) ─────────────────────────────────
|
||||
|
||||
def _validate_url(url: str) -> str:
|
||||
"""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 ""
|
||||
|
||||
if hostname in BLOCKED_HOSTS:
|
||||
#logger.warning("Blocked internal host: %s", hostname)
|
||||
raise ValueError(f"Blocked internal host: {hostname}")
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── MCP Tools ────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def web_search(query: str, max_results: int = 5) -> str:
|
||||
"""Search the web using DuckDuckGo.
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
max_results: Maximum number of results to return (default 5).
|
||||
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:
|
||||
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:
|
||||
formatted.append(
|
||||
f"Title: {r['title']}\n"
|
||||
f"URL: {r['href']}\n"
|
||||
f"Snippet: {r['body']}"
|
||||
)
|
||||
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}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fetch_page(url: str) -> str:
|
||||
"""Fetch a web page and extract its text content.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch.
|
||||
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:
|
||||
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:
|
||||
#logger.exception("Error parsing HTML: %s", e)
|
||||
return f"Error parsing html: {e}"
|
||||
|
||||
|
||||
# ── Run the server ───────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
@ -1,30 +1,19 @@
|
||||
"""Manages the chat history and communication with the AI model API."""
|
||||
"""Chat Manager - Handles chat history and AI communication"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import requests
|
||||
import json
|
||||
|
||||
from backend.managers.debug_logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class ChatManager:
|
||||
"""Handles sending messages and maintaining conversation history.
|
||||
|
||||
Connects to an OpenAI-compatible REST endpoint configured via environment
|
||||
variables. All messages (user, assistant, system) are kept in memory so
|
||||
the full conversation is sent with every request.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_host = os.getenv("HOST")
|
||||
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"
|
||||
@ -32,64 +21,37 @@ class ChatManager:
|
||||
# Chat history stored in memory
|
||||
self.chat_history = []
|
||||
|
||||
# Maximum number of non-system messages sent to the API.
|
||||
# The system prompt is always included on top regardless of this limit.
|
||||
self.max_history_messages = 20
|
||||
|
||||
def _build_payload_messages(self) -> list:
|
||||
"""Return the messages to send to the API.
|
||||
|
||||
Always puts the system prompt first, then the most recent
|
||||
max_history_messages non-system messages. This guarantees the system
|
||||
prompt is never dropped even in long conversations.
|
||||
"""
|
||||
system = [m for m in self.chat_history if m["role"] == "system"]
|
||||
others = [m for m in self.chat_history if m["role"] != "system"]
|
||||
return system + others[-self.max_history_messages:]
|
||||
|
||||
def add_message(self, role: str, content: str) -> None:
|
||||
"""Append a single message to the conversation history."""
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def get_history(self) -> list:
|
||||
"""Return a copy of the conversation history."""
|
||||
return list(self.chat_history)
|
||||
return self.chat_history
|
||||
|
||||
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:
|
||||
"""Send a user message to the AI and return its reply.
|
||||
|
||||
Adds the user message to history, calls the API with the full history
|
||||
as context, and appends the AI reply to history before returning it.
|
||||
"""
|
||||
# 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}"
|
||||
|
||||
# System prompt + most recent messages — system prompt is always preserved.
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": self._build_payload_messages(),
|
||||
"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}"
|
||||
|
||||
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
|
||||
@ -97,52 +59,39 @@ class ChatManager:
|
||||
|
||||
# Check if request was successful
|
||||
if response.status_code != 200:
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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}")
|
||||
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()
|
||||
raise Exception(error_msg)
|
||||
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}")
|
||||
logger.exception("JSON parsing and message formatting failed: %s", e)
|
||||
raise RuntimeError("JSON parsing and message formatting failed") from e
|
||||
raise Exception(error_msg)
|
||||
|
||||
def get_chat_display(self) -> list:
|
||||
return [
|
||||
{"role": msg["role"], "content": msg["content"]}
|
||||
for msg in self.chat_history
|
||||
]
|
||||
|
||||
@ -1,123 +1,9 @@
|
||||
"""
|
||||
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:
|
||||
|
||||
_initialized = False
|
||||
_error_log: list[str] = []
|
||||
|
||||
@classmethod
|
||||
def setup(cls):
|
||||
# prevents multiple setup
|
||||
if cls._initialized:
|
||||
return
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] [%(name)s: Line %(lineno)d] %(message)s"
|
||||
)
|
||||
|
||||
# Main log file
|
||||
file_handler = RotatingFileHandler(
|
||||
LOG_DIR / "app.log",
|
||||
maxBytes=5_000_000,
|
||||
backupCount=5,
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Separate Error-Log
|
||||
error_handler = RotatingFileHandler(
|
||||
LOG_DIR / "errors.log",
|
||||
maxBytes=5_000_000,
|
||||
backupCount=3,
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(error_handler)
|
||||
#root_logger.propagate = False
|
||||
|
||||
cls._initialized = True
|
||||
|
||||
@classmethod
|
||||
def get_logger(cls, name: str):
|
||||
cls.setup()
|
||||
return logging.getLogger(name)
|
||||
|
||||
@classmethod
|
||||
def log_error(cls, error_message: str) -> None:
|
||||
cls.setup()
|
||||
logging.error(error_message)
|
||||
cls._error_log.append(error_message)
|
||||
|
||||
@classmethod
|
||||
def get_errors(cls) -> list[str]:
|
||||
return cls._error_log
|
||||
|
||||
@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)
|
||||
def __init__(self):
|
||||
self.logs = []
|
||||
|
||||
def log(self, message):
|
||||
self.logs.append(message)
|
||||
|
||||
def get_logs(self):
|
||||
return self.logs
|
||||
@ -1,85 +1,41 @@
|
||||
"""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
|
||||
import sys
|
||||
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
|
||||
|
||||
RUN_TIMEOUT = 30 # seconds
|
||||
|
||||
class ExecutionEngine:
|
||||
"""Runs files from the editor in a subprocess and returns the output.
|
||||
|
||||
Currently supports Python (.py) and LaTeX (.tex) files.
|
||||
Returns a dict with keys: stdout, stderr, rc (return code).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
def run_code(self, active_file: Path) -> dict:
|
||||
"""Execute the given file and return its output.
|
||||
|
||||
Args:
|
||||
active_file: Absolute path to the file that should be run.
|
||||
|
||||
Returns:
|
||||
{"stdout": str, "stderr": str, "rc": int}
|
||||
rc == 0 means success, anything else is an error.
|
||||
"""
|
||||
suffix = active_file.suffix
|
||||
current_dir = active_file.parent.resolve()
|
||||
|
||||
# Build the shell command depending on file type
|
||||
if suffix == ".py":
|
||||
cmd = [sys.executable, active_file.name]
|
||||
cmd = ["py", active_file.name]
|
||||
elif suffix == ".tex":
|
||||
cmd = [
|
||||
"pdflatex",
|
||||
"-interaction=nonstopmode",
|
||||
f"-output-directory={current_dir}",
|
||||
active_file.name,
|
||||
]
|
||||
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(
|
||||
cmd,
|
||||
cwd=current_dir, # run inside the file's own directory
|
||||
cwd=current_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=RUN_TIMEOUT,
|
||||
)
|
||||
logger.info("File ran successfully.")
|
||||
return self.capture_output(proc)
|
||||
|
||||
return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode}
|
||||
|
||||
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,
|
||||
}
|
||||
return {"stdout": "", "stderr": str(e), "rc": -1}
|
||||
@ -1,36 +1,10 @@
|
||||
"""Manages all file and folder operations inside the workspace directory.
|
||||
|
||||
Every method validates that the target path stays inside the workspace before
|
||||
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)
|
||||
|
||||
# File extensions that are shown in the explorer when filter_extensions=True.
|
||||
CODE_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".html", ".css", ".json",
|
||||
".yaml", ".yml", ".sh", ".md", ".txt", ".tex",
|
||||
".c", ".cpp", ".java", ".rs", ".go",
|
||||
}
|
||||
|
||||
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)
|
||||
@ -47,19 +21,14 @@ 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
|
||||
|
||||
|
||||
name = Path(name)
|
||||
if relative_path:
|
||||
relative_path = Path(relative_path)
|
||||
@ -68,21 +37,17 @@ class FileManager:
|
||||
|
||||
folder_path = (self.base_path / relative_path / name).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}")
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -97,19 +62,15 @@ 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)
|
||||
else:
|
||||
@ -117,94 +78,77 @@ class FileManager:
|
||||
|
||||
file_path = (self.base_path / relative_path / name).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}")
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
|
||||
def read_file(self, relative_path: Path) -> str:
|
||||
"""
|
||||
Reads the content of a file.
|
||||
Accepts an absolute Path object (as stored in st.session_state.open_files).
|
||||
The path is validated to ensure it stays inside the workspace.
|
||||
The relative_path should be the path to the file relative to the base path.
|
||||
|
||||
Args:
|
||||
relative_path (Path): Absolute path to the file to read.
|
||||
relative_path (str): The relative path (without base path) to the file to read, including the file name
|
||||
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:
|
||||
content = f.read()
|
||||
logger.info("File read successfully.")
|
||||
return content
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
st.error(f"File not found: {relative_path}")
|
||||
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:
|
||||
"""
|
||||
Saves content to a file.
|
||||
Accepts an absolute path string (as stored in st.session_state.open_files).
|
||||
The path is validated to ensure it stays inside the workspace.
|
||||
|
||||
Saves content to a file.
|
||||
The relative_path should be the path to the file relative to the base path.
|
||||
|
||||
Args:
|
||||
relative_path (str): Absolute path to the file to save, including the file name.
|
||||
content (str): The content to write to the file.
|
||||
relative_path (str): The relative path(without base path) to the file to save, including the file name
|
||||
content (str): The content to write to the file
|
||||
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:
|
||||
"""
|
||||
Renames a file while keeping the same extension.
|
||||
@ -216,40 +160,28 @@ 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
|
||||
new_name = Path(new_name)
|
||||
|
||||
# Force the original extension so the file type cannot be changed by renaming.
|
||||
if not Path(new_name).suffix == file_type:
|
||||
new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same
|
||||
|
||||
old_file_path = (Path(self.base_path / old_relative_path)).resolve()
|
||||
new_file_path = old_file_path.parent / new_name
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@ -262,29 +194,22 @@ 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:
|
||||
@ -296,56 +221,44 @@ 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()
|
||||
print(f"Absolute file path resolved to: {abs_file_path}") # Debugging info
|
||||
|
||||
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, filter_extensions: bool = True) -> dict:
|
||||
"""Builds a nested dictionary representing the file tree.
|
||||
|
||||
Directories are represented as keys with dictionary values,
|
||||
files as keys with None.
|
||||
|
||||
Args:
|
||||
filter_extensions: When True (default), only files whose suffix is
|
||||
in CODE_EXTENSIONS are included. Directories are always shown,
|
||||
even when they are empty after filtering.
|
||||
def get_file_tree(self):
|
||||
"""
|
||||
Builds a nested dictionary representing the file tree starting from the base path.
|
||||
Directories are represented as keys with dictionary values,
|
||||
and files are represented as keys with None
|
||||
|
||||
Returns:
|
||||
dict: A nested dictionary representing the file tree.
|
||||
"""
|
||||
logger.info("Getting file tree ...")
|
||||
def build_tree(path: Path):
|
||||
|
||||
def build_tree(path: Path) -> dict:
|
||||
tree = {}
|
||||
|
||||
for item in sorted(path.iterdir()):
|
||||
if item.is_dir():
|
||||
tree[item.name] = build_tree(item)
|
||||
else:
|
||||
if filter_extensions and item.suffix not in CODE_EXTENSIONS:
|
||||
continue
|
||||
tree[item.name] = None
|
||||
return tree
|
||||
|
||||
return build_tree(self.base_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
FileManager()
|
||||
FileManager()
|
||||
@ -1,122 +0,0 @@
|
||||
"""Handles internet search requests and page fetching for use as AI chat context."""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from ddgs import DDGS
|
||||
|
||||
# Maximum characters extracted from a fetched page before truncating.
|
||||
MAX_PAGE_CHARS = 3000
|
||||
|
||||
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; AICodeEditor/1.0)"}
|
||||
|
||||
|
||||
class SearchManager:
|
||||
"""Performs DuckDuckGo searches and fetches web pages for AI context injection.
|
||||
|
||||
All outbound requests are validated against an SSRF blocklist so that
|
||||
localhost and private network addresses can never be reached.
|
||||
"""
|
||||
|
||||
def perform_search(self, query: str, max_results: int = 5) -> list[dict]:
|
||||
"""Execute a DuckDuckGo text search and return normalised results.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
max_results: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
List of {"title": str, "url": str, "snippet": str} dicts,
|
||||
or an empty list if the search fails.
|
||||
"""
|
||||
try:
|
||||
with DDGS() as ddgs:
|
||||
raw = list(ddgs.text(query, max_results=max_results))
|
||||
return self.parse_results(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def parse_results(self, raw_results: list[dict]) -> list[dict]:
|
||||
"""Normalise raw DDGS result dicts to a consistent {"title", "url", "snippet"} shape.
|
||||
|
||||
Args:
|
||||
raw_results: List of raw dicts returned by ddgs.text().
|
||||
|
||||
Returns:
|
||||
Normalised list of result dicts.
|
||||
"""
|
||||
results = []
|
||||
for r in raw_results:
|
||||
results.append({
|
||||
"title": r.get("title", ""),
|
||||
"url": r.get("href", r.get("url", "")),
|
||||
"snippet": r.get("body", r.get("snippet", "")),
|
||||
})
|
||||
return results
|
||||
|
||||
def fetch_page(self, url: str) -> str:
|
||||
"""Fetch a web page and return its plain text content, truncated to MAX_PAGE_CHARS.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch.
|
||||
|
||||
Returns:
|
||||
Plain text extracted from the page, or an error message string.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL fails the SSRF safety check.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
try:
|
||||
response = requests.get(url, timeout=10, headers=_HEADERS)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Remove non-content elements before extracting text.
|
||||
for tag in soup(["script", "style", "nav", "footer"]):
|
||||
tag.decompose()
|
||||
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
|
||||
if len(text) > MAX_PAGE_CHARS:
|
||||
text = text[:MAX_PAGE_CHARS] + "\n... [truncated]"
|
||||
|
||||
return text
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return f"Error fetching page: {e}"
|
||||
|
||||
def _validate_url(self, url: str) -> None:
|
||||
"""Block localhost, private IPs, and non-http(s) schemes to prevent SSRF attacks.
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL is considered unsafe.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Blocked: only http/https allowed, got '{parsed.scheme}'")
|
||||
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
|
||||
raise ValueError("Blocked: localhost access denied")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
raise ValueError(f"Blocked: private/loopback IP denied ({ip})")
|
||||
except (socket.gaierror, ValueError) as e:
|
||||
# Re-raise our own ValueError; ignore DNS resolution failures
|
||||
# (let requests handle unknown hostnames naturally).
|
||||
if isinstance(e, ValueError):
|
||||
raise
|
||||
@ -1,139 +1,38 @@
|
||||
"""Builds the system prompt that is sent to the AI at the start of each chat session."""
|
||||
"""System Prompter - Builds system prompts with optional file context"""
|
||||
|
||||
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.
|
||||
# Appended to every prompt — ensures generated code is safe to run on Windows
|
||||
# where the console encoding is cp1252 and cannot handle emojis or non-ASCII chars.
|
||||
_CODE_SAFETY_NOTE = (
|
||||
" When writing or suggesting code, never use emojis, umlauts (ä, ö, ü, Ä, Ö, Ü, ß), "
|
||||
"or any non-ASCII characters in string literals or print statements, "
|
||||
"as the execution environment uses cp1252 encoding which cannot handle them."
|
||||
)
|
||||
|
||||
_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."
|
||||
+ _CODE_SAFETY_NOTE
|
||||
),
|
||||
"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."
|
||||
+ _CODE_SAFETY_NOTE
|
||||
),
|
||||
"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."
|
||||
+ _CODE_SAFETY_NOTE
|
||||
),
|
||||
"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."
|
||||
+ _CODE_SAFETY_NOTE
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
MAX_FILE_CHARS = 4000 # Limit file context to avoid token overflow
|
||||
|
||||
|
||||
class SystemPrompter:
|
||||
"""Generates system prompts for the chat assistant.
|
||||
|
||||
When a file is open in the editor it can be embedded in the prompt so the
|
||||
AI has direct context of the code the user is currently working on.
|
||||
"""
|
||||
|
||||
@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.
|
||||
def generate_prompt(file_context: dict | None = None) -> str:
|
||||
"""Build a system prompt, optionally embedding a file's content.
|
||||
|
||||
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.
|
||||
file_context: dict with keys 'name' and 'content', or None.
|
||||
|
||||
Returns:
|
||||
A ready-to-use system prompt string.
|
||||
A system prompt string.
|
||||
"""
|
||||
logger.info("Generating system prompt (task_type=%s).", task_type)
|
||||
prompt = _TASK_PROMPTS.get(task_type, _TASK_PROMPTS["default"])
|
||||
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."
|
||||
)
|
||||
|
||||
if file_context:
|
||||
logger.info("Appending file context.")
|
||||
name = file_context.get("name", "unknown")
|
||||
content = file_context.get("content", "")
|
||||
|
||||
# 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 += (
|
||||
# Truncate large files to avoid exceeding token limits
|
||||
if len(content) > MAX_FILE_CHARS:
|
||||
content = content[:MAX_FILE_CHARS] + "\n... [truncated]"
|
||||
file_section = (
|
||||
f"\n\nThe user currently has the following file open in the editor:\n"
|
||||
f"<file name=\"{name}\">\n"
|
||||
f"<code>\n{content}\n</code>\n"
|
||||
f"</file>\n"
|
||||
f"Refer to this file when answering questions about the code."
|
||||
)
|
||||
return base + file_section
|
||||
|
||||
if search_context:
|
||||
search_section = "\n\nThe user has performed a web search. Use the results below as additional context if relevant:\n<search_results>\n"
|
||||
for i, r in enumerate(search_context, 1):
|
||||
search_section += (
|
||||
f"[{i}] {r.get('title', '')}\n"
|
||||
f"URL: {r.get('url', '')}\n"
|
||||
f"{r.get('snippet', '')}\n\n"
|
||||
)
|
||||
search_section += "</search_results>"
|
||||
prompt += search_section
|
||||
|
||||
return prompt
|
||||
return base
|
||||
|
||||
0
backend/utils/__init__.py
Normal file
0
backend/utils/__init__.py
Normal file
0
backend/utils/server_utils.py
Normal file
0
backend/utils/server_utils.py
Normal file
@ -1,60 +1,41 @@
|
||||
"""Entry point for the Streamlit app.
|
||||
|
||||
Runs with: streamlit run frontend/app.py
|
||||
|
||||
Responsibilities:
|
||||
- Configure the page layout
|
||||
- Inject global CSS tweaks
|
||||
- Render the sidebar (navigation + file explorer)
|
||||
- Delegate to the correct view (Chat or Code Editor) based on the radio selection
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to sys.path so backend imports work regardless of
|
||||
# where streamlit is launched from.
|
||||
# Add project root to Python path for imports
|
||||
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
|
||||
from frontend.state import init_state
|
||||
|
||||
# Initialise all session-state keys before any widget is rendered
|
||||
init_state()
|
||||
|
||||
|
||||
def main():
|
||||
st.set_page_config(page_title="Lightweight code editor", layout="wide")
|
||||
|
||||
# Small spacing corrections applied globally:
|
||||
# - Reduce the default top padding of the main content area
|
||||
# - Pull the sidebar content up so the logo sits at the very top
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
.block-container { padding-top: 4rem; }
|
||||
[data-testid="stSidebarContent"] { padding-top: 0rem; }
|
||||
.block-container { padding-top: 1rem; }
|
||||
[data-testid="stSidebarContent"] { padding-top: 0rem; margin-top: -2rem; }
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
st.title("Lightweight code editor")
|
||||
|
||||
init_state()
|
||||
|
||||
render_sidebar()
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
483
frontend/chat.py
483
frontend/chat.py
@ -1,46 +1,17 @@
|
||||
"""Chat view — renders both the normal chat interface and the Coding Agent mode."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
||||
def _run_async(coro):
|
||||
"""Execute an async coroutine from synchronous Streamlit code.
|
||||
|
||||
Streamlit always runs in a plain synchronous thread with no running event
|
||||
loop, so we always create a fresh loop here.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run.
|
||||
|
||||
Returns:
|
||||
The return value of the coroutine.
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
def _start_agent(task: str):
|
||||
"""Create a new CodingAgent, feed it the task, and propose the first action.
|
||||
Stores the agent and its state in session_state so Streamlit can reference
|
||||
them across reruns without losing progress.
|
||||
"""
|
||||
logger.info("Starting coding agent.")
|
||||
"""Initialise a fresh CodingAgent, start the task,
|
||||
and propose the first action."""
|
||||
from backend.agent.coding_agent import CodingAgent
|
||||
agent = CodingAgent()
|
||||
agent.start_task(task)
|
||||
action = _run_async(agent.propose_next_action())
|
||||
action = agent.propose_next_action()
|
||||
st.session_state.coding_agent = agent
|
||||
st.session_state.agent_pending_action = action
|
||||
st.session_state.agent_status = "waiting_approval"
|
||||
@ -48,14 +19,13 @@ def _start_agent(task: str):
|
||||
|
||||
|
||||
def _approve_action():
|
||||
"""Execute the pending action, log it, then immediately propose the next step."""
|
||||
"""Execute the pending action,
|
||||
append it to the log, then propose the next step."""
|
||||
agent = st.session_state.coding_agent
|
||||
pending = st.session_state.agent_pending_action
|
||||
|
||||
result = _run_async(agent.approve())
|
||||
logger.info("Approve action and propose next step.")
|
||||
result = agent.approve()
|
||||
|
||||
# Append a record to the log so the user can review every completed step.
|
||||
st.session_state.agent_log.append({
|
||||
"thought": pending.get("thought", ""),
|
||||
"tool": result["tool"],
|
||||
@ -64,47 +34,34 @@ def _approve_action():
|
||||
})
|
||||
|
||||
if result["is_done"]:
|
||||
# Agent called the "done" tool — task is fully complete.
|
||||
st.session_state.agent_status = "done"
|
||||
st.session_state.agent_pending_action = None
|
||||
else:
|
||||
next_action = _run_async(agent.propose_next_action())
|
||||
next_action = agent.propose_next_action()
|
||||
st.session_state.agent_pending_action = next_action
|
||||
st.session_state.agent_status = "waiting_approval"
|
||||
|
||||
|
||||
def _reject_action(feedback: str):
|
||||
"""Reject the pending action with feedback so the agent replans.
|
||||
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.")
|
||||
"""Reject the pending action with optional feedback, then replan."""
|
||||
agent = st.session_state.coding_agent
|
||||
agent.reject(feedback or "Please try a different approach.")
|
||||
next_action = _run_async(agent.propose_next_action())
|
||||
next_action = agent.propose_next_action()
|
||||
st.session_state.agent_pending_action = next_action
|
||||
st.session_state.agent_status = "waiting_approval"
|
||||
|
||||
def _handle_reject():
|
||||
feedback = st.session_state.agent_reject_feedback
|
||||
with st.spinner("Agent is replanning..."):
|
||||
_reject_action(feedback)
|
||||
st.session_state.agent_reject_feedback = ""
|
||||
|
||||
|
||||
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")
|
||||
"""Inject a follow-up question into the finished agent and resume the loop."""
|
||||
agent = st.session_state.coding_agent
|
||||
agent.follow_up(question)
|
||||
action = _run_async(agent.propose_next_action())
|
||||
action = agent.propose_next_action()
|
||||
st.session_state.agent_pending_action = action
|
||||
st.session_state.agent_status = "waiting_approval"
|
||||
|
||||
|
||||
def _reset_agent():
|
||||
"""Clear all agent state and return to the idle (task input) screen."""
|
||||
logger.info("Resetting Agent")
|
||||
"""Reset all agent state back to idle."""
|
||||
st.session_state.coding_agent = None
|
||||
st.session_state.agent_status = "idle"
|
||||
st.session_state.agent_log = []
|
||||
@ -113,96 +70,29 @@ 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.
|
||||
|
||||
Three distinct screens based on agent_status:
|
||||
- "idle" → task description input + Start button
|
||||
- "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.
|
||||
# Toggle must always be rendered so Streamlit keeps agent_mode=True in session_state
|
||||
st.toggle("Agent Mode", key="agent_mode")
|
||||
|
||||
agent_status = st.session_state.get("agent_status", "idle")
|
||||
agent_log = st.session_state.get("agent_log", [])
|
||||
|
||||
# ── Agent Log ────────────────────────────────────────────────────────────
|
||||
# Collapsed by default so it doesn't clutter the UI during active tasks.
|
||||
if agent_log:
|
||||
with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False):
|
||||
for i, step in enumerate(agent_log):
|
||||
with st.chat_message("assistant"):
|
||||
st.markdown(f"**Step {i + 1} — `{step['tool']}`**")
|
||||
st.caption(f"Thought: {step['thought']}")
|
||||
if step.get("arguments"):
|
||||
_render_arguments(step["arguments"])
|
||||
with st.expander("➡️ Result", expanded=False):
|
||||
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"):
|
||||
st.error(result_text)
|
||||
elif result_text.startswith("OK") or result_text.startswith("DONE"):
|
||||
st.success(result_text)
|
||||
else:
|
||||
st.code(result_text, language=None)
|
||||
if step.get("arguments"):
|
||||
st.json(step["arguments"])
|
||||
result_text = step.get("result", "")
|
||||
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
|
||||
st.error(result_text)
|
||||
elif result_text.startswith("OK") or result_text.startswith("DONE"):
|
||||
st.success(result_text)
|
||||
else:
|
||||
st.code(result_text, language=None)
|
||||
|
||||
# ── Idle: task input ──────────────────────────────────────────────────────
|
||||
if agent_status == "idle":
|
||||
@ -230,12 +120,15 @@ def render_agent_mode():
|
||||
|
||||
args = pending.get("arguments", {})
|
||||
if args:
|
||||
_render_arguments(args)
|
||||
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)
|
||||
|
||||
if "agent_reject_feedback" not in st.session_state:
|
||||
st.session_state.agent_reject_feedback = ""
|
||||
|
||||
st.text_input(
|
||||
feedback = st.text_input(
|
||||
"Rejection feedback (optional):",
|
||||
key="agent_reject_feedback",
|
||||
placeholder="e.g. Use a different approach...",
|
||||
@ -248,11 +141,10 @@ def render_agent_mode():
|
||||
_approve_action()
|
||||
st.rerun()
|
||||
with col2:
|
||||
st.button(
|
||||
"Reject",
|
||||
use_container_width=True,
|
||||
on_click=_handle_reject,
|
||||
)
|
||||
if st.button("Reject", use_container_width=True):
|
||||
with st.spinner("Agent is replanning..."):
|
||||
_reject_action(feedback)
|
||||
st.rerun()
|
||||
with col3:
|
||||
if st.button("Abort Task", use_container_width=True):
|
||||
_reset_agent()
|
||||
@ -287,319 +179,54 @@ def render_agent_mode():
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ── Normal Chat helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _strip_search_context_from_history(chat_manager: ChatManager) -> None:
|
||||
"""Remove <search_context> blocks from all user messages in the API history.
|
||||
|
||||
Called when the user clears search results so the AI no longer receives
|
||||
the stale context in follow-up messages.
|
||||
"""
|
||||
for msg in chat_manager.chat_history:
|
||||
if msg["role"] == "user" and "<search_context>" in msg["content"]:
|
||||
msg["content"] = re.sub(
|
||||
r"<search_context>.*?</search_context>\n\n",
|
||||
"",
|
||||
msg["content"],
|
||||
flags=re.DOTALL,
|
||||
).strip()
|
||||
|
||||
|
||||
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():
|
||||
"""Render the collapsible web search panel above the chat toolbar.
|
||||
|
||||
Stores results in session_state.search_results so they are automatically
|
||||
injected as context into the next message the user sends.
|
||||
"""
|
||||
search_results = st.session_state.get("search_results", [])
|
||||
label = f"🔍 Web Search ({len(search_results)} result{'s' if len(search_results) != 1 else ''} active)" if search_results else "🔍 Web Search"
|
||||
|
||||
with st.expander(label, expanded=bool(search_results)):
|
||||
col_input, col_btn = st.columns([5, 1])
|
||||
with col_input:
|
||||
query = st.text_input(
|
||||
"Search query",
|
||||
key="search_query_input",
|
||||
placeholder="e.g. Python asyncio best practices",
|
||||
label_visibility="collapsed",
|
||||
)
|
||||
with col_btn:
|
||||
search_clicked = st.button("Search", use_container_width=True)
|
||||
|
||||
if search_clicked and query.strip():
|
||||
with st.spinner("Searching..."):
|
||||
sm = SearchManager()
|
||||
results = sm.perform_search(query.strip())
|
||||
if results:
|
||||
st.session_state.search_results = results
|
||||
st.rerun()
|
||||
else:
|
||||
st.warning("No results found.")
|
||||
|
||||
# Display active results with a clear button.
|
||||
if search_results:
|
||||
st.caption("Results will be injected as context into your next message.")
|
||||
for r in search_results:
|
||||
st.markdown(f"**{r['title']}** \n{r['snippet']} \n[{r['url']}]({r['url']})")
|
||||
st.divider()
|
||||
if st.button("Clear search results", use_container_width=True):
|
||||
st.session_state.search_results = []
|
||||
cm = st.session_state.get("chat_manager")
|
||||
if cm:
|
||||
_strip_search_context_from_history(cm)
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render_normal_chat():
|
||||
"""Render the standard multi-turn chat interface.
|
||||
|
||||
On the first message the system prompt is injected into the history,
|
||||
including any active search results as context.
|
||||
Each subsequent message appends to the same conversation so the AI retains
|
||||
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.
|
||||
"""
|
||||
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).
|
||||
# Chat history as bubbles
|
||||
for message in st.session_state.chat_history:
|
||||
if message["role"] == "system":
|
||||
role = message["role"]
|
||||
if role == "system":
|
||||
continue
|
||||
with st.chat_message(message["role"]):
|
||||
with st.chat_message(role):
|
||||
st.markdown(message["content"])
|
||||
|
||||
# ── Toolbar — directly above the sticky chat input ────────────────────────
|
||||
# In Streamlit, st.chat_input is a fixed footer. Elements placed BEFORE it
|
||||
# in code appear in the scrollable content area right above the input bar.
|
||||
_render_search_panel()
|
||||
|
||||
col_clear, col_agent, col_settings = st.columns([1, 1, 1])
|
||||
with col_clear:
|
||||
if st.button("🗑️ Clear Chat", use_container_width=True):
|
||||
_clear_chat_dialog()
|
||||
with col_agent:
|
||||
st.toggle("Agent Mode", key="agent_mode")
|
||||
with col_settings:
|
||||
with st.popover("⚙️ Settings", use_container_width=True):
|
||||
current_file = st.session_state.get("active_file")
|
||||
if current_file:
|
||||
st.toggle(
|
||||
f"Include current file as context: **{Path(current_file).name}**",
|
||||
key="include_file_context",
|
||||
value=True,
|
||||
)
|
||||
else:
|
||||
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 []
|
||||
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.",
|
||||
)
|
||||
|
||||
# Chat input — sticky footer, always at the very bottom of the viewport.
|
||||
# Supports /search <query> and /search clear as special commands.
|
||||
user_input = st.chat_input("Type a message or /search <query>...")
|
||||
# Chat input — Enter to send, no extra button needed
|
||||
user_input = st.chat_input("Type your message here...")
|
||||
if user_input:
|
||||
stripped = user_input.strip()
|
||||
chat_manager = st.session_state.chat_manager
|
||||
|
||||
# ── /search command ───────────────────────────────────────────────────
|
||||
if stripped.lower().startswith("/search"):
|
||||
arg = stripped[len("/search"):].strip()
|
||||
# Inject system prompt on the first message
|
||||
if not chat_manager.get_history():
|
||||
system_prompt = SystemPrompter.generate_prompt()
|
||||
chat_manager.add_message("system", system_prompt)
|
||||
|
||||
with st.chat_message("user"):
|
||||
st.markdown(stripped)
|
||||
|
||||
if arg.lower() == "clear" or arg == "":
|
||||
# /search clear (or bare /search) — remove active results.
|
||||
st.session_state.search_results = []
|
||||
with st.chat_message("assistant"):
|
||||
st.markdown("Search context cleared.")
|
||||
st.session_state.chat_history.append({"role": "user", "content": stripped})
|
||||
st.session_state.chat_history.append({"role": "assistant", "content": "Search context cleared."})
|
||||
else:
|
||||
# /search <query> — run search and store results in context.
|
||||
with st.chat_message("assistant"):
|
||||
with st.spinner(f'Searching for "{arg}"...'):
|
||||
sm = SearchManager()
|
||||
results = sm.perform_search(arg)
|
||||
|
||||
if results:
|
||||
st.session_state.search_results = results
|
||||
response_text = f"🔍 Found {len(results)} result(s) for **'{arg}'**. Results are shown in the search panel above."
|
||||
st.markdown(response_text)
|
||||
else:
|
||||
msg = f'No results found for "{arg}".'
|
||||
st.warning(msg)
|
||||
response_text = msg
|
||||
|
||||
st.session_state.chat_history.append({"role": "user", "content": stripped})
|
||||
st.session_state.chat_history.append({"role": "assistant", "content": response_text})
|
||||
|
||||
st.rerun()
|
||||
return
|
||||
|
||||
# ── Normal chat message ───────────────────────────────────────────────
|
||||
search_results = st.session_state.get("search_results", [])
|
||||
|
||||
# 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.
|
||||
if search_results:
|
||||
context_block = "<search_context>\n"
|
||||
for r in search_results:
|
||||
context_block += (
|
||||
f"Title: {r['title']}\n"
|
||||
f"URL: {r['url']}\n"
|
||||
f"Snippet: {r['snippet']}\n\n"
|
||||
)
|
||||
context_block += "</search_context>\n\n"
|
||||
message_to_send = context_block + user_input
|
||||
else:
|
||||
message_to_send = user_input
|
||||
|
||||
# Show the original user text in the UI (not the context-enriched version).
|
||||
# Show user message immediately without waiting for response
|
||||
with st.chat_message("user"):
|
||||
st.markdown(user_input)
|
||||
|
||||
# Show response with spinner while API is called
|
||||
with st.chat_message("assistant"):
|
||||
with st.spinner("Thinking..."):
|
||||
try:
|
||||
ai_response = chat_manager.send_message(message_to_send)
|
||||
ai_response = chat_manager.send_message(user_input)
|
||||
except Exception as e:
|
||||
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.
|
||||
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)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def render_chat():
|
||||
"""Top-level chat view — switches between Agent Mode and normal chat."""
|
||||
if st.session_state.get("agent_mode", False):
|
||||
st.subheader("Coding Agent")
|
||||
render_agent_mode()
|
||||
|
||||
@ -1,32 +1,23 @@
|
||||
"""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 get_logger, DebugLogger
|
||||
logger = get_logger(__name__)
|
||||
from backend.managers.debug_logger import DebugLogger
|
||||
|
||||
# Maps file extensions to Ace editor language modes for syntax highlighting.
|
||||
LANG_MAP = {
|
||||
".py": "python", ".js": "javascript",
|
||||
".py": "python", ".tex": "latex", ".js": "javascript",
|
||||
".html": "html", ".css": "css", ".sh": "bash",
|
||||
".json": "json", ".yaml": "yaml", ".yml": "yaml"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ── Modals ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@st.dialog("Rename File")
|
||||
def _rename_dialog(file_path: str):
|
||||
"""Dialog for renaming the given file.
|
||||
Updates open_files, files_content and active_file in session_state so
|
||||
all tabs and the editor reference the new path immediately.
|
||||
"""
|
||||
fm = FileManager()
|
||||
st.write(f"Current name: **{Path(file_path).name}**")
|
||||
new_name = st.text_input("New name:", value=Path(file_path).stem)
|
||||
@ -43,20 +34,15 @@ def _rename_dialog(file_path: str):
|
||||
if fm.rename_file(rel_path, new_name.strip()):
|
||||
ext = Path(file_path).suffix
|
||||
new_file_path = str(Path(file_path).parent / (Path(new_name.strip()).stem + ext))
|
||||
# Update the open-files list in place so the tab order is preserved.
|
||||
i = st.session_state.open_files.index(file_path)
|
||||
st.session_state.open_files[i] = new_file_path
|
||||
# Transfer cached editor content to the new path key.
|
||||
st.session_state.files_content[new_file_path] = \
|
||||
st.session_state.files_content.pop(file_path)
|
||||
# Update active_file if the renamed file was the active one.
|
||||
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:
|
||||
logger.warning("Rename failed.")
|
||||
st.error("Rename failed. Check that the file %s still exists.", file_path)
|
||||
st.error("Rename failed. Check that the file still exists.")
|
||||
with col2:
|
||||
if st.button("Cancel", use_container_width=True):
|
||||
st.rerun()
|
||||
@ -64,14 +50,6 @@ 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))
|
||||
@ -89,196 +67,125 @@ 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 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
|
||||
|
||||
if not active_file:
|
||||
st.warning("No active file to run.")
|
||||
return
|
||||
|
||||
execution_engine = ExecutionEngine()
|
||||
|
||||
logger.info("Executing code from %s...", active_file)
|
||||
execution_engine = ExecutionEngine()
|
||||
debug_logger = DebugLogger()
|
||||
|
||||
# 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
|
||||
debug_logger.log(f"Executing code from {active_file}...")
|
||||
|
||||
with st.spinner(f"Running {Path(active_file).name}..."):
|
||||
output = execution_engine.run_code(Path(active_file))
|
||||
|
||||
if output["rc"] == 0:
|
||||
logger.info("Execution completed successfully.")
|
||||
else:
|
||||
logger.error("Execution failed with exit code %s.", output['rc'])
|
||||
|
||||
result = {
|
||||
debug_logger.log("Execution completed.")
|
||||
|
||||
st.session_state.code_execution_output = {
|
||||
"stdout": output["stdout"],
|
||||
"stderr": output["stderr"],
|
||||
"return_code": output["rc"],
|
||||
"ast_error": False,
|
||||
"return_code": output["rc"]
|
||||
}
|
||||
st.session_state.exec_results[active_file] = result
|
||||
result = st.session_state.code_execution_output
|
||||
return result
|
||||
|
||||
class FileViewer:
|
||||
"""UI component that renders the code editor tabs, Ace editor, and run output."""
|
||||
def render_editor():
|
||||
st.subheader("Code Editor")
|
||||
|
||||
def __init__(self):
|
||||
self.fm = FileManager()
|
||||
if not st.session_state.open_files:
|
||||
st.info("Please select a file to edit.")
|
||||
return
|
||||
|
||||
def render(self):
|
||||
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
|
||||
st.subheader("Code Editor")
|
||||
fm = FileManager()
|
||||
|
||||
if not st.session_state.open_files:
|
||||
st.info("Please select a file to edit.")
|
||||
return
|
||||
# ── Tab bar via st.tabs() ─────────────────────────────────────────────────
|
||||
tab_names = [Path(f).name for f in st.session_state.open_files]
|
||||
tabs = st.tabs(tab_names)
|
||||
|
||||
# ── Tab bar via st.tabs() ─────────────────────────────────────────────
|
||||
tab_names = [Path(f).name for f in st.session_state.open_files]
|
||||
tabs = st.tabs(tab_names)
|
||||
for idx, file_path in enumerate(st.session_state.open_files):
|
||||
with tabs[idx]:
|
||||
if file_path not in st.session_state.files_content:
|
||||
st.session_state.files_content[file_path] = fm.read_file(Path(file_path))
|
||||
|
||||
# Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun.
|
||||
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,
|
||||
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
|
||||
|
||||
code = st_ace.st_ace(
|
||||
value=st.session_state.files_content[file_path],
|
||||
language=file_language,
|
||||
theme="monokai",
|
||||
key=f"code_editor_{file_path}",
|
||||
auto_update=True,
|
||||
height=400,
|
||||
)
|
||||
|
||||
for idx, file_path in enumerate(st.session_state.open_files):
|
||||
with tabs[idx]:
|
||||
# Load file content from disk on first open; use cached version afterwards.
|
||||
if file_path not in st.session_state.files_content:
|
||||
st.session_state.files_content[file_path] = self.fm.read_file(Path(file_path))
|
||||
if code != st.session_state.files_content[file_path]:
|
||||
st.session_state.files_content[file_path] = code
|
||||
|
||||
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
|
||||
cols = st.columns([1, 1, 1, 1])
|
||||
with cols[0]:
|
||||
if st.button("Save Changes", key=f"save_{file_path}"):
|
||||
if fm.save_file(file_path, code):
|
||||
st.success("File saved successfully!")
|
||||
|
||||
code = st_ace.st_ace(
|
||||
value=st.session_state.files_content[file_path],
|
||||
language=file_language,
|
||||
theme="monokai",
|
||||
key=f"code_editor_{file_path}",
|
||||
auto_update=True,
|
||||
height=400,
|
||||
)
|
||||
|
||||
if code != st.session_state.files_content[file_path]:
|
||||
st.session_state.files_content[file_path] = code
|
||||
|
||||
cols = st.columns([1, 1, 1, 1, 1])
|
||||
with cols[0]:
|
||||
if st.button("Save Changes", key=f"save_{file_path}"):
|
||||
if self.fm.save_file(file_path, code):
|
||||
st.success("File saved successfully!")
|
||||
|
||||
with cols[1]:
|
||||
st.download_button(
|
||||
label="Download",
|
||||
data=st.session_state.files_content.get(file_path, ""),
|
||||
file_name=Path(file_path).name,
|
||||
mime="text/plain",
|
||||
key=f"download_{file_path}",
|
||||
with cols[1]:
|
||||
if st.button("Close File", key=f"close_{file_path}"):
|
||||
st.session_state.open_files.remove(file_path)
|
||||
st.session_state.files_content.pop(file_path, None)
|
||||
st.session_state.active_file = (
|
||||
st.session_state.open_files[0]
|
||||
if st.session_state.open_files else None
|
||||
)
|
||||
|
||||
with cols[2]:
|
||||
if st.button("Close File", key=f"close_{file_path}"):
|
||||
st.session_state.open_files.remove(file_path)
|
||||
st.session_state.files_content.pop(file_path, None)
|
||||
st.session_state.active_file = (
|
||||
st.session_state.open_files[0]
|
||||
if st.session_state.open_files else None
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
with cols[3]:
|
||||
if st.button("Rename File", key=f"rename_{file_path}"):
|
||||
_rename_dialog(file_path)
|
||||
|
||||
with cols[4]:
|
||||
if st.button("Delete File", key=f"delete_{file_path}"):
|
||||
_delete_dialog(file_path)
|
||||
|
||||
# ── Run + Output ──────────────────────────────────────────────
|
||||
if st.button("▶ Run Code", key=f"run_code_{file_path}", type="primary"):
|
||||
run_active_file()
|
||||
st.rerun()
|
||||
|
||||
result = st.session_state.get("exec_results", {}).get(file_path)
|
||||
if result:
|
||||
st.subheader("Execution Output")
|
||||
with cols[2]:
|
||||
if st.button("Rename File", key=f"rename_{file_path}"):
|
||||
_rename_dialog(file_path)
|
||||
|
||||
if result.get("ast_error"):
|
||||
st.warning("⚠️ Syntax Error detected before execution — code was not run.")
|
||||
elif result["return_code"] == 0:
|
||||
st.success("✅ Exit code: 0")
|
||||
else:
|
||||
st.error(f"❌ Exit code: {result['return_code']}")
|
||||
|
||||
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.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.")
|
||||
|
||||
|
||||
def render_editor():
|
||||
"""Entry point for app.py — delegates to FileViewer."""
|
||||
FileViewer().render()
|
||||
with cols[3]:
|
||||
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()
|
||||
|
||||
st.subheader("Execution Output")
|
||||
|
||||
if result["return_code"] == 0:
|
||||
st.success(f"Exit code: {result['return_code']}")
|
||||
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")
|
||||
|
||||
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 __name__ == "__main__":
|
||||
render_editor()
|
||||
render_editor()
|
||||
@ -1,15 +1,10 @@
|
||||
"""Sidebar — navigation radio, logo, and the workspace file explorer."""
|
||||
|
||||
import streamlit as st
|
||||
from streamlit_arborist import tree_view
|
||||
from pathlib import Path
|
||||
from backend.managers.file_manager import FileManager
|
||||
|
||||
# Shared FileManager instance for all sidebar operations.
|
||||
fm = FileManager()
|
||||
|
||||
# Maps file extensions (and special keys "folder"/"default") to display emojis
|
||||
# shown next to each entry in the file tree.
|
||||
SUFFIX_MAP = {
|
||||
".py": "🐍", # Python
|
||||
".js": "🟨", # JavaScript (Gelbes Quadrat/Logo)
|
||||
@ -34,13 +29,11 @@ SUFFIX_MAP = {
|
||||
|
||||
@st.dialog("Delete Folder")
|
||||
def _delete_folder_dialog(folder_rel: str, folder_name: str):
|
||||
"""Confirmation dialog before permanently deleting a folder and its contents."""
|
||||
st.warning(f"Delete **{folder_name}** and all its contents? This cannot be undone.")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("Delete", type="primary", use_container_width=True):
|
||||
if fm.delete_folder(folder_rel):
|
||||
# Clear the selected-folder state so the action bar disappears.
|
||||
st.session_state.selected_folder = None
|
||||
st.session_state.selected_folder_rel = None
|
||||
st.rerun()
|
||||
@ -53,12 +46,6 @@ 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)
|
||||
@ -83,12 +70,6 @@ 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)
|
||||
@ -110,36 +91,115 @@ def _add_folder_dialog(parent_path: str = ""):
|
||||
if cancel:
|
||||
st.rerun()
|
||||
|
||||
|
||||
@st.dialog("Rename File")
|
||||
def _rename_file_dialog(relative_file_path: str, file_name: str):
|
||||
"""
|
||||
Dialog to rename a file.
|
||||
|
||||
Args:
|
||||
relative_file_path (str): The current relative path (without base path) to the file to rename, including the file name.
|
||||
file_name (str): The current name of the file, including the extension.
|
||||
"""
|
||||
st.write(f"Current name: **{file_name}**")
|
||||
|
||||
with st.form("rename_file_form"):
|
||||
new_name = st.text_input(
|
||||
"New name:",
|
||||
value=Path(relative_file_path).stem
|
||||
)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
submitted = st.form_submit_button(
|
||||
"Confirm",
|
||||
type="primary",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
with col2:
|
||||
cancel = st.form_submit_button(
|
||||
"Cancel",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
if submitted:
|
||||
if not new_name.strip():
|
||||
st.warning("Please enter a name.")
|
||||
elif "/" in new_name or "\\" in new_name or "." in new_name:
|
||||
st.warning("Name must not contain slashes.")
|
||||
else:
|
||||
if fm.rename_file(relative_file_path, new_name.strip()):
|
||||
absolute_file_path = str(Path(fm.base_path / relative_file_path))
|
||||
ext = Path(absolute_file_path).suffix
|
||||
new_file_path = str(
|
||||
Path(absolute_file_path).parent / (Path(new_name.strip()).stem + ext)
|
||||
)
|
||||
|
||||
if absolute_file_path in st.session_state.open_files:
|
||||
i = st.session_state.open_files.index(absolute_file_path)
|
||||
st.session_state.open_files[i] = new_file_path
|
||||
|
||||
if absolute_file_path in st.session_state.files_content:
|
||||
st.session_state.files_content[new_file_path] = \
|
||||
st.session_state.files_content.pop(absolute_file_path)
|
||||
|
||||
if st.session_state.active_file == absolute_file_path:
|
||||
st.session_state.active_file = new_file_path
|
||||
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Rename failed. Check that the file still exists.")
|
||||
st.error(f"Attempted to rename: {relative_file_path} to {new_name.strip()}")
|
||||
|
||||
if cancel:
|
||||
st.rerun()
|
||||
|
||||
|
||||
@st.dialog("Delete File")
|
||||
def _delete_file_dialog(relative_file_path: str, file_name: str):
|
||||
st.warning(f"Delete **{file_name}**? This cannot be undone.")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
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)
|
||||
if st.session_state.active_file == abs_file_path:
|
||||
st.session_state.active_file = (
|
||||
st.session_state.open_files[0]
|
||||
if st.session_state.open_files else None
|
||||
)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Delete failed. Check that the file still exists.")
|
||||
with col2:
|
||||
if st.button("Cancel", use_container_width=True):
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ── File tree ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_arborist_tree(tree, parent_path=Path()):
|
||||
"""Convert the FileManager dict tree into the node format expected by streamlit-arborist.
|
||||
|
||||
Folders become nodes with a "children" list; files become leaf nodes
|
||||
with an emoji prefix derived from their extension.
|
||||
|
||||
Args:
|
||||
tree: Nested dict from FileManager.get_file_tree()
|
||||
parent_path: Accumulates the relative path while recursing.
|
||||
|
||||
Returns:
|
||||
List of node dicts accepted by tree_view().
|
||||
"""
|
||||
nodes = []
|
||||
|
||||
for name, content in sorted(tree.items()):
|
||||
full_path = parent_path / name
|
||||
node_id = str(full_path.as_posix()) # forward-slash IDs work cross-platform
|
||||
node_id = str(full_path.as_posix())
|
||||
|
||||
if isinstance(content, dict):
|
||||
# Directory — recurse to build child nodes.
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"name": f"{name}",
|
||||
"children": build_arborist_tree(content, full_path)
|
||||
})
|
||||
else:
|
||||
# File — pick an emoji based on extension, fall back to default.
|
||||
suffix = Path(name).suffix
|
||||
icon = SUFFIX_MAP.get(suffix, SUFFIX_MAP["default"])
|
||||
|
||||
@ -153,31 +213,15 @@ 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.
|
||||
|
||||
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=350,
|
||||
selection=active_selection,
|
||||
select_internal_nodes=True, # allow clicking folder names, not just files
|
||||
open_by_default=True,
|
||||
height=200,
|
||||
selection=None,
|
||||
select_internal_nodes=True,
|
||||
open_by_default=False
|
||||
)
|
||||
|
||||
return selected
|
||||
@ -186,21 +230,6 @@ def render_filetree_arborist(tree):
|
||||
# ── Sidebar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def render_sidebar():
|
||||
"""Render the full sidebar: navigation radio and workspace file explorer.
|
||||
|
||||
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()
|
||||
@ -230,33 +259,25 @@ def render_sidebar():
|
||||
if selected:
|
||||
selected_path = selected.get("id")
|
||||
|
||||
# Only react when the user clicks a *different* node to
|
||||
# avoid re-running on every Streamlit rerender.
|
||||
if st.session_state.last_selected != selected_path:
|
||||
st.session_state.last_selected = selected_path
|
||||
abs_path = fm.base_path / selected_path
|
||||
|
||||
if abs_path.is_file():
|
||||
# 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():
|
||||
# Select the folder so its action bar appears below.
|
||||
st.session_state.selected_folder = str(abs_path)
|
||||
st.session_state.selected_folder_rel = selected_path
|
||||
st.rerun()
|
||||
|
||||
# Folder action bar — rendered unconditionally outside the selection
|
||||
# block so it persists across reruns even when no new click happens.
|
||||
# Folder actions — rendered outside the selection block so they persist across reruns
|
||||
if st.session_state.get("selected_folder"):
|
||||
folder_name = Path(st.session_state.selected_folder).name
|
||||
folder_rel = st.session_state.selected_folder_rel
|
||||
@ -269,9 +290,19 @@ def render_sidebar():
|
||||
_add_folder_dialog(folder_rel)
|
||||
if st.button("Delete Folder", key="btn_delete_folder", use_container_width=True):
|
||||
_delete_folder_dialog(folder_rel, folder_name)
|
||||
|
||||
if st.session_state.get("active_file"):
|
||||
active_file_name = Path(st.session_state.active_file).name
|
||||
file_rel = str(Path(st.session_state.active_file).relative_to(fm.base_path))
|
||||
|
||||
with st.container(border=True):
|
||||
st.write(f"**File actions:** {active_file_name}")
|
||||
if st.button("Rename File", key="btn_rename_file", use_container_width=True):
|
||||
_rename_file_dialog(file_rel, active_file_name)
|
||||
if st.button("Delete File", key="btn_delete_active_file", use_container_width=True):
|
||||
_delete_file_dialog(file_rel, active_file_name)
|
||||
|
||||
with add_more:
|
||||
# Popover for workspace-root actions (not tied to any selected folder).
|
||||
with st.popover("⚙️ Explorer Options", key="popover_options", use_container_width=True):
|
||||
if st.button("Add File", key="btn_add_file", use_container_width=True):
|
||||
_add_file_dialog("")
|
||||
@ -279,21 +310,7 @@ def render_sidebar():
|
||||
if st.button("Add Folder", key="btn_add_folder", use_container_width=True):
|
||||
_add_folder_dialog("")
|
||||
|
||||
st.divider()
|
||||
|
||||
uploaded = st.file_uploader(
|
||||
"Upload File",
|
||||
type=["py", "js", "html", "css", "json", "yaml", "txt", "md"],
|
||||
key="sidebar_file_upload",
|
||||
)
|
||||
if uploaded is not None:
|
||||
if uploaded.size > 1_000_000:
|
||||
st.error("File is too large (max 1 MB).")
|
||||
else:
|
||||
content = uploaded.getvalue().decode("utf-8", errors="replace")
|
||||
dest = str(fm.base_path / uploaded.name)
|
||||
if fm.save_file(dest, content):
|
||||
st.success(f"'{uploaded.name}' uploaded successfully.")
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -1,102 +1,72 @@
|
||||
"""Centralised session-state initialisation for the Streamlit app.
|
||||
|
||||
All keys used throughout the app are declared here with their default values.
|
||||
Calling init_state() at the top of app.py ensures every key exists before any
|
||||
page tries to read it, preventing KeyError on the first load.
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
from backend.managers.chat_manager import ChatManager
|
||||
|
||||
|
||||
def init_state():
|
||||
"""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.
|
||||
# Sidebar state initialization
|
||||
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.
|
||||
# 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
# ChatManager keeps the full conversation history in memory across reruns.
|
||||
# Instantiated once and reused so history is not lost on page rerenders.
|
||||
# Chat manager (persists across reruns)
|
||||
if "chat_manager" not in st.session_state:
|
||||
st.session_state.chat_manager = ChatManager()
|
||||
|
||||
# ── Editor state ──────────────────────────────────────────────────────────
|
||||
|
||||
# Ordered list of absolute file paths currently open as editor tabs.
|
||||
# The list order determines the visual tab order in the UI.
|
||||
# Editor state initialization
|
||||
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", ... ]
|
||||
"""
|
||||
st.session_state.open_files = []
|
||||
|
||||
# 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", ... }
|
||||
"""
|
||||
st.session_state.files_content = {}
|
||||
|
||||
# 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."""
|
||||
st.session_state.active_file = None
|
||||
|
||||
if "active_tab" not in st.session_state:
|
||||
st.session_state.active_tab = 0
|
||||
|
||||
# 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 = {}
|
||||
if "is_editing" not in st.session_state:
|
||||
st.session_state.is_editing = False
|
||||
|
||||
# ── Chat state ────────────────────────────────────────────────────────────
|
||||
if "code_suggestions" not in st.session_state:
|
||||
st.session_state.code_suggestions = []
|
||||
|
||||
# Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles.
|
||||
# System messages are stored here too but skipped during display.
|
||||
if "code_execution_output" not in st.session_state:
|
||||
st.session_state.code_execution_output = ""
|
||||
|
||||
# Chat state initialization
|
||||
if "chat_history" not in st.session_state:
|
||||
st.session_state.chat_history = []
|
||||
|
||||
# ── Agent Mode state ──────────────────────────────────────────────────────
|
||||
|
||||
# Boolean toggle — True while the UI is in Coding Agent mode.
|
||||
# Agent Mode state
|
||||
if "agent_mode" not in st.session_state:
|
||||
st.session_state.agent_mode = False
|
||||
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
|
||||
# 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 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
|
||||
|
||||
# Web search results to be injected as context into the next AI message.
|
||||
# List of {"title": str, "url": str, "snippet": str} dicts, or empty list.
|
||||
if "search_results" not in st.session_state:
|
||||
st.session_state.search_results = []
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
BIN
project_exercise.pdf
Normal file
BIN
project_exercise.pdf
Normal file
Binary file not shown.
@ -1,11 +1,8 @@
|
||||
# Core Framework
|
||||
streamlit==1.57.0
|
||||
streamlit_arborist>=0.1.0
|
||||
streamlit>=1.28.0
|
||||
|
||||
# AI/LLM Integration
|
||||
openai>=1.0.0
|
||||
mcp>=0.1.0
|
||||
ddgs>=0.1.0
|
||||
|
||||
# Web & API
|
||||
requests>=2.31.0
|
||||
@ -18,14 +15,9 @@ pandas>=2.0.0
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
|
||||
# Development & Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
#For code editor functionality
|
||||
streamlit-ace>=0.1.0
|
||||
|
||||
#MCP-Code execution tools
|
||||
pyflakes>=0.1.0
|
||||
pygame>=0.1.0
|
||||
78
run_agent.py
Normal file
78
run_agent.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""
|
||||
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.")
|
||||
@ -1,24 +0,0 @@
|
||||
"""Shared pytest configuration — runs before any test module is imported.
|
||||
|
||||
Patches MCPToolAdapter at the sys.modules level so that importing
|
||||
backend.agent.coding_agent never tries to start real MCP subprocess servers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
# Build a fake adapter instance whose async methods return immediately.
|
||||
_mock_adapter = MagicMock()
|
||||
_mock_adapter.initialize_all_servers = AsyncMock(return_value=None)
|
||||
_mock_adapter.get_all_tools = MagicMock(return_value=[])
|
||||
_mock_adapter.call_tool = AsyncMock(return_value=MagicMock(isError=False, content=[]))
|
||||
|
||||
# Wrap as a class mock: calling MCPToolAdapter() returns _mock_adapter.
|
||||
_mock_adapter_cls = MagicMock(return_value=_mock_adapter)
|
||||
|
||||
# Inject before any test imports coding_agent so the module-level
|
||||
# asyncio.run(adapter.initialize_all_servers()) uses the mock.
|
||||
sys.modules.setdefault(
|
||||
"backend.agent.mcp_server_adapter",
|
||||
MagicMock(MCPToolAdapter=_mock_adapter_cls),
|
||||
)
|
||||
@ -1,136 +1,242 @@
|
||||
"""Tests for ChatManager (backend/managers/chat_manager.py)."""
|
||||
"""Test script for ChatManager - Pytest compatible tests"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Add project root to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.managers.chat_manager import ChatManager
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _mock_ok(content="AI reply"):
|
||||
"""Return a mocked 200 response with a single assistant choice."""
|
||||
mock = MagicMock()
|
||||
mock.status_code = 200
|
||||
mock.json.return_value = {
|
||||
"choices": [{"message": {"role": "assistant", "content": content}}]
|
||||
}
|
||||
mock.text = ""
|
||||
return mock
|
||||
|
||||
|
||||
# ── History management ────────────────────────────────────────────────────────
|
||||
|
||||
class TestHistory:
|
||||
"""Tests for add_message and clear_history."""
|
||||
class TestChatManager:
|
||||
"""Test suite for ChatManager functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def cm(self):
|
||||
def chat_manager(self):
|
||||
return ChatManager()
|
||||
|
||||
def test_add_message_appends_correct_entry(self, cm):
|
||||
cm.add_message("user", "Hello")
|
||||
assert cm.chat_history == [{"role": "user", "content": "Hello"}]
|
||||
def test_initialization(self, chat_manager):
|
||||
"""Test that ChatManager initializes correctly."""
|
||||
assert chat_manager.api_url is not None
|
||||
assert chat_manager.model is not None
|
||||
assert chat_manager.chat_history == []
|
||||
|
||||
def test_add_multiple_messages_preserves_order(self, cm):
|
||||
cm.add_message("user", "Hi")
|
||||
cm.add_message("assistant", "Hello!")
|
||||
assert cm.chat_history[0]["role"] == "user"
|
||||
assert cm.chat_history[1]["role"] == "assistant"
|
||||
def test_add_message(self, chat_manager):
|
||||
"""Test adding messages to chat history."""
|
||||
chat_manager.add_message("user", "Hello")
|
||||
assert len(chat_manager.chat_history) == 1
|
||||
assert chat_manager.chat_history[0]["role"] == "user"
|
||||
assert chat_manager.chat_history[0]["content"] == "Hello"
|
||||
|
||||
def test_clear_history_empties_list(self, cm):
|
||||
cm.add_message("user", "Hi")
|
||||
cm.clear_history()
|
||||
assert cm.chat_history == []
|
||||
def test_get_history(self, chat_manager):
|
||||
"""Test retrieving chat history."""
|
||||
chat_manager.add_message("user", "Hello")
|
||||
chat_manager.add_message("assistant", "Hi there!")
|
||||
|
||||
history = chat_manager.get_history()
|
||||
assert len(history) == 2
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[1]["role"] == "assistant"
|
||||
|
||||
def test_clear_history(self, chat_manager):
|
||||
"""Test clearing chat history."""
|
||||
chat_manager.add_message("user", "Hello")
|
||||
assert len(chat_manager.chat_history) == 1
|
||||
|
||||
chat_manager.clear_history()
|
||||
assert len(chat_manager.chat_history) == 0
|
||||
|
||||
def test_send_message_integration(self, chat_manager):
|
||||
"""
|
||||
Integration test for sending message to AI.
|
||||
This test actually communicates with the API.
|
||||
"""
|
||||
try:
|
||||
# Send a simple test message
|
||||
response = chat_manager.send_message("Hello, what is 2+2?")
|
||||
|
||||
# Verify response is not empty
|
||||
assert isinstance(response, str)
|
||||
assert len(response) > 0
|
||||
|
||||
# Verify message was added to history
|
||||
assert len(chat_manager.chat_history) == 2 # user + assistant
|
||||
assert chat_manager.chat_history[0]["role"] == "user"
|
||||
assert chat_manager.chat_history[1]["role"] == "assistant"
|
||||
|
||||
print(f"API Test Passed")
|
||||
print(f"Response: {response}")
|
||||
|
||||
except Exception as e:
|
||||
# If API is not reachable, mark as skipped
|
||||
pytest.skip(f"API not reachable: {str(e)}")
|
||||
|
||||
def test_multiple_messages(self, chat_manager):
|
||||
"""Test sending multiple messages in a conversation."""
|
||||
try:
|
||||
# Send first message
|
||||
response1 = chat_manager.send_message("What is your name?")
|
||||
assert len(response1) > 0
|
||||
|
||||
# Send follow-up message
|
||||
response2 = chat_manager.send_message("Tell me more")
|
||||
assert len(response2) > 0
|
||||
|
||||
# Verify full conversation is in history
|
||||
assert len(chat_manager.chat_history) == 4 # 2 user + 2 assistant
|
||||
|
||||
print(f"Conversation Test Passed")
|
||||
print(f"Messages: {len(chat_manager.chat_history)}")
|
||||
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable: {str(e)}")
|
||||
|
||||
|
||||
# ── send_message (mocked HTTP) ────────────────────────────────────────────────
|
||||
|
||||
class TestSendMessage:
|
||||
"""Tests for send_message: history updates, HTTP payload, error handling, and auth headers."""
|
||||
class TestChatManagerSendMessage:
|
||||
"""Unit tests for send_message using mocked HTTP requests."""
|
||||
|
||||
@pytest.fixture
|
||||
def cm(self):
|
||||
def chat_manager(self):
|
||||
return ChatManager()
|
||||
|
||||
def test_user_message_added_to_history(self, cm):
|
||||
with patch("requests.post", return_value=_mock_ok()):
|
||||
cm.send_message("Hello")
|
||||
assert cm.chat_history[0] == {"role": "user", "content": "Hello"}
|
||||
def _mock_response(self, content="AI reply", status_code=200):
|
||||
mock = MagicMock()
|
||||
mock.status_code = status_code
|
||||
mock.json.return_value = {
|
||||
"choices": [{"message": {"role": "assistant", "content": content}}]
|
||||
}
|
||||
mock.text = "error text"
|
||||
return mock
|
||||
|
||||
def test_assistant_reply_added_to_history(self, cm):
|
||||
with patch("requests.post", return_value=_mock_ok("Hi there")):
|
||||
cm.send_message("Hello")
|
||||
assert cm.chat_history[1] == {"role": "assistant", "content": "Hi there"}
|
||||
def test_send_message_adds_user_message_to_history(self, chat_manager):
|
||||
with patch("requests.post", return_value=self._mock_response()):
|
||||
chat_manager.send_message("Hello")
|
||||
assert chat_manager.chat_history[0] == {"role": "user", "content": "Hello"}
|
||||
|
||||
def test_returns_assistant_content_string(self, cm):
|
||||
with patch("requests.post", return_value=_mock_ok("Answer")):
|
||||
result = cm.send_message("Question")
|
||||
assert result == "Answer"
|
||||
def test_send_message_adds_assistant_response_to_history(self, chat_manager):
|
||||
with patch("requests.post", return_value=self._mock_response("Hi there")):
|
||||
chat_manager.send_message("Hello")
|
||||
assert chat_manager.chat_history[1] == {"role": "assistant", "content": "Hi there"}
|
||||
|
||||
def test_full_history_sent_in_request_payload(self, cm):
|
||||
"""All prior messages must be forwarded so the model has conversation context."""
|
||||
cm.add_message("system", "You are helpful.")
|
||||
with patch("requests.post", return_value=_mock_ok()) as mock_post:
|
||||
cm.send_message("Hello")
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["messages"][0]["role"] == "system"
|
||||
assert payload["messages"][1]["role"] == "user"
|
||||
def test_send_message_returns_ai_content(self, chat_manager):
|
||||
with patch("requests.post", return_value=self._mock_response("Answer")):
|
||||
response = chat_manager.send_message("Question")
|
||||
assert response == "Answer"
|
||||
|
||||
def test_connection_error_raises_and_adds_error_to_history(self, cm):
|
||||
def test_send_message_history_grows_with_each_call(self, chat_manager):
|
||||
with patch("requests.post", return_value=self._mock_response()):
|
||||
chat_manager.send_message("First")
|
||||
chat_manager.send_message("Second")
|
||||
assert len(chat_manager.chat_history) == 4 # 2 user + 2 assistant
|
||||
|
||||
def test_send_message_connection_error_raises(self, chat_manager):
|
||||
import requests
|
||||
with patch("requests.post", side_effect=requests.exceptions.ConnectionError("refused")):
|
||||
with pytest.raises(Exception, match="Connection Error"):
|
||||
cm.send_message("Hello")
|
||||
assert any("Error" in msg["content"] for msg in cm.chat_history)
|
||||
chat_manager.send_message("Hello")
|
||||
|
||||
def test_api_error_status_raises(self, cm):
|
||||
mock = MagicMock()
|
||||
mock.status_code = 500
|
||||
mock.text = "Internal Server Error"
|
||||
def test_send_message_api_error_status_raises(self, chat_manager):
|
||||
mock = self._mock_response(status_code=500)
|
||||
with patch("requests.post", return_value=mock):
|
||||
with pytest.raises(Exception, match="API Error 500"):
|
||||
cm.send_message("Hello")
|
||||
chat_manager.send_message("Hello")
|
||||
|
||||
def test_timeout_raises(self, cm):
|
||||
with patch("requests.post", side_effect=requests.exceptions.Timeout()):
|
||||
with pytest.raises(Exception):
|
||||
cm.send_message("Hello")
|
||||
|
||||
def test_empty_choices_raises(self, cm):
|
||||
def test_send_message_empty_choices_raises(self, chat_manager):
|
||||
mock = MagicMock()
|
||||
mock.status_code = 200
|
||||
mock.json.return_value = {"choices": []}
|
||||
with patch("requests.post", return_value=mock):
|
||||
with pytest.raises(Exception, match="Invalid API response format"):
|
||||
cm.send_message("Hello")
|
||||
chat_manager.send_message("Hello")
|
||||
|
||||
def test_api_key_included_in_header_when_set(self, cm):
|
||||
cm.api_key = "test-key-123"
|
||||
with patch("requests.post", return_value=_mock_ok()) as mock_post:
|
||||
cm.send_message("Hello")
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers.get("Authorization") == "Bearer test-key-123"
|
||||
|
||||
def test_api_key_excluded_from_header_when_empty_sentinel(self, cm):
|
||||
# "EMPTY" is the sentinel string the UI writes when the user leaves the key field blank.
|
||||
cm.api_key = "EMPTY"
|
||||
with patch("requests.post", return_value=_mock_ok()) as mock_post:
|
||||
cm.send_message("Hello")
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_json_decode_error_raises(self, cm):
|
||||
import json
|
||||
def test_send_message_missing_choices_key_raises(self, chat_manager):
|
||||
mock = MagicMock()
|
||||
mock.status_code = 200
|
||||
mock.json.side_effect = json.JSONDecodeError("bad json", "", 0)
|
||||
mock.json.return_value = {}
|
||||
with patch("requests.post", return_value=mock):
|
||||
with pytest.raises(Exception, match="JSON Decode Error"):
|
||||
cm.send_message("Hello")
|
||||
with pytest.raises(Exception):
|
||||
chat_manager.send_message("Hello")
|
||||
|
||||
def test_send_message_timeout_raises(self, chat_manager):
|
||||
import requests
|
||||
with patch("requests.post", side_effect=requests.exceptions.Timeout()):
|
||||
with pytest.raises(Exception):
|
||||
chat_manager.send_message("Hello")
|
||||
|
||||
|
||||
class TestChatManagerGetChatDisplay:
|
||||
"""Tests for get_chat_display()."""
|
||||
|
||||
@pytest.fixture
|
||||
def chat_manager(self):
|
||||
return ChatManager()
|
||||
|
||||
def test_empty_history_returns_empty_list(self, chat_manager):
|
||||
assert chat_manager.get_chat_display() == []
|
||||
|
||||
def test_display_contains_role_and_content_keys(self, chat_manager):
|
||||
chat_manager.add_message("user", "Hello")
|
||||
display = chat_manager.get_chat_display()
|
||||
assert "role" in display[0]
|
||||
assert "content" in display[0]
|
||||
|
||||
def test_display_preserves_message_order(self, chat_manager):
|
||||
chat_manager.add_message("user", "First")
|
||||
chat_manager.add_message("assistant", "Second")
|
||||
display = chat_manager.get_chat_display()
|
||||
assert display[0]["role"] == "user"
|
||||
assert display[1]["role"] == "assistant"
|
||||
|
||||
def test_display_matches_history(self, chat_manager):
|
||||
chat_manager.add_message("user", "Hi")
|
||||
chat_manager.add_message("assistant", "Hello!")
|
||||
assert chat_manager.get_chat_display() == chat_manager.get_history()
|
||||
|
||||
def test_system_message_included_in_display(self, chat_manager):
|
||||
chat_manager.add_message("system", "You are a helper.")
|
||||
display = chat_manager.get_chat_display()
|
||||
assert display[0]["role"] == "system"
|
||||
|
||||
|
||||
def test_chat_manager_demo():
|
||||
"""Demo test - Shows interactive chat (can be run manually)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("ChatManager Demo - Interactive Test")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
chat_manager = ChatManager()
|
||||
|
||||
print(f"Connected to API: {chat_manager.api_url}")
|
||||
print(f"Model: {chat_manager.model}\n")
|
||||
|
||||
# Demo conversation
|
||||
test_messages = ["Hello! What can you do?", "Tell me a joke", "What is Python?"]
|
||||
|
||||
print("Starting conversation...\n")
|
||||
|
||||
for message in test_messages:
|
||||
print(f"User: {message}")
|
||||
|
||||
try:
|
||||
response = chat_manager.send_message(message)
|
||||
print(f"Assistant: {response}\n")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}\n")
|
||||
pytest.skip(f"API not reachable: {str(e)}")
|
||||
|
||||
# Display full chat history
|
||||
print("=" * 60)
|
||||
print("Chat History:")
|
||||
print("=" * 60)
|
||||
|
||||
for msg in chat_manager.get_history():
|
||||
print(f"{msg['role'].upper()}: {msg['content']}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run with: pytest tests/test_chat_manager.py -v -s
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
|
||||
@ -3,16 +3,13 @@ Tests for CodingAgent (backend/agent/coding_agent.py)
|
||||
|
||||
Structure:
|
||||
TestHelpers – truncate_result, trim_messages, _strip_code_fences
|
||||
TestDispatcher – dispatch_tool routing
|
||||
TestTools – tool functions (read_file, write_file, …) using tmp workspace
|
||||
TestCodingAgentInit – __init__ and start_task
|
||||
TestProposeNextAction – propose_next_action with mocked API
|
||||
TestApprove – approve with mocked API + real tool execution
|
||||
TestReject – reject injects feedback correctly
|
||||
TestFullLoop – integration: real API, skipped if unreachable
|
||||
|
||||
Note: TestTools (write_file, read_file, etc.) and TestDispatcher were removed
|
||||
because those tool functions are now MCP server tools, not standalone functions
|
||||
in coding_agent.py. They will be tested via test_mcp_server_*.py once the MCP
|
||||
servers are finalised.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -25,12 +22,20 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.agent.coding_agent import (
|
||||
MAX_ITERATIONS,
|
||||
MAX_HISTORY_CHARS,
|
||||
MAX_RESULT_LENGTH,
|
||||
CodingAgent,
|
||||
_strip_code_fences,
|
||||
dispatch_tool,
|
||||
done,
|
||||
grep_search,
|
||||
list_files,
|
||||
read_file,
|
||||
run_python,
|
||||
truncate_result,
|
||||
trim_messages,
|
||||
validate_python,
|
||||
write_file,
|
||||
)
|
||||
|
||||
|
||||
@ -50,8 +55,6 @@ def _make_api_response(content: str, status_code: int = 200):
|
||||
|
||||
|
||||
def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) -> str:
|
||||
"""Return a JSON string in the exact format the agent expects from the LLM:
|
||||
{"thought": "...", "tool": "<name>", "arguments": {...}}."""
|
||||
return json.dumps({"thought": thought, "tool": tool, "arguments": arguments})
|
||||
|
||||
|
||||
@ -60,7 +63,6 @@ def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) ->
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTruncateResult:
|
||||
"""Tests for truncate_result(): ensures long tool outputs are capped before entering the message history."""
|
||||
|
||||
def test_short_result_unchanged(self):
|
||||
assert truncate_result("hello") == "hello"
|
||||
@ -71,6 +73,10 @@ class TestTruncateResult:
|
||||
assert len(result) < len(long)
|
||||
assert "TRUNCATED" in result
|
||||
|
||||
def test_exact_limit_not_truncated(self):
|
||||
text = "a" * MAX_RESULT_LENGTH
|
||||
assert truncate_result(text) == text
|
||||
|
||||
def test_truncated_keeps_start_and_end(self):
|
||||
text = "START" + "x" * MAX_RESULT_LENGTH + "END"
|
||||
result = truncate_result(text)
|
||||
@ -79,11 +85,8 @@ class TestTruncateResult:
|
||||
|
||||
|
||||
class TestTrimMessages:
|
||||
"""Tests for trim_messages(): keeps system + original task, drops old turns when history grows too large."""
|
||||
|
||||
def _make_messages(self, n_extra: int, chars_each: int = 100) -> list:
|
||||
"""Build a message list with a fixed system + user header followed by
|
||||
n_extra assistant/user pairs, each pair consuming 2*chars_each characters."""
|
||||
msgs = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "original task"},
|
||||
@ -102,6 +105,8 @@ class TestTrimMessages:
|
||||
original_total = sum(len(m["content"]) for m in msgs)
|
||||
trimmed = trim_messages(msgs)
|
||||
trimmed_total = sum(len(m["content"]) for m in trimmed)
|
||||
# Must be significantly shorter than the original
|
||||
# (slightly above MAX_HISTORY_CHARS is acceptable due to the injected reminder message)
|
||||
assert trimmed_total < original_total
|
||||
assert len(trimmed) < len(msgs)
|
||||
|
||||
@ -116,8 +121,6 @@ class TestTrimMessages:
|
||||
assert trimmed[1]["content"] == "original task"
|
||||
|
||||
def test_reminder_injected_when_trimmed(self):
|
||||
# trim_messages inserts a "system_note" message so the agent knows that
|
||||
# earlier turns were dropped and it should not reference missing context.
|
||||
msgs = self._make_messages(n_extra=500, chars_each=200)
|
||||
trimmed = trim_messages(msgs)
|
||||
contents = [m["content"] for m in trimmed]
|
||||
@ -125,7 +128,6 @@ class TestTrimMessages:
|
||||
|
||||
|
||||
class TestStripCodeFences:
|
||||
"""Tests for _strip_code_fences(): the LLM sometimes wraps its JSON in markdown fences — this strips them."""
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
assert _strip_code_fences("hello") == "hello"
|
||||
@ -138,13 +140,171 @@ class TestStripCodeFences:
|
||||
text = "```\nhello\n```"
|
||||
assert _strip_code_fences(text) == "hello"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _strip_code_fences(" hello ") == "hello"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestDispatcher
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDispatcher:
|
||||
|
||||
def test_unknown_tool_returns_error(self):
|
||||
result = dispatch_tool("nonexistent_tool", {})
|
||||
assert "ERROR" in result
|
||||
assert "nonexistent_tool" in result
|
||||
|
||||
def test_done_tool_dispatched(self):
|
||||
result = dispatch_tool("done", {"summary": "finished"})
|
||||
assert "finished" in result
|
||||
|
||||
def test_wrong_arguments_returns_error(self):
|
||||
result = dispatch_tool("read_file", {"wrong_param": "x"})
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestTools (patched WORKSPACE → tmp_path)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestWriteFile:
|
||||
|
||||
def test_write_creates_file(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = write_file("hello.py", "print('hi')")
|
||||
assert result.startswith("OK:")
|
||||
assert (tmp_path / "hello.py").read_text() == "print('hi')"
|
||||
|
||||
def test_write_outside_workspace_blocked(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = write_file("../evil.py", "bad")
|
||||
assert "ERROR" in result
|
||||
|
||||
def test_write_unsupported_extension_blocked(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = write_file("script.sh", "echo hi")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
|
||||
def test_read_existing_file(self, tmp_path):
|
||||
(tmp_path / "data.txt").write_text("hello world")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("data.txt")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_read_nonexistent_file(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("ghost.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
def test_read_outside_workspace_blocked(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("../secret.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
def test_read_unsupported_extension(self, tmp_path):
|
||||
(tmp_path / "data.csv").write_text("a,b")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("data.csv")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
class TestListFiles:
|
||||
|
||||
def test_empty_workspace(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = list_files()
|
||||
assert "No files" in result
|
||||
|
||||
def test_lists_existing_files(self, tmp_path):
|
||||
(tmp_path / "a.py").touch()
|
||||
(tmp_path / "b.txt").touch()
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = list_files()
|
||||
assert "a.py" in result
|
||||
assert "b.txt" in result
|
||||
|
||||
def test_glob_filter(self, tmp_path):
|
||||
(tmp_path / "a.py").touch()
|
||||
(tmp_path / "b.txt").touch()
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = list_files("*.py")
|
||||
assert "a.py" in result
|
||||
assert "b.txt" not in result
|
||||
|
||||
|
||||
class TestGrepSearch:
|
||||
|
||||
def test_finds_pattern(self, tmp_path):
|
||||
(tmp_path / "code.py").write_text("def hello():\n pass\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = grep_search("def hello")
|
||||
assert "code.py" in result
|
||||
assert "def hello" in result
|
||||
|
||||
def test_no_match_returns_message(self, tmp_path):
|
||||
(tmp_path / "code.py").write_text("x = 1\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = grep_search("nonexistent_pattern")
|
||||
assert "No matches" in result
|
||||
|
||||
def test_returns_line_number(self, tmp_path):
|
||||
(tmp_path / "code.py").write_text("x = 1\ndef foo():\n pass\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = grep_search("def foo")
|
||||
assert ":2:" in result
|
||||
|
||||
|
||||
class TestValidatePython:
|
||||
|
||||
def test_valid_syntax(self, tmp_path):
|
||||
(tmp_path / "good.py").write_text("def f(x):\n return x * 2\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = validate_python("good.py")
|
||||
assert result == "OK: syntax is valid."
|
||||
|
||||
def test_invalid_syntax(self, tmp_path):
|
||||
(tmp_path / "bad.py").write_text("def f(x)\n return x\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = validate_python("bad.py")
|
||||
assert "SYNTAX ERROR" in result
|
||||
|
||||
def test_file_not_found(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = validate_python("ghost.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
class TestRunPython:
|
||||
|
||||
def test_successful_execution(self, tmp_path):
|
||||
(tmp_path / "hello.py").write_text("print('hello world')\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = run_python("hello.py")
|
||||
assert "hello world" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
def test_runtime_error_captured(self, tmp_path):
|
||||
(tmp_path / "bad.py").write_text("raise ValueError('oops')\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = run_python("bad.py")
|
||||
assert "ValueError" in result
|
||||
assert "Exit code: 1" in result
|
||||
|
||||
def test_file_not_found(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = run_python("ghost.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestCodingAgentInit
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCodingAgentInit:
|
||||
"""Tests for CodingAgent.__init__ and start_task(): state is clean before and after task setup."""
|
||||
|
||||
def test_initial_state_is_clean(self):
|
||||
agent = CodingAgent()
|
||||
@ -153,6 +313,11 @@ class TestCodingAgentInit:
|
||||
assert agent.is_done is False
|
||||
assert agent.iteration == 0
|
||||
|
||||
def test_api_url_is_set(self):
|
||||
agent = CodingAgent()
|
||||
assert agent.api_url.startswith("http://")
|
||||
assert "/v1/chat/completions" in agent.api_url
|
||||
|
||||
def test_start_task_sets_messages(self):
|
||||
agent = CodingAgent()
|
||||
agent.start_task("Write fibonacci.py")
|
||||
@ -183,7 +348,6 @@ class TestCodingAgentInit:
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestProposeNextAction:
|
||||
"""Tests for propose_next_action(): API is mocked so no real HTTP calls are made."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self):
|
||||
@ -195,72 +359,55 @@ class TestProposeNextAction:
|
||||
payload = _agent_action_json(tool, thought, **args)
|
||||
agent._call_api = MagicMock(return_value=payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_dict_with_required_keys(self, agent):
|
||||
def test_returns_dict_with_required_keys(self, agent):
|
||||
self._mock_api(agent)
|
||||
action = await agent.propose_next_action()
|
||||
action = agent.propose_next_action()
|
||||
assert "thought" in action
|
||||
assert "tool" in action
|
||||
assert "arguments" in action
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increments_iteration(self, agent):
|
||||
def test_increments_iteration(self, agent):
|
||||
self._mock_api(agent)
|
||||
await agent.propose_next_action()
|
||||
agent.propose_next_action()
|
||||
assert agent.iteration == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_pending_action(self, agent):
|
||||
def test_stores_pending_action(self, agent):
|
||||
self._mock_api(agent)
|
||||
await agent.propose_next_action()
|
||||
agent.propose_next_action()
|
||||
assert agent.pending_action is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_correct_tool(self, agent):
|
||||
def test_returns_correct_tool(self, agent):
|
||||
self._mock_api(agent, tool="list_files")
|
||||
action = await agent.propose_next_action()
|
||||
action = agent.propose_next_action()
|
||||
assert action["tool"] == "list_files"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_json_parse_error_gracefully(self, agent):
|
||||
def test_handles_json_parse_error_gracefully(self, agent):
|
||||
agent._call_api = MagicMock(return_value="this is not json {{")
|
||||
action = await agent.propose_next_action()
|
||||
action = agent.propose_next_action()
|
||||
assert action["tool"] == "done"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_api_exception_gracefully(self, agent):
|
||||
def test_handles_api_exception_gracefully(self, agent):
|
||||
agent._call_api = MagicMock(side_effect=Exception("connection refused"))
|
||||
action = await agent.propose_next_action()
|
||||
action = agent.propose_next_action()
|
||||
assert action["tool"] == "done"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_code_fences_from_response(self, agent):
|
||||
def test_strips_code_fences_from_response(self, agent):
|
||||
payload = "```json\n" + _agent_action_json("list_files", "thinking") + "\n```"
|
||||
agent._call_api = MagicMock(return_value=payload)
|
||||
action = await agent.propose_next_action()
|
||||
action = agent.propose_next_action()
|
||||
assert action["tool"] == "list_files"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_done_returns_done_action(self, agent):
|
||||
def test_already_done_returns_done_action(self, agent):
|
||||
agent.is_done = True
|
||||
action = await agent.propose_next_action()
|
||||
action = agent.propose_next_action()
|
||||
assert action["tool"] == "done"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_iterations_returns_done_without_api_call(self, agent):
|
||||
agent.iteration = MAX_ITERATIONS
|
||||
agent._call_api = MagicMock(side_effect=AssertionError("API must not be called"))
|
||||
action = await agent.propose_next_action()
|
||||
assert action["tool"] == "done"
|
||||
agent._call_api.assert_not_called()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestApprove (mocked API + mocked dispatch_tool)
|
||||
# TestApprove (mocked API + real tool execution via tmp_path)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestApprove:
|
||||
"""Tests for approve(): dispatch_tool is mocked so no filesystem or subprocess side-effects occur."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self):
|
||||
@ -269,68 +416,56 @@ class TestApprove:
|
||||
return a
|
||||
|
||||
def _set_pending(self, agent, tool: str, **arguments):
|
||||
"""Inject a pending_action into the agent as if propose_next_action() had just run.
|
||||
'raw' holds the original JSON string; 'action' holds the parsed dict."""
|
||||
raw = _agent_action_json(tool, "thought", **arguments)
|
||||
agent.pending_action = {
|
||||
"raw": raw,
|
||||
"action": {"thought": "thought", "tool": tool, "arguments": arguments},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_without_pending_raises(self, agent):
|
||||
def test_approve_without_pending_raises(self, agent):
|
||||
with pytest.raises(Exception):
|
||||
await agent.approve()
|
||||
agent.approve()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_done_sets_is_done(self, agent):
|
||||
def test_approve_done_sets_is_done(self, agent):
|
||||
self._set_pending(agent, "done", summary="all done")
|
||||
result = await agent.approve()
|
||||
result = agent.approve()
|
||||
assert result["is_done"] is True
|
||||
assert agent.is_done is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_done_returns_summary(self, agent):
|
||||
def test_approve_done_returns_summary(self, agent):
|
||||
self._set_pending(agent, "done", summary="finished successfully")
|
||||
result = await agent.approve()
|
||||
result = agent.approve()
|
||||
assert "finished successfully" in result["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_clears_pending_action(self, agent):
|
||||
def test_approve_clears_pending_action(self, agent):
|
||||
self._set_pending(agent, "done", summary="x")
|
||||
await agent.approve()
|
||||
agent.approve()
|
||||
assert agent.pending_action is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_appends_assistant_message(self, agent):
|
||||
def test_approve_appends_assistant_message(self, agent):
|
||||
self._set_pending(agent, "done", summary="x")
|
||||
before = len(agent.messages)
|
||||
await agent.approve()
|
||||
agent.approve()
|
||||
assert len(agent.messages) > before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_tool_result_appended_to_messages(self, agent):
|
||||
with patch("backend.agent.coding_agent.dispatch_tool", return_value="file list"):
|
||||
def test_approve_tool_result_appended_to_messages(self, agent, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
self._set_pending(agent, "list_files")
|
||||
await agent.approve()
|
||||
agent.approve()
|
||||
tool_results = [m for m in agent.messages if "tool_result" in m["content"]]
|
||||
assert len(tool_results) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_error_result_adds_replan_tag(self, agent):
|
||||
# When a tool returns an error, approve() adds a "replan" tag to the message
|
||||
# so the LLM knows the last action failed and must choose a different approach.
|
||||
with patch("backend.agent.coding_agent.dispatch_tool", return_value="ERROR: file not found"):
|
||||
def test_approve_error_result_adds_replan_tag(self, agent, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
self._set_pending(agent, "read_file", path="nonexistent.py")
|
||||
await agent.approve()
|
||||
agent.approve()
|
||||
last_msg = agent.messages[-1]["content"]
|
||||
assert "replan" in last_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_returns_tool_name_in_result(self, agent):
|
||||
with patch("backend.agent.coding_agent.dispatch_tool", return_value="(empty)"):
|
||||
def test_approve_returns_tool_name_in_result(self, agent, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
self._set_pending(agent, "list_files")
|
||||
result = await agent.approve()
|
||||
result = agent.approve()
|
||||
assert result["tool"] == "list_files"
|
||||
assert result["is_done"] is False
|
||||
|
||||
@ -340,7 +475,6 @@ class TestApprove:
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestReject:
|
||||
"""Tests for reject(): user feedback is injected into the history and the pending action is discarded."""
|
||||
|
||||
@pytest.fixture
|
||||
def agent(self):
|
||||
@ -374,12 +508,66 @@ class TestReject:
|
||||
|
||||
def test_reject_without_pending_does_not_crash(self, agent):
|
||||
agent.pending_action = None
|
||||
agent.reject("no pending action")
|
||||
agent.reject("no pending action") # should not raise
|
||||
|
||||
def test_reject_does_not_execute_tool(self, agent, tmp_path):
|
||||
self._set_pending(agent, "write_file")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent.reject("Do not write anything")
|
||||
assert not list(tmp_path.glob("*"))
|
||||
assert not list(tmp_path.glob("*")) # no files created
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestFullLoop (integration – real API, skipped if unreachable)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestFullLoop:
|
||||
"""End-to-end test: agent runs a real task against the live API.
|
||||
Skipped automatically if the API is not reachable.
|
||||
"""
|
||||
|
||||
MAX_STEPS = 15 # safety limit for the test loop
|
||||
|
||||
def _run_until_done(self, agent) -> list:
|
||||
"""Drive the agent loop until done or MAX_STEPS reached."""
|
||||
steps = []
|
||||
for _ in range(self.MAX_STEPS):
|
||||
action = agent.propose_next_action()
|
||||
result = agent.approve()
|
||||
steps.append(result)
|
||||
if result["is_done"]:
|
||||
break
|
||||
return steps
|
||||
|
||||
def test_agent_completes_hello_world_task(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent = CodingAgent()
|
||||
try:
|
||||
agent.start_task(
|
||||
"Write a Python file called hello.py that prints 'Hello World'. "
|
||||
"Validate it and run it."
|
||||
)
|
||||
steps = self._run_until_done(agent)
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable: {e}")
|
||||
|
||||
assert agent.is_done, "Agent did not reach done state"
|
||||
tools_used = [s["tool"] for s in steps]
|
||||
assert "write_file" in tools_used
|
||||
assert "done" in tools_used
|
||||
|
||||
def test_agent_creates_file_on_disk(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent = CodingAgent()
|
||||
try:
|
||||
agent.start_task("Write a file called output.txt containing the text 'test passed'.")
|
||||
self._run_until_done(agent)
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable: {e}")
|
||||
|
||||
py_files = list(tmp_path.glob("*.txt")) + list(tmp_path.glob("*.py"))
|
||||
assert len(py_files) > 0, "Agent did not create any file"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
"""Tests for DebugLogger (backend/managers/debug_logger.py).
|
||||
|
||||
DebugLogger is a classmethod-based utility. Its _error_log class variable
|
||||
persists across tests, so every test that modifies it must call
|
||||
DebugLogger.clear_errors() in teardown (handled by the autouse fixture).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.managers.debug_logger import DebugLogger
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_error_log():
|
||||
"""Reset the shared _error_log class variable before and after each test."""
|
||||
DebugLogger.clear_errors()
|
||||
yield
|
||||
DebugLogger.clear_errors()
|
||||
|
||||
|
||||
# ── log_error() ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLogError:
|
||||
"""Tests for log_error(): appends the message to the in-memory error list."""
|
||||
|
||||
def test_log_error_appends_to_error_log(self):
|
||||
DebugLogger.log_error("something broke")
|
||||
assert "something broke" in DebugLogger.get_errors()
|
||||
|
||||
def test_log_error_multiple_messages_all_stored(self):
|
||||
DebugLogger.log_error("first error")
|
||||
DebugLogger.log_error("second error")
|
||||
errors = DebugLogger.get_errors()
|
||||
assert "first error" in errors
|
||||
assert "second error" in errors
|
||||
|
||||
def test_log_error_preserves_order(self):
|
||||
DebugLogger.log_error("alpha")
|
||||
DebugLogger.log_error("beta")
|
||||
errors = DebugLogger.get_errors()
|
||||
assert errors.index("alpha") < errors.index("beta")
|
||||
|
||||
|
||||
# ── get_errors() ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetErrors:
|
||||
"""Tests for get_errors(): returns the current in-memory error list."""
|
||||
|
||||
def test_get_errors_empty_initially(self):
|
||||
assert DebugLogger.get_errors() == []
|
||||
|
||||
def test_get_errors_reflects_logged_errors(self):
|
||||
DebugLogger.log_error("boom")
|
||||
assert len(DebugLogger.get_errors()) == 1
|
||||
|
||||
|
||||
# ── clear_errors() ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestClearErrors:
|
||||
"""Tests for clear_errors(): wipes the in-memory error list."""
|
||||
|
||||
def test_clear_errors_empties_list(self):
|
||||
DebugLogger.log_error("will be cleared")
|
||||
DebugLogger.clear_errors()
|
||||
assert DebugLogger.get_errors() == []
|
||||
|
||||
def test_clear_errors_on_empty_list_does_not_raise(self):
|
||||
DebugLogger.clear_errors() # already empty from autouse fixture
|
||||
|
||||
|
||||
# ── format_debug_output() ─────────────────────────────────────────────────────
|
||||
|
||||
class TestFormatDebugOutput:
|
||||
"""Tests for format_debug_output(): renders return_code, stdout, and stderr."""
|
||||
|
||||
def test_contains_execution_result_header(self):
|
||||
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
|
||||
assert "=== Execution Result ===" in result
|
||||
|
||||
def test_exit_code_zero_appears_in_output(self):
|
||||
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
|
||||
assert "Exit Code: 0" in result
|
||||
|
||||
def test_nonzero_exit_code_appears_in_output(self):
|
||||
result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": ""})
|
||||
assert "Exit Code: 1" in result
|
||||
|
||||
def test_stdout_included_when_present(self):
|
||||
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "Hello", "stderr": ""})
|
||||
assert "Hello" in result
|
||||
|
||||
def test_stderr_included_when_present(self):
|
||||
result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": "NameError"})
|
||||
assert "NameError" in result
|
||||
|
||||
def test_empty_stdout_shows_none_placeholder(self):
|
||||
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
|
||||
assert "(none)" in result
|
||||
|
||||
def test_missing_keys_do_not_raise(self):
|
||||
# format_debug_output uses .get() so absent keys fall back to defaults.
|
||||
result = DebugLogger.format_debug_output({})
|
||||
assert isinstance(result, str)
|
||||
@ -1,387 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
import subprocess
|
||||
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"]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 17. .py nutzt sys.executable als Interpreter
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_python_uses_sys_executable(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] == sys.executable
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 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
|
||||
@ -1,220 +0,0 @@
|
||||
"""Tests for FileManager (backend/managers/file_manager.py)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.managers.file_manager import FileManager
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_streamlit():
|
||||
"""Suppress all st.error / st.warning calls — they require a running Streamlit app."""
|
||||
with patch("backend.managers.file_manager.st"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fm(tmp_path):
|
||||
"""Return a FileManager whose workspace is an isolated pytest temp directory."""
|
||||
return FileManager(base_path=tmp_path)
|
||||
|
||||
|
||||
# ── create_folder ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCreateFolder:
|
||||
"""Tests for create_folder(): name validation, path-traversal protection, and nested creation."""
|
||||
|
||||
def test_creates_folder_successfully(self, fm, tmp_path):
|
||||
result = fm.create_folder("", "myfolder")
|
||||
assert result is True
|
||||
assert (tmp_path / "myfolder").is_dir()
|
||||
|
||||
def test_empty_name_returns_false(self, fm):
|
||||
assert fm.create_folder("", "") is False
|
||||
|
||||
def test_slash_in_name_returns_false(self, fm):
|
||||
assert fm.create_folder("", "a/b") is False
|
||||
|
||||
def test_backslash_in_name_returns_false(self, fm):
|
||||
assert fm.create_folder("", "a\\b") is False
|
||||
|
||||
def test_duplicate_folder_returns_false(self, fm, tmp_path):
|
||||
(tmp_path / "existing").mkdir()
|
||||
assert fm.create_folder("", "existing") is False
|
||||
|
||||
def test_path_traversal_returns_false(self, fm):
|
||||
assert fm.create_folder("../../", "evil") is False
|
||||
|
||||
def test_nested_folder_created_inside_base(self, fm, tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
result = fm.create_folder("sub", "child")
|
||||
assert result is True
|
||||
assert (tmp_path / "sub" / "child").is_dir()
|
||||
|
||||
|
||||
# ── create_file ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCreateFile:
|
||||
"""Tests for create_file(): name validation, auto .txt extension, and path-traversal protection."""
|
||||
|
||||
def test_creates_file_successfully(self, fm, tmp_path):
|
||||
result = fm.create_file("", "test.py")
|
||||
assert result is True
|
||||
assert (tmp_path / "test.py").is_file()
|
||||
|
||||
def test_empty_name_returns_false(self, fm):
|
||||
assert fm.create_file("", "") is False
|
||||
|
||||
def test_whitespace_only_name_returns_false(self, fm):
|
||||
assert fm.create_file("", " ") is False
|
||||
|
||||
def test_no_extension_defaults_to_txt(self, fm, tmp_path):
|
||||
fm.create_file("", "notes")
|
||||
assert (tmp_path / "notes.txt").is_file()
|
||||
|
||||
def test_duplicate_file_returns_false(self, fm, tmp_path):
|
||||
(tmp_path / "existing.py").touch()
|
||||
assert fm.create_file("", "existing.py") is False
|
||||
|
||||
def test_path_traversal_returns_false(self, fm):
|
||||
assert fm.create_file("../../", "evil.py") is False
|
||||
|
||||
|
||||
# ── read_file ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestReadFile:
|
||||
"""Tests for read_file(): accepts an absolute Path, validates workspace boundary, returns content or ""."""
|
||||
|
||||
def test_reads_file_content(self, fm, tmp_path):
|
||||
f = tmp_path / "hello.py"
|
||||
f.write_text("print('hello')")
|
||||
assert fm.read_file(f) == "print('hello')"
|
||||
|
||||
def test_nonexistent_file_returns_empty_string(self, fm, tmp_path):
|
||||
assert fm.read_file(tmp_path / "ghost.py") == ""
|
||||
|
||||
def test_file_outside_workspace_returns_empty_string(self, fm, tmp_path):
|
||||
outside = tmp_path.parent / "outside.py"
|
||||
outside.write_text("secret")
|
||||
assert fm.read_file(outside) == ""
|
||||
|
||||
|
||||
# ── save_file ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSaveFile:
|
||||
"""Tests for save_file(): accepts an absolute path string, overwrites content, and blocks path traversal."""
|
||||
|
||||
def test_saves_content_to_file(self, fm, tmp_path):
|
||||
f = tmp_path / "output.py"
|
||||
f.touch()
|
||||
result = fm.save_file(str(f), "x = 1")
|
||||
assert result is True
|
||||
assert f.read_text() == "x = 1"
|
||||
|
||||
def test_overwrites_existing_content(self, fm, tmp_path):
|
||||
f = tmp_path / "script.py"
|
||||
f.write_text("old content")
|
||||
fm.save_file(str(f), "new content")
|
||||
assert f.read_text() == "new content"
|
||||
|
||||
def test_path_traversal_returns_false(self, fm, tmp_path):
|
||||
outside = str(tmp_path.parent / "evil.py")
|
||||
assert fm.save_file(outside, "bad") is False
|
||||
|
||||
|
||||
# ── rename_file ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRenameFile:
|
||||
"""Tests for rename_file(): renames by stem only — the original extension is always preserved."""
|
||||
|
||||
def test_renames_file_successfully(self, fm, tmp_path):
|
||||
(tmp_path / "old.py").touch()
|
||||
result = fm.rename_file("old.py", "new")
|
||||
assert result is True
|
||||
assert (tmp_path / "new.py").exists()
|
||||
assert not (tmp_path / "old.py").exists()
|
||||
|
||||
def test_preserves_original_extension(self, fm, tmp_path):
|
||||
# Even if the caller passes a different extension (.txt), rename_file
|
||||
# silently replaces it with the original (.py) to prevent accidental type changes.
|
||||
(tmp_path / "script.py").touch()
|
||||
fm.rename_file("script.py", "renamed.txt")
|
||||
assert (tmp_path / "renamed.py").exists()
|
||||
|
||||
def test_empty_new_name_returns_false(self, fm, tmp_path):
|
||||
(tmp_path / "file.py").touch()
|
||||
assert fm.rename_file("file.py", "") is False
|
||||
|
||||
def test_nonexistent_file_returns_false(self, fm):
|
||||
assert fm.rename_file("ghost.py", "new_name") is False
|
||||
|
||||
def test_path_traversal_returns_false(self, fm):
|
||||
assert fm.rename_file("../../evil.py", "new_name") is False
|
||||
|
||||
|
||||
# ── delete_file ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeleteFile:
|
||||
"""Tests for delete_file(): accepts a relative path, validates workspace boundary, removes the file."""
|
||||
|
||||
def test_deletes_file_successfully(self, fm, tmp_path):
|
||||
f = tmp_path / "todelete.py"
|
||||
f.touch()
|
||||
result = fm.delete_file("todelete.py")
|
||||
assert result is True
|
||||
assert not f.exists()
|
||||
|
||||
def test_nonexistent_file_returns_false(self, fm):
|
||||
assert fm.delete_file("ghost.py") is False
|
||||
|
||||
def test_path_traversal_returns_false(self, fm):
|
||||
assert fm.delete_file("../../evil.py") is False
|
||||
|
||||
|
||||
# ── delete_folder ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeleteFolder:
|
||||
"""Tests for delete_folder(): recursively removes a folder and all its contents."""
|
||||
|
||||
def test_deletes_folder_and_contents(self, fm, tmp_path):
|
||||
sub = tmp_path / "todelete"
|
||||
sub.mkdir()
|
||||
(sub / "file.py").touch()
|
||||
result = fm.delete_folder("todelete")
|
||||
assert result is True
|
||||
assert not sub.exists()
|
||||
|
||||
def test_path_traversal_returns_false(self, fm):
|
||||
assert fm.delete_folder("../../") is False
|
||||
|
||||
|
||||
# ── get_file_tree ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetFileTree:
|
||||
"""Tests for get_file_tree(): returns a nested dict where files map to None and dirs map to dicts."""
|
||||
|
||||
def test_empty_workspace_returns_empty_dict(self, fm):
|
||||
assert fm.get_file_tree() == {}
|
||||
|
||||
def test_file_is_represented_as_none(self, fm, tmp_path):
|
||||
(tmp_path / "main.py").touch()
|
||||
tree = fm.get_file_tree()
|
||||
assert tree["main.py"] is None
|
||||
|
||||
def test_directory_is_represented_as_dict(self, fm, tmp_path):
|
||||
(tmp_path / "src").mkdir()
|
||||
tree = fm.get_file_tree()
|
||||
assert isinstance(tree["src"], dict)
|
||||
|
||||
def test_nested_structure_is_correct(self, fm, tmp_path):
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "app.py").touch()
|
||||
tree = fm.get_file_tree()
|
||||
assert tree["src"]["app.py"] is None
|
||||
|
||||
6
tests/test_main.py
Normal file
6
tests/test_main.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Test cases for the main module."""
|
||||
|
||||
|
||||
def test_placeholder():
|
||||
"""Placeholder test."""
|
||||
assert True
|
||||
@ -1,265 +0,0 @@
|
||||
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
|
||||
@ -1,285 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
from backend.agent.servers import mcp_server_file_search as server
|
||||
|
||||
|
||||
# =========================================================
|
||||
# FIXTURES
|
||||
# =========================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def workspace(tmp_path, monkeypatch):
|
||||
"""
|
||||
Erstellt einen isolierten Workspace für jeden Test.
|
||||
"""
|
||||
ws = tmp_path / "workspace"
|
||||
ws.mkdir()
|
||||
|
||||
monkeypatch.setattr(server, "ALLOWED_DIR", ws)
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
# =========================================================
|
||||
# BASIC TESTS (1–10)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 1. _safe_path erlaubt gültige Pfade
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_safe_path_valid(workspace):
|
||||
result = server._safe_path("test.txt")
|
||||
|
||||
assert result == workspace / "test.txt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. _safe_path blockiert Path Traversal
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_safe_path_blocks_traversal(workspace):
|
||||
with pytest.raises(ValueError):
|
||||
server._safe_path("../secret.txt")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 3. list_files liefert leeren Hinweis
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_list_files_empty(workspace):
|
||||
result = server.list_files()
|
||||
|
||||
assert result == "No files found in the project directory."
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 4. list_files findet Dateien rekursiv
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_list_files_recursive(workspace):
|
||||
src = workspace / "src"
|
||||
src.mkdir()
|
||||
|
||||
(src / "main.py").write_text("print('hello')")
|
||||
|
||||
result = server.list_files()
|
||||
|
||||
# list_files() uses Path.relative_to() which yields OS-native separators;
|
||||
# check for the components instead of a hard-coded slash style.
|
||||
assert "src" in result and "main.py" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. read_file liest Datei korrekt
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_read_file_success(workspace):
|
||||
file = workspace / "hello.txt"
|
||||
file.write_text("Hello World")
|
||||
|
||||
result = server.read_file("hello.txt")
|
||||
|
||||
assert result == "Hello World"
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 6. read_file erkennt fehlende Datei
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_read_file_missing(workspace):
|
||||
result = server.read_file("missing.txt")
|
||||
|
||||
assert "does not exist" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 7. write_new_file erstellt Datei
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_write_new_file_success(workspace):
|
||||
result = server.write_new_file("new.txt", "content")
|
||||
|
||||
assert "OK:" in result
|
||||
assert (workspace / "new.txt").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 8. write_new_file verhindert Überschreiben
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_write_new_file_existing(workspace):
|
||||
file = workspace / "exists.txt"
|
||||
file.write_text("old")
|
||||
|
||||
result = server.write_new_file("exists.txt", "new")
|
||||
|
||||
assert "already exists" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 9. create_new_directory erstellt Verzeichnis
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_create_new_directory_success(workspace):
|
||||
result = server.create_new_directory("mydir")
|
||||
|
||||
assert "OK:" in result
|
||||
assert (workspace / "mydir").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 10. search_files findet Inhalte
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_search_files_content_match(workspace):
|
||||
file = workspace / "notes.txt"
|
||||
file.write_text("Python MCP Server")
|
||||
|
||||
result = server.search_files("mcp")
|
||||
|
||||
assert "[content]" in result
|
||||
|
||||
|
||||
# =========================================================
|
||||
# EDGE CASE TESTS (11–20)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 11. Mehrfaches Traversal blockieren
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_safe_path_double_traversal(workspace):
|
||||
with pytest.raises(ValueError):
|
||||
server._safe_path("../../../../etc/passwd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 12. Symlink Escape verhindern
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require special privileges on Windows")
|
||||
def test_safe_path_symlink_escape(workspace):
|
||||
outside = workspace.parent / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
target = outside / "evil.txt"
|
||||
target.write_text("bad")
|
||||
|
||||
link = workspace / "link"
|
||||
link.symlink_to(outside)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
server._safe_path("link/evil.txt")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 13. Dateien ohne Extension blockieren
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_write_file_without_extension(workspace):
|
||||
result = server.write_new_file("README", "test")
|
||||
|
||||
assert "can only write" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 14. Hidden Files blockieren
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_write_hidden_file(workspace):
|
||||
result = server.write_new_file(".env", "SECRET=123")
|
||||
|
||||
assert "can only write" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 15. Binary Files korrekt behandeln
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_read_binary_file(workspace):
|
||||
binary = workspace / "data.bin"
|
||||
binary.write_bytes(b"\xFF\xFE\xFD")
|
||||
|
||||
result = server.read_file("data.bin")
|
||||
|
||||
assert "not a text file" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 16. Sehr große Zeilen durchsuchen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_search_huge_line(workspace):
|
||||
huge_text = "A" * 1_000_000 + "needle"
|
||||
|
||||
file = workspace / "huge.txt"
|
||||
file.write_text(huge_text)
|
||||
|
||||
result = server.search_files("needle")
|
||||
|
||||
assert "[content]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 17. Leere Dateien lesen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_read_empty_file(workspace):
|
||||
file = workspace / "empty.txt"
|
||||
file.write_text("")
|
||||
|
||||
result = server.read_file("empty.txt")
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 18. Sonderzeichen im Query
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_search_special_characters(workspace):
|
||||
file = workspace / "test.txt"
|
||||
file.write_text("hello [world] (test)")
|
||||
|
||||
result = server.search_files("[world]")
|
||||
|
||||
assert "[content]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 19. Unicode-Dateinamen unterstützen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_write_unicode_filename(workspace):
|
||||
filename = "🔥_überraschung.txt"
|
||||
|
||||
result = server.write_new_file(filename, "unicode")
|
||||
|
||||
assert "OK:" in result
|
||||
assert (workspace / filename).exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 20. Tiefe Verzeichnisstrukturen
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="Windows MAX_PATH limit (260 chars) prevents deep nesting",
|
||||
)
|
||||
def test_list_files_deep_nesting(workspace):
|
||||
current = workspace
|
||||
|
||||
for i in range(50):
|
||||
current = current / f"dir_{i}"
|
||||
current.mkdir()
|
||||
|
||||
file = current / "deep.txt"
|
||||
file.write_text("deep")
|
||||
|
||||
result = server.list_files()
|
||||
|
||||
assert "deep.txt" in result
|
||||
@ -1,299 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from backend.agent.servers import mcp_server_web_search as server
|
||||
|
||||
|
||||
# =========================================================
|
||||
# BASIC TESTS (1–10)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 1. _validate_url erlaubt HTTPS
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_https():
|
||||
url = "https://example.com"
|
||||
|
||||
result = server._validate_url(url)
|
||||
|
||||
assert result == url
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. _validate_url erlaubt HTTP
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_http():
|
||||
url = "http://example.com"
|
||||
|
||||
result = server._validate_url(url)
|
||||
|
||||
assert result == url
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 3. _validate_url blockiert localhost
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_localhost():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("http://localhost/admin")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 4. _validate_url blockiert 127.0.0.1
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_loopback():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("http://127.0.0.1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. _validate_url blockiert private IP
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_private_ip():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("http://192.168.1.10")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 6. web_search liefert Suchergebnisse
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("backend.agent.servers.mcp_server_web_search.DDGS")
|
||||
def test_web_search_success(mock_ddgs):
|
||||
mock_instance = Mock()
|
||||
|
||||
mock_instance.text.return_value = [
|
||||
{
|
||||
"title": "Example",
|
||||
"href": "https://example.com",
|
||||
"body": "Example snippet"
|
||||
}
|
||||
]
|
||||
|
||||
mock_ddgs.return_value = mock_instance
|
||||
|
||||
result = server.web_search("example")
|
||||
|
||||
assert "Title: Example" in result
|
||||
assert "https://example.com" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 7. web_search ohne Ergebnisse
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("backend.agent.servers.mcp_server_web_search.DDGS")
|
||||
def test_web_search_no_results(mock_ddgs):
|
||||
mock_instance = Mock()
|
||||
mock_instance.text.return_value = []
|
||||
|
||||
mock_ddgs.return_value = mock_instance
|
||||
|
||||
result = server.web_search("nothing")
|
||||
|
||||
assert "No results found" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 8. fetch_page lädt HTML
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_success(mock_get):
|
||||
response = Mock()
|
||||
|
||||
response.status_code = 200
|
||||
response.text = """
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
mock_get.return_value = response
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
assert "Hello World" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 9. fetch_page entfernt script Tags
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_removes_script(mock_get):
|
||||
response = Mock()
|
||||
|
||||
response.status_code = 200
|
||||
response.text = """
|
||||
<html>
|
||||
<script>alert('xss')</script>
|
||||
<body>Hello</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
mock_get.return_value = response
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
assert "alert" not in result
|
||||
assert "Hello" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 10. fetch_page erkennt HTTP Fehler
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_http_error(mock_get):
|
||||
response = Mock()
|
||||
|
||||
response.status_code = 404
|
||||
response.text = "Not Found"
|
||||
|
||||
mock_get.return_value = response
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
assert "HTTP error 404" in result
|
||||
|
||||
|
||||
# =========================================================
|
||||
# EDGE CASE TESTS (11–20)
|
||||
# =========================================================
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 11. Blockiere file:// SSRF
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_blocks_file_scheme():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("file:///etc/passwd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 12. Blockiere ftp://
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_blocks_ftp():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("ftp://example.com")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 13. Blockiere AWS Metadata Endpoint
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_blocks_metadata_ip():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("http://169.254.169.254")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 14. Blockiere internes Docker Netzwerk
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_blocks_docker_network():
|
||||
with pytest.raises(ValueError):
|
||||
server._validate_url("http://172.20.0.5")
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 15. Sehr lange URL
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def test_validate_url_very_long():
|
||||
long_url = "https://example.com/" + ("a" * 5000)
|
||||
|
||||
result = server._validate_url(long_url)
|
||||
|
||||
assert result == long_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 16. fetch_page behandelt Timeout
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_timeout(mock_get):
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.Timeout("timeout")
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
# fetch_page catches RequestException (which includes Timeout) and returns
|
||||
# "HTTP-Fehler: <exception>" — verify an error string comes back.
|
||||
assert "HTTP-Fehler" in result or "Error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 17. fetch_page behandelt Connection Error
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_connection_error(mock_get):
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.ConnectionError("connection failed")
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
assert "HTTP-Fehler" in result or "Error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 18. fetch_page truncatet große Seiten
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_truncates_large_content(mock_get):
|
||||
response = Mock()
|
||||
|
||||
response.status_code = 200
|
||||
response.text = "<html><body>" + ("A" * 10000) + "</body></html>"
|
||||
|
||||
mock_get.return_value = response
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
assert "[... truncated ...]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 19. fetch_page bei leerem Body
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("requests.get")
|
||||
def test_fetch_page_empty_content(mock_get):
|
||||
response = Mock()
|
||||
|
||||
response.status_code = 200
|
||||
response.text = "<html></html>"
|
||||
|
||||
mock_get.return_value = response
|
||||
|
||||
result = server.fetch_page("https://example.com")
|
||||
|
||||
assert "no text content found" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 20. web_search behandelt Exception sauber
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@patch("backend.agent.servers.mcp_server_web_search.DDGS")
|
||||
def test_web_search_exception(mock_ddgs):
|
||||
mock_ddgs.side_effect = Exception("DDGS failed")
|
||||
|
||||
result = server.web_search("test")
|
||||
|
||||
assert "Search error" in result
|
||||
@ -1,361 +0,0 @@
|
||||
"""Tests for SearchManager — no real network calls, all I/O mocked."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
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")
|
||||
@ -5,17 +5,30 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import pytest
|
||||
from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS
|
||||
|
||||
|
||||
class TestSystemPrompterBasePrompt:
|
||||
"""Tests for generate_prompt() without file context."""
|
||||
|
||||
def test_returns_non_empty_string(self):
|
||||
prompt = SystemPrompter.generate_prompt()
|
||||
assert isinstance(prompt, str)
|
||||
assert len(prompt) > 0
|
||||
|
||||
def test_describes_code_assistant(self):
|
||||
prompt = SystemPrompter.generate_prompt()
|
||||
assert "code assistant" in prompt.lower()
|
||||
|
||||
def test_contains_no_file_xml_tag(self):
|
||||
prompt = SystemPrompter.generate_prompt()
|
||||
assert "<file" not in prompt
|
||||
assert "<code>" not in prompt
|
||||
|
||||
def test_none_equals_no_argument(self):
|
||||
assert SystemPrompter.generate_prompt(file_context=None) == SystemPrompter.generate_prompt()
|
||||
|
||||
|
||||
class TestSystemPrompterWithFileContext:
|
||||
"""Tests for generate_prompt() with file_context provided."""
|
||||
@ -36,6 +49,15 @@ class TestSystemPrompterWithFileContext:
|
||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": "pass"})
|
||||
assert "<code>" in prompt
|
||||
|
||||
def test_with_context_is_longer_than_base(self):
|
||||
base = SystemPrompter.generate_prompt()
|
||||
with_ctx = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": "x=1"})
|
||||
assert len(with_ctx) > len(base)
|
||||
|
||||
def test_missing_name_key_uses_unknown(self):
|
||||
prompt = SystemPrompter.generate_prompt(file_context={"content": "some code"})
|
||||
assert "unknown" in prompt
|
||||
|
||||
def test_missing_content_key_does_not_raise(self):
|
||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "empty.py"})
|
||||
assert "empty.py" in prompt
|
||||
@ -55,19 +77,12 @@ class TestSystemPrompterTruncation:
|
||||
assert "[truncated]" not in prompt
|
||||
assert content in prompt
|
||||
|
||||
def test_file_exactly_at_limit_is_not_truncated(self):
|
||||
content = "x" * MAX_FILE_CHARS
|
||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
|
||||
assert "[truncated]" not in prompt
|
||||
|
||||
def test_file_one_over_limit_is_truncated(self):
|
||||
content = "x" * (MAX_FILE_CHARS + 1)
|
||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
|
||||
assert "[truncated]" in prompt
|
||||
|
||||
|
||||
class TestSystemPrompterSpecialCharacters:
|
||||
"""Tests that XML special characters in file content are handled without breaking the prompt."""
|
||||
|
||||
def test_xml_tags_in_content_are_preserved_literally(self):
|
||||
# User code often contains HTML or XML. The prompt builder must embed it
|
||||
# verbatim — escaping or stripping tags would corrupt the file content.
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "template.html", "content": "<div>hello</div>"}
|
||||
)
|
||||
assert "<div>hello</div>" in prompt
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user