Compare commits
No commits in common. "main" and "comments" have entirely different histories.
10
.env.example
10
.env.example
@ -3,9 +3,11 @@
|
|||||||
# NOTE: Never commit .env with real secrets to version control!
|
# NOTE: Never commit .env with real secrets to version control!
|
||||||
|
|
||||||
# Silicon Server Configuration
|
# Silicon Server Configuration
|
||||||
HOST=
|
HOST=silicon.fhgr.ch
|
||||||
PORT=
|
PORT=7080
|
||||||
API_KEY=EMPTY
|
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
|
||||||
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)
|
||||||
|
|
||||||
---
|
## Project Structure
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
AISE_AIAgent/
|
AISE_AIAgent/
|
||||||
├── frontend/ # Streamlit UI-Komponenten
|
├── frontend/ # Streamlit UI Components
|
||||||
│ ├── app.py # Einstiegspunkt der App; Seitenkonfiguration + Routing
|
│ ├── __init__.py
|
||||||
│ ├── state.py # Zentrale Session-State-Initialisierung
|
│ ├── app.py # Main Streamlit application entry point
|
||||||
│ ├── sidebar.py # Datei-Explorer + Navigations-Radio
|
│ ├── sidebar.py # File navigation sidebar component
|
||||||
│ ├── editor.py # Ace-Editor-Tabs + Ausführungs-Output
|
│ ├── editor.py # Code editor pane component
|
||||||
│ └── chat.py # Chat-Interface + Agent-Mode-UI
|
│ └── chat.py # Chat interface component
|
||||||
│
|
│
|
||||||
├── backend/
|
├── backend/ # Backend Logic Modules
|
||||||
│ ├── managers/ # Business-Logik, direkt vom Frontend aufgerufen
|
│ ├── __init__.py
|
||||||
│ │ ├── file_manager.py # Workspace-CRUD mit Path-Traversal-Schutz
|
│ ├── managers/ # Business logic for UI operations
|
||||||
│ │ ├── chat_manager.py # LLM-API-Wrapper + Sliding-Window-History
|
│ │ ├── __init__.py
|
||||||
│ │ ├── system_prompter.py # Kontextbewusste System-Prompt-Generierung
|
│ │ ├── file_manager.py # File I/O operations for UI (read, write, list files)
|
||||||
│ │ ├── search_manager.py # DuckDuckGo-Websuche + Seitenabruf
|
│ │ ├── chat_manager.py # AI chat management and history
|
||||||
│ │ ├── execution_engine.py # Subprocess-basierte Code-Ausführung (Python, LaTeX)
|
│ │ ├── system_prompter.py # System prompts and context injection
|
||||||
│ │ └── debug_logger.py # Rotierende Logdatei + Fehler-Aggregation
|
│ │ ├── 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)
|
│ ├── agents/ # AI Agent System
|
||||||
│ ├── coding_agent.py # Plan→Aktion→Beobachten-Schleife
|
│ │ ├── __init__.py
|
||||||
│ ├── mcp_server_adapter.py # Verbindet den Agenten mit MCP-Tool-Servern
|
│ │ ├── coding_agent.py # Main agent loop (plan-act-observe cycle)
|
||||||
│ ├── mcp_server_config.json # Welche MCP-Server gestartet werden (Pfade + Befehle)
|
│ │ └── tools.py # Tools available to agent (7 functions + dispatcher)
|
||||||
│ └── servers/ # MCP-Server-Implementierungen (stdio-Transport)
|
│ │
|
||||||
│ ├── mcp_server_code_execution.py # Tool: Sandbox-Python-Ausführung + Linting
|
│ └── utils/ # Helper Utilities
|
||||||
│ ├── mcp_server_file_search.py # Tool: Workspace-Datei lesen/schreiben/suchen
|
│ ├── __init__.py
|
||||||
│ └── mcp_server_web_search.py # Tool: DuckDuckGo-Suche + Seitenabruf
|
│ └── server_utils.py # LLM client init, chat functions, formatters
|
||||||
│
|
│
|
||||||
├── tests/ # pytest-Unit-Tests
|
├── tests/ # Unit Tests
|
||||||
│ ├── conftest.py # Globaler MCP-Mock (keine echten Subprozesse in Tests)
|
│ ├── __init__.py
|
||||||
│ ├── test_chat_manager.py
|
│ ├── test_file_manager.py # Tests for file operations
|
||||||
│ ├── test_coding_agent.py
|
│ ├── test_chat_manager.py # Tests for chat functionality
|
||||||
│ ├── test_debug_logger.py
|
│ ├── test_execution_engine.py # Tests for code execution
|
||||||
│ ├── test_execution_engine.py
|
│ └── test_main.py # Integration tests
|
||||||
│ ├── 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
|
|
||||||
│
|
│
|
||||||
├── workspace/ # Sandbox-Verzeichnis für Agent- und Editor-Dateien
|
├── workspace/ # Agent Sandbox Directory
|
||||||
├── logs/ # Rotierende Logdateien (app.log, errors.log)
|
│ └── .gitkeep # Placeholder for agent to work safely in isolation
|
||||||
├── .env # Lokale Umgebungsvariablen (nicht eingecheckt)
|
│
|
||||||
└── .env.example # Vorlage für erforderliche Umgebungsvariablen
|
├── .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
|
```bash
|
||||||
# Windows (PowerShell)
|
# Windows
|
||||||
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)
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
.\.venv\Scripts\Activate.ps1
|
||||||
```
|
|
||||||
|
|
||||||
> Falls PowerShell die Ausführung blockiert, einmalig folgenden Befehl ausführen und danach erneut versuchen:
|
# macOS/Linux
|
||||||
> ```powershell
|
|
||||||
> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
> ```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS (Terminal)
|
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
Nach erfolgreicher Aktivierung erscheint `(.venv)` am Anfang der Eingabezeile.
|
### 3. Install Dependencies
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Schritt 5 — Abhängigkeiten installieren
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
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.
|
### 4. Run Application
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Schritt 6 — Umgebungsvariablen konfigurieren
|
|
||||||
|
|
||||||
```bash
|
```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 run frontend/app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Streamlit öffnet die App automatisch im Standard-Browser unter `http://localhost:8501`.
|
### 5. Run Tests
|
||||||
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)
|
|
||||||
|
|
||||||
```bash
|
```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,
|
- **Frontend**: Streamlit UI components (sidebar, editor, chat)
|
||||||
sondern delegieren alles an die Backend-Manager.
|
- **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`
|
## Development
|
||||||
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.
|
|
||||||
|
|
||||||
### `state.py`
|
Use Git to track changes:
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest tests/ -v # alle Tests
|
git add .
|
||||||
pytest tests/test_chat_manager.py -v # einzelne Datei
|
git commit -m "Your message"
|
||||||
pytest tests/ -q # Kurzausgabe
|
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.
|
|
||||||
|
|||||||
@ -18,19 +18,18 @@ import os
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import pprint
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
#from mcp_server_adapter import MCPToolAdapter # Import from current directory for easier testing without package structure
|
||||||
from backend.agent.mcp_server_adapter import MCPToolAdapter
|
from backend.agent.mcp_server_adapter import MCPToolAdapter
|
||||||
|
|
||||||
from backend.managers.debug_logger import get_logger
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# ── mcp server initialization ────────────────────────────────────────────────────────────────
|
# ── mcp server initialization ────────────────────────────────────────────────────────────────
|
||||||
adapter = MCPToolAdapter()
|
adapter = MCPToolAdapter()
|
||||||
logger.info("MCPToolAdapter created. Listing all tools from servers...")
|
print("MCPToolAdapter created. Listing all tools from servers...")
|
||||||
asyncio.run(adapter.initialize_all_servers())
|
asyncio.run(adapter.initialize_all_servers())
|
||||||
logger.info("Listed tools from all servers")
|
print("listed tools from all servers")
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@ -50,63 +49,40 @@ MAX_HISTORY_CHARS = 80_000
|
|||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def build_all_tool_description() -> str:
|
def build_all_tool_description() -> str:
|
||||||
"""Build a formatted string listing every registered MCP tool.
|
"""Get relevant tools from the MCP servers based on the query."""
|
||||||
|
|
||||||
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()
|
all_tools = adapter.get_all_tools()
|
||||||
logger.info("Building tool description for %s tools.", str(len(all_tools)))
|
print(f"Building tool description for {len(all_tools)} tools.")
|
||||||
|
|
||||||
descriptions = []
|
descriptions = []
|
||||||
for tool in all_tools:
|
for tool in all_tools:
|
||||||
|
pprint.pprint(f"{tool}")
|
||||||
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
|
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
|
||||||
|
|
||||||
return "\n".join(descriptions)
|
return "\n".join(descriptions)
|
||||||
|
|
||||||
async def dispatch_tool(tool_name: str, arguments: dict) -> str:
|
async def dispatch_tool(tool_name: str, arguments: dict) -> str:
|
||||||
"""Execute a named tool and return its output as a plain string.
|
"""Call a tool by name with the given arguments using the MCP adapter."""
|
||||||
|
|
||||||
Handles the special "done" pseudo-tool locally (it signals completion and
|
|
||||||
is never forwarded to an MCP server). All other tools are forwarded to the
|
|
||||||
MCPToolAdapter which routes them to the correct MCP server process.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: Name of the tool to execute (e.g. "write_file", "done").
|
|
||||||
arguments: Dict of arguments for the tool.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The tool's text output, a "DONE: ..." completion message, or an error
|
|
||||||
string beginning with "Tool error:" / "Error calling tool:" on failure.
|
|
||||||
"""
|
|
||||||
if tool_name == "done":
|
if tool_name == "done":
|
||||||
# The "done" tool is a sentinel — it lives only in the agent protocol,
|
# Handle the "done" tool locally since it's not an MCP tool
|
||||||
# not in any MCP server, so we resolve it directly here.
|
|
||||||
summary = arguments.get("summary", "Task completed.")
|
summary = arguments.get("summary", "Task completed.")
|
||||||
return f"DONE: {summary}"
|
return f"DONE: {summary}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name)
|
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
|
||||||
result = await adapter.call_tool(tool_name, arguments)
|
result = await adapter.call_tool(tool_name, arguments)
|
||||||
|
|
||||||
logger.info("Result from tool '%s' received", tool_name)
|
print(f"Raw result from tool '{tool_name}': {result}")
|
||||||
|
|
||||||
if result.isError:
|
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"]
|
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)}"
|
return f"Tool error: {' '.join(texts)}"
|
||||||
|
|
||||||
texts = [block.text for block in result.content if block.type == "text"]
|
texts = [block.text for block in result.content if block.type == "text"]
|
||||||
return "\n".join(texts)
|
return "\n".join(texts)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error calling tool '%s' with argument: %s", tool_name, arguments)
|
|
||||||
return f"Error calling tool '{tool_name}': {e}"
|
return f"Error calling tool '{tool_name}': {e}"
|
||||||
|
|
||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
@ -167,7 +143,6 @@ Example:
|
|||||||
- After validation passes, run it with run_python to verify correctness.
|
- 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).
|
- If an error occurs, analyse it and try to fix it (up to 3 retries).
|
||||||
- Stay within the workspace directory.
|
- 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.
|
- 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 <human_message>, acknowledge it and adjust your plan.
|
||||||
- If you receive a <replan> tag, revise your plan before choosing the next tool.
|
- If you receive a <replan> tag, revise your plan before choosing the next tool.
|
||||||
@ -181,7 +156,6 @@ Example:
|
|||||||
|
|
||||||
def truncate_result(result: str) -> str:
|
def truncate_result(result: str) -> str:
|
||||||
"""Truncate a tool result that exceeds MAX_RESULT_LENGTH."""
|
"""Truncate a tool result that exceeds MAX_RESULT_LENGTH."""
|
||||||
logger.info("Result has been truncated")
|
|
||||||
if len(result) <= MAX_RESULT_LENGTH:
|
if len(result) <= MAX_RESULT_LENGTH:
|
||||||
return result
|
return result
|
||||||
half = MAX_RESULT_LENGTH // 2
|
half = MAX_RESULT_LENGTH // 2
|
||||||
@ -193,35 +167,23 @@ def truncate_result(result: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def trim_messages(messages: list) -> list:
|
def trim_messages(messages: list) -> list:
|
||||||
"""Kürzt die Konversations-History wenn sie das Kontextfenster überschreitet.
|
"""Drop old messages when history exceeds MAX_HISTORY_CHARS.
|
||||||
|
Always keeps the system prompt (index 0) and original task (index 1).
|
||||||
Behält immer den System-Prompt (Index 0) und die ursprüngliche Aufgabe (Index 1).
|
|
||||||
Entfernt die ältesten Nachrichten zuerst und injiziert danach einen Erinnerungs-
|
|
||||||
Hinweis damit der Agent den Überblick behält.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
messages: Vollständige Konversations-History als Liste von {role, content} Dicts.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Gekürzte History mit maximal MAX_HISTORY_CHARS Zeichen, immer mit Head + Reminder + Tail.
|
|
||||||
"""
|
"""
|
||||||
logger.info("Message is being trimmed")
|
|
||||||
|
|
||||||
total = sum(len(m["content"]) for m in messages)
|
total = sum(len(m["content"]) for m in messages)
|
||||||
if total <= MAX_HISTORY_CHARS:
|
if total <= MAX_HISTORY_CHARS:
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
# Protect the two anchor messages that must never be discarded.
|
|
||||||
head = messages[:2]
|
head = messages[:2]
|
||||||
tail = messages[2:]
|
tail = messages[2:]
|
||||||
original_task = messages[1]["content"] if len(messages) > 1 else ""
|
original_task = messages[1]["content"] if len(messages) > 1 else ""
|
||||||
|
|
||||||
# Drop the oldest non-anchor messages first until we are under the limit.
|
# Drop the oldest messages first (index 2 onwards) until we are under the limit.
|
||||||
|
# The system prompt (0) and original task (1) are never dropped.
|
||||||
while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
|
while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
|
||||||
tail.pop(0)
|
tail.pop(0)
|
||||||
|
|
||||||
# After trimming, inject a reminder so the agent doesn't lose track of its goal.
|
# Inject a reminder so the agent doesn't lose track of its goal after trimming.
|
||||||
# Without this the agent might restart the task or repeat work it already did.
|
|
||||||
reminder = {
|
reminder = {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": (
|
||||||
@ -234,22 +196,13 @@ def trim_messages(messages: list) -> list:
|
|||||||
return head + [reminder] + tail
|
return head + [reminder] + tail
|
||||||
|
|
||||||
def _repair_json_strings(text: str) -> str:
|
def _repair_json_strings(text: str) -> str:
|
||||||
"""Replace unescaped control characters inside JSON string values.
|
"""
|
||||||
|
Replace unescaped control characters (newline, tab, carriage return)
|
||||||
|
inside JSON string values with their proper escape sequences.
|
||||||
|
|
||||||
LLMs frequently emit literal newlines, tabs, or carriage-returns inside
|
LLMs frequently emit literal newlines inside long string values, which
|
||||||
long string values (e.g. code content), which is invalid JSON. This
|
is invalid JSON. This function fixes that without touching structural
|
||||||
function replaces those characters with their proper ``\\n`` / ``\\t`` /
|
whitespace outside strings.
|
||||||
``\\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] = []
|
result: list[str] = []
|
||||||
in_string = False
|
in_string = False
|
||||||
@ -257,8 +210,6 @@ def _repair_json_strings(text: str) -> str:
|
|||||||
_escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'}
|
_escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'}
|
||||||
for ch in text:
|
for ch in text:
|
||||||
if escape:
|
if escape:
|
||||||
# The previous character was a backslash — emit this char literally
|
|
||||||
# and reset the escape flag.
|
|
||||||
result.append(ch)
|
result.append(ch)
|
||||||
escape = False
|
escape = False
|
||||||
continue
|
continue
|
||||||
@ -267,12 +218,10 @@ def _repair_json_strings(text: str) -> str:
|
|||||||
escape = True
|
escape = True
|
||||||
continue
|
continue
|
||||||
if ch == '"':
|
if ch == '"':
|
||||||
# Toggle string-mode on every unescaped double quote.
|
|
||||||
in_string = not in_string
|
in_string = not in_string
|
||||||
result.append(ch)
|
result.append(ch)
|
||||||
continue
|
continue
|
||||||
if in_string and ch in _escapes:
|
if in_string and ch in _escapes:
|
||||||
# Replace the bare control character with its escape sequence.
|
|
||||||
result.append(_escapes[ch])
|
result.append(_escapes[ch])
|
||||||
continue
|
continue
|
||||||
result.append(ch)
|
result.append(ch)
|
||||||
@ -336,25 +285,13 @@ def extract_json(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _strip_code_fences(text: str) -> str:
|
def _strip_code_fences(text: str) -> str:
|
||||||
"""Remove a single wrapping markdown code fence from a string.
|
"""Remove markdown code fences (```json ... ```) 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:
|
if text is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
lines = text.split("\n")
|
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)
|
end = -1 if lines[-1].strip() == "```" else len(lines)
|
||||||
text = "\n".join(lines[1:end])
|
text = "\n".join(lines[1:end])
|
||||||
return text.strip()
|
return text.strip()
|
||||||
@ -391,7 +328,9 @@ class CodingAgent:
|
|||||||
self.api_key = os.getenv("API_KEY")
|
self.api_key = os.getenv("API_KEY")
|
||||||
self.model = os.getenv("MODEL")
|
self.model = os.getenv("MODEL")
|
||||||
|
|
||||||
|
#async def _call_api(self, messages: list) -> str:
|
||||||
def _call_api(self, messages: list) -> str:
|
def _call_api(self, messages: list) -> str:
|
||||||
|
|
||||||
"""Make a raw API call and return the response content string."""
|
"""Make a raw API call and return the response content string."""
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
@ -401,33 +340,20 @@ class CodingAgent:
|
|||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
# Low temperature keeps the agent's tool selections deterministic and
|
"temperature": 0.2, # low temperature → deterministic, more reliable tool calls
|
||||||
# reduces the chance of hallucinated tool names or argument formats.
|
|
||||||
"temperature": 0.2,
|
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"API Error {response.status_code}: {response.text}")
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
self.api_url,
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
timeout=60)
|
|
||||||
response.raise_for_status()
|
|
||||||
logger.info("LLM API response requested")
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
logger.exception("API Error; HTTP-Fehler: %s", exc)
|
|
||||||
raise Exception(f"HTTP-Fehler: {exc}") from exc
|
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if "choices" in data and len(data["choices"]) > 0:
|
if "choices" in data and len(data["choices"]) > 0:
|
||||||
logger.info("valid API output, data returned")
|
|
||||||
return data["choices"][0]["message"]["content"]
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
logger.error("Invalid API response format")
|
|
||||||
raise Exception("Invalid API response format")
|
raise Exception("Invalid API response format")
|
||||||
|
|
||||||
|
|
||||||
# ── Public interface ──────────────────────────────────────────────────────
|
# ── Public interface ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -440,7 +366,6 @@ class CodingAgent:
|
|||||||
self.pending_action = None
|
self.pending_action = None
|
||||||
self.is_done = False
|
self.is_done = False
|
||||||
self.iteration = 0
|
self.iteration = 0
|
||||||
logger.info("New ask initialized")
|
|
||||||
|
|
||||||
async def propose_next_action(self) -> dict:
|
async def propose_next_action(self) -> dict:
|
||||||
"""Ask the LLM what to do next.
|
"""Ask the LLM what to do next.
|
||||||
@ -467,7 +392,6 @@ class CodingAgent:
|
|||||||
raw = _strip_code_fences(raw)
|
raw = _strip_code_fences(raw)
|
||||||
cleaned = extract_json(raw)
|
cleaned = extract_json(raw)
|
||||||
action = json.loads(cleaned)
|
action = json.loads(cleaned)
|
||||||
logger.info("Propose next action successfull")
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
action = {
|
action = {
|
||||||
"thought": "Could not parse LLM response as JSON.",
|
"thought": "Could not parse LLM response as JSON.",
|
||||||
@ -475,7 +399,6 @@ class CodingAgent:
|
|||||||
"arguments": {"summary": "Stopped: JSON parse error."},
|
"arguments": {"summary": "Stopped: JSON parse error."},
|
||||||
}
|
}
|
||||||
raw = json.dumps(action)
|
raw = json.dumps(action)
|
||||||
logger.critical("Parsing API response into valid JASON failed in Step 'propose_next_action'")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
action = {
|
action = {
|
||||||
"thought": f"API call failed: {e}",
|
"thought": f"API call failed: {e}",
|
||||||
@ -483,7 +406,6 @@ class CodingAgent:
|
|||||||
"arguments": {"summary": f"Stopped: {e}"},
|
"arguments": {"summary": f"Stopped: {e}"},
|
||||||
}
|
}
|
||||||
raw = json.dumps(action)
|
raw = json.dumps(action)
|
||||||
logger.critical("API call faliled in Step %s: %s", self.iteration, e)
|
|
||||||
|
|
||||||
self.pending_action = {"raw": raw, "action": action}
|
self.pending_action = {"raw": raw, "action": action}
|
||||||
return action
|
return action
|
||||||
@ -506,8 +428,6 @@ class CodingAgent:
|
|||||||
self.messages.append({"role": "assistant", "content": raw})
|
self.messages.append({"role": "assistant", "content": raw})
|
||||||
self.pending_action = None
|
self.pending_action = None
|
||||||
|
|
||||||
logger.info("Messages prepared after approval")
|
|
||||||
|
|
||||||
# Handle completion
|
# Handle completion
|
||||||
if tool_name == "done":
|
if tool_name == "done":
|
||||||
self.is_done = True
|
self.is_done = True
|
||||||
@ -521,7 +441,6 @@ class CodingAgent:
|
|||||||
# Execute the tool
|
# Execute the tool
|
||||||
result = await dispatch_tool(tool_name, arguments)
|
result = await dispatch_tool(tool_name, arguments)
|
||||||
result = truncate_result(result)
|
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.
|
# 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
|
# Append a <replan> tag on errors to force the agent to reconsider
|
||||||
@ -533,7 +452,6 @@ class CodingAgent:
|
|||||||
"Re-examine your plan: what went wrong and what should you do differently? "
|
"Re-examine your plan: what went wrong and what should you do differently? "
|
||||||
"State your revised plan in your next thought.</replan>"
|
"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})
|
self.messages.append({"role": "user", "content": feedback})
|
||||||
|
|
||||||
@ -561,7 +479,6 @@ class CodingAgent:
|
|||||||
"address their question accordingly.</replan>"
|
"address their question accordingly.</replan>"
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
logger.info("Follow-up message appended.")
|
|
||||||
|
|
||||||
def reject(self, feedback: str) -> None:
|
def reject(self, feedback: str) -> None:
|
||||||
"""Reject the pending action and inject user feedback.
|
"""Reject the pending action and inject user feedback.
|
||||||
@ -588,4 +505,39 @@ class CodingAgent:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
self.pending_action = None
|
self.pending_action = None
|
||||||
logger.info("Rejection message appended.")
|
|
||||||
|
def main():
|
||||||
|
"""Example of how to use the CodingAgent in a simple loop."""
|
||||||
|
agent = CodingAgent()
|
||||||
|
task = "Write a Python function that returns the nth Fibonacci number."
|
||||||
|
agent.start_task(task)
|
||||||
|
|
||||||
|
if agent.pending_action:
|
||||||
|
print(f"Initial proposed action: {agent.pending_action['action']}")
|
||||||
|
|
||||||
|
|
||||||
|
while not agent.is_done:
|
||||||
|
action = asyncio.run(agent.propose_next_action())
|
||||||
|
print(f"Proposed action: {action}")
|
||||||
|
|
||||||
|
if action["tool"] == "done":
|
||||||
|
print("Task completed.")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
user_feedback = input("Approve this action? (y/n) ")
|
||||||
|
if user_feedback.lower() == "y":
|
||||||
|
result = asyncio.run(agent.approve())
|
||||||
|
print(f"Tool result: {result}")
|
||||||
|
elif user_feedback.lower() == "n":
|
||||||
|
feedback = input("Enter feedback for the agent: ")
|
||||||
|
agent.reject(feedback)
|
||||||
|
|
||||||
|
|
||||||
|
if result["is_done"]:
|
||||||
|
print("Task completed.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,3 @@
|
|||||||
"""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 asyncio
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@ -19,89 +7,62 @@ from pathlib import Path
|
|||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
|
|
||||||
from backend.managers.debug_logger import get_logger
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
class MCPToolAdapter:
|
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"):
|
def __init__(self, config_path: str = "mcp_server_config.json"):
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
self.servers: Dict[str, Dict] = {}
|
self.servers: Dict[str, Dict] = {}
|
||||||
|
#self.exit_stack: Dict[str, Any] = {}
|
||||||
self.tool_registry: List[Dict[str, Any]] = []
|
self.tool_registry: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
def _load_config(self) -> Dict[str, Any]:
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
"""Load the MCP server configuration from the JSON file next to this module.
|
"""Lädt die Server-Konfiguration aus der JSON-Datei."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed config dict, or an empty dict if the file is missing or invalid.
|
|
||||||
"""
|
|
||||||
path = Path(__file__).parent / self.config_path
|
path = Path(__file__).parent / self.config_path
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
logger.warning("Config file not found: %s", path)
|
print(f"Config file not found: {path}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
config_file = json.load(f)
|
return json.load(f)
|
||||||
logger.info("MCP-Server config loaded successfully")
|
|
||||||
return config_file
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.critical("Error decoding JSON from server config: %s", e)
|
print(f"Error decoding JSON config: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def initialize_all_servers(self):
|
async def initialize_all_servers(self):
|
||||||
"""Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren."""
|
"""Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren."""
|
||||||
|
print("Initializing MCP sessions...")
|
||||||
config = self._load_config()
|
config = self._load_config()
|
||||||
logger.info("Loaded config for servers: %s", list(config.keys()))
|
print(f"Loaded config for servers: {list(config.keys())}")
|
||||||
|
|
||||||
for server_name, params in config.items():
|
for server_name, params in config.items():
|
||||||
logger.info("Initializing connection to %s", server_name)
|
print(f"Testing connection to {server_name}...")
|
||||||
|
|
||||||
self.servers[server_name] = params
|
self.servers[server_name] = params
|
||||||
server_script = str(Path(__file__).parent / params["args"][0])
|
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"]:
|
if params.get("command") in ["py", "python", "python3"]:
|
||||||
server_command = sys.executable
|
server_command = sys.executable
|
||||||
else:
|
else:
|
||||||
server_command = params["command"]
|
server_command = params["command"]
|
||||||
|
|
||||||
server_params = StdioServerParameters(
|
server_params = StdioServerParameters(
|
||||||
command=server_command,
|
command=server_command,
|
||||||
args=[server_script],
|
args=[server_script],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Verbindung aufbauen
|
||||||
async with stdio_client(server_params) as (read_stream, write_stream):
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||||
logger.info("Connected to %s. Initializing session...", server_name)
|
print(f"Connected to {server_name}. Initializing session...")
|
||||||
async with ClientSession(read_stream, write_stream) as session:
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
logger.info("Session initialized for %s. Requesting tools...", server_name)
|
print(f"Session initialized for {server_name}. Requesting tools...")
|
||||||
result = await session.list_tools()
|
result = await session.list_tools()
|
||||||
|
print(f"Tools received from {server_name}: {result}")
|
||||||
tools = result.tools
|
tools = result.tools
|
||||||
logger.info("Tools received from %s: %s Tools", server_name, str(len(tools)))
|
print(f"Tools received from {server_name}: {result}")
|
||||||
|
#tools = getattr(result, 'tools', [])
|
||||||
|
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
# Build a human-readable parameter description for the system prompt.
|
|
||||||
t_params = tool.inputSchema.get("properties", {})
|
t_params = tool.inputSchema.get("properties", {})
|
||||||
if t_params:
|
if t_params:
|
||||||
param_lines = []
|
param_lines = []
|
||||||
@ -115,44 +76,32 @@ class MCPToolAdapter:
|
|||||||
|
|
||||||
t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}"
|
t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}"
|
||||||
|
|
||||||
|
|
||||||
self.tool_registry.append({
|
self.tool_registry.append({
|
||||||
"server": server_name,
|
"server": server_name,
|
||||||
"tool_name": tool.name,
|
"tool_name": tool.name,
|
||||||
"tool_description": t_definition
|
"tool_description": t_definition
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info("Registered tool '%s' from %s.", tool.name, server_name)
|
print(f"Registered tool '{tool.name}' from {server_name}.")
|
||||||
|
|
||||||
|
print(f"Session for {server_name} ready. {len(tools)} tools found.")
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to initialize %s: %s", server_name, str(e))
|
print(f"Failed to initialize {server_name}: {e}")
|
||||||
|
|
||||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||||
"""Return the full list of registered tools across all servers.
|
"""Gibt alle gesammelten Tools zurück."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dicts, each with keys "server", "tool_name", "tool_description".
|
|
||||||
"""
|
|
||||||
return self.tool_registry
|
return self.tool_registry
|
||||||
|
|
||||||
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]):
|
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.
|
"""Findet den richtigen Server für ein Tool und führt es aus."""
|
||||||
|
# Suche in der Registry nach dem passenden Server
|
||||||
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)
|
tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None)
|
||||||
|
|
||||||
if not tool_entry:
|
if not tool_entry:
|
||||||
logger.warning("Tool '%s' not found in MCP adapter registry.", tool_name)
|
print(f"Tool '{tool_name}' not found in MCP adapter registry.")
|
||||||
return f"Error: Tool '{tool_name}' not found in registry."
|
return f"Error: Tool '{tool_name}' not found in registry."
|
||||||
|
|
||||||
server_name = tool_entry["server"]
|
server_name = tool_entry["server"]
|
||||||
@ -160,12 +109,11 @@ class MCPToolAdapter:
|
|||||||
|
|
||||||
if s_params:
|
if s_params:
|
||||||
server_script = str(Path(__file__).parent / s_params["args"][0])
|
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"]:
|
if s_params.get("command") in ["py", "python", "python3"]:
|
||||||
server_command = sys.executable
|
server_command = sys.executable
|
||||||
else:
|
else:
|
||||||
server_command = s_params["command"]
|
server_command = s_params["command"]
|
||||||
|
|
||||||
server_params = StdioServerParameters(
|
server_params = StdioServerParameters(
|
||||||
command=server_command,
|
command=server_command,
|
||||||
args=[server_script],
|
args=[server_script],
|
||||||
@ -175,15 +123,24 @@ class MCPToolAdapter:
|
|||||||
async with stdio_client(server_params) as (read_stream, write_stream):
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||||
async with ClientSession(read_stream, write_stream) as session:
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
await session.initialize()
|
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)
|
result = await session.call_tool(tool_name, arguments)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error calling tool '%s' on server '%s': %s", tool_name, server_name, str(e))
|
|
||||||
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
|
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
|
||||||
|
|
||||||
|
return f"Error: Session for server '{server_name}' not active."
|
||||||
|
|
||||||
|
async def shutdown_all_sessions(self):
|
||||||
|
"""Schließt alle offenen Verbindungen sauber."""
|
||||||
|
for server_name, (transport_gen, session) in self.exit_stack.items():
|
||||||
|
try:
|
||||||
|
await session.__aexit__(None, None, None)
|
||||||
|
await transport_gen.__aexit__(None, None, None)
|
||||||
|
print(f"Session for {server_name} shut down.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during shutdown of {server_name}: {e}")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Debug Function for Tool-Registry"""
|
|
||||||
adapter = MCPToolAdapter()
|
adapter = MCPToolAdapter()
|
||||||
asyncio.run(adapter.initialize_all_servers())
|
asyncio.run(adapter.initialize_all_servers())
|
||||||
print("All servers initialized. Registered tools:")
|
print("All servers initialized. Registered tools:")
|
||||||
|
|||||||
114
backend/agent/mcp_server_adapter_RAG.py
Normal file
114
backend/agent/mcp_server_adapter_RAG.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
# import os
|
||||||
|
import numpy as np
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sentence_transformers import SentenceTransformer # embedder
|
||||||
|
from mcp import ClientSession, StdioServerParameters
|
||||||
|
from mcp.client.stdio import stdio_client
|
||||||
|
|
||||||
|
class MCPToolRAGAdapter:
|
||||||
|
def __init__ (self, config_path: str = "mcp_server_config.json"):
|
||||||
|
self.config_path = config_path
|
||||||
|
self.tools = []
|
||||||
|
self.toolnames = []
|
||||||
|
self.embedder = SentenceTransformer('all-MiniLM-L6-v2') # for embedding tool descriptions
|
||||||
|
self.sessions = {}
|
||||||
|
self.exit_stack = {}
|
||||||
|
self.tool_registry = {}
|
||||||
|
self.tool_embeddings = None
|
||||||
|
|
||||||
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
|
config_path = Path(__file__).parent / self.config_path
|
||||||
|
if not config_path.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error decoding JSON config: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def initialize_all_sessions(self):
|
||||||
|
"""Initialize all MCP sessions defined in the config file and index their tools."""
|
||||||
|
config = self._load_config()
|
||||||
|
for server_name, params in config.items():
|
||||||
|
print(f"initializing session for {server_name} with params: {params}")
|
||||||
|
server_params = StdioServerParameters(
|
||||||
|
commanf=params["command"],
|
||||||
|
args=params.get("args", []),
|
||||||
|
# env=params.get("env", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verbindung aufbauen (Kontext-Manager manuell handhaben für Langzeit-Sessions)
|
||||||
|
transport_gen = stdio_client(server_params)
|
||||||
|
read, write = await transport_gen.__aenter__()
|
||||||
|
session = ClientSession(read, write)
|
||||||
|
await session.__aenter__()
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
|
self.sessions[server_name] = session
|
||||||
|
self.exit_stack[server_name] = (transport_gen, session) # Zum späteren sauberen Schließen speichern
|
||||||
|
print(f"Session for {server_name} initialized successfully.")
|
||||||
|
|
||||||
|
# call tools and index thme
|
||||||
|
result = await session.list_tools()
|
||||||
|
tools = result.get("tools", [])
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
self.tool_registry.append({
|
||||||
|
"server": server_name,
|
||||||
|
"tool_name": tool["name"],
|
||||||
|
"definition": tool,
|
||||||
|
"search_text": f"{tool['name']}: {tool.get('description', '')}",
|
||||||
|
})
|
||||||
|
self.tool_names.append(tool["name"])
|
||||||
|
|
||||||
|
# embeddings for all tools in this session
|
||||||
|
if self.tool_registry:
|
||||||
|
texts = [t["search_text"] for t in self.tool_registry]
|
||||||
|
self.tool_embeddings = self.embedder.encode(texts)
|
||||||
|
print(f"Indexing completed. {len(texts)} tools ready.")
|
||||||
|
|
||||||
|
def get_relevant_tools(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||||
|
"""Given a user query, return the most relevant tools based on semantic similarity."""
|
||||||
|
if not self.tool_embeddings or not self.tool_registry:
|
||||||
|
print("No tools indexed yet.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
query_embedding = self.embedder.encode([query])
|
||||||
|
similarities = np.dot(self.tool_embeddings, query_embedding.T).flatten()
|
||||||
|
top_indices = np.argsort(similarities)[-top_k:][::-1]
|
||||||
|
|
||||||
|
relevant_tools = [self.tool_registry[i] for i in top_indices]
|
||||||
|
|
||||||
|
return relevant_tools
|
||||||
|
|
||||||
|
async def call_tool(self, tool_name: str, arguments: Dict):
|
||||||
|
""" Finds the right server for the tool and calls it with the provided arguments. """
|
||||||
|
for item in self.tool_registry:
|
||||||
|
if item["definition"].name == tool_name:
|
||||||
|
server_name = item["server"]
|
||||||
|
session = self.sessions.get(server_name)
|
||||||
|
if session:
|
||||||
|
try:
|
||||||
|
result = await session.call_tool(tool_name, arguments)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error calling tool {tool_name} on server {server_name}: {e}")
|
||||||
|
return f"Error calling tool: {e}"
|
||||||
|
|
||||||
|
return f"Tool '{tool_name}' not found in registry."
|
||||||
|
|
||||||
|
async def shutdown_all_sessions(self):
|
||||||
|
"""Gracefully shutdown all MCP sessions."""
|
||||||
|
for server_name, (transport_gen, session) in self.exit_stack.items():
|
||||||
|
try:
|
||||||
|
await session.__aexit__(None, None, None)
|
||||||
|
await transport_gen.__aexit__(None, None, None)
|
||||||
|
print(f"Session for {server_name} shut down successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error shutting down session for {server_name}: {e}")
|
||||||
@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
"WebSearchServer": {
|
"WebSearchServer": {
|
||||||
"command": "py",
|
"command": "py",
|
||||||
"args": ["servers/mcp_server_web_search.py"]
|
"args": ["servers/mcp_server_web_search.py"],
|
||||||
|
"env": {
|
||||||
|
"DDGS_API_KEY": "your_ddgs_api_key_here"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"CodeExecutionServer": {
|
"CodeExecutionServer": {
|
||||||
|
|||||||
@ -1,34 +1,35 @@
|
|||||||
"""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 ast
|
||||||
import os
|
from datetime import datetime
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from pyflakes.api import check # For linting Code
|
from pyflakes.api import check
|
||||||
from pyflakes.reporter import Reporter # For linting Code
|
from pyflakes.reporter import Reporter
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pathlib import Path
|
||||||
|
import venv
|
||||||
|
import shutil
|
||||||
|
|
||||||
#from backend.managers.debug_logger import get_logger
|
# ── Sandbox venv ────────────────────────────────────────────────────────────
|
||||||
#logger = get_logger(__name__)
|
SERVER_BASE_DIR = Path(__file__).parent.resolve()
|
||||||
|
SANDBOX_DIR = SERVER_BASE_DIR / ".mcp_sandbox"
|
||||||
|
WORKSPACE_DIR = SERVER_BASE_DIR.parent.parent.parent.parent / "workspace"
|
||||||
|
|
||||||
|
def get_sandbox_paths():
|
||||||
|
"""Bestimmt die Executables innerhalb der Venv ohne os-Modul."""
|
||||||
|
if not SANDBOX_DIR.exists():
|
||||||
|
venv.create(SANDBOX_DIR, with_pip=True)
|
||||||
|
|
||||||
|
bin_folder = "Scripts" if Path("C:/").exists() else "bin" # Einfacher Check für Windows
|
||||||
|
|
||||||
|
python_exe = SANDBOX_DIR / bin_folder / "python"
|
||||||
|
pip_exe = SANDBOX_DIR / bin_folder / "pip"
|
||||||
|
|
||||||
|
return str(python_exe), str(pip_exe)
|
||||||
|
|
||||||
|
PYTHON_EXE, PIP_EXE = get_sandbox_paths()
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────────────────────
|
# ── Configuration ────────────────────────────────────────────────────────────
|
||||||
EXEC_TIMEOUT = 15 # seconds before killing the subprocess
|
EXEC_TIMEOUT = 10 # seconds before killing the subprocess
|
||||||
MAX_OUTPUT_LENGTH = 3000 # max characters of stdout+stderr to return
|
MAX_OUTPUT_LENGTH = 3000 # max characters of stdout+stderr to return
|
||||||
|
|
||||||
# ── Create the MCP server ────────────────────────────────────────────────────
|
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||||
@ -67,9 +68,6 @@ FORBIDDEN_SEQUENCES = ["../", "..\\", "/etc/", "/dev/",
|
|||||||
"C:\\Windows", "C:\\Program Files", "C:\\Users",
|
"C:\\Windows", "C:\\Program Files", "C:\\Users",
|
||||||
"compile(", "__import__", "os.", "sys.", "subprocess."]
|
"compile(", "__import__", "os.", "sys.", "subprocess."]
|
||||||
|
|
||||||
"""
|
|
||||||
Pre-installed Packages in Sandbox: "pygame", "numpy", "pandas"
|
|
||||||
"""
|
|
||||||
# ── Static Analysis ────────────────────────────────────────────────────
|
# ── Static Analysis ────────────────────────────────────────────────────
|
||||||
def check_code_safety(code: str) -> str | None:
|
def check_code_safety(code: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
@ -82,13 +80,10 @@ def check_code_safety(code: str) -> str | None:
|
|||||||
str or None
|
str or None
|
||||||
Error message if forbidden code found, None if safe.
|
Error message if forbidden code found, None if safe.
|
||||||
"""
|
"""
|
||||||
#logger.info("Checking code safety.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(code)
|
tree = ast.parse(code)
|
||||||
#logger.info("Code has valid Syntax")
|
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
#logger.exception("SyntaxError: %s", e)
|
|
||||||
return f"SyntaxError: {e}"
|
return f"SyntaxError: {e}"
|
||||||
|
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
@ -96,7 +91,6 @@ def check_code_safety(code: str) -> str | None:
|
|||||||
for alias in node.names:
|
for alias in node.names:
|
||||||
top_level_module = alias.name.split('.')[0]
|
top_level_module = alias.name.split('.')[0]
|
||||||
if top_level_module in BLOCKED_IMPORTS:
|
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."
|
return (f"Blocked import: Import of '{alias.name}' is not allowed."
|
||||||
f"line {node.lineno}")
|
f"line {node.lineno}")
|
||||||
|
|
||||||
@ -104,19 +98,16 @@ def check_code_safety(code: str) -> str | None:
|
|||||||
if node.module:
|
if node.module:
|
||||||
top_level = node.module.split(".")[0]
|
top_level = node.module.split(".")[0]
|
||||||
if top_level in BLOCKED_IMPORTS:
|
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."
|
return (f"Blocked import: Import from '{node.module}' is not allowed."
|
||||||
f"(module '{top_level}' is blocked) line {node.lineno}")
|
f"(module '{top_level}' is blocked) line {node.lineno}")
|
||||||
|
|
||||||
elif isinstance(node, ast.Call):
|
elif isinstance(node, ast.Call):
|
||||||
if isinstance(node.func, ast.Name):
|
if isinstance(node.func, ast.Name):
|
||||||
if node.func.id in BLOCKED_BUILTINS:
|
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."
|
return f"Blocked builtin: Use of builtin '{node.func.id}' is not allowed."
|
||||||
|
|
||||||
for seq in FORBIDDEN_SEQUENCES:
|
for seq in FORBIDDEN_SEQUENCES:
|
||||||
if seq in code:
|
if seq in code:
|
||||||
#logger.warning("Suspect path sequence '%s' detected.", seq)
|
|
||||||
return f"Blocked: Suspect path sequence '{seq}' detected."
|
return f"Blocked: Suspect path sequence '{seq}' detected."
|
||||||
|
|
||||||
return None # No violations found
|
return None # No violations found
|
||||||
@ -131,16 +122,11 @@ def analyse_structure(code: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A summary of the code's structure, including functions, classes, and imports.
|
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:
|
try:
|
||||||
tree = ast.parse(code)
|
tree = ast.parse(code)
|
||||||
#logger.info("Code tree parsed successfully")
|
|
||||||
except SyntaxError as e:
|
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}"
|
return f"Syntax Error: Invalid Python code provided. Line {e.lineno}: {e.msg}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error parsing code: %s", str(e))
|
|
||||||
return f"Error parsing code: {str(e)}"
|
return f"Error parsing code: {str(e)}"
|
||||||
|
|
||||||
analysis = {
|
analysis = {
|
||||||
@ -196,7 +182,6 @@ def analyse_structure(code: str) -> str:
|
|||||||
lines.append(f" - def {func['name']}({args_str})")
|
lines.append(f" - def {func['name']}({args_str})")
|
||||||
|
|
||||||
if not any([analysis["imports"], analysis["classes"], analysis["functions"]]):
|
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 "Analysis complete: No top-level imports, classes, or functions found."
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@ -213,8 +198,6 @@ def lint_code(code: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A report of linting issues or a success message if the code is clean.
|
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()
|
error_buffer = io.StringIO()
|
||||||
warning_buffer = io.StringIO()
|
warning_buffer = io.StringIO()
|
||||||
|
|
||||||
@ -222,9 +205,7 @@ def lint_code(code: str) -> str:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
check(code, filename="<agent_code>", reporter=reporter)
|
check(code, filename="<agent_code>", reporter=reporter)
|
||||||
#logger.info("Linting successfull")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Critical error during linting: %s", str(e))
|
|
||||||
return f"Critical error during linting: {str(e)}"
|
return f"Critical error during linting: {str(e)}"
|
||||||
|
|
||||||
errors = error_buffer.getvalue().strip()
|
errors = error_buffer.getvalue().strip()
|
||||||
@ -232,7 +213,6 @@ def lint_code(code: str) -> str:
|
|||||||
|
|
||||||
# Ergebnis-String zusammenbauen
|
# Ergebnis-String zusammenbauen
|
||||||
if not errors and not warnings:
|
if not errors and not warnings:
|
||||||
#logger.info("No issues found")
|
|
||||||
return "Linting complete: No issues found. The code is syntactically sound."
|
return "Linting complete: No issues found. The code is syntactically sound."
|
||||||
|
|
||||||
report = ["--- Linting Report ---"]
|
report = ["--- Linting Report ---"]
|
||||||
@ -247,11 +227,88 @@ def lint_code(code: str) -> str:
|
|||||||
|
|
||||||
report.append("\nAdvice: Please fix these issues before attempting to execute the code.")
|
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)
|
return "\n".join(report)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def run_python_sandboxed(code: str) -> str:
|
def list_sandbox_packages() -> str:
|
||||||
|
"""
|
||||||
|
Lists all Python-Packages, that are installed in the Sandbox and their Version.
|
||||||
|
Helpful to determine if packages like 'pygame', 'numpy' or similair are already available
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[PIP_EXE, "list"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f"Error while listing the packages: {result.stderr}"
|
||||||
|
|
||||||
|
if not result.stdout.strip():
|
||||||
|
return "The Sandbox environment is empty (only Standard-Libraries are available)."
|
||||||
|
|
||||||
|
return f"Installed Packages: {result.stdout}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error trying to list packages from the Sandbox venv: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def install_package_into_sandbox(package_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Install a Python package into the sandbox environment using pip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
package_name: The name of the package to install (e.g., "requests").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A success message or an error message if installation fails.
|
||||||
|
"""
|
||||||
|
clean_name = "".join(e for e in package_name if e.isalnum() or e in "-_.")
|
||||||
|
|
||||||
|
if clean_name in BLOCKED_IMPORTS:
|
||||||
|
return f"Error: Installation of package '{clean_name}' is blocked due to security policies."
|
||||||
|
|
||||||
|
if clean_name in BLOCKED_BUILTINS:
|
||||||
|
return f"Error: Installation of package '{clean_name}' is blocked due to security policies."
|
||||||
|
|
||||||
|
if not clean_name:
|
||||||
|
return "Error: Invalid package name provided."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[PIP_EXE, "install", clean_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=EXEC_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return f"Package '{clean_name}' installed successfully in the sandbox."
|
||||||
|
else:
|
||||||
|
return (f"Error installing package '{clean_name}':\n"
|
||||||
|
f"{result.stdout}\n{result.stderr}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return f"Error: Package installation exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error during package installation: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def reset_sandbox() -> str:
|
||||||
|
"""Löscht die gesamte Sandbox und erstellt sie neu (Full Reset)."""
|
||||||
|
if SANDBOX_DIR.exists():
|
||||||
|
shutil.rmtree(SANDBOX_DIR)
|
||||||
|
get_sandbox_paths()
|
||||||
|
return "Sandbox wurde komplett zurückgesetzt."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def run_python_code_sandboxed(code: str) -> str:
|
||||||
"""
|
"""
|
||||||
Run Python code in a sandboxed environment.
|
Run Python code in a sandboxed environment.
|
||||||
|
|
||||||
@ -267,82 +324,75 @@ def run_python_sandboxed(code: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Combined stdout+stderr, or an error message in str format.
|
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)
|
static_safety = check_code_safety(code)
|
||||||
if static_safety:
|
if static_safety:
|
||||||
return f"Code rejected:{static_safety}"
|
return f"Code rejected:{static_safety}"
|
||||||
|
|
||||||
|
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
jail_dir = WORKSPACE_DIR / f"sandbox_run_{run_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Force UTF-8 I/O so the subprocess can print unicode on Windows
|
jail_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# (default console encoding is cp1252 which cannot encode emoji).
|
|
||||||
utf8_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
custom_env = {
|
||||||
|
"PYTHONPATH": str(WORKSPACE_DIR),
|
||||||
|
"PATH": str(Path(PYTHON_EXE).parent),
|
||||||
|
"HOME": str(jail_dir),
|
||||||
|
"TMPDIR": str(jail_dir)
|
||||||
|
}
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-c", code],
|
[PYTHON_EXE, "-c", code],
|
||||||
stdin=subprocess.DEVNULL,
|
cwd=str(WORKSPACE_DIR),
|
||||||
|
env=custom_env,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
timeout=EXEC_TIMEOUT)
|
||||||
timeout=EXEC_TIMEOUT,
|
|
||||||
env=utf8_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Merge stdout and stderr so the agent sees all output in one block.
|
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
if len(output) > MAX_OUTPUT_LENGTH:
|
if len(output) > MAX_OUTPUT_LENGTH:
|
||||||
output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..."
|
output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..."
|
||||||
|
|
||||||
if not output.strip():
|
if not output.strip():
|
||||||
return "Code executed successfully (no output)."
|
return "Code executed successfully (no output)."
|
||||||
|
|
||||||
#logger.info("Code ran successfully")
|
|
||||||
return output
|
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 subprocess.TimeoutExpired:
|
||||||
|
return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error during code execution: %s", str(e))
|
|
||||||
return f"Error during code execution: {e}"
|
return f"Error during code execution: {e}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if jail_dir.exists():
|
||||||
|
shutil.rmtree(jail_dir)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def python_code_validation(code: str) -> str:
|
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")
|
Validate Python code for syntax and safety without executing it.
|
||||||
|
This tool performs static analysis to check for syntax errors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: The Python code to validate in str format.
|
||||||
|
Returns:
|
||||||
|
A message indicating the validation result.
|
||||||
|
And if sandboxed test execution is allowed.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
ast.parse(code)
|
ast.parse(code)
|
||||||
#logger.info("Ast parsing successfull")
|
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
#logger.warning("Syntax Error while ast parsing code: %s", str(e))
|
|
||||||
return f"SyntaxError: {e}"
|
return f"SyntaxError: {e}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
static_analysis_result = check_code_safety(code)
|
static_analysis_result = check_code_safety(code)
|
||||||
if static_analysis_result:
|
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."
|
return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error during code safety analysis: %e", str(e))
|
|
||||||
return f"Error during code safety analysis: {e}"
|
return f"Error during code safety analysis: {e}"
|
||||||
|
|
||||||
return "Code is valid and can be executed in the sandbox"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Run the server ───────────────────────────────────────────────────────────
|
# ── Run the server ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,30 +1,10 @@
|
|||||||
"""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 pathlib import Path
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
#from backend.managers.debug_logger import get_logger
|
|
||||||
#logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────────────────────
|
# ── Configuration ────────────────────────────────────────────────────────────
|
||||||
# Navigate four levels up from servers/ to the project root, then into workspace/.
|
|
||||||
project_dir = Path(__file__).resolve().parent.parent.parent.parent
|
project_dir = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
ALLOWED_DIR = project_dir / "workspace"
|
ALLOWED_DIR = project_dir / "workspace"
|
||||||
ALLOWED_FILE_TYPES = [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml",
|
ALLOWED_FILE_TYPES = [".py",".js",".html",".css",".json",".yaml",".yml",".sh",".md",".txt",".tex",".c",".cpp",".java"]
|
||||||
".sh", ".md", ".txt", ".tex", ".c", ".cpp", ".java"]
|
|
||||||
|
|
||||||
# ── Create the MCP server ────────────────────────────────────────────────────
|
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||||
mcp = FastMCP("FileSearchServer")
|
mcp = FastMCP("FileSearchServer")
|
||||||
@ -35,12 +15,10 @@ def _safe_path(requested: str) -> Path:
|
|||||||
"""Resolve and validate a path is inside ALLOWED_DIR."""
|
"""Resolve and validate a path is inside ALLOWED_DIR."""
|
||||||
resolved = (ALLOWED_DIR / requested).resolve()
|
resolved = (ALLOWED_DIR / requested).resolve()
|
||||||
if not str(resolved).startswith(str(ALLOWED_DIR)):
|
if not str(resolved).startswith(str(ALLOWED_DIR)):
|
||||||
#logger.warning("Access denied: '%s' resolves outside allowed directory.", str(requested))
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Access denied: '{requested}' resolves outside "
|
f"Access denied: '{requested}' resolves outside "
|
||||||
f"the allowed directory '{ALLOWED_DIR}'"
|
f"the allowed directory '{ALLOWED_DIR}'"
|
||||||
)
|
)
|
||||||
#logger.info("Requested path is safe")
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
@ -52,8 +30,6 @@ def list_files() -> str:
|
|||||||
|
|
||||||
Returns a newline-separated list of relative file paths.
|
Returns a newline-separated list of relative file paths.
|
||||||
"""
|
"""
|
||||||
#logger.info("Tool list_files is being executed on MCP file search server")
|
|
||||||
|
|
||||||
files = sorted(
|
files = sorted(
|
||||||
f.relative_to(ALLOWED_DIR)
|
f.relative_to(ALLOWED_DIR)
|
||||||
for f in ALLOWED_DIR.rglob("*")
|
for f in ALLOWED_DIR.rglob("*")
|
||||||
@ -65,7 +41,7 @@ def list_files() -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_file_tree(dir_path: str="") -> str:
|
def get_file_tree(dir_path: str=ALLOWED_DIR) -> str:
|
||||||
"""Get a tree representation of the project directory.
|
"""Get a tree representation of the project directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -74,50 +50,30 @@ def get_file_tree(dir_path: str="") -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A string representing the directory structure, similar to 'tree' command output.
|
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:
|
try:
|
||||||
safe_dir = _safe_path(dir_path)
|
safe_dir = _safe_path(dir_path)
|
||||||
if not safe_dir.exists():
|
if not safe_dir:
|
||||||
#logger.warning("Directory '%s' does not exist.", dir_path)
|
return f"Error: Invalid directory path '{dir_path}'."
|
||||||
|
elif not safe_dir.exists():
|
||||||
return f"Error: Directory '{dir_path}' does not exist."
|
return f"Error: Directory '{dir_path}' does not exist."
|
||||||
elif not safe_dir.is_dir():
|
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."
|
return f"Error: '{dir_path}' is not a valid directory within the allowed path."
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
#logger.exception("Error while checking directory and its path: %s", str(e))
|
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
#logger.info("Generating file tree.")
|
|
||||||
def _tree(dir_path: Path, prefix="") -> str:
|
def _tree(dir_path: Path, prefix="") -> str:
|
||||||
"""Recursively build a tree string for the given directory.
|
entries = sorted([e for e in dir_path.iterdir() if "__pycache__" not in e.parts], key=lambda x: (x.is_file(), x.name))
|
||||||
|
|
||||||
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 = []
|
lines = []
|
||||||
for i, entry in enumerate(entries):
|
for i, entry in enumerate(entries):
|
||||||
# Use └── for the last entry to close the branch visually.
|
|
||||||
connector = "└── " if i == len(entries) - 1 else "├── "
|
connector = "└── " if i == len(entries) - 1 else "├── "
|
||||||
lines.append(f"{prefix}{connector}{entry.name}")
|
lines.append(f"{prefix}{connector}{entry.name}")
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
# Extend prefix with a blank column (last item) or │ (more items follow).
|
|
||||||
extension = " " if i == len(entries) - 1 else "│ "
|
extension = " " if i == len(entries) - 1 else "│ "
|
||||||
lines.append(_tree(entry, prefix + extension))
|
lines.append(_tree(entry, prefix + extension))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
return _tree(Path(safe_dir))
|
return _tree(dir_path)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@ -130,8 +86,6 @@ def search_files(query: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A formatted string of search results, or a message if no matches found.
|
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()
|
query_lower = query.lower()
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
@ -153,9 +107,7 @@ def search_files(query: str) -> str:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
#logger.info("No matches found for user query")
|
|
||||||
return f"No matches found for '{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
|
return "\n".join(results[:30]) # limit to 30 matches
|
||||||
|
|
||||||
|
|
||||||
@ -169,33 +121,23 @@ def read_file(path: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
The file content as a string, or an error message if the file cannot be read.
|
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:
|
try:
|
||||||
resolved = _safe_path(path)
|
resolved = _safe_path(path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
if not resolved.exists():
|
if not resolved.exists():
|
||||||
#logger.warning("File '%s' does not exist.", path)
|
|
||||||
return f"Error: File '{path}' does not exist."
|
return f"Error: File '{path}' does not exist."
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
#logger.warning("'%s' is not a valid file.", path)
|
|
||||||
return f"Error: '{path}' is not a file."
|
return f"Error: '{path}' is not a file."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = resolved.read_text(encoding="utf-8")
|
return resolved.read_text(encoding="utf-8")
|
||||||
#logger.info("File read successfully.")
|
|
||||||
return text
|
|
||||||
|
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
#logger.warning("'%s' is not a text file (binary content).", path)
|
|
||||||
return f"Error: '{path}' is not a text file (binary content)."
|
return f"Error: '{path}' is not a text file (binary content)."
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
#logger.warning(f"Permission denied when trying to read '%s'.", path)
|
|
||||||
return f"Error: Permission denied when trying to read '{path}'."
|
return f"Error: Permission denied when trying to read '{path}'."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error reading file '%s': %s", path, e)
|
|
||||||
return f"Error reading file '{path}': {e}"
|
return f"Error reading file '{path}': {e}"
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@ -210,7 +152,6 @@ def write_new_file(path: str, content: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A success or error message.
|
A success or error message.
|
||||||
"""
|
"""
|
||||||
#logger.info("Tool write_new_file is being executed on MCP file search server")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = _safe_path(path)
|
resolved = _safe_path(path)
|
||||||
@ -218,30 +159,26 @@ def write_new_file(path: str, content: str) -> str:
|
|||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
if resolved.exists():
|
if resolved.exists():
|
||||||
#logger.warning("Requested file path '%s' already exists, overwriting not allowed.", path)
|
|
||||||
return (f"ERROR: File '{path}' already exists."
|
return (f"ERROR: File '{path}' already exists."
|
||||||
f"Overwriting is not allowed with this tool."
|
f"Overwriting is not allowed with this tool."
|
||||||
f"Use a different path or filename to create a new file.")
|
f"Use a different path or filename to create a new file.")
|
||||||
|
|
||||||
|
|
||||||
if resolved.suffix not in ALLOWED_FILE_TYPES:
|
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}'."
|
return f"ERROR: can only write {', '.join(ALLOWED_FILE_TYPES)} types, got '{resolved.suffix}'."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||||
resolved.write_text(content, encoding="utf-8")
|
resolved.write_text(content, encoding="utf-8")
|
||||||
#logger.info("File written successfully.")
|
|
||||||
return f"OK: wrote {len(content)} chars to {path}."
|
return f"OK: wrote {len(content)} chars to {path}."
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
#logger.warning("FileNotFoundError for '%s': %s", path, e)
|
print(f"FileNotFoundError for {path}: {e}")
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
#logger.warning("PermissionError for '%s': %s", path, e)
|
print(f"PermissionError for {path}: {e}")
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error writing file: %s", e)
|
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
@ -255,29 +192,23 @@ def create_new_directory(path: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A success or error message.
|
A success or error message.
|
||||||
"""
|
"""
|
||||||
#logger.info("Tool create_new_directory is being executed on MCP file search server")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = _safe_path(path)
|
resolved = _safe_path(path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
if resolved.exists():
|
if resolved.exists():
|
||||||
#logger.warning("Requested path '%s' already exists, overwriting not allowed.", path)
|
|
||||||
return f"Error: File '{path}' already exists."
|
return f"Error: File '{path}' already exists."
|
||||||
|
|
||||||
if resolved.suffix != "":
|
if resolved.suffix != None and resolved.suffix != "":
|
||||||
#logger.warning("Can only create directories, got '%s'.", resolved.suffix)
|
|
||||||
return f"Error: can only create directories, got '{resolved.suffix}'."
|
return f"Error: can only create directories, got '{resolved.suffix}'."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||||
resolved.mkdir()
|
resolved.mkdir()
|
||||||
#logger.info("Directory '%s' created successfully.", path)
|
|
||||||
return f"OK: created empty directory at {path}."
|
return f"OK: created empty directory at {path}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error creating directory '%s': %s", path, e)
|
return f"Error creating dictionary file '{path}': {e}"
|
||||||
return f"Error creating directory '{path}': {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Run the server ───────────────────────────────────────────────────────────
|
# ── Run the server ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,36 +1,10 @@
|
|||||||
"""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
|
from urllib.parse import urlparse
|
||||||
import requests
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from ddgs import DDGS
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
#from backend.managers.debug_logger import get_logger
|
|
||||||
#logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────────────────────
|
# ── Configuration ────────────────────────────────────────────────────────────
|
||||||
MAX_PAGE_LENGTH = 4000 # max characters to return from a fetched page
|
MAX_PAGE_LENGTH = 4000 # max characters to return from a fetched page
|
||||||
REQUEST_TIMEOUT = 10 # seconds
|
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 ────────────────────────────────────────────────────
|
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||||
mcp = FastMCP("WebSearchServer")
|
mcp = FastMCP("WebSearchServer")
|
||||||
|
|
||||||
@ -38,40 +12,28 @@ mcp = FastMCP("WebSearchServer")
|
|||||||
# ── Helper: URL validation (SSRF prevention) ─────────────────────────────────
|
# ── Helper: URL validation (SSRF prevention) ─────────────────────────────────
|
||||||
|
|
||||||
def _validate_url(url: str) -> str:
|
def _validate_url(url: str) -> str:
|
||||||
"""Validate a URL and raise ValueError if it could be used for an SSRF attack.
|
"""Validate a URL to prevent SSRF attacks."""
|
||||||
|
|
||||||
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)
|
parsed = urlparse(url)
|
||||||
#logger.info("Validateing URL")
|
|
||||||
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
#logger.warning("Blocked scheme '%s'. Only http and https are allowed.", parsed.scheme)
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Blocked scheme '{parsed.scheme}'. Only http and https are allowed."
|
f"Blocked scheme '{parsed.scheme}'. Only http and https are allowed."
|
||||||
)
|
)
|
||||||
|
|
||||||
hostname = parsed.hostname or ""
|
hostname = parsed.hostname or ""
|
||||||
|
|
||||||
if hostname in BLOCKED_HOSTS:
|
blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"}
|
||||||
#logger.warning("Blocked internal host: %s", hostname)
|
if hostname in blocked_hosts:
|
||||||
raise ValueError(f"Blocked internal host: {hostname}")
|
raise ValueError(f"Blocked internal host: {hostname}")
|
||||||
|
|
||||||
for prefix in PRIVATE_PREFIXES:
|
private_prefixes = (
|
||||||
|
"10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||||
|
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
||||||
|
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
||||||
|
"172.30.", "172.31.", "192.168.",
|
||||||
|
)
|
||||||
|
for prefix in private_prefixes:
|
||||||
if hostname.startswith(prefix):
|
if hostname.startswith(prefix):
|
||||||
#logger.warning("Blocked private IP range: %s", hostname)
|
|
||||||
raise ValueError(f"Blocked private IP range: {hostname}")
|
raise ValueError(f"Blocked private IP range: {hostname}")
|
||||||
|
|
||||||
return url
|
return url
|
||||||
@ -89,16 +51,12 @@ def web_search(query: str, max_results: int = 5) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A formatted string of search results, or a message if no matches found.
|
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:
|
try:
|
||||||
|
from ddgs import DDGS
|
||||||
results = DDGS().text(query, max_results=max_results)
|
results = DDGS().text(query, max_results=max_results)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
#logger.info("DDGS API call successful, no web search results found.")
|
|
||||||
return f"No results found for: {query}"
|
return f"No results found for: {query}"
|
||||||
|
|
||||||
#logger.info("DDGS API call successfull, web search results returned.")
|
|
||||||
|
|
||||||
formatted = []
|
formatted = []
|
||||||
for r in results:
|
for r in results:
|
||||||
@ -110,7 +68,6 @@ def web_search(query: str, max_results: int = 5) -> str:
|
|||||||
return "\n---\n".join(formatted)
|
return "\n---\n".join(formatted)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("DDGS API call failed, web search error: %s", e)
|
|
||||||
return f"Search error: {e}"
|
return f"Search error: {e}"
|
||||||
|
|
||||||
|
|
||||||
@ -123,54 +80,38 @@ def fetch_page(url: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
The text content of the fetched page, or an error message.
|
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:
|
try:
|
||||||
url = _validate_url(url)
|
url = _validate_url(url)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return f"URL blocked: {e}"
|
return f"URL blocked: {e}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url,
|
url,
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=REQUEST_TIMEOUT,
|
||||||
headers={"User-Agent": "Mozilla/5.0 (Lightweight Web Search MCP Server)"},
|
headers={"User-Agent": "Mozilla/5.0 (Lightweight Web Search MCP Server)"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
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}"
|
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")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|
||||||
# Remove boilerplate elements that add noise without informational value.
|
|
||||||
for tag in soup(["script", "style", "nav", "footer"]):
|
for tag in soup(["script", "style", "nav", "footer"]):
|
||||||
tag.decompose()
|
tag.decompose()
|
||||||
|
|
||||||
text = soup.get_text(separator="\n", strip=True)
|
text = soup.get_text(separator="\n", strip=True)
|
||||||
|
|
||||||
#logger.info("HTML parsing with BeautifulSoup successfull")
|
|
||||||
|
|
||||||
if len(text) > MAX_PAGE_LENGTH:
|
if len(text) > MAX_PAGE_LENGTH:
|
||||||
text = text[:MAX_PAGE_LENGTH] + "\n\n[... truncated ...]"
|
text = text[:MAX_PAGE_LENGTH] + "\n\n[... truncated ...]"
|
||||||
|
|
||||||
return text if text else "Page fetched but no text content found."
|
return text if text else "Page fetched but no text content found."
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#logger.exception("Error parsing HTML: %s", e)
|
return f"Error fetching page: {e}"
|
||||||
return f"Error parsing html: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Run the server ───────────────────────────────────────────────────────────
|
# ── Run the server ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -5,9 +5,6 @@ from dotenv import load_dotenv
|
|||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from backend.managers.debug_logger import get_logger
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
@ -24,7 +21,6 @@ class ChatManager:
|
|||||||
self.api_port = os.getenv("PORT")
|
self.api_port = os.getenv("PORT")
|
||||||
self.api_key = os.getenv("API_KEY")
|
self.api_key = os.getenv("API_KEY")
|
||||||
self.model = os.getenv("MODEL")
|
self.model = os.getenv("MODEL")
|
||||||
self.max_tokens = 2000
|
|
||||||
|
|
||||||
# API endpoint URL (OpenAI-compatible format)
|
# API endpoint URL (OpenAI-compatible format)
|
||||||
self.api_url = f"http://{self.api_host}:{self.api_port}/v1/chat/completions"
|
self.api_url = f"http://{self.api_host}:{self.api_port}/v1/chat/completions"
|
||||||
@ -32,32 +28,16 @@ class ChatManager:
|
|||||||
# Chat history stored in memory
|
# Chat history stored in memory
|
||||||
self.chat_history = []
|
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:
|
def add_message(self, role: str, content: str) -> None:
|
||||||
"""Append a single message to the conversation history."""
|
"""Append a single message to the conversation history."""
|
||||||
self.chat_history.append({"role": role, "content": content})
|
self.chat_history.append({"role": role, "content": content})
|
||||||
|
|
||||||
def get_history(self) -> list:
|
def get_history(self) -> list:
|
||||||
"""Return a copy of the conversation history."""
|
"""Return the full conversation history."""
|
||||||
return list(self.chat_history)
|
return self.chat_history
|
||||||
|
|
||||||
def clear_history(self) -> None:
|
def clear_history(self) -> None:
|
||||||
"""Wipe the conversation history (starts a fresh chat)."""
|
"""Wipe the conversation history (starts a fresh chat)."""
|
||||||
logger.info("Chat history was cleared")
|
|
||||||
self.chat_history = []
|
self.chat_history = []
|
||||||
|
|
||||||
def send_message(self, user_message: str) -> str:
|
def send_message(self, user_message: str) -> str:
|
||||||
@ -69,27 +49,25 @@ class ChatManager:
|
|||||||
# Add user message to history
|
# Add user message to history
|
||||||
self.add_message("user", user_message)
|
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:
|
try:
|
||||||
|
# Prepare request to OpenAI-compatible API
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add API key if available
|
||||||
|
if self.api_key and self.api_key != "EMPTY":
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
# Full history is sent so the model has multi-turn conversation context
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": self.chat_history,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 2000,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
|
||||||
# Make API request
|
# Make API request
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.api_url, headers=headers, json=payload, timeout=30
|
self.api_url, headers=headers, json=payload, timeout=30
|
||||||
@ -97,52 +75,40 @@ class ChatManager:
|
|||||||
|
|
||||||
# Check if request was successful
|
# Check if request was successful
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.warning("API HTTP status error %s: %s", response.status_code, response.text)
|
error_msg = f"API Error {response.status_code}: {response.text}"
|
||||||
raise Exception(f"API Error {response.status_code}")
|
raise Exception(error_msg)
|
||||||
|
|
||||||
logger.info("Response recieved from API")
|
# Parse response
|
||||||
|
response_data = response.json()
|
||||||
except requests.exceptions.Timeout as e:
|
|
||||||
error_msg = f"Timeout Error: {str(e)}"
|
# Extract AI message
|
||||||
self.add_message("assistant", f"Error: {error_msg}")
|
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||||
logger.exception("LLM API timeout: %s", e)
|
ai_message = response_data["choices"][0]["message"]["content"]
|
||||||
raise RuntimeError("LLM API timeout") from e
|
|
||||||
|
# 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:
|
except requests.exceptions.RequestException as e:
|
||||||
error_msg = f"Connection Error: {str(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}")
|
self.add_message("assistant", f"Error: {error_msg}")
|
||||||
logger.exception("LLM API connection failed: %s", e)
|
raise Exception(error_msg)
|
||||||
raise RuntimeError("Connection Error: LLM API connection failed") from e
|
|
||||||
|
|
||||||
return self.receive_response(response)
|
|
||||||
|
|
||||||
def receive_response(self, response) -> str:
|
|
||||||
"""Parse an API response object and return the AI reply text.
|
|
||||||
|
|
||||||
Extracts the message content from the JSON body, appends it to history,
|
|
||||||
and returns it. Raises on malformed JSON or unexpected response shape.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response_data = response.json()
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
error_msg = f"JSON Decode Error: {str(e)}"
|
error_msg = f"JSON Decode Error: {str(e)}"
|
||||||
self.add_message("assistant", f"Error: {error_msg}")
|
self.add_message("assistant", f"Error: {error_msg}")
|
||||||
logger.exception("JSON Decode Error: %s", e)
|
|
||||||
raise Exception(error_msg)
|
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:
|
except Exception as e:
|
||||||
error_msg = f"Error: {str(e)}"
|
error_msg = f"Error: {str(e)}"
|
||||||
self.add_message("assistant", f"Error: {error_msg}")
|
self.add_message("assistant", f"Error: {error_msg}")
|
||||||
logger.exception("JSON parsing and message formatting failed: %s", e)
|
raise Exception(error_msg)
|
||||||
raise RuntimeError("JSON parsing and message formatting failed") from e
|
|
||||||
|
|
||||||
|
def get_chat_display(self) -> list:
|
||||||
|
"""Return a copy of the history suitable for display in the UI."""
|
||||||
|
return [
|
||||||
|
{"role": msg["role"], "content": msg["content"]}
|
||||||
|
for msg in self.chat_history
|
||||||
|
]
|
||||||
|
|||||||
@ -1,123 +1,14 @@
|
|||||||
"""
|
# Simple in-memory logger used during code execution.
|
||||||
Central logging setup for the application.
|
# Messages are collected in a list and can be retrieved for display or debugging.
|
||||||
|
|
||||||
- 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:
|
class DebugLogger:
|
||||||
|
def __init__(self):
|
||||||
|
self.logs = []
|
||||||
|
|
||||||
_initialized = False
|
def log(self, message):
|
||||||
_error_log: list[str] = []
|
"""Append a log message to the in-memory list."""
|
||||||
|
self.logs.append(message)
|
||||||
|
|
||||||
@classmethod
|
def get_logs(self):
|
||||||
def setup(cls):
|
"""Return all collected log messages."""
|
||||||
# prevents multiple setup
|
return self.logs
|
||||||
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)
|
|
||||||
|
|||||||
@ -1,17 +1,6 @@
|
|||||||
"""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 subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
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.
|
# Maximum time (seconds) a subprocess is allowed to run before being killed.
|
||||||
RUN_TIMEOUT = 30
|
RUN_TIMEOUT = 30
|
||||||
|
|
||||||
@ -41,11 +30,17 @@ class ExecutionEngine:
|
|||||||
|
|
||||||
# Build the shell command depending on file type
|
# Build the shell command depending on file type
|
||||||
if suffix == ".py":
|
if suffix == ".py":
|
||||||
cmd = [sys.executable, active_file.name]
|
cmd = ["py", active_file.name]
|
||||||
|
elif suffix == ".tex":
|
||||||
|
# pdflatex in non-interactive mode so it never waits for input
|
||||||
|
cmd = [
|
||||||
|
"pdflatex",
|
||||||
|
"-interaction=nonstopmode",
|
||||||
|
f"-output-directory={current_dir}",
|
||||||
|
active_file.name,
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1}
|
return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1}
|
||||||
|
|
||||||
logger.info("Running file %s with suffix %s", active_file.name, suffix)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
@ -55,31 +50,12 @@ class ExecutionEngine:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=RUN_TIMEOUT,
|
timeout=RUN_TIMEOUT,
|
||||||
)
|
)
|
||||||
logger.info("File ran successfully.")
|
return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode}
|
||||||
return self.capture_output(proc)
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning("Time out afte %s s", RUN_TIMEOUT)
|
|
||||||
return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1}
|
return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
# Raised when the interpreter/compiler binary is not found on PATH
|
# 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}
|
return {"stdout": "", "stderr": str(e), "rc": -1}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error while running %s: %s", active_file.name, e)
|
|
||||||
return {"stdout": "", "stderr": str(e), "rc": -1}
|
return {"stdout": "", "stderr": str(e), "rc": -1}
|
||||||
|
|
||||||
def capture_output(self, proc: subprocess.CompletedProcess) -> dict:
|
|
||||||
"""Extract stdout, stderr, and return code from a completed subprocess.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
proc: The CompletedProcess returned by subprocess.run().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{"stdout": str, "stderr": str, "rc": int} with whitespace stripped.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"stdout": proc.stdout.strip(),
|
|
||||||
"stderr": proc.stderr.strip(),
|
|
||||||
"rc": proc.returncode,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,30 +7,11 @@ touching the filesystem, preventing path-traversal attacks.
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
from pathlib import Path
|
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.
|
# The workspace folder is created at module load so it always exists.
|
||||||
WORKSPACE = Path("workspace")
|
WORKSPACE = Path("workspace")
|
||||||
WORKSPACE.mkdir(exist_ok=True)
|
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:
|
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:
|
def __init__(self, base_path=Path("workspace")) -> None:
|
||||||
self.base_path = Path(base_path)
|
self.base_path = Path(base_path)
|
||||||
self.base_path.mkdir(exist_ok=True)
|
self.base_path.mkdir(exist_ok=True)
|
||||||
@ -47,16 +28,12 @@ class FileManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if folder was created successfully, False otherwise.
|
bool: True if folder was created successfully, False otherwise.
|
||||||
"""
|
"""
|
||||||
logger.info("Creating folder at %s named %s", relative_path, name)
|
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
logger.warning("Invalid folder name")
|
|
||||||
st.error(f"Invalid folder name: {name}")
|
st.error(f"Invalid folder name: {name}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Slashes in the name would silently create nested paths — reject them.
|
# Slashes in the name would silently create nested paths — reject them.
|
||||||
if "/" in name or "\\" in name:
|
if "/" in name or "\\" in name:
|
||||||
logger.warning("'/' or '\\' in foldername not allowed")
|
|
||||||
st.error(f"Invalid folder name (no slashes allowed): {name}")
|
st.error(f"Invalid folder name (no slashes allowed): {name}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -75,14 +52,11 @@ class FileManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
folder_path.mkdir(exist_ok=False)
|
folder_path.mkdir(exist_ok=False)
|
||||||
logger.info("Folder created successfully.")
|
|
||||||
return True
|
return True
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
logger.warning("Folder already exists.")
|
|
||||||
st.warning(f"Folder already exists: {relative_path}")
|
st.warning(f"Folder already exists: {relative_path}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
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)}")
|
st.error(f"Error creating folder {relative_path}: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -97,18 +71,14 @@ class FileManager:
|
|||||||
name (str): The name of the new file to create (should not contain slashes).
|
name (str): The name of the new file to create (should not contain slashes).
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if file was created successfully, False otherwise.
|
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() == "" :
|
if not name or name.strip() == "" :
|
||||||
logger.warning("Invalid folder name")
|
|
||||||
st.error(f"Invalid file name: {name}")
|
st.error(f"Invalid file name: {name}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
name = Path(name)
|
name = Path(name)
|
||||||
if not name.suffix:
|
if not name.suffix:
|
||||||
name = name.with_suffix(".txt") # Default to .txt if no extension provided
|
name = name.with_suffix(".txt") # Default to .txt if no extension provided
|
||||||
logger.info("No suffix was provided, creating .txt file")
|
|
||||||
|
|
||||||
if relative_path:
|
if relative_path:
|
||||||
relative_path = Path(relative_path)
|
relative_path = Path(relative_path)
|
||||||
@ -124,85 +94,71 @@ class FileManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
file_path.touch(exist_ok=False)
|
file_path.touch(exist_ok=False)
|
||||||
logger.info("File created successfully.")
|
|
||||||
return True
|
return True
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
logger.warning("Folder already exists.")
|
|
||||||
st.warning(f"File already exists: {relative_path}")
|
st.warning(f"File already exists: {relative_path}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
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)}")
|
st.error(f"Error creating file {relative_path}: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def read_file(self, relative_path: Path) -> str:
|
def read_file(self, relative_path: Path) -> str:
|
||||||
"""
|
"""
|
||||||
Reads the content of a file.
|
Reads the content of a file.
|
||||||
Accepts an absolute Path object (as stored in st.session_state.open_files).
|
The relative_path should be the path to the file relative to the base path.
|
||||||
The path is validated to ensure it stays inside the workspace.
|
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
str: The content of the file, or an empty string if there was an error.
|
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()
|
file_path = (relative_path).resolve()
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
st.error(f"File not found: {relative_path}")
|
st.error(f"File not found: {relative_path}")
|
||||||
logger.warning("Filepath does not exist.")
|
|
||||||
return ""
|
return ""
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
st.error(f"Path is not a file: {relative_path}")
|
st.error(f"Path is not a file: {relative_path}")
|
||||||
logger.warning("Path is not a file.")
|
|
||||||
return ""
|
return ""
|
||||||
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
||||||
if not str(file_path).startswith(str(self.base_path.resolve())):
|
if not str(file_path).startswith(str(self.base_path.resolve())):
|
||||||
st.error(f"Access denied: {relative_path}")
|
st.error(f"Access denied: {relative_path}")
|
||||||
logger.warning("Access denied. File ist outside WORKSPACE")
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, "r") as f:
|
with open(file_path, "r") as f:
|
||||||
content = f.read()
|
return f.read()
|
||||||
logger.info("File read successfully.")
|
except FileNotFoundError:
|
||||||
return content
|
st.error(f"File not found: {relative_path}")
|
||||||
|
return ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error reading file {relative_path}: {str(e)}")
|
st.error(f"Error reading file {relative_path}: {str(e)}")
|
||||||
logger.exception("Error reading file at %s: %s", relative_path, e)
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def save_file(self, relative_path: str, content: str) -> bool:
|
def save_file(self, relative_path: str, content: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Saves content to a file.
|
Saves content to a file.
|
||||||
Accepts an absolute path string (as stored in st.session_state.open_files).
|
The relative_path should be the path to the file relative to the base path.
|
||||||
The path is validated to ensure it stays inside the workspace.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
relative_path (str): Absolute path to the file to save, including the file name.
|
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.
|
content (str): The content to write to the file
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if save was successful, False otherwise.
|
bool: True if save was successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
logger.info("Saving file at %s.", relative_path)
|
|
||||||
|
|
||||||
file_path = (Path(relative_path)).resolve()
|
file_path = (Path(relative_path)).resolve()
|
||||||
|
|
||||||
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
||||||
if not str(file_path).startswith(str(self.base_path.resolve())):
|
if not str(file_path).startswith(str(self.base_path.resolve())):
|
||||||
st.error(f"Access denied: {relative_path}")
|
st.error(f"Access denied: {relative_path}")
|
||||||
logger.warning("Access denied. File outside WORKSPACE.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, "w") as f:
|
with open(file_path, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
logger.info("File written successfully.")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error saving file {relative_path}: {str(e)}")
|
st.error(f"Error saving file {relative_path}: {str(e)}")
|
||||||
logger.exception("Error saving file %s: %s", relative_path, e)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def rename_file(self, old_relative_path: str, new_name: str) -> bool:
|
def rename_file(self, old_relative_path: str, new_name: str) -> bool:
|
||||||
@ -216,11 +172,8 @@ class FileManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if rename was successful, False otherwise.
|
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() == "":
|
if not new_name or new_name.strip() == "":
|
||||||
st.error(f"Invalid file name: {new_name}")
|
st.error(f"Invalid file name: {new_name}")
|
||||||
logger.warning("New Name is empty.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file_type = Path(old_relative_path).suffix
|
file_type = Path(old_relative_path).suffix
|
||||||
@ -236,20 +189,13 @@ class FileManager:
|
|||||||
# Both old and new paths must stay inside the workspace.
|
# 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())):
|
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}")
|
st.error(f"Access denied: {old_relative_path}")
|
||||||
logger.warning("Access denied, file outside WORKSPACE.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
old_file_path.rename(new_file_path)
|
old_file_path.rename(new_file_path)
|
||||||
logger.info("Renamed successfully.")
|
|
||||||
return True
|
return True
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
st.error(f"File not found: {old_relative_path}")
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -262,29 +208,23 @@ class FileManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if deletion was successful, False otherwise.
|
bool: True if deletion was successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
logger.info("Deleting folder %s.", relative_path)
|
|
||||||
|
|
||||||
folder_path = (self.base_path / relative_path).resolve()
|
folder_path = (self.base_path / relative_path).resolve()
|
||||||
|
|
||||||
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
||||||
if not str(folder_path).startswith(str(self.base_path.resolve())):
|
if not str(folder_path).startswith(str(self.base_path.resolve())):
|
||||||
st.error(f"Access denied: {relative_path}")
|
st.error(f"Access denied: {relative_path}")
|
||||||
logger.warning("Access denied, folder outside WORKSPACE.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not folder_path.exists():
|
if not folder_path.exists():
|
||||||
st.error(f"Folder not found: {relative_path}")
|
st.error(f"Folder not found: {relative_path}")
|
||||||
logger.warning("Folder path not found.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(folder_path)
|
shutil.rmtree(folder_path)
|
||||||
logger.info("Folder deleted successfully.")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error deleting folder {relative_path}: {str(e)}")
|
st.error(f"Error deleting folder {relative_path}: {str(e)}")
|
||||||
logger.exception("Error deleting folder %s: %s", relative_path, str(e))
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_file(self, relative_path: str) -> bool:
|
def delete_file(self, relative_path: str) -> bool:
|
||||||
@ -296,55 +236,43 @@ class FileManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if deletion was successful, False otherwise.
|
bool: True if deletion was successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
logger.info("Deleting file %s.", relative_path)
|
|
||||||
file_path = Path(relative_path)
|
file_path = Path(relative_path)
|
||||||
abs_file_path = (Path(self.base_path) / file_path).resolve()
|
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())):
|
if not str(abs_file_path).startswith(str(self.base_path.resolve())):
|
||||||
st.error(f"Access denied: {relative_path}")
|
st.error(f"Access denied: {relative_path}")
|
||||||
logger.warning("Access denied, file outside WORKSPACE.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
abs_file_path.unlink()
|
abs_file_path.unlink()
|
||||||
logger.info("File deleted successfully.")
|
|
||||||
return True
|
return True
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
st.error(f"File not found: {relative_path}")
|
st.error(f"File not found: {relative_path}")
|
||||||
logger.warning("File not found")
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error deleting file {relative_path}: {str(e)}")
|
st.error(f"Error deleting file {relative_path}: {str(e)}")
|
||||||
logger.exception("Error deleting folder %s: %s", relative_path, str(e))
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_file_tree(self, filter_extensions: bool = True) -> dict:
|
def get_file_tree(self):
|
||||||
"""Builds a nested dictionary representing the file tree.
|
"""
|
||||||
|
Builds a nested dictionary representing the file tree starting from the base path.
|
||||||
Directories are represented as keys with dictionary values,
|
Directories are represented as keys with dictionary values,
|
||||||
files as keys with None.
|
and files are represented 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.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A nested dictionary representing the file tree.
|
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 = {}
|
tree = {}
|
||||||
|
|
||||||
for item in sorted(path.iterdir()):
|
for item in sorted(path.iterdir()):
|
||||||
if item.is_dir():
|
if item.is_dir():
|
||||||
tree[item.name] = build_tree(item)
|
tree[item.name] = build_tree(item) # recurse into sub-folders
|
||||||
else:
|
else:
|
||||||
if filter_extensions and item.suffix not in CODE_EXTENSIONS:
|
tree[item.name] = None # leaf node for files
|
||||||
continue
|
|
||||||
tree[item.name] = None
|
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
return build_tree(self.base_path)
|
return build_tree(self.base_path)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -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,79 +1,8 @@
|
|||||||
"""Builds the system prompt that is sent to the AI at the start of each chat session."""
|
"""Builds the system prompt that is sent to the AI at the start of each chat session."""
|
||||||
|
|
||||||
import ast
|
|
||||||
|
|
||||||
from backend.managers.debug_logger import get_logger
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# Prevents very large files from flooding the context window with tokens.
|
# Prevents very large files from flooding the context window with tokens.
|
||||||
MAX_FILE_CHARS = 4000
|
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
|
|
||||||
|
|
||||||
|
|
||||||
class SystemPrompter:
|
class SystemPrompter:
|
||||||
"""Generates system prompts for the chat assistant.
|
"""Generates system prompts for the chat assistant.
|
||||||
@ -83,57 +12,37 @@ class SystemPrompter:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_prompt(
|
def generate_prompt(file_context: dict | None = None) -> str:
|
||||||
user_message: str = "",
|
"""Build a system prompt, optionally embedding a file's content.
|
||||||
file_context: dict | None = None,
|
|
||||||
search_context: list[dict] | None = None,
|
|
||||||
task_type: str = "default",
|
|
||||||
) -> str:
|
|
||||||
"""Build a system prompt, optionally embedding a file and/or web search results.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_message: The current user input — used for task-type detection
|
file_context: dict with keys 'name' (filename) and 'content' (raw text),
|
||||||
and selective context extraction. Reserved for future
|
or None if no file should be included.
|
||||||
task-specific prompt tuning beyond what task_type covers.
|
|
||||||
file_context: dict with keys 'name' (filename) and 'content' (raw text),
|
|
||||||
or None if no file should be included.
|
|
||||||
search_context: list of {"title", "url", "snippet"} dicts from SearchManager,
|
|
||||||
or None if no search results should be included.
|
|
||||||
task_type: One of "debug", "explain", "optimize", "default".
|
|
||||||
Selects the matching base prompt from _TASK_PROMPTS.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A ready-to-use system prompt string.
|
A ready-to-use system prompt string.
|
||||||
"""
|
"""
|
||||||
logger.info("Generating system prompt (task_type=%s).", task_type)
|
base = (
|
||||||
prompt = _TASK_PROMPTS.get(task_type, _TASK_PROMPTS["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."
|
||||||
|
)
|
||||||
|
|
||||||
if file_context:
|
if file_context:
|
||||||
logger.info("Appending file context.")
|
|
||||||
name = file_context.get("name", "unknown")
|
name = file_context.get("name", "unknown")
|
||||||
content = file_context.get("content", "")
|
content = file_context.get("content", "")
|
||||||
|
|
||||||
# Extract only the relevant function/class when the user mentions one;
|
# Truncate large files to avoid exceeding the model's token limit
|
||||||
# otherwise fall back to simple truncation at MAX_FILE_CHARS.
|
if len(content) > MAX_FILE_CHARS:
|
||||||
content = _extract_relevant_context(content, user_message)
|
content = content[:MAX_FILE_CHARS] + "\n... [truncated]"
|
||||||
|
|
||||||
prompt += (
|
file_section = (
|
||||||
f"\n\nThe user currently has the following file open in the editor:\n"
|
f"\n\nThe user currently has the following file open in the editor:\n"
|
||||||
f"<file name=\"{name}\">\n"
|
f"<file name=\"{name}\">\n"
|
||||||
f"<code>\n{content}\n</code>\n"
|
f"<code>\n{content}\n</code>\n"
|
||||||
f"</file>\n"
|
f"</file>\n"
|
||||||
f"Refer to this file when answering questions about the code."
|
f"Refer to this file when answering questions about the code."
|
||||||
)
|
)
|
||||||
|
return base + file_section
|
||||||
|
|
||||||
if search_context:
|
return base
|
||||||
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
|
|
||||||
|
|||||||
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
@ -17,9 +17,6 @@ from pathlib import Path
|
|||||||
# where streamlit is launched from.
|
# where streamlit is launched from.
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
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.sidebar import render_sidebar
|
||||||
from frontend.editor import render_editor
|
from frontend.editor import render_editor
|
||||||
from frontend.chat import render_chat
|
from frontend.chat import render_chat
|
||||||
@ -38,21 +35,24 @@ def main():
|
|||||||
st.markdown(
|
st.markdown(
|
||||||
"""
|
"""
|
||||||
<style>
|
<style>
|
||||||
.block-container { padding-top: 4rem; }
|
.block-container { padding-top: 1rem; }
|
||||||
[data-testid="stSidebarContent"] { padding-top: 0rem; }
|
[data-testid="stSidebarContent"] { padding-top: 0rem; margin-top: -2rem; }
|
||||||
</style>
|
</style>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
st.title("Lightweight code editor")
|
||||||
|
|
||||||
|
# Re-run init_state to cover any keys that might have been missed on cold start
|
||||||
|
init_state()
|
||||||
|
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
|
|
||||||
# Switch between the two main views based on the sidebar radio button
|
# Switch between the two main views based on the sidebar radio button
|
||||||
if st.session_state.get("radio_interface_options") == "Code Editor":
|
if st.session_state.get("radio_interface_options") == "Code Editor":
|
||||||
logger.info("Editor mode")
|
|
||||||
render_editor()
|
render_editor()
|
||||||
elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant":
|
elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant":
|
||||||
logger.info("Chat/Agent mode")
|
|
||||||
render_chat()
|
render_chat()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
446
frontend/chat.py
446
frontend/chat.py
@ -1,35 +1,20 @@
|
|||||||
"""Chat view — renders both the normal chat interface and the Coding Agent mode."""
|
"""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
|
import streamlit as st
|
||||||
from backend.managers.chat_manager import ChatManager
|
from backend.managers.chat_manager import ChatManager
|
||||||
from backend.managers.system_prompter import SystemPrompter
|
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__)
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
||||||
def _run_async(coro):
|
def _run_async(coro):
|
||||||
"""Execute an async coroutine from synchronous Streamlit code.
|
"""Hilfsfunktion um async Code in sync Streamlit auszuführen"""
|
||||||
|
try:
|
||||||
Streamlit always runs in a plain synchronous thread with no running event
|
loop = asyncio.get_running_loop()
|
||||||
loop, so we always create a fresh loop here.
|
except RuntimeError:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
Args:
|
asyncio.set_event_loop(loop)
|
||||||
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)
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
def _start_agent(task: str):
|
def _start_agent(task: str):
|
||||||
@ -37,7 +22,7 @@ def _start_agent(task: str):
|
|||||||
Stores the agent and its state in session_state so Streamlit can reference
|
Stores the agent and its state in session_state so Streamlit can reference
|
||||||
them across reruns without losing progress.
|
them across reruns without losing progress.
|
||||||
"""
|
"""
|
||||||
logger.info("Starting coding agent.")
|
from backend.agent.coding_agent import CodingAgent
|
||||||
agent = CodingAgent()
|
agent = CodingAgent()
|
||||||
agent.start_task(task)
|
agent.start_task(task)
|
||||||
action = _run_async(agent.propose_next_action())
|
action = _run_async(agent.propose_next_action())
|
||||||
@ -53,7 +38,6 @@ def _approve_action():
|
|||||||
pending = st.session_state.agent_pending_action
|
pending = st.session_state.agent_pending_action
|
||||||
|
|
||||||
result = _run_async(agent.approve())
|
result = _run_async(agent.approve())
|
||||||
logger.info("Approve action and propose next step.")
|
|
||||||
|
|
||||||
# Append a record to the log so the user can review every completed step.
|
# Append a record to the log so the user can review every completed step.
|
||||||
st.session_state.agent_log.append({
|
st.session_state.agent_log.append({
|
||||||
@ -78,23 +62,15 @@ def _reject_action(feedback: str):
|
|||||||
The pending action is discarded; the agent receives the user's feedback and
|
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().
|
proposes a different approach on the next call to propose_next_action().
|
||||||
"""
|
"""
|
||||||
logger.info("Rejecting proposed action.")
|
|
||||||
agent = st.session_state.coding_agent
|
agent = st.session_state.coding_agent
|
||||||
agent.reject(feedback or "Please try a different approach.")
|
agent.reject(feedback or "Please try a different approach.")
|
||||||
next_action = _run_async(agent.propose_next_action())
|
next_action = _run_async(agent.propose_next_action())
|
||||||
st.session_state.agent_pending_action = next_action
|
st.session_state.agent_pending_action = next_action
|
||||||
st.session_state.agent_status = "waiting_approval"
|
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):
|
def _followup_agent(question: str):
|
||||||
"""Continue a finished task by injecting a follow-up question and resuming the loop."""
|
"""Continue a finished task by injecting a follow-up question and resuming the loop."""
|
||||||
logger.info("Asking follow up question")
|
|
||||||
agent = st.session_state.coding_agent
|
agent = st.session_state.coding_agent
|
||||||
agent.follow_up(question)
|
agent.follow_up(question)
|
||||||
action = _run_async(agent.propose_next_action())
|
action = _run_async(agent.propose_next_action())
|
||||||
@ -104,7 +80,6 @@ def _followup_agent(question: str):
|
|||||||
|
|
||||||
def _reset_agent():
|
def _reset_agent():
|
||||||
"""Clear all agent state and return to the idle (task input) screen."""
|
"""Clear all agent state and return to the idle (task input) screen."""
|
||||||
logger.info("Resetting Agent")
|
|
||||||
st.session_state.coding_agent = None
|
st.session_state.coding_agent = None
|
||||||
st.session_state.agent_status = "idle"
|
st.session_state.agent_status = "idle"
|
||||||
st.session_state.agent_log = []
|
st.session_state.agent_log = []
|
||||||
@ -113,62 +88,6 @@ def _reset_agent():
|
|||||||
|
|
||||||
# ── Agent Mode UI ─────────────────────────────────────────────────────────────
|
# ── 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():
|
def render_agent_mode():
|
||||||
"""Render the step-by-step agent UI.
|
"""Render the step-by-step agent UI.
|
||||||
|
|
||||||
@ -177,7 +96,6 @@ def render_agent_mode():
|
|||||||
- "waiting_approval" → show proposed action, Approve / Reject / Abort
|
- "waiting_approval" → show proposed action, Approve / Reject / Abort
|
||||||
- "done" → success message, follow-up input, New Task button
|
- "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.
|
# The toggle must always render so Streamlit keeps agent_mode=True in session_state.
|
||||||
st.toggle("Agent Mode", key="agent_mode")
|
st.toggle("Agent Mode", key="agent_mode")
|
||||||
|
|
||||||
@ -192,17 +110,16 @@ def render_agent_mode():
|
|||||||
with st.chat_message("assistant"):
|
with st.chat_message("assistant"):
|
||||||
st.markdown(f"**Step {i + 1} — `{step['tool']}`**")
|
st.markdown(f"**Step {i + 1} — `{step['tool']}`**")
|
||||||
st.caption(f"Thought: {step['thought']}")
|
st.caption(f"Thought: {step['thought']}")
|
||||||
if step.get("arguments"):
|
if step.get("arguments"):
|
||||||
_render_arguments(step["arguments"])
|
st.json(step["arguments"])
|
||||||
with st.expander("➡️ Result", expanded=False):
|
result_text = step.get("result", "")
|
||||||
result_text = step.get("result", "")
|
# Colour the result based on whether the tool succeeded or failed.
|
||||||
# Colour the result based on whether the tool succeeded or failed.
|
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
|
||||||
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
|
st.error(result_text)
|
||||||
st.error(result_text)
|
elif result_text.startswith("OK") or result_text.startswith("DONE"):
|
||||||
elif result_text.startswith("OK") or result_text.startswith("DONE"):
|
st.success(result_text)
|
||||||
st.success(result_text)
|
else:
|
||||||
else:
|
st.code(result_text, language=None)
|
||||||
st.code(result_text, language=None)
|
|
||||||
|
|
||||||
# ── Idle: task input ──────────────────────────────────────────────────────
|
# ── Idle: task input ──────────────────────────────────────────────────────
|
||||||
if agent_status == "idle":
|
if agent_status == "idle":
|
||||||
@ -213,6 +130,8 @@ def render_agent_mode():
|
|||||||
placeholder="e.g. Write a function that sorts a list and saves it to sorted.py",
|
placeholder="e.g. Write a function that sorts a list and saves it to sorted.py",
|
||||||
)
|
)
|
||||||
if st.button("Start Agent", type="primary", use_container_width=True):
|
if st.button("Start Agent", type="primary", use_container_width=True):
|
||||||
|
#loop = asyncio.new_event_loop()
|
||||||
|
#asyncio.set_event_loop(loop)
|
||||||
if task.strip():
|
if task.strip():
|
||||||
with st.spinner("Agent is thinking..."):
|
with st.spinner("Agent is thinking..."):
|
||||||
_start_agent(task.strip())
|
_start_agent(task.strip())
|
||||||
@ -230,12 +149,17 @@ def render_agent_mode():
|
|||||||
|
|
||||||
args = pending.get("arguments", {})
|
args = pending.get("arguments", {})
|
||||||
if args:
|
if args:
|
||||||
_render_arguments(args)
|
# Show file content separately as a code block for readability;
|
||||||
|
# other arguments are displayed as JSON.
|
||||||
|
if "content" in args:
|
||||||
|
display_args = {k: v for k, v in args.items() if k != "content"}
|
||||||
|
if display_args:
|
||||||
|
st.json(display_args)
|
||||||
|
st.code(args["content"], language="python")
|
||||||
|
else:
|
||||||
|
st.json(args)
|
||||||
|
|
||||||
if "agent_reject_feedback" not in st.session_state:
|
feedback = st.text_input(
|
||||||
st.session_state.agent_reject_feedback = ""
|
|
||||||
|
|
||||||
st.text_input(
|
|
||||||
"Rejection feedback (optional):",
|
"Rejection feedback (optional):",
|
||||||
key="agent_reject_feedback",
|
key="agent_reject_feedback",
|
||||||
placeholder="e.g. Use a different approach...",
|
placeholder="e.g. Use a different approach...",
|
||||||
@ -248,11 +172,10 @@ def render_agent_mode():
|
|||||||
_approve_action()
|
_approve_action()
|
||||||
st.rerun()
|
st.rerun()
|
||||||
with col2:
|
with col2:
|
||||||
st.button(
|
if st.button("Reject", use_container_width=True):
|
||||||
"Reject",
|
with st.spinner("Agent is replanning..."):
|
||||||
use_container_width=True,
|
_reject_action(feedback)
|
||||||
on_click=_handle_reject,
|
st.rerun()
|
||||||
)
|
|
||||||
with col3:
|
with col3:
|
||||||
if st.button("Abort Task", use_container_width=True):
|
if st.button("Abort Task", use_container_width=True):
|
||||||
_reset_agent()
|
_reset_agent()
|
||||||
@ -287,313 +210,56 @@ def render_agent_mode():
|
|||||||
st.rerun()
|
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 ───────────────────────────────────────────────────────────────
|
# ── 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():
|
def render_normal_chat():
|
||||||
"""Render the standard multi-turn chat interface.
|
"""Render the standard multi-turn chat interface.
|
||||||
|
|
||||||
On the first message the system prompt is injected into the history,
|
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
|
Each subsequent message appends to the same conversation so the AI retains
|
||||||
full context throughout the session. If search results are active when the
|
full context throughout the session.
|
||||||
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).
|
# Replay the conversation history as chat bubbles (skip system messages).
|
||||||
for message in st.session_state.chat_history:
|
for message in st.session_state.chat_history:
|
||||||
if message["role"] == "system":
|
role = message["role"]
|
||||||
|
if role == "system":
|
||||||
continue
|
continue
|
||||||
with st.chat_message(message["role"]):
|
with st.chat_message(role):
|
||||||
st.markdown(message["content"])
|
st.markdown(message["content"])
|
||||||
|
|
||||||
# ── Toolbar — directly above the sticky chat input ────────────────────────
|
# Chat input — Enter to send, no extra button needed
|
||||||
# In Streamlit, st.chat_input is a fixed footer. Elements placed BEFORE it
|
user_input = st.chat_input("Type your message here...")
|
||||||
# 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>...")
|
|
||||||
if user_input:
|
if user_input:
|
||||||
stripped = user_input.strip()
|
chat_manager = st.session_state.chat_manager
|
||||||
|
|
||||||
# ── /search command ───────────────────────────────────────────────────
|
# On the very first user message, prepend the system prompt so the AI
|
||||||
if stripped.lower().startswith("/search"):
|
# knows it is a code assistant embedded in an editor.
|
||||||
arg = stripped[len("/search"):].strip()
|
if not chat_manager.get_history():
|
||||||
|
system_prompt = SystemPrompter.generate_prompt()
|
||||||
|
chat_manager.add_message("system", system_prompt)
|
||||||
|
|
||||||
with st.chat_message("user"):
|
# Show user message immediately without waiting for response.
|
||||||
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).
|
|
||||||
with st.chat_message("user"):
|
with st.chat_message("user"):
|
||||||
st.markdown(user_input)
|
st.markdown(user_input)
|
||||||
|
|
||||||
|
# Call the AI and show its response with a spinner while waiting.
|
||||||
with st.chat_message("assistant"):
|
with st.chat_message("assistant"):
|
||||||
with st.spinner("Thinking..."):
|
with st.spinner("Thinking..."):
|
||||||
try:
|
try:
|
||||||
ai_response = chat_manager.send_message(message_to_send)
|
ai_response = chat_manager.send_message(user_input)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ai_response = f"Error: {e}"
|
ai_response = f"Error: {e}"
|
||||||
st.markdown(ai_response)
|
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.session_state.chat_history.append({"role": "assistant", "content": ai_response})
|
||||||
st.rerun()
|
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 ───────────────────────────────────────────────────────────────
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
"""Code Editor view — renders the Ace editor, file tabs, and execution output."""
|
"""Code Editor view — renders the Ace editor, file tabs, and execution output."""
|
||||||
|
|
||||||
import ast
|
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
import streamlit_ace as st_ace
|
import streamlit_ace as st_ace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.managers.file_manager import FileManager
|
from backend.managers.file_manager import FileManager
|
||||||
from backend.managers.execution_engine import ExecutionEngine
|
from backend.managers.execution_engine import ExecutionEngine
|
||||||
from backend.managers.debug_logger import get_logger, DebugLogger
|
from backend.managers.debug_logger import DebugLogger
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# Maps file extensions to Ace editor language modes for syntax highlighting.
|
# Maps file extensions to Ace editor language modes for syntax highlighting.
|
||||||
LANG_MAP = {
|
LANG_MAP = {
|
||||||
".py": "python", ".js": "javascript",
|
".py": "python", ".tex": "latex", ".js": "javascript",
|
||||||
".html": "html", ".css": "css", ".sh": "bash",
|
".html": "html", ".css": "css", ".sh": "bash",
|
||||||
".json": "json", ".yaml": "yaml", ".yml": "yaml"
|
".json": "json", ".yaml": "yaml", ".yml": "yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── Modals ────────────────────────────────────────────────────────────────────
|
# ── Modals ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@st.dialog("Rename File")
|
@st.dialog("Rename File")
|
||||||
@ -53,10 +51,8 @@ def _rename_dialog(file_path: str):
|
|||||||
if st.session_state.active_file == file_path:
|
if st.session_state.active_file == file_path:
|
||||||
st.session_state.active_file = new_file_path
|
st.session_state.active_file = new_file_path
|
||||||
st.rerun()
|
st.rerun()
|
||||||
logger.info("Rename file %s to %s successfull", file_path, new_name )
|
|
||||||
else:
|
else:
|
||||||
logger.warning("Rename failed.")
|
st.error("Rename failed. Check that the file still exists.")
|
||||||
st.error("Rename failed. Check that the file %s still exists.", file_path)
|
|
||||||
with col2:
|
with col2:
|
||||||
if st.button("Cancel", use_container_width=True):
|
if st.button("Cancel", use_container_width=True):
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@ -64,14 +60,6 @@ def _rename_dialog(file_path: str):
|
|||||||
|
|
||||||
@st.dialog("Delete File")
|
@st.dialog("Delete File")
|
||||||
def _delete_dialog(abs_file_path: str):
|
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()
|
fm = FileManager()
|
||||||
file_name = Path(abs_file_path).name
|
file_name = Path(abs_file_path).name
|
||||||
relative_path = str(Path(abs_file_path).relative_to(fm.base_path))
|
relative_path = str(Path(abs_file_path).relative_to(fm.base_path))
|
||||||
@ -89,23 +77,17 @@ def _delete_dialog(abs_file_path: str):
|
|||||||
if st.session_state.open_files else None
|
if st.session_state.open_files else None
|
||||||
)
|
)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
logger.info("Deleting file %s successfull.", abs_file_path)
|
|
||||||
else:
|
else:
|
||||||
st.error("Delete failed. Check that the file still exists.")
|
st.error("Delete failed. Check that the file still exists.")
|
||||||
logger.warning("Deleting file %s failed.", abs_file_path)
|
|
||||||
with col2:
|
with col2:
|
||||||
if st.button("Cancel", use_container_width=True):
|
if st.button("Cancel", use_container_width=True):
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
def run_active_file():
|
def run_active_file():
|
||||||
"""Execute the currently active file and store the result in exec_results[file_path].
|
"""Execute the currently active file and store the result in session_state.
|
||||||
|
Returns the execution result dict {stdout, stderr, return_code}, or None
|
||||||
Runs an ast.parse() check first — if the syntax is invalid the file is not
|
if no active file is set.
|
||||||
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
|
active_file = st.session_state.active_file
|
||||||
|
|
||||||
@ -114,170 +96,116 @@ def run_active_file():
|
|||||||
return
|
return
|
||||||
|
|
||||||
execution_engine = ExecutionEngine()
|
execution_engine = ExecutionEngine()
|
||||||
|
debug_logger = DebugLogger()
|
||||||
logger.info("Executing code from %s...", active_file)
|
|
||||||
|
|
||||||
# ast check — only for Python files
|
debug_logger.log(f"Executing code from {active_file}...")
|
||||||
if Path(active_file).suffix == ".py":
|
|
||||||
source = st.session_state.get("files_content", {}).get(active_file, "")
|
|
||||||
try:
|
|
||||||
ast.parse(source)
|
|
||||||
except SyntaxError as e:
|
|
||||||
result = {"stdout": "", "stderr": str(e), "return_code": -1, "ast_error": True}
|
|
||||||
st.session_state.exec_results[active_file] = result
|
|
||||||
logger.error("Syntax error: %s", e)
|
|
||||||
return result
|
|
||||||
|
|
||||||
with st.spinner(f"Running {Path(active_file).name}..."):
|
with st.spinner(f"Running {Path(active_file).name}..."):
|
||||||
output = execution_engine.run_code(Path(active_file))
|
output = execution_engine.run_code(Path(active_file))
|
||||||
|
|
||||||
if output["rc"] == 0:
|
debug_logger.log("Execution completed.")
|
||||||
logger.info("Execution completed successfully.")
|
|
||||||
else:
|
|
||||||
logger.error("Execution failed with exit code %s.", output['rc'])
|
|
||||||
|
|
||||||
result = {
|
st.session_state.code_execution_output = {
|
||||||
"stdout": output["stdout"],
|
"stdout": output["stdout"],
|
||||||
"stderr": output["stderr"],
|
"stderr": output["stderr"],
|
||||||
"return_code": output["rc"],
|
"return_code": output["rc"]
|
||||||
"ast_error": False,
|
|
||||||
}
|
}
|
||||||
st.session_state.exec_results[active_file] = result
|
result = st.session_state.code_execution_output
|
||||||
return result
|
return result
|
||||||
|
|
||||||
class FileViewer:
|
def render_editor():
|
||||||
"""UI component that renders the code editor tabs, Ace editor, and run output."""
|
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
|
||||||
|
st.subheader("Code Editor")
|
||||||
|
|
||||||
def __init__(self):
|
if not st.session_state.open_files:
|
||||||
self.fm = FileManager()
|
st.info("Please select a file to edit.")
|
||||||
|
return
|
||||||
|
|
||||||
def render(self):
|
fm = FileManager()
|
||||||
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
|
|
||||||
st.subheader("Code Editor")
|
|
||||||
|
|
||||||
if not st.session_state.open_files:
|
# ── Tab bar via st.tabs() ─────────────────────────────────────────────────
|
||||||
st.info("Please select a file to edit.")
|
# Build one tab per open file, named by the file's basename.
|
||||||
return
|
tab_names = [Path(f).name for f in st.session_state.open_files]
|
||||||
|
tabs = st.tabs(tab_names)
|
||||||
|
|
||||||
# ── Tab bar via st.tabs() ─────────────────────────────────────────────
|
for idx, file_path in enumerate(st.session_state.open_files):
|
||||||
tab_names = [Path(f).name for f in st.session_state.open_files]
|
with tabs[idx]:
|
||||||
tabs = st.tabs(tab_names)
|
# Load file content from disk on first open; afterwards use the cached version.
|
||||||
|
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.
|
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
|
||||||
jump_target = st.session_state.pop("_jump_to_tab", None)
|
|
||||||
if jump_target and jump_target in st.session_state.open_files:
|
# Ace editor widget — auto_update sends content to Python on each keystroke.
|
||||||
idx = st.session_state.open_files.index(jump_target)
|
code = st_ace.st_ace(
|
||||||
st.components.v1.html(
|
value=st.session_state.files_content[file_path],
|
||||||
f"""<script>
|
language=file_language,
|
||||||
(function() {{
|
theme="monokai",
|
||||||
setTimeout(function() {{
|
key=f"code_editor_{file_path}",
|
||||||
const tabs = window.parent.document
|
auto_update=True,
|
||||||
.querySelectorAll('button[data-baseweb="tab"]');
|
height=400,
|
||||||
if (tabs[{idx}]) tabs[{idx}].click();
|
|
||||||
}}, 100);
|
|
||||||
}})();
|
|
||||||
</script>""",
|
|
||||||
height=0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for idx, file_path in enumerate(st.session_state.open_files):
|
# Keep the in-memory cache in sync with what the editor currently shows.
|
||||||
with tabs[idx]:
|
if code != st.session_state.files_content[file_path]:
|
||||||
# Load file content from disk on first open; use cached version afterwards.
|
st.session_state.files_content[file_path] = code
|
||||||
if file_path not in st.session_state.files_content:
|
|
||||||
st.session_state.files_content[file_path] = self.fm.read_file(Path(file_path))
|
|
||||||
|
|
||||||
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(
|
with cols[1]:
|
||||||
value=st.session_state.files_content[file_path],
|
if st.button("Close File", key=f"close_{file_path}"):
|
||||||
language=file_language,
|
st.session_state.open_files.remove(file_path)
|
||||||
theme="monokai",
|
st.session_state.files_content.pop(file_path, None)
|
||||||
key=f"code_editor_{file_path}",
|
# Switch active_file to the next available tab.
|
||||||
auto_update=True,
|
st.session_state.active_file = (
|
||||||
height=400,
|
st.session_state.open_files[0]
|
||||||
)
|
if st.session_state.open_files else None
|
||||||
|
|
||||||
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[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()
|
st.rerun()
|
||||||
|
|
||||||
result = st.session_state.get("exec_results", {}).get(file_path)
|
with cols[2]:
|
||||||
if result:
|
if st.button("Rename File", key=f"rename_{file_path}"):
|
||||||
st.subheader("Execution Output")
|
_rename_dialog(file_path)
|
||||||
|
|
||||||
if result.get("ast_error"):
|
with cols[3]:
|
||||||
st.warning("⚠️ Syntax Error detected before execution — code was not run.")
|
if st.button("Delete File", key=f"delete_{file_path}"):
|
||||||
elif result["return_code"] == 0:
|
_delete_dialog(file_path)
|
||||||
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("▶ Run Code", key="run_code"):
|
||||||
if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"):
|
result = run_active_file()
|
||||||
file_name = Path(file_path).name
|
if not result:
|
||||||
code_content = st.session_state.files_content.get(file_path, "")
|
st.stop()
|
||||||
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.subheader("Execution Output")
|
||||||
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.")
|
|
||||||
|
|
||||||
|
# Green on exit code 0 (success), red on anything else (error/crash).
|
||||||
|
if result["return_code"] == 0:
|
||||||
|
st.success(f"Exit code: {result['return_code']}")
|
||||||
|
else:
|
||||||
|
st.error(f"Exit code: {result['return_code']}")
|
||||||
|
|
||||||
|
if result["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.")
|
||||||
|
|
||||||
def render_editor():
|
|
||||||
"""Entry point for app.py — delegates to FileViewer."""
|
|
||||||
FileViewer().render()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -53,12 +53,6 @@ def _delete_folder_dialog(folder_rel: str, folder_name: str):
|
|||||||
|
|
||||||
@st.dialog("Add File")
|
@st.dialog("Add File")
|
||||||
def _add_file_dialog(parent_path: str = ""):
|
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"):
|
with st.form("add_file_form"):
|
||||||
name = st.text_input("File name:", placeholder="e.g. script.py")
|
name = st.text_input("File name:", placeholder="e.g. script.py")
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
@ -83,12 +77,6 @@ def _add_file_dialog(parent_path: str = ""):
|
|||||||
|
|
||||||
@st.dialog("Add Folder")
|
@st.dialog("Add Folder")
|
||||||
def _add_folder_dialog(parent_path: str = ""):
|
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"):
|
with st.form("add_folder_form"):
|
||||||
name = st.text_input("Folder name:", placeholder="e.g. utils")
|
name = st.text_input("Folder name:", placeholder="e.g. utils")
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
@ -110,6 +98,99 @@ def _add_folder_dialog(parent_path: str = ""):
|
|||||||
if cancel:
|
if cancel:
|
||||||
st.rerun()
|
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 ─────────────────────────────────────────────────────────────────
|
# ── File tree ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def build_arborist_tree(tree, parent_path=Path()):
|
def build_arborist_tree(tree, parent_path=Path()):
|
||||||
@ -153,31 +234,16 @@ def build_arborist_tree(tree, parent_path=Path()):
|
|||||||
|
|
||||||
|
|
||||||
def render_filetree_arborist(tree):
|
def render_filetree_arborist(tree):
|
||||||
"""Render the interactive file tree and return the currently selected node dict.
|
"""Render the interactive file tree and return the currently selected node dict."""
|
||||||
|
|
||||||
Passes the active file's relative path as ``selection`` so the tree always
|
|
||||||
highlights whichever file is currently open in the editor, even when the
|
|
||||||
user switches tabs instead of clicking the tree.
|
|
||||||
"""
|
|
||||||
data = build_arborist_tree(tree)
|
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(
|
selected = tree_view(
|
||||||
data=data,
|
data=data,
|
||||||
icons={"open": "📂", "closed": "📁"},
|
icons={"open": "📂", "closed": "📁"},
|
||||||
height=350,
|
height=200,
|
||||||
selection=active_selection,
|
selection=None,
|
||||||
select_internal_nodes=True, # allow clicking folder names, not just files
|
select_internal_nodes=True, # allow clicking folder names, not just files
|
||||||
open_by_default=True,
|
open_by_default=False
|
||||||
)
|
)
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
@ -191,16 +257,7 @@ def render_sidebar():
|
|||||||
Tree click handling:
|
Tree click handling:
|
||||||
- Clicking a file → appended to open_files, set as active_file
|
- Clicking a file → appended to open_files, set as active_file
|
||||||
- Clicking a folder → stored in selected_folder so the action bar appears
|
- 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")
|
st.sidebar.title("Navigation")
|
||||||
|
|
||||||
navigation_section = st.sidebar.container()
|
navigation_section = st.sidebar.container()
|
||||||
@ -237,16 +294,13 @@ def render_sidebar():
|
|||||||
abs_path = fm.base_path / selected_path
|
abs_path = fm.base_path / selected_path
|
||||||
|
|
||||||
if abs_path.is_file():
|
if abs_path.is_file():
|
||||||
# Open the file in the editor, jump to its tab,
|
# Open the file in the editor.
|
||||||
# and switch the view to the Editor pane.
|
|
||||||
st.session_state.selected_folder = None
|
st.session_state.selected_folder = None
|
||||||
st.session_state.selected_folder_rel = None
|
st.session_state.selected_folder_rel = None
|
||||||
file_str = str(abs_path)
|
file_str = str(abs_path)
|
||||||
if file_str not in st.session_state.open_files:
|
if file_str not in st.session_state.open_files:
|
||||||
st.session_state.open_files.append(file_str)
|
st.session_state.open_files.append(file_str)
|
||||||
st.session_state.active_file = 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()
|
st.rerun()
|
||||||
|
|
||||||
elif abs_path.is_dir():
|
elif abs_path.is_dir():
|
||||||
@ -269,6 +323,17 @@ def render_sidebar():
|
|||||||
_add_folder_dialog(folder_rel)
|
_add_folder_dialog(folder_rel)
|
||||||
if st.button("Delete Folder", key="btn_delete_folder", use_container_width=True):
|
if st.button("Delete Folder", key="btn_delete_folder", use_container_width=True):
|
||||||
_delete_folder_dialog(folder_rel, folder_name)
|
_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:
|
with add_more:
|
||||||
# Popover for workspace-root actions (not tied to any selected folder).
|
# Popover for workspace-root actions (not tied to any selected folder).
|
||||||
@ -279,21 +344,7 @@ def render_sidebar():
|
|||||||
if st.button("Add Folder", key="btn_add_folder", use_container_width=True):
|
if st.button("Add Folder", key="btn_add_folder", use_container_width=True):
|
||||||
_add_folder_dialog("")
|
_add_folder_dialog("")
|
||||||
|
|
||||||
st.divider()
|
return
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -10,93 +10,84 @@ from backend.managers.chat_manager import ChatManager
|
|||||||
|
|
||||||
|
|
||||||
def init_state():
|
def init_state():
|
||||||
"""Initialise all Streamlit session-state keys with safe defaults.
|
# Sidebar state initialization
|
||||||
|
|
||||||
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
|
# last_selected tracks the previously clicked tree node to detect new clicks
|
||||||
# and avoid re-running the same file-open logic on every Streamlit rerender.
|
|
||||||
if "last_selected" not in st.session_state:
|
if "last_selected" not in st.session_state:
|
||||||
st.session_state.last_selected = None
|
st.session_state.last_selected = None
|
||||||
|
|
||||||
# Absolute path and workspace-relative path of the currently highlighted folder.
|
# Absolute path and workspace-relative path of the currently highlighted folder
|
||||||
# Both are set together; both are cleared together when a folder is deselected.
|
|
||||||
if "selected_folder" not in st.session_state:
|
if "selected_folder" not in st.session_state:
|
||||||
st.session_state.selected_folder = None
|
st.session_state.selected_folder = None
|
||||||
|
|
||||||
if "selected_folder_rel" not in st.session_state:
|
if "selected_folder_rel" not in st.session_state:
|
||||||
st.session_state.selected_folder_rel = None
|
st.session_state.selected_folder_rel = None
|
||||||
|
|
||||||
# ── Chat manager ──────────────────────────────────────────────────────────
|
# Chat manager (persists across reruns)
|
||||||
|
# ChatManager keeps the full conversation history in memory across reruns
|
||||||
# ChatManager keeps the full conversation history in memory across reruns.
|
|
||||||
# Instantiated once and reused so history is not lost on page rerenders.
|
|
||||||
if "chat_manager" not in st.session_state:
|
if "chat_manager" not in st.session_state:
|
||||||
st.session_state.chat_manager = ChatManager()
|
st.session_state.chat_manager = ChatManager()
|
||||||
|
|
||||||
# ── Editor state ──────────────────────────────────────────────────────────
|
# Editor state initialization
|
||||||
|
# List of absolute file paths that are currently open as tabs
|
||||||
# Ordered list of absolute file paths currently open as editor tabs.
|
|
||||||
# The list order determines the visual tab order in the UI.
|
|
||||||
if "open_files" not in st.session_state:
|
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 = []
|
st.session_state.open_files = []
|
||||||
|
|
||||||
# Dict mapping absolute file path → current editor content (may differ from
|
# Dict mapping file path → current editor content (may be unsaved)
|
||||||
# disk if the user has unsaved changes).
|
|
||||||
if "files_content" not in st.session_state:
|
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 = {}
|
st.session_state.files_content = {}
|
||||||
|
|
||||||
# Absolute path of the file whose tab is currently active in the editor.
|
# Absolute path of the file whose tab is currently active
|
||||||
# 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:
|
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
|
st.session_state.active_file = None
|
||||||
|
|
||||||
# Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}}
|
# Index of the active tab (used by st.tabs)
|
||||||
if "exec_results" not in st.session_state:
|
if "active_tab" not in st.session_state:
|
||||||
st.session_state.exec_results = {}
|
st.session_state.active_tab = 0
|
||||||
|
|
||||||
# ── Chat state ────────────────────────────────────────────────────────────
|
if "is_editing" not in st.session_state:
|
||||||
|
st.session_state.is_editing = False
|
||||||
|
|
||||||
# Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles.
|
if "code_suggestions" not in st.session_state:
|
||||||
# System messages are stored here too but skipped during display.
|
st.session_state.code_suggestions = []
|
||||||
|
|
||||||
|
# Output dict from the last code run: {stdout, stderr, return_code}
|
||||||
|
if "code_execution_output" not in st.session_state:
|
||||||
|
st.session_state.code_execution_output = ""
|
||||||
|
|
||||||
|
# Chat state initialization
|
||||||
|
# Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles
|
||||||
if "chat_history" not in st.session_state:
|
if "chat_history" not in st.session_state:
|
||||||
st.session_state.chat_history = []
|
st.session_state.chat_history = []
|
||||||
|
|
||||||
# ── Agent Mode state ──────────────────────────────────────────────────────
|
# Agent Mode state
|
||||||
|
# Whether the UI is currently in Agent Mode (vs normal chat)
|
||||||
# Boolean toggle — True while the UI is in Coding Agent mode.
|
|
||||||
if "agent_mode" not in st.session_state:
|
if "agent_mode" not in st.session_state:
|
||||||
st.session_state.agent_mode = False
|
st.session_state.agent_mode = False
|
||||||
|
|
||||||
# The live CodingAgent instance while a task is running.
|
# The live CodingAgent instance while a task is running
|
||||||
# Set by _start_agent(), cleared by _reset_agent().
|
|
||||||
if "coding_agent" not in st.session_state:
|
if "coding_agent" not in st.session_state:
|
||||||
st.session_state.coding_agent = None
|
st.session_state.coding_agent = None
|
||||||
|
|
||||||
# Lifecycle state of the agent: "idle" | "waiting_approval" | "done".
|
# Current status of the agent: "idle" | "waiting_approval" | "done"
|
||||||
# Controls which sub-screen render_agent_mode() displays.
|
|
||||||
if "agent_status" not in st.session_state:
|
if "agent_status" not in st.session_state:
|
||||||
st.session_state.agent_status = "idle"
|
st.session_state.agent_status = "idle"
|
||||||
|
|
||||||
# Chronological list of completed step records shown in the Agent Log expander.
|
# List of completed steps shown in the collapsible Agent Log
|
||||||
# Each entry: {"thought": str, "tool": str, "arguments": dict, "result": str}
|
|
||||||
if "agent_log" not in st.session_state:
|
if "agent_log" not in st.session_state:
|
||||||
st.session_state.agent_log = []
|
st.session_state.agent_log = []
|
||||||
|
|
||||||
# The action the agent has proposed but that has not yet been approved or
|
# The action the agent proposed but has not yet been approved or rejected
|
||||||
# rejected by the user. Stored as the raw dict returned by propose_next_action().
|
|
||||||
if "agent_pending_action" not in st.session_state:
|
if "agent_pending_action" not in st.session_state:
|
||||||
st.session_state.agent_pending_action = None
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
BIN
project_exercise.pdf
Normal file
BIN
project_exercise.pdf
Normal file
Binary file not shown.
@ -1,6 +1,5 @@
|
|||||||
# Core Framework
|
# Core Framework
|
||||||
streamlit==1.57.0
|
streamlit>=1.28.0
|
||||||
streamlit_arborist>=0.1.0
|
|
||||||
|
|
||||||
# AI/LLM Integration
|
# AI/LLM Integration
|
||||||
openai>=1.0.0
|
openai>=1.0.0
|
||||||
@ -18,14 +17,9 @@ pandas>=2.0.0
|
|||||||
# Testing
|
# Testing
|
||||||
pytest>=7.0.0
|
pytest>=7.0.0
|
||||||
pytest-cov>=4.0.0
|
pytest-cov>=4.0.0
|
||||||
pytest-asyncio>=0.23.0
|
|
||||||
|
|
||||||
# Development & Utilities
|
# Development & Utilities
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
#For code editor functionality
|
#For code editor functionality
|
||||||
streamlit-ace>=0.1.0
|
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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
# Add project root to Python path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from backend.managers.chat_manager import ChatManager
|
from backend.managers.chat_manager import ChatManager
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
class TestChatManager:
|
||||||
|
"""Test suite for ChatManager functionality."""
|
||||||
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."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cm(self):
|
def chat_manager(self):
|
||||||
return ChatManager()
|
return ChatManager()
|
||||||
|
|
||||||
def test_add_message_appends_correct_entry(self, cm):
|
def test_initialization(self, chat_manager):
|
||||||
cm.add_message("user", "Hello")
|
"""Test that ChatManager initializes correctly."""
|
||||||
assert cm.chat_history == [{"role": "user", "content": "Hello"}]
|
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):
|
def test_add_message(self, chat_manager):
|
||||||
cm.add_message("user", "Hi")
|
"""Test adding messages to chat history."""
|
||||||
cm.add_message("assistant", "Hello!")
|
chat_manager.add_message("user", "Hello")
|
||||||
assert cm.chat_history[0]["role"] == "user"
|
assert len(chat_manager.chat_history) == 1
|
||||||
assert cm.chat_history[1]["role"] == "assistant"
|
assert chat_manager.chat_history[0]["role"] == "user"
|
||||||
|
assert chat_manager.chat_history[0]["content"] == "Hello"
|
||||||
|
|
||||||
def test_clear_history_empties_list(self, cm):
|
def test_get_history(self, chat_manager):
|
||||||
cm.add_message("user", "Hi")
|
"""Test retrieving chat history."""
|
||||||
cm.clear_history()
|
chat_manager.add_message("user", "Hello")
|
||||||
assert cm.chat_history == []
|
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 TestChatManagerSendMessage:
|
||||||
|
"""Unit tests for send_message using mocked HTTP requests."""
|
||||||
class TestSendMessage:
|
|
||||||
"""Tests for send_message: history updates, HTTP payload, error handling, and auth headers."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cm(self):
|
def chat_manager(self):
|
||||||
return ChatManager()
|
return ChatManager()
|
||||||
|
|
||||||
def test_user_message_added_to_history(self, cm):
|
def _mock_response(self, content="AI reply", status_code=200):
|
||||||
with patch("requests.post", return_value=_mock_ok()):
|
mock = MagicMock()
|
||||||
cm.send_message("Hello")
|
mock.status_code = status_code
|
||||||
assert cm.chat_history[0] == {"role": "user", "content": "Hello"}
|
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):
|
def test_send_message_adds_user_message_to_history(self, chat_manager):
|
||||||
with patch("requests.post", return_value=_mock_ok("Hi there")):
|
with patch("requests.post", return_value=self._mock_response()):
|
||||||
cm.send_message("Hello")
|
chat_manager.send_message("Hello")
|
||||||
assert cm.chat_history[1] == {"role": "assistant", "content": "Hi there"}
|
assert chat_manager.chat_history[0] == {"role": "user", "content": "Hello"}
|
||||||
|
|
||||||
def test_returns_assistant_content_string(self, cm):
|
def test_send_message_adds_assistant_response_to_history(self, chat_manager):
|
||||||
with patch("requests.post", return_value=_mock_ok("Answer")):
|
with patch("requests.post", return_value=self._mock_response("Hi there")):
|
||||||
result = cm.send_message("Question")
|
chat_manager.send_message("Hello")
|
||||||
assert result == "Answer"
|
assert chat_manager.chat_history[1] == {"role": "assistant", "content": "Hi there"}
|
||||||
|
|
||||||
def test_full_history_sent_in_request_payload(self, cm):
|
def test_send_message_returns_ai_content(self, chat_manager):
|
||||||
"""All prior messages must be forwarded so the model has conversation context."""
|
with patch("requests.post", return_value=self._mock_response("Answer")):
|
||||||
cm.add_message("system", "You are helpful.")
|
response = chat_manager.send_message("Question")
|
||||||
with patch("requests.post", return_value=_mock_ok()) as mock_post:
|
assert response == "Answer"
|
||||||
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_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 patch("requests.post", side_effect=requests.exceptions.ConnectionError("refused")):
|
||||||
with pytest.raises(Exception, match="Connection Error"):
|
with pytest.raises(Exception, match="Connection Error"):
|
||||||
cm.send_message("Hello")
|
chat_manager.send_message("Hello")
|
||||||
assert any("Error" in msg["content"] for msg in cm.chat_history)
|
|
||||||
|
|
||||||
def test_api_error_status_raises(self, cm):
|
def test_send_message_api_error_status_raises(self, chat_manager):
|
||||||
mock = MagicMock()
|
mock = self._mock_response(status_code=500)
|
||||||
mock.status_code = 500
|
|
||||||
mock.text = "Internal Server Error"
|
|
||||||
with patch("requests.post", return_value=mock):
|
with patch("requests.post", return_value=mock):
|
||||||
with pytest.raises(Exception, match="API Error 500"):
|
with pytest.raises(Exception, match="API Error 500"):
|
||||||
cm.send_message("Hello")
|
chat_manager.send_message("Hello")
|
||||||
|
|
||||||
def test_timeout_raises(self, cm):
|
def test_send_message_empty_choices_raises(self, chat_manager):
|
||||||
with patch("requests.post", side_effect=requests.exceptions.Timeout()):
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
cm.send_message("Hello")
|
|
||||||
|
|
||||||
def test_empty_choices_raises(self, cm):
|
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
mock.status_code = 200
|
mock.status_code = 200
|
||||||
mock.json.return_value = {"choices": []}
|
mock.json.return_value = {"choices": []}
|
||||||
with patch("requests.post", return_value=mock):
|
with patch("requests.post", return_value=mock):
|
||||||
with pytest.raises(Exception, match="Invalid API response format"):
|
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):
|
def test_send_message_missing_choices_key_raises(self, chat_manager):
|
||||||
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
|
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
mock.status_code = 200
|
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 patch("requests.post", return_value=mock):
|
||||||
with pytest.raises(Exception, match="JSON Decode Error"):
|
with pytest.raises(Exception):
|
||||||
cm.send_message("Hello")
|
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:
|
Structure:
|
||||||
TestHelpers – truncate_result, trim_messages, _strip_code_fences
|
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
|
TestCodingAgentInit – __init__ and start_task
|
||||||
TestProposeNextAction – propose_next_action with mocked API
|
TestProposeNextAction – propose_next_action with mocked API
|
||||||
TestApprove – approve with mocked API + real tool execution
|
TestApprove – approve with mocked API + real tool execution
|
||||||
TestReject – reject injects feedback correctly
|
TestReject – reject injects feedback correctly
|
||||||
TestFullLoop – integration: real API, skipped if unreachable
|
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
|
import json
|
||||||
@ -25,12 +22,20 @@ import pytest
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from backend.agent.coding_agent import (
|
from backend.agent.coding_agent import (
|
||||||
MAX_ITERATIONS,
|
MAX_HISTORY_CHARS,
|
||||||
MAX_RESULT_LENGTH,
|
MAX_RESULT_LENGTH,
|
||||||
CodingAgent,
|
CodingAgent,
|
||||||
_strip_code_fences,
|
_strip_code_fences,
|
||||||
|
dispatch_tool,
|
||||||
|
done,
|
||||||
|
grep_search,
|
||||||
|
list_files,
|
||||||
|
read_file,
|
||||||
|
run_python,
|
||||||
truncate_result,
|
truncate_result,
|
||||||
trim_messages,
|
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:
|
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})
|
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:
|
class TestTruncateResult:
|
||||||
"""Tests for truncate_result(): ensures long tool outputs are capped before entering the message history."""
|
|
||||||
|
|
||||||
def test_short_result_unchanged(self):
|
def test_short_result_unchanged(self):
|
||||||
assert truncate_result("hello") == "hello"
|
assert truncate_result("hello") == "hello"
|
||||||
@ -71,6 +73,10 @@ class TestTruncateResult:
|
|||||||
assert len(result) < len(long)
|
assert len(result) < len(long)
|
||||||
assert "TRUNCATED" in result
|
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):
|
def test_truncated_keeps_start_and_end(self):
|
||||||
text = "START" + "x" * MAX_RESULT_LENGTH + "END"
|
text = "START" + "x" * MAX_RESULT_LENGTH + "END"
|
||||||
result = truncate_result(text)
|
result = truncate_result(text)
|
||||||
@ -79,11 +85,8 @@ class TestTruncateResult:
|
|||||||
|
|
||||||
|
|
||||||
class TestTrimMessages:
|
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:
|
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 = [
|
msgs = [
|
||||||
{"role": "system", "content": "sys"},
|
{"role": "system", "content": "sys"},
|
||||||
{"role": "user", "content": "original task"},
|
{"role": "user", "content": "original task"},
|
||||||
@ -102,6 +105,8 @@ class TestTrimMessages:
|
|||||||
original_total = sum(len(m["content"]) for m in msgs)
|
original_total = sum(len(m["content"]) for m in msgs)
|
||||||
trimmed = trim_messages(msgs)
|
trimmed = trim_messages(msgs)
|
||||||
trimmed_total = sum(len(m["content"]) for m in trimmed)
|
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 trimmed_total < original_total
|
||||||
assert len(trimmed) < len(msgs)
|
assert len(trimmed) < len(msgs)
|
||||||
|
|
||||||
@ -116,8 +121,6 @@ class TestTrimMessages:
|
|||||||
assert trimmed[1]["content"] == "original task"
|
assert trimmed[1]["content"] == "original task"
|
||||||
|
|
||||||
def test_reminder_injected_when_trimmed(self):
|
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)
|
msgs = self._make_messages(n_extra=500, chars_each=200)
|
||||||
trimmed = trim_messages(msgs)
|
trimmed = trim_messages(msgs)
|
||||||
contents = [m["content"] for m in trimmed]
|
contents = [m["content"] for m in trimmed]
|
||||||
@ -125,7 +128,6 @@ class TestTrimMessages:
|
|||||||
|
|
||||||
|
|
||||||
class TestStripCodeFences:
|
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):
|
def test_plain_text_unchanged(self):
|
||||||
assert _strip_code_fences("hello") == "hello"
|
assert _strip_code_fences("hello") == "hello"
|
||||||
@ -138,13 +140,171 @@ class TestStripCodeFences:
|
|||||||
text = "```\nhello\n```"
|
text = "```\nhello\n```"
|
||||||
assert _strip_code_fences(text) == "hello"
|
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
|
# TestCodingAgentInit
|
||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class 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):
|
def test_initial_state_is_clean(self):
|
||||||
agent = CodingAgent()
|
agent = CodingAgent()
|
||||||
@ -153,6 +313,11 @@ class TestCodingAgentInit:
|
|||||||
assert agent.is_done is False
|
assert agent.is_done is False
|
||||||
assert agent.iteration == 0
|
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):
|
def test_start_task_sets_messages(self):
|
||||||
agent = CodingAgent()
|
agent = CodingAgent()
|
||||||
agent.start_task("Write fibonacci.py")
|
agent.start_task("Write fibonacci.py")
|
||||||
@ -183,7 +348,6 @@ class TestCodingAgentInit:
|
|||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestProposeNextAction:
|
class TestProposeNextAction:
|
||||||
"""Tests for propose_next_action(): API is mocked so no real HTTP calls are made."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def agent(self):
|
def agent(self):
|
||||||
@ -195,72 +359,55 @@ class TestProposeNextAction:
|
|||||||
payload = _agent_action_json(tool, thought, **args)
|
payload = _agent_action_json(tool, thought, **args)
|
||||||
agent._call_api = MagicMock(return_value=payload)
|
agent._call_api = MagicMock(return_value=payload)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_returns_dict_with_required_keys(self, agent):
|
||||||
async def test_returns_dict_with_required_keys(self, agent):
|
|
||||||
self._mock_api(agent)
|
self._mock_api(agent)
|
||||||
action = await agent.propose_next_action()
|
action = agent.propose_next_action()
|
||||||
assert "thought" in action
|
assert "thought" in action
|
||||||
assert "tool" in action
|
assert "tool" in action
|
||||||
assert "arguments" in action
|
assert "arguments" in action
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_increments_iteration(self, agent):
|
||||||
async def test_increments_iteration(self, agent):
|
|
||||||
self._mock_api(agent)
|
self._mock_api(agent)
|
||||||
await agent.propose_next_action()
|
agent.propose_next_action()
|
||||||
assert agent.iteration == 1
|
assert agent.iteration == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_stores_pending_action(self, agent):
|
||||||
async def test_stores_pending_action(self, agent):
|
|
||||||
self._mock_api(agent)
|
self._mock_api(agent)
|
||||||
await agent.propose_next_action()
|
agent.propose_next_action()
|
||||||
assert agent.pending_action is not None
|
assert agent.pending_action is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_returns_correct_tool(self, agent):
|
||||||
async def test_returns_correct_tool(self, agent):
|
|
||||||
self._mock_api(agent, tool="list_files")
|
self._mock_api(agent, tool="list_files")
|
||||||
action = await agent.propose_next_action()
|
action = agent.propose_next_action()
|
||||||
assert action["tool"] == "list_files"
|
assert action["tool"] == "list_files"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_handles_json_parse_error_gracefully(self, agent):
|
||||||
async def test_handles_json_parse_error_gracefully(self, agent):
|
|
||||||
agent._call_api = MagicMock(return_value="this is not json {{")
|
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"
|
assert action["tool"] == "done"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_handles_api_exception_gracefully(self, agent):
|
||||||
async def test_handles_api_exception_gracefully(self, agent):
|
|
||||||
agent._call_api = MagicMock(side_effect=Exception("connection refused"))
|
agent._call_api = MagicMock(side_effect=Exception("connection refused"))
|
||||||
action = await agent.propose_next_action()
|
action = agent.propose_next_action()
|
||||||
assert action["tool"] == "done"
|
assert action["tool"] == "done"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_strips_code_fences_from_response(self, agent):
|
||||||
async def test_strips_code_fences_from_response(self, agent):
|
|
||||||
payload = "```json\n" + _agent_action_json("list_files", "thinking") + "\n```"
|
payload = "```json\n" + _agent_action_json("list_files", "thinking") + "\n```"
|
||||||
agent._call_api = MagicMock(return_value=payload)
|
agent._call_api = MagicMock(return_value=payload)
|
||||||
action = await agent.propose_next_action()
|
action = agent.propose_next_action()
|
||||||
assert action["tool"] == "list_files"
|
assert action["tool"] == "list_files"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_already_done_returns_done_action(self, agent):
|
||||||
async def test_already_done_returns_done_action(self, agent):
|
|
||||||
agent.is_done = True
|
agent.is_done = True
|
||||||
action = await agent.propose_next_action()
|
action = agent.propose_next_action()
|
||||||
assert action["tool"] == "done"
|
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:
|
class TestApprove:
|
||||||
"""Tests for approve(): dispatch_tool is mocked so no filesystem or subprocess side-effects occur."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def agent(self):
|
def agent(self):
|
||||||
@ -269,68 +416,56 @@ class TestApprove:
|
|||||||
return a
|
return a
|
||||||
|
|
||||||
def _set_pending(self, agent, tool: str, **arguments):
|
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)
|
raw = _agent_action_json(tool, "thought", **arguments)
|
||||||
agent.pending_action = {
|
agent.pending_action = {
|
||||||
"raw": raw,
|
"raw": raw,
|
||||||
"action": {"thought": "thought", "tool": tool, "arguments": arguments},
|
"action": {"thought": "thought", "tool": tool, "arguments": arguments},
|
||||||
}
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_without_pending_raises(self, agent):
|
||||||
async def test_approve_without_pending_raises(self, agent):
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
await agent.approve()
|
agent.approve()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_done_sets_is_done(self, agent):
|
||||||
async def test_approve_done_sets_is_done(self, agent):
|
|
||||||
self._set_pending(agent, "done", summary="all done")
|
self._set_pending(agent, "done", summary="all done")
|
||||||
result = await agent.approve()
|
result = agent.approve()
|
||||||
assert result["is_done"] is True
|
assert result["is_done"] is True
|
||||||
assert agent.is_done is True
|
assert agent.is_done is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_done_returns_summary(self, agent):
|
||||||
async def test_approve_done_returns_summary(self, agent):
|
|
||||||
self._set_pending(agent, "done", summary="finished successfully")
|
self._set_pending(agent, "done", summary="finished successfully")
|
||||||
result = await agent.approve()
|
result = agent.approve()
|
||||||
assert "finished successfully" in result["result"]
|
assert "finished successfully" in result["result"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_clears_pending_action(self, agent):
|
||||||
async def test_approve_clears_pending_action(self, agent):
|
|
||||||
self._set_pending(agent, "done", summary="x")
|
self._set_pending(agent, "done", summary="x")
|
||||||
await agent.approve()
|
agent.approve()
|
||||||
assert agent.pending_action is None
|
assert agent.pending_action is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_appends_assistant_message(self, agent):
|
||||||
async def test_approve_appends_assistant_message(self, agent):
|
|
||||||
self._set_pending(agent, "done", summary="x")
|
self._set_pending(agent, "done", summary="x")
|
||||||
before = len(agent.messages)
|
before = len(agent.messages)
|
||||||
await agent.approve()
|
agent.approve()
|
||||||
assert len(agent.messages) > before
|
assert len(agent.messages) > before
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_tool_result_appended_to_messages(self, agent, tmp_path):
|
||||||
async def test_approve_tool_result_appended_to_messages(self, agent):
|
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||||
with patch("backend.agent.coding_agent.dispatch_tool", return_value="file list"):
|
|
||||||
self._set_pending(agent, "list_files")
|
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"]]
|
tool_results = [m for m in agent.messages if "tool_result" in m["content"]]
|
||||||
assert len(tool_results) == 1
|
assert len(tool_results) == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_error_result_adds_replan_tag(self, agent, tmp_path):
|
||||||
async def test_approve_error_result_adds_replan_tag(self, agent):
|
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||||
# 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"):
|
|
||||||
self._set_pending(agent, "read_file", path="nonexistent.py")
|
self._set_pending(agent, "read_file", path="nonexistent.py")
|
||||||
await agent.approve()
|
agent.approve()
|
||||||
last_msg = agent.messages[-1]["content"]
|
last_msg = agent.messages[-1]["content"]
|
||||||
assert "replan" in last_msg
|
assert "replan" in last_msg
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_approve_returns_tool_name_in_result(self, agent, tmp_path):
|
||||||
async def test_approve_returns_tool_name_in_result(self, agent):
|
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||||
with patch("backend.agent.coding_agent.dispatch_tool", return_value="(empty)"):
|
|
||||||
self._set_pending(agent, "list_files")
|
self._set_pending(agent, "list_files")
|
||||||
result = await agent.approve()
|
result = agent.approve()
|
||||||
assert result["tool"] == "list_files"
|
assert result["tool"] == "list_files"
|
||||||
assert result["is_done"] is False
|
assert result["is_done"] is False
|
||||||
|
|
||||||
@ -340,7 +475,6 @@ class TestApprove:
|
|||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestReject:
|
class TestReject:
|
||||||
"""Tests for reject(): user feedback is injected into the history and the pending action is discarded."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def agent(self):
|
def agent(self):
|
||||||
@ -374,12 +508,66 @@ class TestReject:
|
|||||||
|
|
||||||
def test_reject_without_pending_does_not_crash(self, agent):
|
def test_reject_without_pending_does_not_crash(self, agent):
|
||||||
agent.pending_action = None
|
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):
|
def test_reject_does_not_execute_tool(self, agent, tmp_path):
|
||||||
self._set_pending(agent, "write_file")
|
self._set_pending(agent, "write_file")
|
||||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||||
agent.reject("Do not write anything")
|
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))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import pytest
|
||||||
from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS
|
from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS
|
||||||
|
|
||||||
|
|
||||||
class TestSystemPrompterBasePrompt:
|
class TestSystemPrompterBasePrompt:
|
||||||
"""Tests for generate_prompt() without file context."""
|
"""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):
|
def test_contains_no_file_xml_tag(self):
|
||||||
prompt = SystemPrompter.generate_prompt()
|
prompt = SystemPrompter.generate_prompt()
|
||||||
assert "<file" not in prompt
|
assert "<file" not in prompt
|
||||||
assert "<code>" 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:
|
class TestSystemPrompterWithFileContext:
|
||||||
"""Tests for generate_prompt() with file_context provided."""
|
"""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"})
|
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": "pass"})
|
||||||
assert "<code>" in prompt
|
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):
|
def test_missing_content_key_does_not_raise(self):
|
||||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "empty.py"})
|
prompt = SystemPrompter.generate_prompt(file_context={"name": "empty.py"})
|
||||||
assert "empty.py" in prompt
|
assert "empty.py" in prompt
|
||||||
@ -55,19 +77,12 @@ class TestSystemPrompterTruncation:
|
|||||||
assert "[truncated]" not in prompt
|
assert "[truncated]" not in prompt
|
||||||
assert content 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):
|
def test_file_one_over_limit_is_truncated(self):
|
||||||
content = "x" * (MAX_FILE_CHARS + 1)
|
content = "x" * (MAX_FILE_CHARS + 1)
|
||||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
|
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
|
||||||
assert "[truncated]" in prompt
|
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