Compare commits
54 Commits
write_test
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e669a2234 | |||
| 5b32b4e6d0 | |||
|
|
a70efea25f | ||
|
|
c702675758 | ||
|
|
adfb780f7c | ||
|
|
8209136bed | ||
|
|
3dcea4ece2 | ||
|
|
db371b906c | ||
|
|
e5e06da35b | ||
|
|
9633def4de | ||
|
|
3e8bab87cb | ||
|
|
46b9c925dd | ||
| 6a7e90ab40 | |||
|
|
3216a9a5f6 | ||
|
|
ba40f6fc82 | ||
|
|
a8403eee92 | ||
|
|
ea05437154 | ||
|
|
da236ce14e | ||
| 33969a7b59 | |||
|
|
8327c54bdb | ||
| 9836c2981a | |||
|
|
53797c161e | ||
| 385ec6eea6 | |||
|
|
63e442c9b9 | ||
| 8ff46921b6 | |||
|
|
91f816168a | ||
|
|
b83cd2a790 | ||
| ff61ef5d16 | |||
|
|
33dd93fbbf | ||
|
|
efdd9351b3 | ||
|
|
3891007fb2 | ||
|
|
d860b73710 | ||
| 06c5c49092 | |||
| 6730a48852 | |||
| e792a217f8 | |||
|
|
d28c0157d8 | ||
|
|
59ff3ead93 | ||
|
|
955a30c512 | ||
|
|
cc14f25e69 | ||
|
|
0751be8e61 | ||
|
|
45ec8f8208 | ||
|
|
7afa345279 | ||
|
|
21b0b3f9dc | ||
|
|
b98b74dad1 | ||
|
|
4342a8e34b | ||
|
|
541735fdd6 | ||
|
|
015564d754 | ||
|
|
c301076e60 | ||
|
|
241c6580f3 | ||
|
|
a65dd047ba | ||
|
|
e75212652e | ||
| 1a5bd6633a | |||
|
|
bdbdbc9b3a | ||
|
|
c8299f89c3 |
10
.env.example
10
.env.example
@ -3,11 +3,9 @@
|
|||||||
# 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=silicon.fhgr.ch
|
HOST=
|
||||||
PORT=7080
|
PORT=
|
||||||
API_KEY=EMPTY
|
API_KEY=EMPTY
|
||||||
MODEL=qwen3.5-35b-a3b
|
MODEL=
|
||||||
|
|
||||||
|
|
||||||
# Optional: Add more configuration variables as needed
|
|
||||||
# DEBUG=False
|
|
||||||
# LOG_LEVEL=INFO
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,3 +50,6 @@ data/raw/
|
|||||||
|
|
||||||
# Workspace
|
# Workspace
|
||||||
workspace/
|
workspace/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
|||||||
704
README.md
704
README.md
@ -1,153 +1,637 @@
|
|||||||
# AISE AI Code Editor
|
# AISE AI Code Editor — Technische Dokumentation
|
||||||
|
|
||||||
AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026)
|
KI-unterstützter Lightweight Code Editor auf Basis von 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 Components
|
├── frontend/ # Streamlit UI-Komponenten
|
||||||
│ ├── __init__.py
|
│ ├── app.py # Einstiegspunkt der App; Seitenkonfiguration + Routing
|
||||||
│ ├── app.py # Main Streamlit application entry point
|
│ ├── state.py # Zentrale Session-State-Initialisierung
|
||||||
│ ├── sidebar.py # File navigation sidebar component
|
│ ├── sidebar.py # Datei-Explorer + Navigations-Radio
|
||||||
│ ├── editor.py # Code editor pane component
|
│ ├── editor.py # Ace-Editor-Tabs + Ausführungs-Output
|
||||||
│ └── chat.py # Chat interface component
|
│ └── chat.py # Chat-Interface + Agent-Mode-UI
|
||||||
│
|
│
|
||||||
├── backend/ # Backend Logic Modules
|
├── backend/
|
||||||
│ ├── __init__.py
|
│ ├── managers/ # Business-Logik, direkt vom Frontend aufgerufen
|
||||||
│ ├── managers/ # Business logic for UI operations
|
│ │ ├── file_manager.py # Workspace-CRUD mit Path-Traversal-Schutz
|
||||||
│ │ ├── __init__.py
|
│ │ ├── chat_manager.py # LLM-API-Wrapper + Sliding-Window-History
|
||||||
│ │ ├── file_manager.py # File I/O operations for UI (read, write, list files)
|
│ │ ├── system_prompter.py # Kontextbewusste System-Prompt-Generierung
|
||||||
│ │ ├── chat_manager.py # AI chat management and history
|
│ │ ├── search_manager.py # DuckDuckGo-Websuche + Seitenabruf
|
||||||
│ │ ├── system_prompter.py # System prompts and context injection
|
│ │ ├── execution_engine.py # Subprocess-basierte Code-Ausführung (Python, LaTeX)
|
||||||
│ │ ├── search_manager.py # Internet search functionality
|
│ │ └── debug_logger.py # Rotierende Logdatei + Fehler-Aggregation
|
||||||
│ │ ├── execution_engine.py # Code execution and sandboxing
|
|
||||||
│ │ └── debug_logger.py # Logging, error handling, debug messages
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── agents/ # AI Agent System
|
│ └── agent/ # Autonomes KI-Agenten-System (MCP-basiert)
|
||||||
│ │ ├── __init__.py
|
│ ├── coding_agent.py # Plan→Aktion→Beobachten-Schleife
|
||||||
│ │ ├── coding_agent.py # Main agent loop (plan-act-observe cycle)
|
│ ├── mcp_server_adapter.py # Verbindet den Agenten mit MCP-Tool-Servern
|
||||||
│ │ └── tools.py # Tools available to agent (7 functions + dispatcher)
|
│ ├── mcp_server_config.json # Welche MCP-Server gestartet werden (Pfade + Befehle)
|
||||||
│ │
|
│ └── servers/ # MCP-Server-Implementierungen (stdio-Transport)
|
||||||
│ └── utils/ # Helper Utilities
|
│ ├── mcp_server_code_execution.py # Tool: Sandbox-Python-Ausführung + Linting
|
||||||
│ ├── __init__.py
|
│ ├── mcp_server_file_search.py # Tool: Workspace-Datei lesen/schreiben/suchen
|
||||||
│ └── server_utils.py # LLM client init, chat functions, formatters
|
│ └── mcp_server_web_search.py # Tool: DuckDuckGo-Suche + Seitenabruf
|
||||||
│
|
│
|
||||||
├── tests/ # Unit Tests
|
├── tests/ # pytest-Unit-Tests
|
||||||
│ ├── __init__.py
|
│ ├── conftest.py # Globaler MCP-Mock (keine echten Subprozesse in Tests)
|
||||||
│ ├── test_file_manager.py # Tests for file operations
|
│ ├── test_chat_manager.py
|
||||||
│ ├── test_chat_manager.py # Tests for chat functionality
|
│ ├── test_coding_agent.py
|
||||||
│ ├── test_execution_engine.py # Tests for code execution
|
│ ├── test_debug_logger.py
|
||||||
│ └── test_main.py # Integration tests
|
│ ├── test_execution_engine.py
|
||||||
|
│ ├── test_file_manager.py
|
||||||
|
│ ├── test_mcp_server_code_execution.py
|
||||||
|
│ ├── test_mcp_server_file_search.py
|
||||||
|
│ ├── test_mcp_server_web_search.py
|
||||||
|
│ ├── test_search_manager.py
|
||||||
|
│ └── test_system_prompter.py
|
||||||
│
|
│
|
||||||
├── workspace/ # Agent Sandbox Directory
|
├── workspace/ # Sandbox-Verzeichnis für Agent- und Editor-Dateien
|
||||||
│ └── .gitkeep # Placeholder for agent to work safely in isolation
|
├── logs/ # Rotierende Logdateien (app.log, errors.log)
|
||||||
│
|
├── .env # Lokale Umgebungsvariablen (nicht eingecheckt)
|
||||||
├── .gitignore # Git exclusions (venv, .env, __pycache__, etc.)
|
└── .env.example # Vorlage für erforderliche Umgebungsvariablen
|
||||||
├── .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
|
---
|
||||||
|
|
||||||
### Frontend (`frontend/`)
|
## Schnellstart
|
||||||
- **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
|
|
||||||
|
|
||||||
### Backend Managers (`backend/managers/`)
|
> Die folgenden Schritte funktionieren auf **Windows** und **macOS** — abweichende Befehle sind jeweils mit dem Betriebssystem gekennzeichnet.
|
||||||
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)
|
|
||||||
|
|
||||||
### Backend Utils (`backend/utils/`)
|
### Schritt 1 — Voraussetzungen prüfen
|
||||||
- **server_utils.py**: LLM client initialization, chat helpers, message formatters
|
|
||||||
|
|
||||||
### Workspace (`workspace/`)
|
**Python 3.10 oder neuer** muss installiert sein.
|
||||||
- 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
|
# Windows (PowerShell)
|
||||||
.\.venv\Scripts\Activate.ps1
|
python --version
|
||||||
|
|
||||||
# macOS/Linux
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
> Falls PowerShell die Ausführung blockiert, einmalig folgenden Befehl ausführen und danach erneut versuchen:
|
||||||
|
> ```powershell
|
||||||
|
> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
> ```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Terminal)
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Install Dependencies
|
Nach erfolgreicher Aktivierung erscheint `(.venv)` am Anfang der Eingabezeile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 5 — Abhängigkeiten installieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run Application
|
Dieser Schritt lädt alle benötigten Pakete herunter (~2–5 Minuten je nach Internetverbindung). Er muss nur einmal ausgeführt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 6 — Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Run Tests
|
Streamlit öffnet die App automatisch im Standard-Browser unter `http://localhost:8501`.
|
||||||
|
Falls der Browser nicht automatisch aufgeht, die URL manuell eingeben.
|
||||||
|
|
||||||
|
Zum **Beenden** der App im Terminal `Ctrl + C` drücken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 8 — Tests ausführen (optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest tests/
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
---
|
||||||
|
|
||||||
The application follows a frontend-backend split:
|
## Frontend
|
||||||
|
|
||||||
- **Frontend**: Streamlit UI components (sidebar, editor, chat)
|
Alle Frontend-Module sind reine Streamlit-Komponenten. Sie enthalten keine Business-Logik,
|
||||||
- **Backend**: Specialized manager modules
|
sondern delegieren alles an die Backend-Manager.
|
||||||
- FileManager: File operations
|
|
||||||
- ChatManager: AI interaction
|
|
||||||
- SystemPrompter: Prompt management
|
|
||||||
- SearchManager: Internet search
|
|
||||||
- ExecutionEngine: Code execution
|
|
||||||
- DebugLogger: Error handling & logging
|
|
||||||
|
|
||||||
## Development
|
### `app.py`
|
||||||
|
Einstiegspunkt der Applikation. Ruft `init_state()` auf Modul-Ebene auf (vor `main()`),
|
||||||
|
damit alle Session-State-Schlüssel existieren, bevor ein Widget gerendert wird. Delegiert
|
||||||
|
an `render_sidebar()`, `render_editor()` und `render_chat()` basierend auf dem Navigations-Radio.
|
||||||
|
|
||||||
Use Git to track changes:
|
### `state.py`
|
||||||
|
Zentrale Quelle aller `st.session_state`-Schlüsselnamen und ihrer Standardwerte.
|
||||||
|
Alle Schlüssel nutzen `if key not in st.session_state`-Guards, damit bei Streamlit-Reruns
|
||||||
|
keine bestehenden Werte überschrieben werden. Aktuell verwaltete Schlüssel:
|
||||||
|
|
||||||
|
| Schlüssel | Standard | Zweck |
|
||||||
|
|-----------|----------|-------|
|
||||||
|
| `last_selected` | `None` | Zuletzt angeklickter Baum-Knoten (verhindert erneutes Ausführen bei jedem Rerender) |
|
||||||
|
| `selected_folder` / `selected_folder_rel` | `None` | Aktuell markierter Ordner |
|
||||||
|
| `chat_manager` | `ChatManager()` | Live-ChatManager-Instanz |
|
||||||
|
| `open_files` | `[]` | Geordnete Liste absoluter Pfade als Editor-Tabs |
|
||||||
|
| `files_content` | `{}` | Pfad → aktueller Editor-Inhalt (kann von Disk abweichen) |
|
||||||
|
| `active_file` | `None` | Absoluter Pfad des aktiven Editor-Tabs |
|
||||||
|
| `exec_results` | `{}` | Pfad → letztes Ausführungsergebnis-Dict |
|
||||||
|
| `chat_history` | `[]` | Flache Liste von `{role, content}`-Dicts zur Anzeige |
|
||||||
|
| `agent_mode` | `False` | Ob die Agent-Mode-UI aktiv ist |
|
||||||
|
| `coding_agent` | `None` | Live-`CodingAgent`-Instanz während einer Aufgabe |
|
||||||
|
| `agent_status` | `"idle"` | `"idle"` / `"waiting_approval"` / `"done"` |
|
||||||
|
| `agent_log` | `[]` | Liste abgeschlossener Schritt-Einträge |
|
||||||
|
| `agent_pending_action` | `None` | Vorgeschlagene Aktion, die auf Benutzer-Genehmigung wartet |
|
||||||
|
| `search_results` | `[]` | Aktive Websuchergebnisse für Kontext-Injektion |
|
||||||
|
|
||||||
|
### `sidebar.py`
|
||||||
|
Rendert das Navigations-Radio und den Workspace-Datei-Explorer (basierend auf
|
||||||
|
`streamlit-arborist` für einen interaktiven Baum). Datei-Klicks öffnen einen neuen
|
||||||
|
Editor-Tab; Ordner-Klicks zeigen eine Aktionsleiste mit «Datei hinzufügen» / «Ordner
|
||||||
|
hinzufügen» / «Löschen». Ein Popover am unteren Rand ermöglicht das Erstellen und
|
||||||
|
Hochladen von Dateien (bis 1 MB) im Workspace-Wurzelverzeichnis.
|
||||||
|
|
||||||
|
### `editor.py`
|
||||||
|
Verwendet `streamlit-ace` für syntaxhervorgehobene Bearbeitung. Jede geöffnete Datei
|
||||||
|
erhält einen eigenen Tab via `st.tabs()`. Die aktive Datei steuert die Schaltflächen
|
||||||
|
«Code ausführen», «Herunterladen», «Schliessen», «Umbenennen» und «Löschen». Der Button
|
||||||
|
**Code ausführen** führt für Python-Dateien zuerst `ast.parse()` durch, um Syntaxfehler
|
||||||
|
vor dem Subprocess zu erkennen. Der Button **Mit KI debuggen** (nach einem fehlgeschlagenen
|
||||||
|
Lauf eingeblendet) formatiert die Fehlerausgabe und navigiert zur Chat-Ansicht mit einer
|
||||||
|
vorausgefüllten Debug-Nachricht.
|
||||||
|
|
||||||
|
### `chat.py`
|
||||||
|
Zwei sich gegenseitig ausschliessende Ansichten, umgeschaltet via `st.toggle("Agent Mode")`:
|
||||||
|
|
||||||
|
**Normaler Chat** (`render_normal_chat()`):
|
||||||
|
- Kompakte 4-spaltige Toolbar direkt über dem Chat-Input:
|
||||||
|
`[● Agent Mode]` `[🔍 Search]` `[🗑️ Clear]` `[⚙️ Settings]`
|
||||||
|
— Web-Suche und Einstellungen jeweils als `st.popover`, Clear öffnet einen Bestätigungs-Dialog.
|
||||||
|
- System-Prompt wird vor jeder ausgehenden Nachricht neu generiert (`_set_system_prompt()`).
|
||||||
|
- Unterstützt Slash-Befehle `/search <Abfrage>` und `/search clear`.
|
||||||
|
- Settings-Popover: Dateikontext-Toggle, Modell-Auswahl, Max-Token-Slider,
|
||||||
|
benutzerdefinierter System-Prompt.
|
||||||
|
- «Mit KI debuggen»-Nachrichten vom Editor werden über `pending_debug_message` im
|
||||||
|
Session-State weitergeleitet.
|
||||||
|
|
||||||
|
**Agent Mode** (`render_agent_mode()`):
|
||||||
|
- `idle` → Aufgabeneingabe + Start-Schaltfläche.
|
||||||
|
- `waiting_approval` → zeigt vorgeschlagenen Gedanken + Tool + Argumente; Benutzer kann
|
||||||
|
Genehmigen, Ablehnen (mit Feedback) oder Abbrechen.
|
||||||
|
- `done` → Erfolgsmeldung + Folgefrage-Eingabe zum Weiterführen der Aufgabe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Manager
|
||||||
|
|
||||||
|
### `file_manager.py`
|
||||||
|
Alle öffentlichen Methoden lösen Pfade auf und prüfen, ob sie innerhalb von `workspace/`
|
||||||
|
bleiben, bevor sie das Dateisystem berühren (**Path-Traversal-Schutz**). Je nach Operation
|
||||||
|
werden relative oder absolute Pfade akzeptiert und zurückgegeben:
|
||||||
|
|
||||||
|
| Methode | Pfad-Typ | Hinweise |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| `create_folder(relative_path, name)` | Workspace-relativ | Erstellt eine Ebene |
|
||||||
|
| `create_file(relative_path, name)` | Workspace-relativ | Standard: `.txt` |
|
||||||
|
| `read_file(absolute_path)` | Absolutes `Path`-Objekt | Vom Editor verwendet |
|
||||||
|
| `save_file(absolute_path, content)` | Absoluter String | Überschreibt vorhandenes |
|
||||||
|
| `rename_file(relative_path, new_name)` | Workspace-relativ | Erweiterung bleibt immer erhalten |
|
||||||
|
| `delete_file(relative_path)` | Workspace-relativ | |
|
||||||
|
| `delete_folder(relative_path)` | Workspace-relativ | Rekursiv via `shutil.rmtree` |
|
||||||
|
| `get_file_tree()` | — | Gibt verschachteltes Dict zurück; Verzeichnisse → Dict, Dateien → None |
|
||||||
|
|
||||||
|
Dateien werden in `get_file_tree()` standardmässig auf `CODE_EXTENSIONS` gefiltert.
|
||||||
|
|
||||||
|
### `chat_manager.py`
|
||||||
|
Kapselt einen OpenAI-kompatiblen REST-Endpunkt, konfiguriert via `.env`.
|
||||||
|
|
||||||
|
**Sliding-Window-History:** `_build_payload_messages()` sendet immer zuerst die
|
||||||
|
System-Nachricht (damit sie nie verworfen wird), gefolgt von den letzten
|
||||||
|
`max_history_messages` (20) Nicht-System-Nachrichten. Das begrenzt die Payload-Grösse,
|
||||||
|
ohne den System-Prompt zu verlieren.
|
||||||
|
|
||||||
|
**Fehlerbehandlung:** Verbindungs-Timeouts und HTTP-Fehler werden abgefangen, geloggt und
|
||||||
|
als Assistenten-Nachrichten in der History gespeichert (sodass die UI den Fehler inline
|
||||||
|
anzeigt).
|
||||||
|
|
||||||
|
**API-Key:** Falls `API_KEY` den Wert `"EMPTY"` hat oder fehlt, wird kein
|
||||||
|
`Authorization`-Header gesendet (unterstützt lokale/anonyme Endpunkte).
|
||||||
|
|
||||||
|
### `system_prompter.py`
|
||||||
|
Generiert kontextbewusste System-Prompts. Signatur:
|
||||||
|
```python
|
||||||
|
SystemPrompter.generate_prompt(
|
||||||
|
user_message="",
|
||||||
|
file_context=None, # {"name": str, "content": str}
|
||||||
|
search_context=None, # list[{"title", "url", "snippet"}] — in system_prompter.py implementiert,
|
||||||
|
# aber in chat.py nicht verwendet: dort wird Search-Kontext direkt
|
||||||
|
# als <search_context>-Block vor die Nachricht eingefügt
|
||||||
|
task_type="default", # "debug" | "explain" | "optimize" | "default"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
Der Aufgabentyp wird anhand von Schlüsselwörtern in der Benutzernachricht durch
|
||||||
|
`_detect_task_type()` in `chat.py` ermittelt. Der Dateiinhalt wird wörtlich in einen
|
||||||
|
XML-ähnlichen `<file>...<code>`-Block eingebettet und bei `MAX_FILE_CHARS` Zeichen
|
||||||
|
abgeschnitten. `_extract_relevant_context()` nutzt `ast.parse()`, um nur die spezifische
|
||||||
|
Funktion oder Klasse zurückzugeben, nach der der Benutzer fragt, anstatt die gesamte Datei.
|
||||||
|
|
||||||
|
### `execution_engine.py`
|
||||||
|
Führt Dateien in einem Subprocess mit `capture_output=True`, `text=True` und einem
|
||||||
|
`RUN_TIMEOUT` von 30 Sekunden aus. Aktuell unterstützt:
|
||||||
|
- `.py` — via `sys.executable` (plattformübergreifend; zeigt auf den aktuell aktiven Python-Interpreter)
|
||||||
|
|
||||||
|
Rückgabe: `{"stdout": str, "stderr": str, "rc": int}`.
|
||||||
|
|
||||||
|
### `search_manager.py`
|
||||||
|
DuckDuckGo-basierte Websuche und Seitenabruf für die Chat-Ansicht. SSRF-geschützt:
|
||||||
|
`_validate_url()` blockiert Nicht-HTTP(S)-Schemata, Loopback- und RFC-1918-private
|
||||||
|
IP-Bereiche. `fetch_page()` extrahiert lesbaren Text via BeautifulSoup, entfernt
|
||||||
|
`<script>`-, `<style>`-, `<nav>`- und `<footer>`-Tags und kürzt auf `MAX_PAGE_CHARS`.
|
||||||
|
|
||||||
|
### `debug_logger.py`
|
||||||
|
Richtet einen rotierenden Datei-Handler für `logs/app.log` (5 MB × 5 Backups) und eine
|
||||||
|
separate `logs/errors.log` für `ERROR`/`CRITICAL`-Einträge ein. Verwendung im Code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.managers.debug_logger import get_logger
|
||||||
|
logger = get_logger(__name__) # Standard-Python-Logger
|
||||||
|
|
||||||
|
logger.info("Dienst gestartet")
|
||||||
|
logger.error("Etwas ist schiefgelaufen")
|
||||||
|
logger.exception("Unerwarteter Fehler") # loggt Stack-Trace
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzliche Classmethods zur sitzungsweisen Fehler-Aggregation:
|
||||||
|
```python
|
||||||
|
DebugLogger.log_error("Nachricht") # loggt + hängt an _error_log-Liste an
|
||||||
|
DebugLogger.get_errors() # gibt Liste der Fehlermeldungen dieser Sitzung zurück
|
||||||
|
DebugLogger.clear_errors() # leert die In-Memory-Liste
|
||||||
|
|
||||||
|
DebugLogger.format_debug_output({ # formatiert Ausführungsergebnis für die KI
|
||||||
|
"return_code": 1,
|
||||||
|
"stdout": "...",
|
||||||
|
"stderr": "...",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Agent (MCP-System)
|
||||||
|
|
||||||
|
### `coding_agent.py`
|
||||||
|
Implementiert eine **Plan → Aktion → Beobachten**-Schleife, die schrittweise durch die
|
||||||
|
Streamlit-UI gesteuert wird. Das LLM antwortet immer mit einer strukturierten JSON-Aktion:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"thought": "...", "tool": "<tool_name>", "arguments": {"key": "value"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige Methoden:
|
||||||
|
|
||||||
|
| Methode | Beschreibung |
|
||||||
|
|---------|--------------|
|
||||||
|
| `start_task(task)` | Setzt den gesamten Zustand zurück, befüllt History mit System + Aufgabe |
|
||||||
|
| `propose_next_action()` | Ruft das LLM auf, parst JSON, speichert als `pending_action` |
|
||||||
|
| `approve()` | Führt das ausstehende Tool via `dispatch_tool()` aus, loggt Ergebnis |
|
||||||
|
| `reject(feedback)` | Injiziert Feedback + Replan-Tag in die History; `propose_next_action()` wird danach separat aufgerufen |
|
||||||
|
| `follow_up(question)` | Fügt nach «done» eine Folgefrage ein, setzt Schleife fort |
|
||||||
|
|
||||||
|
Hilfsfunktionen (Modul-Ebene):
|
||||||
|
|
||||||
|
| Funktion | Beschreibung |
|
||||||
|
|----------|--------------|
|
||||||
|
| `truncate_result(text)` | Begrenzt Tool-Output auf `MAX_RESULT_LENGTH` (10 000 Zeichen) |
|
||||||
|
| `trim_messages(msgs)` | Entfernt alte Turns, wenn History `MAX_HISTORY_CHARS` (80 000 Zeichen) überschreitet; System-Nachricht + Original-Aufgabe bleiben immer erhalten |
|
||||||
|
| `_strip_code_fences(text)` | Entfernt ` ```json `- / ` ``` `-Wrapper aus LLM-Antworten |
|
||||||
|
| `dispatch_tool(name, arguments)` | Leitet weiter an `MCPToolAdapter.call_tool()` |
|
||||||
|
| `build_all_tool_description()` | Erstellt eine menschenlesbare Tool-Liste für den System-Prompt |
|
||||||
|
|
||||||
|
### `mcp_server_adapter.py`
|
||||||
|
Liest `mcp_server_config.json`, startet jeden Server als stdio-Subprocess (immer mit
|
||||||
|
`sys.executable`, unabhängig vom literalen Befehl in der Konfiguration) und registriert
|
||||||
|
alle Tools in einem flachen `tool_registry`. Verbindungen werden pro Aufruf geöffnet
|
||||||
|
(nicht dauerhaft gehalten), da Streamlits synchrones Rerun-Modell langlebige
|
||||||
|
async-Kontextmanager unpraktisch macht.
|
||||||
|
|
||||||
|
### MCP-Server (in `servers/`)
|
||||||
|
|
||||||
|
Alle drei Server sind FastMCP-Applikationen, die über stdio kommunizieren.
|
||||||
|
|
||||||
|
**`mcp_server_file_search.py`** — Workspace-Dateioperationen:
|
||||||
|
- `list_files()` — flache rekursive Auflistung
|
||||||
|
- `get_file_tree(dir_path)` — baumförmige Verzeichnisstruktur
|
||||||
|
- `search_files(query)` — Name- und Inhaltssuche (bis 30 Treffer)
|
||||||
|
- `read_file(path)` — Textdatei lesen
|
||||||
|
- `write_new_file(path, content)` — Erstellen (kein Überschreiben)
|
||||||
|
- `create_new_directory(path)` — Verzeichnis erstellen
|
||||||
|
|
||||||
|
**`mcp_server_web_search.py`** — Webzugriff:
|
||||||
|
- `web_search(query, max_results=5)` — DuckDuckGo-Suche
|
||||||
|
- `fetch_page(url)` — Abrufen + Text extrahieren (max. `MAX_PAGE_LENGTH` Zeichen)
|
||||||
|
- Beide Tools nutzen SSRF-Schutz (gleiche URL-Validierung wie `search_manager.py`)
|
||||||
|
|
||||||
|
**`mcp_server_code_execution.py`** — Sandbox-Python-Analyse:
|
||||||
|
- `analyse_structure(code)` — AST-basierte Strukturzusammenfassung
|
||||||
|
- `lint_code(code)` — pyflakes-Analyse
|
||||||
|
- `python_code_validation(code)` — Sicherheits- + Syntaxprüfung ohne Ausführung
|
||||||
|
- `run_python_sandboxed(code)` — Ausführung in einem Subprocess mit `PYTHONIOENCODING=utf-8`,
|
||||||
|
15 s Timeout, Output begrenzt auf `MAX_OUTPUT_LENGTH`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP-Server-Konfiguration
|
||||||
|
|
||||||
|
### Format: `backend/agent/mcp_server_config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Servername": {
|
||||||
|
"command": "py",
|
||||||
|
"args": ["servers/mcp_server_beispiel.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feld | Pflicht | Beschreibung |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| `command` | Ja | Ausführbare Datei (`py`, `python3`, `node`, …) — wird für Python immer durch `sys.executable` ersetzt |
|
||||||
|
| `args` | Ja | Argumente-Array — erstes Element ist der Server-Script-Pfad relativ zu `backend/agent/` |
|
||||||
|
| `env` | Nein | Zusätzliche Umgebungsvariablen für den Serverprozess |
|
||||||
|
|
||||||
|
### Neuen MCP-Server hinzufügen
|
||||||
|
|
||||||
|
1. Neues FastMCP-Script in `backend/agent/servers/` erstellen:
|
||||||
|
```python
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
mcp = FastMCP("MeinServer")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def mein_tool(param: str) -> str:
|
||||||
|
"""Tool-Beschreibung, die dem Agenten angezeigt wird."""
|
||||||
|
return f"Ergebnis: {param}"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
```
|
||||||
|
2. Eintrag in `mcp_server_config.json` ergänzen:
|
||||||
|
```json
|
||||||
|
"MeinServer": {
|
||||||
|
"command": "py",
|
||||||
|
"args": ["servers/mcp_server_mein_tool.py"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Der Adapter erkennt den neuen Server beim nächsten App-Start automatisch und
|
||||||
|
bindet seine Tools in den System-Prompt des Agenten ein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (Streamlit) │
|
||||||
|
│ app.py ──► sidebar.py / editor.py / chat.py │
|
||||||
|
│ │ │
|
||||||
|
│ state.py (alle Session-State-Schlüssel) │
|
||||||
|
└──────────────┬──────────────────────────────────────────────────┘
|
||||||
|
│ direkte Python-Aufrufe
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend Manager │
|
||||||
|
│ FileManager ChatManager SystemPrompter │
|
||||||
|
│ SearchManager ExecutionEngine DebugLogger │
|
||||||
|
└──────────────┬──────────────────────────────────────────────────┘
|
||||||
|
│ async-Aufrufe (via _run_async-Bridge)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Coding Agent │
|
||||||
|
│ coding_agent.py ◄──► mcp_server_adapter.py │
|
||||||
|
│ │ stdio (Subprocess pro Aufruf) │
|
||||||
|
│ ┌────────────────┼───────────────┐ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ mcp_server_file_search mcp_server_web mcp_server_code │
|
||||||
|
└──────────────┬──────────────────────────────────────────────────┘
|
||||||
|
│ lesen / schreiben
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ workspace/ (isoliertes Sandbox-Verzeichnis) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Async-Bridge:** Streamlit läuft synchron. Der `CodingAgent` verwendet async-Methoden
|
||||||
|
(da die MCP-Client-Bibliothek async ist). `_run_async(coro)` in `chat.py` erstellt pro
|
||||||
|
Aufruf eine neue Event-Loop (`asyncio.new_event_loop()`), was im Thread-Modell von
|
||||||
|
Streamlit sicher ist, da auf dem UI-Thread keine Loop läuft.
|
||||||
|
|
||||||
|
**MCP-Verbindungsmodell:** Der Adapter öffnet pro Tool-Aufruf eine neue stdio-Verbindung
|
||||||
|
statt eine persistente Session zu halten. Das vermeidet die Komplexität langlebiger
|
||||||
|
async-Kontextmanager über Streamlit-Reruns hinweg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Designentscheidungen
|
||||||
|
|
||||||
|
### Warum `get_file_tree()` statt einer flachen Dateiliste?
|
||||||
|
Der interaktive Datei-Explorer in der Sidebar benötigt eine verschachtelte Struktur,
|
||||||
|
um das Baum-Widget aufzubauen. Eine flache Liste würde die clientseitige Rekonstruktion
|
||||||
|
von Eltern-Kind-Beziehungen erfordern. Der MCP-Server `mcp_server_file_search.py` stellt
|
||||||
|
sein eigenes `list_files()`-Tool für den Agenten bereit, wo eine flache Auflistung
|
||||||
|
für das LLM nützlicher ist.
|
||||||
|
|
||||||
|
### Warum wird der System-Prompt bei jeder Nachricht neu generiert?
|
||||||
|
Die im Editor aktive Datei kann sich zwischen Nachrichten ändern. Durch das Neuerstellen
|
||||||
|
des Prompts hat die KI immer den aktuellen Dateikontext. Der vorhandene System-Nachrichten-Eintrag
|
||||||
|
wird in-place aktualisiert (nicht angehängt), sodass die History stets genau eine
|
||||||
|
System-Nachricht enthält.
|
||||||
|
|
||||||
|
### Warum ist die Chat-History auf ein Sliding-Window begrenzt?
|
||||||
|
`ChatManager._build_payload_messages()` behält die System-Nachricht und die letzten 20 Turns.
|
||||||
|
Das verhindert, dass die Payload während langer Sitzungen das Kontextlimit des Modells
|
||||||
|
überschreitet, während der System-Prompt immer erhalten bleibt. Der Agent hat eine eigene,
|
||||||
|
separate Kürzungslogik (`trim_messages()`), die zusätzlich die ursprüngliche Aufgabenbeschreibung
|
||||||
|
bewahrt.
|
||||||
|
|
||||||
|
### Warum werden MCP-Tool-Verbindungen pro Aufruf geöffnet?
|
||||||
|
Streamlit führt das gesamte Script bei jeder Benutzerinteraktion erneut aus. Eine lebende
|
||||||
|
async-MCP-Session über Reruns hinweg zu erhalten würde entweder einen Hintergrund-Thread
|
||||||
|
oder eine persistente asyncio-Loop erfordern — beides erhöht Komplexität und Fehleranfälligkeit.
|
||||||
|
Verbindungen pro Aufruf sind einfacher und zuverlässiger, auf Kosten eines kleinen
|
||||||
|
Subprocess-Start-Overheads pro Tool-Aufruf.
|
||||||
|
|
||||||
|
### Warum wird Fehler-Output NICHT automatisch in den normalen Chat injiziert?
|
||||||
|
Das automatische Einschleusen jedes Laufzeitfehlers würde die Chat-History schnell mit
|
||||||
|
Rauschen überfluten. Stattdessen entscheidet der Benutzer selbst, wann er die KI über
|
||||||
|
den Button «Mit KI debuggen» im Editor einbezieht. Der Agent-Mode behandelt dies anders:
|
||||||
|
Tool-Fehler werden immer als Beobachtungen an das LLM zurückgegeben und lösen automatisches
|
||||||
|
Replanning aus.
|
||||||
|
|
||||||
|
### Warum setzt `run_python_sandboxed()` `PYTHONIOENCODING=utf-8`?
|
||||||
|
Unter Windows ist die Standard-Konsolen-Kodierung cp1252, die Unicode-Zeichen ausserhalb
|
||||||
|
des Latin-1-Bereichs (z. B. Emoji, CJK) nicht kodieren kann. Das Setzen von
|
||||||
|
`PYTHONIOENCODING=utf-8` in der Subprocess-Umgebung stellt sicher, dass `print()` für
|
||||||
|
beliebige Unicode-Inhalte korrekt funktioniert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Tests ausführen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
pytest tests/ -v # alle Tests
|
||||||
git commit -m "Your message"
|
pytest tests/test_chat_manager.py -v # einzelne Datei
|
||||||
git push origin sturcture
|
pytest tests/ -q # Kurzausgabe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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,18 +18,19 @@ 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()
|
||||||
print("MCPToolAdapter created. Listing all tools from servers...")
|
logger.info("MCPToolAdapter created. Listing all tools from servers...")
|
||||||
asyncio.run(adapter.initialize_all_servers())
|
asyncio.run(adapter.initialize_all_servers())
|
||||||
print("listed tools from all servers")
|
logger.info("Listed tools from all servers")
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@ -49,40 +50,63 @@ MAX_HISTORY_CHARS = 80_000
|
|||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def build_all_tool_description() -> str:
|
def build_all_tool_description() -> str:
|
||||||
"""Get relevant tools from the MCP servers based on the query."""
|
"""Build a formatted string listing every registered MCP tool.
|
||||||
|
|
||||||
|
The returned string is embedded verbatim in the SYSTEM_PROMPT so the LLM
|
||||||
|
knows which tools exist and what arguments they expect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Newline-separated list of tool descriptions in the format
|
||||||
|
``"- <tool_name>: <description>"``.
|
||||||
|
"""
|
||||||
all_tools = adapter.get_all_tools()
|
all_tools = adapter.get_all_tools()
|
||||||
print(f"Building tool description for {len(all_tools)} tools.")
|
logger.info("Building tool description for %s tools.", str(len(all_tools)))
|
||||||
|
|
||||||
descriptions = []
|
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:
|
||||||
"""Call a tool by name with the given arguments using the MCP adapter."""
|
"""Execute a named tool and return its output as a plain string.
|
||||||
|
|
||||||
|
Handles the special "done" pseudo-tool locally (it signals completion and
|
||||||
|
is never forwarded to an MCP server). All other tools are forwarded to the
|
||||||
|
MCPToolAdapter which routes them to the correct MCP server process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool to execute (e.g. "write_file", "done").
|
||||||
|
arguments: Dict of arguments for the tool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The tool's text output, a "DONE: ..." completion message, or an error
|
||||||
|
string beginning with "Tool error:" / "Error calling tool:" on failure.
|
||||||
|
"""
|
||||||
if tool_name == "done":
|
if tool_name == "done":
|
||||||
# Handle the "done" tool locally since it's not an MCP tool
|
# The "done" tool is a sentinel — it lives only in the agent protocol,
|
||||||
|
# not in any MCP server, so we resolve it directly here.
|
||||||
summary = arguments.get("summary", "Task completed.")
|
summary = arguments.get("summary", "Task completed.")
|
||||||
return f"DONE: {summary}"
|
return f"DONE: {summary}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
|
logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name)
|
||||||
result = await adapter.call_tool(tool_name, arguments)
|
result = await adapter.call_tool(tool_name, arguments)
|
||||||
|
|
||||||
print(f"Raw result from tool '{tool_name}': {result}")
|
logger.info("Result from tool '%s' received", tool_name)
|
||||||
|
|
||||||
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}"
|
||||||
|
|
||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
@ -143,6 +167,7 @@ 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.
|
||||||
@ -156,6 +181,7 @@ 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
|
||||||
@ -167,23 +193,35 @@ def truncate_result(result: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def trim_messages(messages: list) -> list:
|
def trim_messages(messages: list) -> list:
|
||||||
"""Drop old messages when history exceeds MAX_HISTORY_CHARS.
|
"""Kürzt die Konversations-History wenn sie das Kontextfenster überschreitet.
|
||||||
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 messages first (index 2 onwards) until we are under the limit.
|
# Drop the oldest non-anchor messages first 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)
|
||||||
|
|
||||||
# Inject a reminder so the agent doesn't lose track of its goal after trimming.
|
# After trimming, inject a reminder so the agent doesn't lose track of its goal.
|
||||||
|
# Without this the agent might restart the task or repeat work it already did.
|
||||||
reminder = {
|
reminder = {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": (
|
||||||
@ -196,13 +234,22 @@ 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 inside long string values, which
|
LLMs frequently emit literal newlines, tabs, or carriage-returns inside
|
||||||
is invalid JSON. This function fixes that without touching structural
|
long string values (e.g. code content), which is invalid JSON. This
|
||||||
whitespace outside strings.
|
function replaces those characters with their proper ``\\n`` / ``\\t`` /
|
||||||
|
``\\r`` escape sequences without touching structural whitespace that lives
|
||||||
|
outside of string literals.
|
||||||
|
|
||||||
|
The parser is a simple state-machine that tracks whether the current
|
||||||
|
character is inside a quoted string, respecting backslash escapes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Raw JSON text that may contain unescaped control characters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Repaired JSON text with control characters properly escaped inside strings.
|
||||||
"""
|
"""
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
in_string = False
|
in_string = False
|
||||||
@ -210,6 +257,8 @@ 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
|
||||||
@ -218,10 +267,12 @@ 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)
|
||||||
@ -285,13 +336,25 @@ def extract_json(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _strip_code_fences(text: str) -> str:
|
def _strip_code_fences(text: str) -> str:
|
||||||
"""Remove markdown code fences (```json ... ```) from a string."""
|
"""Remove a single wrapping markdown code fence from a string.
|
||||||
|
|
||||||
|
Handles both `` ```json `` and plain `` ``` `` opening fences. If the text
|
||||||
|
does not start with a fence the string is returned unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Raw LLM response that may be wrapped in a markdown code block.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The text with the opening fence line and optional closing `` ``` `` line
|
||||||
|
removed, stripped of surrounding whitespace.
|
||||||
|
"""
|
||||||
if text is None:
|
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()
|
||||||
@ -328,9 +391,7 @@ 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"}
|
||||||
@ -340,20 +401,33 @@ class CodingAgent:
|
|||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": 0.2, # low temperature → deterministic, more reliable tool calls
|
# Low temperature keeps the agent's tool selections deterministic and
|
||||||
|
# reduces the chance of hallucinated tool names or argument formats.
|
||||||
|
"temperature": 0.2,
|
||||||
"max_tokens": 4096,
|
"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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -366,6 +440,7 @@ 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.
|
||||||
@ -392,6 +467,7 @@ 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.",
|
||||||
@ -399,6 +475,7 @@ 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}",
|
||||||
@ -406,6 +483,7 @@ 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
|
||||||
@ -428,6 +506,8 @@ 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
|
||||||
@ -441,6 +521,7 @@ 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
|
||||||
@ -452,6 +533,7 @@ 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})
|
||||||
|
|
||||||
@ -479,6 +561,7 @@ 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.
|
||||||
@ -505,39 +588,4 @@ 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,3 +1,15 @@
|
|||||||
|
"""Adapter layer between the CodingAgent and one or more MCP tool servers.
|
||||||
|
|
||||||
|
MCPToolAdapter reads a JSON config file that lists MCP server processes, spawns
|
||||||
|
each process via stdio, queries its available tools, and stores them in a flat
|
||||||
|
registry. At call time it re-spawns the appropriate server process, executes
|
||||||
|
the requested tool, and returns the raw MCP result object.
|
||||||
|
|
||||||
|
Design note: connections are opened per-call (not kept alive) because Streamlit
|
||||||
|
reruns make it impractical to maintain long-lived async context managers across
|
||||||
|
the synchronous/asynchronous boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@ -7,62 +19,89 @@ 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]:
|
||||||
"""Lädt die Server-Konfiguration aus der JSON-Datei."""
|
"""Load the MCP server configuration from the JSON file next to this module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed config dict, or an empty dict if the file is missing or invalid.
|
||||||
|
"""
|
||||||
path = Path(__file__).parent / self.config_path
|
path = Path(__file__).parent / self.config_path
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
print(f"Config file not found: {path}")
|
logger.warning("Config file not found: %s", path)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
return json.load(f)
|
config_file = json.load(f)
|
||||||
|
logger.info("MCP-Server config loaded successfully")
|
||||||
|
return config_file
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
print(f"Error decoding JSON config: {e}")
|
logger.critical("Error decoding JSON from server config: %s", 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()
|
||||||
print(f"Loaded config for servers: {list(config.keys())}")
|
logger.info("Loaded config for servers: %s", list(config.keys()))
|
||||||
|
|
||||||
for server_name, params in config.items():
|
for server_name, params in config.items():
|
||||||
print(f"Testing connection to {server_name}...")
|
logger.info("Initializing connection to %s", server_name)
|
||||||
|
|
||||||
self.servers[server_name] = params
|
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):
|
||||||
print(f"Connected to {server_name}. Initializing session...")
|
logger.info("Connected to %s. Initializing session...", server_name)
|
||||||
async with ClientSession(read_stream, write_stream) as session:
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
print(f"Session initialized for {server_name}. Requesting tools...")
|
logger.info("Session initialized for %s. Requesting tools...", server_name)
|
||||||
result = await session.list_tools()
|
result = await session.list_tools()
|
||||||
print(f"Tools received from {server_name}: {result}")
|
|
||||||
tools = result.tools
|
tools = result.tools
|
||||||
print(f"Tools received from {server_name}: {result}")
|
logger.info("Tools received from %s: %s Tools", server_name, str(len(tools)))
|
||||||
#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 = []
|
||||||
@ -76,32 +115,44 @@ 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
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"Registered tool '{tool.name}' from {server_name}.")
|
logger.info("Registered tool '%s' from %s.", tool.name, server_name)
|
||||||
|
|
||||||
print(f"Session for {server_name} ready. {len(tools)} tools found.")
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to initialize {server_name}: {e}")
|
logger.exception("Failed to initialize %s: %s", server_name, str(e))
|
||||||
|
|
||||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||||
"""Gibt alle gesammelten Tools zurück."""
|
"""Return the full list of registered tools across all servers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts, each with keys "server", "tool_name", "tool_description".
|
||||||
|
"""
|
||||||
return self.tool_registry
|
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]):
|
||||||
"""Findet den richtigen Server für ein Tool und führt es aus."""
|
"""Look up a tool in the registry, connect to its server, and execute it.
|
||||||
# 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:
|
||||||
print(f"Tool '{tool_name}' not found in MCP adapter registry.")
|
logger.warning("Tool '%s' not found in MCP adapter registry.", tool_name)
|
||||||
return f"Error: Tool '{tool_name}' not found in registry."
|
return f"Error: Tool '{tool_name}' not found in registry."
|
||||||
|
|
||||||
server_name = tool_entry["server"]
|
server_name = tool_entry["server"]
|
||||||
@ -109,11 +160,12 @@ 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],
|
||||||
@ -123,24 +175,15 @@ 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:")
|
||||||
|
|||||||
@ -1,114 +0,0 @@
|
|||||||
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,10 +5,7 @@
|
|||||||
|
|
||||||
"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,35 +1,34 @@
|
|||||||
|
"""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
|
||||||
from datetime import datetime
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from pyflakes.api import check
|
from pyflakes.api import check # For linting Code
|
||||||
from pyflakes.reporter import Reporter
|
from pyflakes.reporter import Reporter # For linting Code
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from pathlib import Path
|
|
||||||
import venv
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
# ── Sandbox venv ────────────────────────────────────────────────────────────
|
#from backend.managers.debug_logger import get_logger
|
||||||
SERVER_BASE_DIR = Path(__file__).parent.resolve()
|
#logger = get_logger(__name__)
|
||||||
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 = 10 # seconds before killing the subprocess
|
EXEC_TIMEOUT = 15 # 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 ────────────────────────────────────────────────────
|
||||||
@ -68,6 +67,9 @@ 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:
|
||||||
"""
|
"""
|
||||||
@ -80,10 +82,13 @@ 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):
|
||||||
@ -91,6 +96,7 @@ 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}")
|
||||||
|
|
||||||
@ -98,16 +104,19 @@ 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
|
||||||
@ -122,11 +131,16 @@ 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 = {
|
||||||
@ -182,6 +196,7 @@ 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)
|
||||||
@ -198,6 +213,8 @@ 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()
|
||||||
|
|
||||||
@ -205,7 +222,9 @@ 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()
|
||||||
@ -213,6 +232,7 @@ 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 ---"]
|
||||||
@ -227,88 +247,11 @@ 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 list_sandbox_packages() -> str:
|
def run_python_sandboxed(code: str) -> 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.
|
||||||
|
|
||||||
@ -324,75 +267,82 @@ def run_python_code_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:
|
||||||
jail_dir.mkdir(parents=True, exist_ok=True)
|
# Force UTF-8 I/O so the subprocess can print unicode on Windows
|
||||||
|
# (default console encoding is cp1252 which cannot encode emoji).
|
||||||
custom_env = {
|
utf8_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||||
"PYTHONPATH": str(WORKSPACE_DIR),
|
|
||||||
"PATH": str(Path(PYTHON_EXE).parent),
|
|
||||||
"HOME": str(jail_dir),
|
|
||||||
"TMPDIR": str(jail_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[PYTHON_EXE, "-c", code],
|
[sys.executable, "-c", code],
|
||||||
cwd=str(WORKSPACE_DIR),
|
stdin=subprocess.DEVNULL,
|
||||||
env=custom_env,
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=EXEC_TIMEOUT)
|
encoding="utf-8",
|
||||||
|
timeout=EXEC_TIMEOUT,
|
||||||
|
env=utf8_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge stdout and stderr so the agent sees all output in one block.
|
||||||
output = result.stdout + result.stderr
|
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:
|
|
||||||
return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error during code execution: {e}"
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if jail_dir.exists():
|
|
||||||
shutil.rmtree(jail_dir)
|
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
#logger.warning("Code execution exceeded time limit of %s seconds", EXEC_TIMEOUT)
|
||||||
|
return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
#logger.exception("Error during code execution: %s", str(e))
|
||||||
|
return f"Error during code execution: {e}"
|
||||||
|
|
||||||
@mcp.tool()
|
@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.
|
||||||
Validate Python code for syntax and safety without executing it.
|
|
||||||
This tool performs static analysis to check for syntax errors.
|
Performs two checks in sequence:
|
||||||
|
1. AST parsing to catch syntax errors.
|
||||||
|
2. check_code_safety() to detect blocked imports/builtins/path sequences.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
code: The Python code to validate in str format.
|
code: The Python source code to validate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A message indicating the validation result.
|
A message indicating whether the code is valid and safe, or describing
|
||||||
And if sandboxed test execution is allowed.
|
the first violation found. Returns None implicitly when the code is
|
||||||
|
both syntactically valid and safe (no safety concerns found).
|
||||||
"""
|
"""
|
||||||
|
#logger.info("Tool python_code_validation is being executed on MCP code execution server")
|
||||||
|
|
||||||
try:
|
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,10 +1,30 @@
|
|||||||
|
"""MCP server that provides file system read/write tools for the workspace directory.
|
||||||
|
|
||||||
|
All operations are restricted to ALLOWED_DIR (the project workspace). Paths
|
||||||
|
that resolve outside this boundary are rejected with a ValueError so the agent
|
||||||
|
cannot accidentally read or write arbitrary host-filesystem locations.
|
||||||
|
|
||||||
|
Exposes the following MCP tools:
|
||||||
|
- list_files — flat list of all workspace files
|
||||||
|
- get_file_tree — tree-formatted directory listing
|
||||||
|
- search_files — search file names and content
|
||||||
|
- read_file — read a single file
|
||||||
|
- write_new_file — create a new file (no overwrite)
|
||||||
|
- create_new_directory — create a new directory
|
||||||
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from 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",".sh",".md",".txt",".tex",".c",".cpp",".java"]
|
ALLOWED_FILE_TYPES = [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml",
|
||||||
|
".sh", ".md", ".txt", ".tex", ".c", ".cpp", ".java"]
|
||||||
|
|
||||||
# ── Create the MCP server ────────────────────────────────────────────────────
|
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||||
mcp = FastMCP("FileSearchServer")
|
mcp = FastMCP("FileSearchServer")
|
||||||
@ -15,10 +35,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +52,8 @@ 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("*")
|
||||||
@ -41,7 +65,7 @@ def list_files() -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_file_tree(dir_path: str=ALLOWED_DIR) -> str:
|
def get_file_tree(dir_path: str="") -> str:
|
||||||
"""Get a tree representation of the project directory.
|
"""Get a tree representation of the project directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -50,30 +74,50 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> 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:
|
if not safe_dir.exists():
|
||||||
return f"Error: Invalid directory path '{dir_path}'."
|
#logger.warning("Directory '%s' does not exist.", 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:
|
||||||
entries = sorted([e for e in dir_path.iterdir() if "__pycache__" not in e.parts], key=lambda x: (x.is_file(), x.name))
|
"""Recursively build a tree string for the given directory.
|
||||||
|
|
||||||
|
Directories are sorted before files (``key=lambda x: (x.is_file(), x.name)``
|
||||||
|
puts dirs first because False < True). __pycache__ entries are hidden.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dir_path: The directory to render.
|
||||||
|
prefix: Indentation prefix accumulated during recursion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Multi-line string representing the subtree.
|
||||||
|
"""
|
||||||
|
# Exclude __pycache__ at every level to keep output readable for the agent.
|
||||||
|
entries = sorted(
|
||||||
|
[e for e in dir_path.iterdir() if "__pycache__" not in e.parts],
|
||||||
|
key=lambda x: (x.is_file(), x.name) # directories first, then files
|
||||||
|
)
|
||||||
lines = []
|
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(dir_path)
|
return _tree(Path(safe_dir))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@ -86,6 +130,8 @@ 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 = []
|
||||||
|
|
||||||
@ -107,7 +153,9 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -121,23 +169,33 @@ 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:
|
||||||
return resolved.read_text(encoding="utf-8")
|
text = 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()
|
||||||
@ -152,6 +210,7 @@ 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)
|
||||||
@ -159,26 +218,30 @@ 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:
|
||||||
print(f"FileNotFoundError for {path}: {e}")
|
#logger.warning("FileNotFoundError for '%s': %s", path, e)
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
print(f"PermissionError for {path}: {e}")
|
#logger.warning("PermissionError for '%s': %s", 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}"
|
||||||
|
|
||||||
|
|
||||||
@ -192,23 +255,29 @@ 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 != None and resolved.suffix != "":
|
if 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:
|
||||||
return f"Error creating dictionary file '{path}': {e}"
|
#logger.exception("Error creating directory '%s': %s", path, e)
|
||||||
|
return f"Error creating directory '{path}': {e}"
|
||||||
|
|
||||||
|
|
||||||
# ── Run the server ───────────────────────────────────────────────────────────
|
# ── Run the server ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,10 +1,36 @@
|
|||||||
|
"""MCP server that provides web search and page-fetching tools.
|
||||||
|
|
||||||
|
Exposes two MCP tools:
|
||||||
|
- web_search — keyword search via DuckDuckGo, returns titles, URLs, snippets
|
||||||
|
- fetch_page — fetch and extract readable text from a URL
|
||||||
|
|
||||||
|
All outbound requests are guarded by _validate_url() which blocks non-HTTP
|
||||||
|
schemes and private/loopback IP ranges to prevent SSRF vulnerabilities.
|
||||||
|
"""
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
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")
|
||||||
|
|
||||||
@ -12,28 +38,40 @@ 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 to prevent SSRF attacks."""
|
"""Validate a URL and raise ValueError if it could be used for an SSRF attack.
|
||||||
|
|
||||||
|
Blocks:
|
||||||
|
- Non-HTTP(S) schemes (file://, ftp://, etc.)
|
||||||
|
- Loopback and metadata addresses (localhost, 127.0.0.1, 169.254.169.254)
|
||||||
|
- RFC-1918 private IP ranges (10.x, 172.16-31.x, 192.168.x)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL string to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The original URL string unchanged if it passes all checks.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the URL fails any of the security checks.
|
||||||
|
"""
|
||||||
parsed = urlparse(url)
|
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 ""
|
||||||
|
|
||||||
blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"}
|
if hostname in BLOCKED_HOSTS:
|
||||||
if hostname in blocked_hosts:
|
#logger.warning("Blocked internal host: %s", hostname)
|
||||||
raise ValueError(f"Blocked internal host: {hostname}")
|
raise ValueError(f"Blocked internal host: {hostname}")
|
||||||
|
|
||||||
private_prefixes = (
|
for prefix in 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
|
||||||
@ -51,12 +89,16 @@ 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:
|
||||||
@ -68,6 +110,7 @@ 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}"
|
||||||
|
|
||||||
|
|
||||||
@ -80,38 +123,54 @@ 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:
|
||||||
return f"Error fetching page: {e}"
|
#logger.exception("Error parsing HTML: %s", e)
|
||||||
|
return f"Error parsing html: {e}"
|
||||||
|
|
||||||
|
|
||||||
# ── Run the server ───────────────────────────────────────────────────────────
|
# ── Run the server ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -5,6 +5,9 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
@ -21,6 +24,7 @@ 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"
|
||||||
@ -28,6 +32,21 @@ 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})
|
||||||
@ -38,6 +57,7 @@ class ChatManager:
|
|||||||
|
|
||||||
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:
|
||||||
@ -49,25 +69,27 @@ 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
|
||||||
@ -75,40 +97,52 @@ class ChatManager:
|
|||||||
|
|
||||||
# Check if request was successful
|
# Check if request was successful
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
error_msg = f"API Error {response.status_code}: {response.text}"
|
logger.warning("API HTTP status error %s: %s", response.status_code, response.text)
|
||||||
raise Exception(error_msg)
|
raise Exception(f"API Error {response.status_code}")
|
||||||
|
|
||||||
# Parse response
|
logger.info("Response recieved from API")
|
||||||
response_data = response.json()
|
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
# Extract AI message
|
error_msg = f"Timeout Error: {str(e)}"
|
||||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
self.add_message("assistant", f"Error: {error_msg}")
|
||||||
ai_message = response_data["choices"][0]["message"]["content"]
|
logger.exception("LLM API timeout: %s", e)
|
||||||
|
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}")
|
||||||
raise Exception(error_msg)
|
logger.exception("LLM API connection failed: %s", e)
|
||||||
|
raise RuntimeError("Connection Error: LLM API connection failed") from e
|
||||||
|
|
||||||
|
return self.receive_response(response)
|
||||||
|
|
||||||
|
def receive_response(self, response) -> str:
|
||||||
|
"""Parse an API response object and return the AI reply text.
|
||||||
|
|
||||||
|
Extracts the message content from the JSON body, appends it to history,
|
||||||
|
and returns it. Raises on malformed JSON or unexpected response shape.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
except json.JSONDecodeError as e:
|
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}")
|
||||||
raise Exception(error_msg)
|
logger.exception("JSON parsing and message formatting failed: %s", e)
|
||||||
|
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,66 +1,123 @@
|
|||||||
from datetime import datetime
|
"""
|
||||||
|
Central logging setup for the application.
|
||||||
|
|
||||||
|
- Provides a unified logger via get_logger(__name__)
|
||||||
|
- Writes all logs to a central rotating log file (logs/app.log)
|
||||||
|
- Writes errors separately to logs/errors.log
|
||||||
|
- Automatically includes the module name in each log entry
|
||||||
|
- Supports standard logging levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from backend.managers.debug_logger import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
logger.info("Service started")
|
||||||
|
logger.debug("Debug details")
|
||||||
|
logger.error("Something went wrong")
|
||||||
|
|
||||||
|
try:
|
||||||
|
...
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error")
|
||||||
|
|
||||||
|
Logging levels (use consistently):
|
||||||
|
DEBUG: Detailed technical info for developers (variables, flow, internal state).
|
||||||
|
INFO: Normal application events (start/stop, successful operations, key milestones).
|
||||||
|
WARNING: Something unexpected happened, but the program continues normally.
|
||||||
|
ERROR: A specific operation failed, but the application is still running.
|
||||||
|
CRITICAL: A severe failure that may stop the application or make it unusable.
|
||||||
|
EXCEPTION: Same as ERROR, but used inside an `except` block and includes stacktrace
|
||||||
|
(via logger.exception()).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
LOG_DIR = BASE_DIR / "logs"
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
class DebugLogger:
|
class DebugLogger:
|
||||||
"""In-memory logger for code execution events.
|
|
||||||
|
|
||||||
Collects timestamped INFO and ERROR entries during a single run.
|
_initialized = False
|
||||||
Call clear() before each new execution to start fresh.
|
_error_log: list[str] = []
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
@classmethod
|
||||||
self.logs: list[dict] = []
|
def setup(cls):
|
||||||
|
# prevents multiple setup
|
||||||
|
if cls._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
def log(self, message: str) -> None:
|
formatter = logging.Formatter(
|
||||||
"""Append a general info message."""
|
"%(asctime)s [%(levelname)s] [%(name)s: Line %(lineno)d] %(message)s"
|
||||||
self.logs.append({
|
)
|
||||||
"level": "INFO",
|
|
||||||
"message": message,
|
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
|
||||||
})
|
|
||||||
|
|
||||||
def log_error(self, error_message: str) -> None:
|
# Main log file
|
||||||
"""Append an error message."""
|
file_handler = RotatingFileHandler(
|
||||||
self.logs.append({
|
LOG_DIR / "app.log",
|
||||||
"level": "ERROR",
|
maxBytes=5_000_000,
|
||||||
"message": error_message,
|
backupCount=5,
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
encoding="utf-8"
|
||||||
})
|
)
|
||||||
|
|
||||||
def get_logs(self) -> list[dict]:
|
file_handler.setFormatter(formatter)
|
||||||
"""Return a copy of all collected log entries."""
|
|
||||||
return list(self.logs)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
# Separate Error-Log
|
||||||
"""Reset the log — call before each new execution."""
|
error_handler = RotatingFileHandler(
|
||||||
self.logs = []
|
LOG_DIR / "errors.log",
|
||||||
|
maxBytes=5_000_000,
|
||||||
|
backupCount=3,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
def format_debug_output(self, output: dict) -> str:
|
error_handler.setLevel(logging.ERROR)
|
||||||
"""Format an ExecutionEngine result dict into a human-readable string.
|
error_handler.setFormatter(formatter)
|
||||||
|
|
||||||
Args:
|
root_logger = logging.getLogger()
|
||||||
output: dict with keys 'stdout', 'stderr', and 'rc'.
|
|
||||||
|
|
||||||
Returns:
|
root_logger.setLevel(logging.DEBUG)
|
||||||
A formatted string ready for display in the UI.
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
status = "SUCCESS" if output.get("rc") == 0 else "FAILED"
|
root_logger.addHandler(file_handler)
|
||||||
lines.append(f"[{status}] Exit code: {output.get('rc')}")
|
root_logger.addHandler(error_handler)
|
||||||
|
#root_logger.propagate = False
|
||||||
|
|
||||||
if output.get("stdout"):
|
cls._initialized = True
|
||||||
lines.append("\n--- stdout ---")
|
|
||||||
lines.append(output["stdout"].rstrip())
|
|
||||||
|
|
||||||
if output.get("stderr"):
|
@classmethod
|
||||||
lines.append("\n--- stderr ---")
|
def get_logger(cls, name: str):
|
||||||
lines.append(output["stderr"].rstrip())
|
cls.setup()
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
if not output.get("stdout") and not output.get("stderr"):
|
@classmethod
|
||||||
lines.append("No output produced.")
|
def log_error(cls, error_message: str) -> None:
|
||||||
|
cls.setup()
|
||||||
|
logging.error(error_message)
|
||||||
|
cls._error_log.append(error_message)
|
||||||
|
|
||||||
for entry in self.logs:
|
@classmethod
|
||||||
lines.append(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}")
|
def get_errors(cls) -> list[str]:
|
||||||
|
return cls._error_log
|
||||||
|
|
||||||
return "\n".join(lines)
|
@classmethod
|
||||||
|
def clear_errors(cls) -> None:
|
||||||
|
cls._error_log.clear()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def format_debug_output(cls, output: dict) -> str:
|
||||||
|
stdout = output.get("stdout", "").strip() or "(none)"
|
||||||
|
stderr = output.get("stderr", "").strip() or "(none)"
|
||||||
|
return_code = output.get("return_code", "")
|
||||||
|
return (
|
||||||
|
"=== Execution Result ===\n"
|
||||||
|
f"Exit Code: {return_code}\n"
|
||||||
|
"--- stdout ---\n"
|
||||||
|
f"{stdout}\n"
|
||||||
|
"--- stderr ---\n"
|
||||||
|
f"{stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# praktische shortcut function
|
||||||
|
def get_logger(name: str):
|
||||||
|
return DebugLogger.get_logger(name)
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
@ -30,17 +41,11 @@ 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 = ["py", active_file.name]
|
cmd = [sys.executable, 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(
|
||||||
@ -50,12 +55,31 @@ class ExecutionEngine:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=RUN_TIMEOUT,
|
timeout=RUN_TIMEOUT,
|
||||||
)
|
)
|
||||||
return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode}
|
logger.info("File ran successfully.")
|
||||||
|
return self.capture_output(proc)
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
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,11 +7,30 @@ 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)
|
||||||
@ -28,12 +47,16 @@ 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
|
||||||
|
|
||||||
@ -52,11 +75,14 @@ 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
|
||||||
|
|
||||||
@ -71,14 +97,18 @@ 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)
|
||||||
@ -94,11 +124,14 @@ 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
|
||||||
|
|
||||||
@ -113,27 +146,31 @@ class FileManager:
|
|||||||
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:
|
||||||
return f.read()
|
content = f.read()
|
||||||
except FileNotFoundError:
|
logger.info("File read successfully.")
|
||||||
st.error(f"File not found: {relative_path}")
|
return content
|
||||||
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:
|
||||||
@ -148,19 +185,24 @@ class FileManager:
|
|||||||
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:
|
||||||
@ -174,8 +216,11 @@ 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
|
||||||
@ -191,13 +236,20 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -210,23 +262,29 @@ 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:
|
||||||
@ -238,42 +296,55 @@ 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()
|
||||||
|
|
||||||
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):
|
def get_file_tree(self, filter_extensions: bool = True) -> dict:
|
||||||
"""
|
"""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,
|
||||||
and files are represented as keys with None
|
files as keys with None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_extensions: When True (default), only files whose suffix is
|
||||||
|
in CODE_EXTENSIONS are included. Directories are always shown,
|
||||||
|
even when they are empty after filtering.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A nested dictionary representing the file tree.
|
dict: A nested dictionary representing the file tree.
|
||||||
"""
|
"""
|
||||||
def build_tree(path: Path):
|
logger.info("Getting file tree ...")
|
||||||
|
|
||||||
|
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) # recurse into sub-folders
|
tree[item.name] = build_tree(item)
|
||||||
else:
|
else:
|
||||||
tree[item.name] = None # leaf node for files
|
if filter_extensions and item.suffix not in CODE_EXTENSIONS:
|
||||||
|
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__":
|
||||||
|
|||||||
@ -0,0 +1,122 @@
|
|||||||
|
"""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,8 +1,79 @@
|
|||||||
"""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.
|
||||||
@ -12,37 +83,57 @@ class SystemPrompter:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_prompt(file_context: dict | None = None) -> str:
|
def generate_prompt(
|
||||||
"""Build a system prompt, optionally embedding a file's content.
|
user_message: str = "",
|
||||||
|
file_context: dict | None = None,
|
||||||
|
search_context: list[dict] | None = None,
|
||||||
|
task_type: str = "default",
|
||||||
|
) -> str:
|
||||||
|
"""Build a system prompt, optionally embedding a file and/or web search results.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_context: dict with keys 'name' (filename) and 'content' (raw text),
|
user_message: The current user input — used for task-type detection
|
||||||
or None if no file should be included.
|
and selective context extraction. Reserved for future
|
||||||
|
task-specific prompt tuning beyond what task_type covers.
|
||||||
|
file_context: dict with keys 'name' (filename) and 'content' (raw text),
|
||||||
|
or None if no file should be included.
|
||||||
|
search_context: list of {"title", "url", "snippet"} dicts from SearchManager,
|
||||||
|
or None if no search results should be included.
|
||||||
|
task_type: One of "debug", "explain", "optimize", "default".
|
||||||
|
Selects the matching base prompt from _TASK_PROMPTS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A ready-to-use system prompt string.
|
A ready-to-use system prompt string.
|
||||||
"""
|
"""
|
||||||
base = (
|
logger.info("Generating system prompt (task_type=%s).", task_type)
|
||||||
"You are an expert code assistant integrated into a lightweight code editor. "
|
prompt = _TASK_PROMPTS.get(task_type, _TASK_PROMPTS["default"])
|
||||||
"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", "")
|
||||||
|
|
||||||
# Truncate large files to avoid exceeding the model's token limit
|
# Extract only the relevant function/class when the user mentions one;
|
||||||
if len(content) > MAX_FILE_CHARS:
|
# otherwise fall back to simple truncation at MAX_FILE_CHARS.
|
||||||
content = content[:MAX_FILE_CHARS] + "\n... [truncated]"
|
content = _extract_relevant_context(content, user_message)
|
||||||
|
|
||||||
file_section = (
|
prompt += (
|
||||||
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
|
|
||||||
|
|
||||||
return base
|
if search_context:
|
||||||
|
search_section = "\n\nThe user has performed a web search. Use the results below as additional context if relevant:\n<search_results>\n"
|
||||||
|
for i, r in enumerate(search_context, 1):
|
||||||
|
search_section += (
|
||||||
|
f"[{i}] {r.get('title', '')}\n"
|
||||||
|
f"URL: {r.get('url', '')}\n"
|
||||||
|
f"{r.get('snippet', '')}\n\n"
|
||||||
|
)
|
||||||
|
search_section += "</search_results>"
|
||||||
|
prompt += search_section
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|||||||
@ -17,6 +17,9 @@ 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
|
||||||
@ -35,24 +38,21 @@ def main():
|
|||||||
st.markdown(
|
st.markdown(
|
||||||
"""
|
"""
|
||||||
<style>
|
<style>
|
||||||
.block-container { padding-top: 1rem; }
|
.block-container { padding-top: 4rem; }
|
||||||
[data-testid="stSidebarContent"] { padding-top: 0rem; margin-top: -2rem; }
|
[data-testid="stSidebarContent"] { padding-top: 0rem; }
|
||||||
</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,20 +1,35 @@
|
|||||||
"""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
|
||||||
|
|
||||||
import asyncio
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
||||||
def _run_async(coro):
|
def _run_async(coro):
|
||||||
"""Hilfsfunktion um async Code in sync Streamlit auszuführen"""
|
"""Execute an async coroutine from synchronous Streamlit code.
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
Streamlit always runs in a plain synchronous thread with no running event
|
||||||
except RuntimeError:
|
loop, so we always create a fresh loop here.
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
Args:
|
||||||
|
coro: The coroutine to run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The return value of the coroutine.
|
||||||
|
"""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
return loop.run_until_complete(coro)
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
def _start_agent(task: str):
|
def _start_agent(task: str):
|
||||||
@ -22,7 +37,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.
|
||||||
"""
|
"""
|
||||||
from backend.agent.coding_agent import CodingAgent
|
logger.info("Starting coding agent.")
|
||||||
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())
|
||||||
@ -38,6 +53,7 @@ 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({
|
||||||
@ -62,15 +78,23 @@ 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())
|
||||||
@ -80,6 +104,7 @@ 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 = []
|
||||||
@ -88,6 +113,62 @@ 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.
|
||||||
|
|
||||||
@ -96,6 +177,7 @@ 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")
|
||||||
|
|
||||||
@ -110,16 +192,17 @@ 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"):
|
||||||
st.json(step["arguments"])
|
_render_arguments(step["arguments"])
|
||||||
result_text = step.get("result", "")
|
with st.expander("➡️ Result", expanded=False):
|
||||||
# Colour the result based on whether the tool succeeded or failed.
|
result_text = step.get("result", "")
|
||||||
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
|
# Colour the result based on whether the tool succeeded or failed.
|
||||||
st.error(result_text)
|
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
|
||||||
elif result_text.startswith("OK") or result_text.startswith("DONE"):
|
st.error(result_text)
|
||||||
st.success(result_text)
|
elif result_text.startswith("OK") or result_text.startswith("DONE"):
|
||||||
else:
|
st.success(result_text)
|
||||||
st.code(result_text, language=None)
|
else:
|
||||||
|
st.code(result_text, language=None)
|
||||||
|
|
||||||
# ── Idle: task input ──────────────────────────────────────────────────────
|
# ── Idle: task input ──────────────────────────────────────────────────────
|
||||||
if agent_status == "idle":
|
if agent_status == "idle":
|
||||||
@ -130,8 +213,6 @@ 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())
|
||||||
@ -149,17 +230,12 @@ def render_agent_mode():
|
|||||||
|
|
||||||
args = pending.get("arguments", {})
|
args = pending.get("arguments", {})
|
||||||
if args:
|
if args:
|
||||||
# Show file content separately as a code block for readability;
|
_render_arguments(args)
|
||||||
# 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)
|
|
||||||
|
|
||||||
feedback = st.text_input(
|
if "agent_reject_feedback" not in st.session_state:
|
||||||
|
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...",
|
||||||
@ -172,10 +248,11 @@ def render_agent_mode():
|
|||||||
_approve_action()
|
_approve_action()
|
||||||
st.rerun()
|
st.rerun()
|
||||||
with col2:
|
with col2:
|
||||||
if st.button("Reject", use_container_width=True):
|
st.button(
|
||||||
with st.spinner("Agent is replanning..."):
|
"Reject",
|
||||||
_reject_action(feedback)
|
use_container_width=True,
|
||||||
st.rerun()
|
on_click=_handle_reject,
|
||||||
|
)
|
||||||
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()
|
||||||
@ -210,56 +287,313 @@ 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.
|
full context throughout the session. If search results are active when the
|
||||||
|
user sends a message, they are prepended to that message as a context block.
|
||||||
"""
|
"""
|
||||||
|
logger.info("Chat mode")
|
||||||
|
chat_manager: ChatManager = st.session_state.chat_manager
|
||||||
|
|
||||||
|
# Apply model/token overrides from the Settings panel before any API call.
|
||||||
|
if st.session_state.get("selected_model"):
|
||||||
|
chat_manager.model = st.session_state.selected_model
|
||||||
|
if "chat_max_tokens" in st.session_state:
|
||||||
|
chat_manager.max_tokens = st.session_state.chat_max_tokens
|
||||||
|
|
||||||
|
# Consume a debug message forwarded from the editor's "Debug with AI" button.
|
||||||
|
pending_debug = st.session_state.pop("pending_debug_message", None)
|
||||||
|
if pending_debug:
|
||||||
|
_set_system_prompt(chat_manager, pending_debug)
|
||||||
|
with st.spinner("Sending debug info to AI..."):
|
||||||
|
try:
|
||||||
|
ai_response = chat_manager.send_message(pending_debug)
|
||||||
|
except Exception as e:
|
||||||
|
ai_response = f"Error: {e}"
|
||||||
|
st.session_state.chat_history.append({"role": "user", "content": pending_debug})
|
||||||
|
st.session_state.chat_history.append(
|
||||||
|
{"role": "assistant", "content": ai_response}
|
||||||
|
)
|
||||||
|
st.rerun()
|
||||||
|
return
|
||||||
|
|
||||||
# Replay the conversation history as chat bubbles (skip system messages).
|
# 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:
|
||||||
role = message["role"]
|
if message["role"] == "system":
|
||||||
if role == "system":
|
|
||||||
continue
|
continue
|
||||||
with st.chat_message(role):
|
with st.chat_message(message["role"]):
|
||||||
st.markdown(message["content"])
|
st.markdown(message["content"])
|
||||||
|
|
||||||
# Chat input — Enter to send, no extra button needed
|
# ── Toolbar — directly above the sticky chat input ────────────────────────
|
||||||
user_input = st.chat_input("Type your message here...")
|
# In Streamlit, st.chat_input is a fixed footer. Elements placed BEFORE it
|
||||||
|
# in code appear in the scrollable content area right above the input bar.
|
||||||
|
_render_search_panel()
|
||||||
|
|
||||||
|
col_clear, col_agent, col_settings = st.columns([1, 1, 1])
|
||||||
|
with col_clear:
|
||||||
|
if st.button("🗑️ Clear Chat", use_container_width=True):
|
||||||
|
_clear_chat_dialog()
|
||||||
|
with col_agent:
|
||||||
|
st.toggle("Agent Mode", key="agent_mode")
|
||||||
|
with col_settings:
|
||||||
|
with st.popover("⚙️ Settings", use_container_width=True):
|
||||||
|
current_file = st.session_state.get("active_file")
|
||||||
|
if current_file:
|
||||||
|
st.toggle(
|
||||||
|
f"Include current file as context: **{Path(current_file).name}**",
|
||||||
|
key="include_file_context",
|
||||||
|
value=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.toggle(
|
||||||
|
"Include current file as context",
|
||||||
|
key="include_file_context",
|
||||||
|
value=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
default_model = chat_manager.model or ""
|
||||||
|
model_options = [default_model] if default_model else []
|
||||||
|
st.selectbox("Model", model_options, key="selected_model")
|
||||||
|
|
||||||
|
st.slider(
|
||||||
|
"Max Response Tokens",
|
||||||
|
min_value=256,
|
||||||
|
max_value=8000,
|
||||||
|
value=chat_manager.max_tokens,
|
||||||
|
step=256,
|
||||||
|
key="chat_max_tokens",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
st.text_area(
|
||||||
|
"Custom System Prompt (overrides default if set)",
|
||||||
|
key="custom_system_prompt",
|
||||||
|
height=120,
|
||||||
|
placeholder="Leave empty to use the default assistant prompt with optional file context.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chat input — sticky footer, always at the very bottom of the viewport.
|
||||||
|
# Supports /search <query> and /search clear as special commands.
|
||||||
|
user_input = st.chat_input("Type a message or /search <query>...")
|
||||||
if user_input:
|
if user_input:
|
||||||
chat_manager = st.session_state.chat_manager
|
stripped = user_input.strip()
|
||||||
|
|
||||||
# On the very first user message, prepend the system prompt so the AI
|
# ── /search command ───────────────────────────────────────────────────
|
||||||
# knows it is a code assistant embedded in an editor.
|
if stripped.lower().startswith("/search"):
|
||||||
if not chat_manager.get_history():
|
arg = stripped[len("/search"):].strip()
|
||||||
system_prompt = SystemPrompter.generate_prompt()
|
|
||||||
chat_manager.add_message("system", system_prompt)
|
|
||||||
|
|
||||||
# Show user message immediately without waiting for response.
|
with st.chat_message("user"):
|
||||||
|
st.markdown(stripped)
|
||||||
|
|
||||||
|
if arg.lower() == "clear" or arg == "":
|
||||||
|
# /search clear (or bare /search) — remove active results.
|
||||||
|
st.session_state.search_results = []
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
st.markdown("Search context cleared.")
|
||||||
|
st.session_state.chat_history.append({"role": "user", "content": stripped})
|
||||||
|
st.session_state.chat_history.append({"role": "assistant", "content": "Search context cleared."})
|
||||||
|
else:
|
||||||
|
# /search <query> — run search and store results in context.
|
||||||
|
with st.chat_message("assistant"):
|
||||||
|
with st.spinner(f'Searching for "{arg}"...'):
|
||||||
|
sm = SearchManager()
|
||||||
|
results = sm.perform_search(arg)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
st.session_state.search_results = results
|
||||||
|
response_text = f"🔍 Found {len(results)} result(s) for **'{arg}'**. Results are shown in the search panel above."
|
||||||
|
st.markdown(response_text)
|
||||||
|
else:
|
||||||
|
msg = f'No results found for "{arg}".'
|
||||||
|
st.warning(msg)
|
||||||
|
response_text = msg
|
||||||
|
|
||||||
|
st.session_state.chat_history.append({"role": "user", "content": stripped})
|
||||||
|
st.session_state.chat_history.append({"role": "assistant", "content": response_text})
|
||||||
|
|
||||||
|
st.rerun()
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Normal chat message ───────────────────────────────────────────────
|
||||||
|
search_results = st.session_state.get("search_results", [])
|
||||||
|
|
||||||
|
# 5g — System-prompt logic: inject on first message, update on file change.
|
||||||
|
_set_system_prompt(chat_manager, user_input)
|
||||||
|
|
||||||
|
# If search results are active, prepend them as a context block so the
|
||||||
|
# AI can reference them regardless of where in the conversation we are.
|
||||||
|
if search_results:
|
||||||
|
context_block = "<search_context>\n"
|
||||||
|
for r in search_results:
|
||||||
|
context_block += (
|
||||||
|
f"Title: {r['title']}\n"
|
||||||
|
f"URL: {r['url']}\n"
|
||||||
|
f"Snippet: {r['snippet']}\n\n"
|
||||||
|
)
|
||||||
|
context_block += "</search_context>\n\n"
|
||||||
|
message_to_send = context_block + user_input
|
||||||
|
else:
|
||||||
|
message_to_send = user_input
|
||||||
|
|
||||||
|
# Show the original user text in the UI (not the context-enriched version).
|
||||||
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(user_input)
|
ai_response = chat_manager.send_message(message_to_send)
|
||||||
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,22 +1,24 @@
|
|||||||
"""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 DebugLogger
|
from backend.managers.debug_logger import get_logger, DebugLogger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Maps file extensions to Ace editor language modes for syntax highlighting.
|
# Maps file extensions to Ace editor language modes for syntax highlighting.
|
||||||
LANG_MAP = {
|
LANG_MAP = {
|
||||||
".py": "python", ".tex": "latex", ".js": "javascript",
|
".py": "python", ".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")
|
||||||
@ -51,8 +53,10 @@ 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:
|
||||||
st.error("Rename failed. Check that the file still exists.")
|
logger.warning("Rename failed.")
|
||||||
|
st.error("Rename failed. Check that the file %s still exists.", file_path)
|
||||||
with col2:
|
with col2:
|
||||||
if st.button("Cancel", use_container_width=True):
|
if st.button("Cancel", use_container_width=True):
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@ -60,6 +64,14 @@ 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))
|
||||||
@ -77,17 +89,23 @@ 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 session_state.
|
"""Execute the currently active file and store the result in exec_results[file_path].
|
||||||
Returns the execution result dict {stdout, stderr, return_code}, or None
|
|
||||||
if no active file is set.
|
Runs an ast.parse() check first — if the syntax is invalid the file is not
|
||||||
|
executed and ast_error=True is stored so the UI can show a targeted warning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The result dict, or None if no active file is set.
|
||||||
"""
|
"""
|
||||||
active_file = st.session_state.active_file
|
active_file = st.session_state.active_file
|
||||||
|
|
||||||
@ -96,120 +114,170 @@ def run_active_file():
|
|||||||
return
|
return
|
||||||
|
|
||||||
execution_engine = ExecutionEngine()
|
execution_engine = ExecutionEngine()
|
||||||
debug_logger = DebugLogger()
|
|
||||||
|
logger.info("Executing code from %s...", active_file)
|
||||||
|
|
||||||
debug_logger.clear()
|
# 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:
|
if output["rc"] == 0:
|
||||||
debug_logger.log("Execution completed successfully.")
|
logger.info("Execution completed successfully.")
|
||||||
else:
|
else:
|
||||||
debug_logger.log_error(f"Execution failed with exit code {output['rc']}.")
|
logger.error("Execution failed with exit code %s.", output['rc'])
|
||||||
|
|
||||||
st.session_state.code_execution_output = {
|
result = {
|
||||||
"stdout": output["stdout"],
|
"stdout": output["stdout"],
|
||||||
"stderr": output["stderr"],
|
"stderr": output["stderr"],
|
||||||
"return_code": output["rc"]
|
"return_code": output["rc"],
|
||||||
|
"ast_error": False,
|
||||||
}
|
}
|
||||||
result = st.session_state.code_execution_output
|
st.session_state.exec_results[active_file] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def render_editor():
|
class FileViewer:
|
||||||
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
|
"""UI component that renders the code editor tabs, Ace editor, and run output."""
|
||||||
st.subheader("Code Editor")
|
|
||||||
|
|
||||||
if not st.session_state.open_files:
|
def __init__(self):
|
||||||
st.info("Please select a file to edit.")
|
self.fm = FileManager()
|
||||||
return
|
|
||||||
|
|
||||||
fm = FileManager()
|
def render(self):
|
||||||
|
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
|
||||||
|
st.subheader("Code Editor")
|
||||||
|
|
||||||
# ── Tab bar via st.tabs() ─────────────────────────────────────────────────
|
if not st.session_state.open_files:
|
||||||
# Build one tab per open file, named by the file's basename.
|
st.info("Please select a file to edit.")
|
||||||
tab_names = [Path(f).name for f in st.session_state.open_files]
|
return
|
||||||
tabs = st.tabs(tab_names)
|
|
||||||
|
|
||||||
for idx, file_path in enumerate(st.session_state.open_files):
|
# ── Tab bar via st.tabs() ─────────────────────────────────────────────
|
||||||
with tabs[idx]:
|
tab_names = [Path(f).name for f in st.session_state.open_files]
|
||||||
# Load file content from disk on first open; afterwards use the cached version.
|
tabs = st.tabs(tab_names)
|
||||||
if file_path not in st.session_state.files_content:
|
|
||||||
st.session_state.files_content[file_path] = fm.read_file(Path(file_path))
|
|
||||||
|
|
||||||
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
|
# Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun.
|
||||||
|
jump_target = st.session_state.pop("_jump_to_tab", None)
|
||||||
# Ace editor widget — auto_update sends content to Python on each keystroke.
|
if jump_target and jump_target in st.session_state.open_files:
|
||||||
code = st_ace.st_ace(
|
idx = st.session_state.open_files.index(jump_target)
|
||||||
value=st.session_state.files_content[file_path],
|
st.components.v1.html(
|
||||||
language=file_language,
|
f"""<script>
|
||||||
theme="monokai",
|
(function() {{
|
||||||
key=f"code_editor_{file_path}",
|
setTimeout(function() {{
|
||||||
auto_update=True,
|
const tabs = window.parent.document
|
||||||
height=400,
|
.querySelectorAll('button[data-baseweb="tab"]');
|
||||||
|
if (tabs[{idx}]) tabs[{idx}].click();
|
||||||
|
}}, 100);
|
||||||
|
}})();
|
||||||
|
</script>""",
|
||||||
|
height=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Keep the in-memory cache in sync with what the editor currently shows.
|
for idx, file_path in enumerate(st.session_state.open_files):
|
||||||
if code != st.session_state.files_content[file_path]:
|
with tabs[idx]:
|
||||||
st.session_state.files_content[file_path] = code
|
# Load file content from disk on first open; use cached version afterwards.
|
||||||
|
if file_path not in st.session_state.files_content:
|
||||||
|
st.session_state.files_content[file_path] = self.fm.read_file(Path(file_path))
|
||||||
|
|
||||||
cols = st.columns([1, 1, 1, 1])
|
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
|
||||||
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!")
|
|
||||||
|
|
||||||
with cols[1]:
|
code = st_ace.st_ace(
|
||||||
if st.button("Close File", key=f"close_{file_path}"):
|
value=st.session_state.files_content[file_path],
|
||||||
st.session_state.open_files.remove(file_path)
|
language=file_language,
|
||||||
st.session_state.files_content.pop(file_path, None)
|
theme="monokai",
|
||||||
# Switch active_file to the next available tab.
|
key=f"code_editor_{file_path}",
|
||||||
st.session_state.active_file = (
|
auto_update=True,
|
||||||
st.session_state.open_files[0]
|
height=400,
|
||||||
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()
|
||||||
|
|
||||||
with cols[2]:
|
result = st.session_state.get("exec_results", {}).get(file_path)
|
||||||
if st.button("Rename File", key=f"rename_{file_path}"):
|
if result:
|
||||||
_rename_dialog(file_path)
|
st.subheader("Execution Output")
|
||||||
|
|
||||||
with cols[3]:
|
if result.get("ast_error"):
|
||||||
if st.button("Delete File", key=f"delete_{file_path}"):
|
st.warning("⚠️ Syntax Error detected before execution — code was not run.")
|
||||||
_delete_dialog(file_path)
|
elif result["return_code"] == 0:
|
||||||
|
st.success("✅ Exit code: 0")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Exit code: {result['return_code']}")
|
||||||
|
|
||||||
if st.button("▶ Run Code", key="run_code"):
|
if result["return_code"] != 0 or result.get("stderr"):
|
||||||
result = run_active_file()
|
if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"):
|
||||||
if not result:
|
file_name = Path(file_path).name
|
||||||
st.stop()
|
code_content = st.session_state.files_content.get(file_path, "")
|
||||||
|
lang = LANG_MAP.get(Path(file_path).suffix, "python")
|
||||||
|
formatted_output = DebugLogger.format_debug_output(result)
|
||||||
|
debug_message = (
|
||||||
|
f"I got an error while running **{file_name}**:\n\n"
|
||||||
|
f"```\n{formatted_output}\n```\n\n"
|
||||||
|
f"**Here is the code:**\n```{lang}\n{code_content}\n```\n\n"
|
||||||
|
f"Can you help me fix this?"
|
||||||
|
)
|
||||||
|
st.session_state.pending_debug_message = debug_message
|
||||||
|
st.session_state["_navigate_to_chat"] = True
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
st.subheader("Execution Output")
|
if result.get("stdout"):
|
||||||
|
st.text_area("Standard Output", value=result["stdout"], height=200,
|
||||||
|
disabled=True, key=f"run_stdout_{file_path}")
|
||||||
|
if result.get("stderr"):
|
||||||
|
st.text_area("Standard Error", value=result["stderr"], height=200,
|
||||||
|
disabled=True, key=f"run_stderr_{file_path}")
|
||||||
|
if not result.get("stdout") and not result.get("stderr"):
|
||||||
|
st.info("No output produced by the code execution.")
|
||||||
|
|
||||||
# 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,6 +53,12 @@ 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)
|
||||||
@ -77,6 +83,12 @@ 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)
|
||||||
@ -98,99 +110,6 @@ 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()):
|
||||||
@ -234,16 +153,31 @@ 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=200,
|
height=350,
|
||||||
selection=None,
|
selection=active_selection,
|
||||||
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=False
|
open_by_default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
@ -257,7 +191,16 @@ 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()
|
||||||
@ -294,13 +237,16 @@ 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.
|
# Open the file in the editor, jump to its tab,
|
||||||
|
# and switch the view to the Editor pane.
|
||||||
st.session_state.selected_folder = None
|
st.session_state.selected_folder = 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():
|
||||||
@ -323,17 +269,6 @@ 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).
|
||||||
@ -344,7 +279,21 @@ 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("")
|
||||||
|
|
||||||
return
|
st.divider()
|
||||||
|
|
||||||
|
uploaded = st.file_uploader(
|
||||||
|
"Upload File",
|
||||||
|
type=["py", "js", "html", "css", "json", "yaml", "txt", "md"],
|
||||||
|
key="sidebar_file_upload",
|
||||||
|
)
|
||||||
|
if uploaded is not None:
|
||||||
|
if uploaded.size > 1_000_000:
|
||||||
|
st.error("File is too large (max 1 MB).")
|
||||||
|
else:
|
||||||
|
content = uploaded.getvalue().decode("utf-8", errors="replace")
|
||||||
|
dest = str(fm.base_path / uploaded.name)
|
||||||
|
if fm.save_file(dest, content):
|
||||||
|
st.success(f"'{uploaded.name}' uploaded successfully.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -10,84 +10,93 @@ from backend.managers.chat_manager import ChatManager
|
|||||||
|
|
||||||
|
|
||||||
def init_state():
|
def init_state():
|
||||||
# Sidebar state initialization
|
"""Initialise all Streamlit session-state keys with safe defaults.
|
||||||
|
|
||||||
|
Uses ``if key not in st.session_state`` guards throughout so that existing
|
||||||
|
values are never overwritten on subsequent reruns — only missing keys are
|
||||||
|
set. This means it is safe to call multiple times per session.
|
||||||
|
"""
|
||||||
|
# ── Sidebar state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# last_selected tracks the previously clicked tree node to detect new clicks
|
# 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 (persists across reruns)
|
# ── Chat manager ──────────────────────────────────────────────────────────
|
||||||
# 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 initialization
|
# ── Editor state ──────────────────────────────────────────────────────────
|
||||||
# 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 file path → current editor content (may be unsaved)
|
# Dict mapping absolute file path → current editor content (may differ from
|
||||||
|
# disk if the user has unsaved changes).
|
||||||
if "files_content" not in st.session_state:
|
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
|
# Absolute path of the file whose tab is currently active in the editor.
|
||||||
|
# Must always be one of the paths in open_files, or None if no file is open.
|
||||||
if "active_file" not in st.session_state:
|
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
|
||||||
|
|
||||||
# Index of the active tab (used by st.tabs)
|
# Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}}
|
||||||
if "active_tab" not in st.session_state:
|
if "exec_results" not in st.session_state:
|
||||||
st.session_state.active_tab = 0
|
st.session_state.exec_results = {}
|
||||||
|
|
||||||
if "is_editing" not in st.session_state:
|
# ── Chat state ────────────────────────────────────────────────────────────
|
||||||
st.session_state.is_editing = False
|
|
||||||
|
|
||||||
if "code_suggestions" not in st.session_state:
|
# Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles.
|
||||||
st.session_state.code_suggestions = []
|
# System messages are stored here too but skipped during display.
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Current status of the agent: "idle" | "waiting_approval" | "done"
|
# Lifecycle state of the agent: "idle" | "waiting_approval" | "done".
|
||||||
|
# Controls which sub-screen render_agent_mode() displays.
|
||||||
if "agent_status" not in st.session_state:
|
if "agent_status" not in st.session_state:
|
||||||
st.session_state.agent_status = "idle"
|
st.session_state.agent_status = "idle"
|
||||||
|
|
||||||
# List of completed steps shown in the collapsible Agent Log
|
# Chronological list of completed step records shown in the Agent Log expander.
|
||||||
|
# Each entry: {"thought": str, "tool": str, "arguments": dict, "result": str}
|
||||||
if "agent_log" not in st.session_state:
|
if "agent_log" not in st.session_state:
|
||||||
st.session_state.agent_log = []
|
st.session_state.agent_log = []
|
||||||
|
|
||||||
# The action the agent proposed but has not yet been approved or rejected
|
# The action the agent has proposed but that has not yet been approved or
|
||||||
|
# rejected by the user. Stored as the raw dict returned by propose_next_action().
|
||||||
if "agent_pending_action" not in st.session_state:
|
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__":
|
||||||
|
|||||||
Binary file not shown.
@ -1,5 +1,6 @@
|
|||||||
# Core Framework
|
# Core Framework
|
||||||
streamlit>=1.28.0
|
streamlit==1.57.0
|
||||||
|
streamlit_arborist>=0.1.0
|
||||||
|
|
||||||
# AI/LLM Integration
|
# AI/LLM Integration
|
||||||
openai>=1.0.0
|
openai>=1.0.0
|
||||||
@ -24,3 +25,7 @@ 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
78
run_agent.py
@ -1,78 +0,0 @@
|
|||||||
"""
|
|
||||||
Temporäres Test-Script für den CodingAgent – kann danach gelöscht werden.
|
|
||||||
|
|
||||||
Ausführen:
|
|
||||||
python run_agent.py
|
|
||||||
|
|
||||||
Steuerung:
|
|
||||||
Enter → Aktion ausführen (approve)
|
|
||||||
Text + Enter → Feedback geben (reject + replan)
|
|
||||||
stop → Abbrechen
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
from backend.agent.coding_agent import CodingAgent, WORKSPACE
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print(" CodingAgent – Interaktiver Test")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f" Workspace: {WORKSPACE}")
|
|
||||||
print(" [Enter] = Aktion ausführen | Text = Feedback | 'stop' = Abbruch")
|
|
||||||
print("=" * 60 + "\n")
|
|
||||||
|
|
||||||
task = input("Aufgabe eingeben: ").strip()
|
|
||||||
if not task:
|
|
||||||
print("Keine Aufgabe eingegeben. Beende.")
|
|
||||||
return
|
|
||||||
|
|
||||||
agent = CodingAgent()
|
|
||||||
agent.start_task(task)
|
|
||||||
print(f"\nAgent gestartet für: '{task}'\n")
|
|
||||||
|
|
||||||
step = 0
|
|
||||||
while not agent.is_done:
|
|
||||||
step += 1
|
|
||||||
print(f"\n{'─' * 60}")
|
|
||||||
print(f" Schritt {step} – Agent überlegt...")
|
|
||||||
|
|
||||||
action = agent.propose_next_action()
|
|
||||||
|
|
||||||
print(f"\n Thought : {action.get('thought', '')}")
|
|
||||||
print(f" Tool : {action.get('tool', '')}")
|
|
||||||
print(f" Arguments: {action.get('arguments', {})}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
user_input = input(" [Enter]=ausführen | Text=Feedback | stop=Abbruch: ").strip()
|
|
||||||
|
|
||||||
if user_input.lower() in ("stop", "abort"):
|
|
||||||
print("\nAbgebrochen.")
|
|
||||||
break
|
|
||||||
|
|
||||||
if user_input:
|
|
||||||
agent.reject(user_input)
|
|
||||||
print(f" → Feedback injiziert. Agent plant neu.\n")
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = agent.approve()
|
|
||||||
|
|
||||||
print(f"\n Resultat ({result['tool']}):")
|
|
||||||
print(f" {result['result'][:300]}{'...' if len(result['result']) > 300 else ''}")
|
|
||||||
|
|
||||||
if result["is_done"]:
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print(" FERTIG!")
|
|
||||||
print(f" {result['result']}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
run()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nUnterbrochen.")
|
|
||||||
@ -134,24 +134,3 @@ class TestSendMessage:
|
|||||||
cm.send_message("Hello")
|
cm.send_message("Hello")
|
||||||
|
|
||||||
|
|
||||||
# ── get_chat_display ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestGetChatDisplay:
|
|
||||||
"""Tests for get_chat_display: correct shape and ordering."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def cm(self):
|
|
||||||
return ChatManager()
|
|
||||||
|
|
||||||
def test_display_has_role_and_content_keys(self, cm):
|
|
||||||
cm.add_message("user", "Hello")
|
|
||||||
entry = cm.get_chat_display()[0]
|
|
||||||
assert "role" in entry
|
|
||||||
assert "content" in entry
|
|
||||||
|
|
||||||
def test_display_preserves_message_order(self, cm):
|
|
||||||
cm.add_message("user", "First")
|
|
||||||
cm.add_message("assistant", "Second")
|
|
||||||
display = cm.get_chat_display()
|
|
||||||
assert display[0]["role"] == "user"
|
|
||||||
assert display[1]["role"] == "assistant"
|
|
||||||
|
|||||||
@ -21,12 +21,10 @@ from pathlib import Path
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
|
||||||
|
|
||||||
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_HISTORY_CHARS,
|
|
||||||
MAX_ITERATIONS,
|
MAX_ITERATIONS,
|
||||||
MAX_RESULT_LENGTH,
|
MAX_RESULT_LENGTH,
|
||||||
CodingAgent,
|
CodingAgent,
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
"""Tests for DebugLogger (backend/managers/debug_logger.py)."""
|
"""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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -11,100 +16,94 @@ from backend.managers.debug_logger import DebugLogger
|
|||||||
|
|
||||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(autouse=True)
|
||||||
def logger():
|
def clear_error_log():
|
||||||
"""Return a fresh DebugLogger for each test."""
|
"""Reset the shared _error_log class variable before and after each test."""
|
||||||
return DebugLogger()
|
DebugLogger.clear_errors()
|
||||||
|
yield
|
||||||
|
DebugLogger.clear_errors()
|
||||||
# ── log() ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestLog:
|
|
||||||
"""Tests for log(): each call appends an INFO-level entry with message."""
|
|
||||||
|
|
||||||
def test_log_appends_entry(self, logger):
|
|
||||||
logger.log("started")
|
|
||||||
assert len(logger.logs) == 1
|
|
||||||
|
|
||||||
def test_log_sets_level_info(self, logger):
|
|
||||||
logger.log("started")
|
|
||||||
assert logger.logs[0]["level"] == "INFO"
|
|
||||||
|
|
||||||
def test_log_stores_message(self, logger):
|
|
||||||
logger.log("executing file.py")
|
|
||||||
assert logger.logs[0]["message"] == "executing file.py"
|
|
||||||
|
|
||||||
|
|
||||||
# ── log_error() ───────────────────────────────────────────────────────────────
|
# ── log_error() ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestLogError:
|
class TestLogError:
|
||||||
"""Tests for log_error(): level is ERROR, not INFO."""
|
"""Tests for log_error(): appends the message to the in-memory error list."""
|
||||||
|
|
||||||
def test_log_error_sets_level_error(self, logger):
|
def test_log_error_appends_to_error_log(self):
|
||||||
logger.log_error("something broke")
|
DebugLogger.log_error("something broke")
|
||||||
assert logger.logs[0]["level"] == "ERROR"
|
assert "something broke" in DebugLogger.get_errors()
|
||||||
|
|
||||||
def test_log_and_log_error_are_distinct_levels(self, logger):
|
def test_log_error_multiple_messages_all_stored(self):
|
||||||
logger.log("info message")
|
DebugLogger.log_error("first error")
|
||||||
logger.log_error("error message")
|
DebugLogger.log_error("second error")
|
||||||
assert logger.logs[0]["level"] == "INFO"
|
errors = DebugLogger.get_errors()
|
||||||
assert logger.logs[1]["level"] == "ERROR"
|
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_logs() ────────────────────────────────────────────────────────────────
|
# ── get_errors() ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestGetLogs:
|
class TestGetErrors:
|
||||||
"""Tests for get_logs()."""
|
"""Tests for get_errors(): returns the current in-memory error list."""
|
||||||
|
|
||||||
def test_get_logs_returns_all_entries(self, logger):
|
def test_get_errors_empty_initially(self):
|
||||||
logger.log("first")
|
assert DebugLogger.get_errors() == []
|
||||||
logger.log_error("second")
|
|
||||||
assert len(logger.get_logs()) == 2
|
def test_get_errors_reflects_logged_errors(self):
|
||||||
|
DebugLogger.log_error("boom")
|
||||||
|
assert len(DebugLogger.get_errors()) == 1
|
||||||
|
|
||||||
|
|
||||||
# ── clear() ───────────────────────────────────────────────────────────────────
|
# ── clear_errors() ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestClear:
|
class TestClearErrors:
|
||||||
"""Tests for clear()."""
|
"""Tests for clear_errors(): wipes the in-memory error list."""
|
||||||
|
|
||||||
def test_clear_removes_all_entries(self, logger):
|
def test_clear_errors_empties_list(self):
|
||||||
logger.log("first")
|
DebugLogger.log_error("will be cleared")
|
||||||
logger.log_error("second")
|
DebugLogger.clear_errors()
|
||||||
logger.clear()
|
assert DebugLogger.get_errors() == []
|
||||||
assert logger.logs == []
|
|
||||||
|
def test_clear_errors_on_empty_list_does_not_raise(self):
|
||||||
|
DebugLogger.clear_errors() # already empty from autouse fixture
|
||||||
|
|
||||||
|
|
||||||
# ── format_debug_output() ─────────────────────────────────────────────────────
|
# ── format_debug_output() ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestFormatDebugOutput:
|
class TestFormatDebugOutput:
|
||||||
"""Tests for format_debug_output(): renders rc, stdout, stderr, and log entries."""
|
"""Tests for format_debug_output(): renders return_code, stdout, and stderr."""
|
||||||
|
|
||||||
def test_success_exit_code_shows_success(self, logger):
|
def test_contains_execution_result_header(self):
|
||||||
result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""})
|
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
|
||||||
assert "[SUCCESS]" in result
|
assert "=== Execution Result ===" in result
|
||||||
|
|
||||||
def test_nonzero_exit_code_shows_failed(self, logger):
|
def test_exit_code_zero_appears_in_output(self):
|
||||||
result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": ""})
|
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
|
||||||
assert "[FAILED]" in result
|
assert "Exit Code: 0" in result
|
||||||
|
|
||||||
def test_stdout_included_when_present(self, logger):
|
def test_nonzero_exit_code_appears_in_output(self):
|
||||||
result = logger.format_debug_output({"rc": 0, "stdout": "Hello", "stderr": ""})
|
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
|
assert "Hello" in result
|
||||||
assert "stdout" in result
|
|
||||||
|
|
||||||
def test_stderr_included_when_present(self, logger):
|
def test_stderr_included_when_present(self):
|
||||||
result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": "NameError"})
|
result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": "NameError"})
|
||||||
assert "NameError" in result
|
assert "NameError" in result
|
||||||
assert "stderr" in result
|
|
||||||
|
|
||||||
def test_log_entries_appended_to_output(self, logger):
|
def test_empty_stdout_shows_none_placeholder(self):
|
||||||
logger.log("Executing file.py...")
|
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
|
||||||
logger.log_error("exit code 1")
|
assert "(none)" in result
|
||||||
result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": ""})
|
|
||||||
assert "Executing file.py..." in result
|
|
||||||
assert "exit code 1" in result
|
|
||||||
|
|
||||||
def test_missing_keys_do_not_raise(self, logger):
|
def test_missing_keys_do_not_raise(self):
|
||||||
# Defensive: format_debug_output uses .get() so missing keys are safe
|
# format_debug_output uses .get() so absent keys fall back to defaults.
|
||||||
result = logger.format_debug_output({})
|
result = DebugLogger.format_debug_output({})
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|||||||
@ -0,0 +1,387 @@
|
|||||||
|
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
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
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
|
||||||
@ -0,0 +1,285 @@
|
|||||||
|
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
|
||||||
@ -0,0 +1,299 @@
|
|||||||
|
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
|
||||||
361
tests/test_search_manager.py
Normal file
361
tests/test_search_manager.py
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
"""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,7 +5,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user