Compare commits

...

72 Commits

Author SHA1 Message Date
6e669a2234 Update README.md 2026-05-29 17:37:51 +02:00
5b32b4e6d0 Update README.md 2026-05-29 17:32:44 +02:00
Livio Meuli
a70efea25f Fix tests 2026-05-29 14:33:13 +02:00
Livio Meuli
c702675758 Remove env Variablen konfiguration aus dem Git 2026-05-29 14:14:29 +02:00
Livio Meuli
adfb780f7c Remove Latex 2026-05-29 13:47:28 +02:00
Livio Meuli
8209136bed Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project 2026-05-28 11:49:41 +02:00
Livio Meuli
3dcea4ece2 ReadMeUpdate 2026-05-28 11:49:37 +02:00
Irina Rueegg
db371b906c Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project 2026-05-28 11:46:55 +02:00
Irina Rueegg
e5e06da35b delete unused sidebarfunctions 2026-05-28 11:46:10 +02:00
Livio Meuli
9633def4de remove TItel 2026-05-28 11:44:49 +02:00
Irina Rueegg
3e8bab87cb Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project 2026-05-28 11:30:10 +02:00
Irina Rueegg
46b9c925dd Result expander in Agent log 2026-05-28 11:28:54 +02:00
6a7e90ab40 Merge pull request 'func_improvments' (#24) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#24
2026-05-28 11:27:09 +02:00
Livio Meuli
3216a9a5f6 fix Merge Confict 2026-05-28 11:25:52 +02:00
Livio Meuli
ba40f6fc82 Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into func_improvments 2026-05-28 11:21:19 +02:00
Livio Meuli
a8403eee92 small changes 2026-05-28 11:19:44 +02:00
Irina Rueegg
ea05437154 remove rejection feedback after onclick 2026-05-28 10:10:25 +02:00
Irina Rueegg
da236ce14e toggle file context 2026-05-28 09:46:20 +02:00
33969a7b59 Merge pull request 'Remove some stuff' (#22) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#22
2026-05-28 09:18:52 +02:00
Livio Meuli
8327c54bdb Remove some stuff 2026-05-28 09:17:08 +02:00
9836c2981a Merge pull request 'finetuning' (#21) from finetuning into main
Reviewed-on: meulilivio/AISE1_Project#21
2026-05-28 09:14:42 +02:00
Irina Rueegg
53797c161e finetuning 2026-05-27 19:54:45 +02:00
385ec6eea6 Merge pull request 'Update Readme' (#20) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#20
2026-05-27 08:00:08 +02:00
Livio Meuli
63e442c9b9 Update Readme 2026-05-27 07:59:36 +02:00
8ff46921b6 Merge pull request 'func_improvments' (#19) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#19
2026-05-27 07:54:56 +02:00
Livio Meuli
91f816168a Minor Updates 2026-05-27 07:54:07 +02:00
Livio Meuli
b83cd2a790 search improvments, chat, improvments 2026-05-26 17:48:23 +02:00
ff61ef5d16 Merge pull request 'func_improvments' (#18) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#18
2026-05-26 15:02:32 +02:00
Livio Meuli
33dd93fbbf Fixes and Updates 2026-05-26 06:31:02 +02:00
Livio Meuli
efdd9351b3 improvments 2026-05-25 19:02:20 +02:00
Livio Meuli
3891007fb2 Merge branch 'logger' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into func_improvments 2026-05-25 15:00:23 +02:00
Livio Meuli
d860b73710 Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into func_improvments 2026-05-25 14:54:46 +02:00
06c5c49092 Merge pull request 'MCP-setup' (#16) from MCP-setup into main
Reviewed-on: meulilivio/AISE1_Project#16
2026-05-25 13:20:31 +02:00
6730a48852 Merge branch 'main' into MCP-setup 2026-05-25 13:20:21 +02:00
e792a217f8 Merge pull request 'start web search in chat' (#15) from websearchmanager into main
Reviewed-on: meulilivio/AISE1_Project#15
2026-05-25 13:18:35 +02:00
Irina Rueegg
d28c0157d8 logger in editor 2026-05-25 11:03:38 +02:00
Irina Rueegg
59ff3ead93 Logger implementation, MCP commented out since logging is blocking server 2026-05-25 11:02:44 +02:00
Livio Meuli
955a30c512 docs: merge updated README.md and READMEnew.md from Agent 3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:40:17 +02:00
Livio Meuli
cc14f25e69 refactor: add REVIEW annotations for dead/debug code across frontend and backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:38:59 +02:00
Livio Meuli
0751be8e61 docs: merge docstrings and inline comments from agent 2026-05-24 10:28:54 +02:00
Livio Meuli
45ec8f8208 chore: prior changes on func_improvments before agent merge 2026-05-24 10:28:44 +02:00
Livio Meuli
7afa345279 docs: update README.md and READMEnew.md with full project documentation 2026-05-24 10:28:35 +02:00
Livio Meuli
21b0b3f9dc docs: add docstrings and inline comments to all Python files 2026-05-24 10:28:28 +02:00
Livio Meuli
b98b74dad1 Merge branch 'MCP-setup' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into MCP-setup 2026-05-22 14:56:27 +02:00
Livio Meuli
4342a8e34b fix comment 2026-05-22 14:56:21 +02:00
Irina Rueegg
541735fdd6 Merge branch 'MCP-setup' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into MCP-setup 2026-05-22 14:55:19 +02:00
Irina Rueegg
015564d754 testts 2026-05-22 14:54:49 +02:00
Livio Meuli
c301076e60 fix problem 2026-05-22 14:51:08 +02:00
Livio Meuli
241c6580f3 some implementetd stuff 2026-05-22 14:25:22 +02:00
Irina Rueegg
a65dd047ba Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into MCP-setup 2026-05-21 16:13:40 +02:00
Irina Rueegg
e75212652e validate fix 2026-05-21 16:11:21 +02:00
1a5bd6633a Merge pull request 'write_tests' (#14) from write_tests into main
Reviewed-on: meulilivio/AISE1_Project#14
2026-05-21 16:05:13 +02:00
Livio Meuli
bdbdbc9b3a start web search in chat 2026-05-21 15:41:22 +02:00
Irina Rueegg
c8299f89c3 mcp code execution fix 2026-05-21 15:39:34 +02:00
Livio Meuli
f91707b5b5 remove some testcases 2026-05-21 12:06:32 +02:00
Livio Meuli
1c6e4a2b9d rework tests 2026-05-21 11:50:32 +02:00
Livio Meuli
2a471988cc Adding Comments and Docstrings to testFiles for Documentation 2026-05-21 11:37:05 +02:00
Livio Meuli
ee64b8c706 Update tests and add some new test Cases. Add new requirement 2026-05-21 11:26:28 +02:00
Livio Meuli
855c4edde4 added new testfiles and completed some testfiles with tests.
test_execution_engine, test_mcp_server_code_execution
test_mcp_server_files_search
test_mcp_server_web_search
müssen noch gemacht werden
2026-05-21 10:46:01 +02:00
Livio Meuli
9dedd0403c Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into comments 2026-05-21 09:53:58 +02:00
901fbab543 Merge pull request 'MCP-setup' (#13) from MCP-setup into main
Reviewed-on: meulilivio/AISE1_Project#13
2026-05-16 10:33:19 +02:00
Irina Rueegg
3c677a13ab feat MCP Sandboxing for python 2026-05-10 21:44:53 +02:00
Irina Rueegg
560e56b596 MCP-Tool fine tuning 2026-05-10 15:52:34 +02:00
Irina Rueegg
e5831f69a8 feat MCP adapter: functional adapter and tool calls. Tools need to be fine grained 2026-05-10 13:46:48 +02:00
Irina Rueegg
88c647a428 addition of debug print messages, bug in adapter, initializing sessions, line 113 2026-05-10 11:32:40 +02:00
Livio Meuli
eacf8c5137 Comments im ganzen Projekt 2026-05-07 15:42:29 +02:00
Irina Rueegg
4e0d3a7438 reset coding_agent to non dynamic tool choice 2026-05-07 12:02:08 +02:00
Irina Rueegg
582e0bd711 additional mcp-adapter without RAG logic 2026-05-07 11:36:56 +02:00
Irina Rueegg
0a329139ca async mcp implementation in agentloop - buggy 2026-05-06 21:19:03 +02:00
Irina Rueegg
2a37ae3aa4 Docstring anpassungen mcp server 2026-05-06 19:25:48 +02:00
Irina Rueegg
2a65efb046 mcp servers with config and adapter files 2026-05-06 19:01:12 +02:00
Irina Rueegg
73ba4a7931 rename and delet options per file in File Tree/sidebar 2026-05-05 19:38:04 +02:00
37 changed files with 5549 additions and 1166 deletions

View File

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

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

704
README.md
View File

@ -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 (~25 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.

View File

@ -13,16 +13,24 @@ step-by-step methods so Streamlit can drive the loop via session_state:
agent.reject(feedback) # skip action, inject user feedback agent.reject(feedback) # skip action, inject user feedback
""" """
import ast
import inspect
import json import json
import os import os
import subprocess import re
import sys
from pathlib import Path from pathlib import Path
import asyncio
import requests import requests
from dotenv import load_dotenv from dotenv import load_dotenv
from backend.agent.mcp_server_adapter import MCPToolAdapter
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# ── mcp server initialization ────────────────────────────────────────────────────────────────
adapter = MCPToolAdapter()
logger.info("MCPToolAdapter created. Listing all tools from servers...")
asyncio.run(adapter.initialize_all_servers())
logger.info("Listed tools from all servers")
load_dotenv() load_dotenv()
@ -38,179 +46,94 @@ MAX_HISTORY_CHARS = 80_000
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# PART A TOOL FUNCTIONS # Tool dispatching and result handling
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
#
# Each tool is a plain Python function decorated with @register_tool.
# The decorator adds the function to TOOL_REGISTRY so the dispatcher
# can call it by name at runtime.
TOOL_REGISTRY: dict[str, callable] = {} def build_all_tool_description() -> str:
"""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.
def register_tool(func): Returns:
"""Decorator adds a function to the global tool registry.""" Newline-separated list of tool descriptions in the format
TOOL_REGISTRY[func.__name__] = func ``"- <tool_name>: <description>"``.
return func """
all_tools = adapter.get_all_tools()
logger.info("Building tool description for %s tools.", str(len(all_tools)))
descriptions = []
for tool in all_tools:
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
@register_tool return "\n".join(descriptions)
def read_file(path: str) -> str:
"""Read a .py or .txt file from the workspace and return its contents."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if not target.exists():
return f"ERROR: file '{path}' not found."
if target.suffix not in (".py", ".txt"):
return f"ERROR: can only read .py and .txt files, got '{target.suffix}'."
return target.read_text()
async def dispatch_tool(tool_name: str, arguments: dict) -> str:
"""Execute a named tool and return its output as a plain string.
@register_tool Handles the special "done" pseudo-tool locally (it signals completion and
def write_file(path: str, content: str) -> str: is never forwarded to an MCP server). All other tools are forwarded to the
"""Write content to a .py or .txt file in the workspace.""" MCPToolAdapter which routes them to the correct MCP server process.
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if target.suffix not in (".py", ".txt"):
return f"ERROR: can only write .py and .txt files, got '{target.suffix}'."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return f"OK: wrote {len(content)} chars to {path}."
Args:
tool_name: Name of the tool to execute (e.g. "write_file", "done").
arguments: Dict of arguments for the tool.
@register_tool Returns:
def list_files(file_glob: str = "*") -> str: The tool's text output, a "DONE: ..." completion message, or an error
"""List files in the workspace matching the glob pattern.""" string beginning with "Tool error:" / "Error calling tool:" on failure.
found = sorted(WORKSPACE.glob(file_glob)) """
found = [f.relative_to(WORKSPACE) for f in found if f.is_file()] if tool_name == "done":
if not found: # The "done" tool is a sentinel — it lives only in the agent protocol,
return f"No files matching '{file_glob}' in workspace." # not in any MCP server, so we resolve it directly here.
return "\n".join(str(f) for f in found) summary = arguments.get("summary", "Task completed.")
return f"DONE: {summary}"
@register_tool
def grep_search(pattern: str, file_glob: str = "*.py") -> str:
"""Search for a pattern in workspace files and return matching lines with line numbers."""
matches = []
for filepath in sorted(WORKSPACE.glob(file_glob)):
if filepath.suffix not in (".py", ".txt"):
continue
try:
lines = filepath.read_text().splitlines()
except Exception:
continue
for i, line in enumerate(lines, 1):
if pattern in line:
rel = filepath.relative_to(WORKSPACE)
matches.append(f"{rel}:{i}: {line}")
if not matches:
return f"No matches for '{pattern}' in {file_glob}."
return "\n".join(matches)
@register_tool
def run_python(path: str) -> str:
"""Execute a Python file in the workspace and return stdout and stderr."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if not target.exists():
return f"ERROR: file '{path}' not found."
result = subprocess.run(
[sys.executable, str(target)],
capture_output=True, text=True, timeout=30,
cwd=str(WORKSPACE),
)
output = ""
if result.stdout:
output += f"STDOUT:\n{result.stdout}"
if result.stderr:
output += f"STDERR:\n{result.stderr}"
output += f"\nExit code: {result.returncode}"
return output.strip()
@register_tool
def validate_python(path: str) -> str:
"""Check whether a Python file has valid syntax using ast.parse."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if not target.exists():
return f"ERROR: file '{path}' not found."
source = target.read_text()
try: try:
ast.parse(source) logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name)
return "OK: syntax is valid." result = await adapter.call_tool(tool_name, arguments)
except SyntaxError as e:
return f"SYNTAX ERROR: {e}"
logger.info("Result from tool '%s' received", tool_name)
@register_tool if result.isError:
def done(summary: str) -> str: # MCP servers signal tool-level errors via the isError flag rather
"""Signal that the agent has finished its task.""" # than raising exceptions, so we surface them explicitly.
return f"DONE: {summary}" texts = [block.text for block in result.content if block.type == "text"]
logger.warning("Result from '%s' is Error", tool_name)
return f"Tool error: {' '.join(texts)}"
texts = [block.text for block in result.content if block.type == "text"]
return "\n".join(texts)
# ═════════════════════════════════════════════════════════════════════════════
# PART B TOOL DISPATCHER
# ═════════════════════════════════════════════════════════════════════════════
def build_tool_description() -> str:
"""Auto-generate tool descriptions from function signatures and docstrings."""
lines = []
for name, func in TOOL_REGISTRY.items():
sig = inspect.signature(func)
params = []
for pname, param in sig.parameters.items():
if param.default is inspect.Parameter.empty:
params.append(f'"{pname}": "<value>"')
else:
params.append(f'"{pname}": "<optional, default={param.default!r}>"')
param_str = ", ".join(params)
doc = (func.__doc__ or "").strip().split("\n")[0]
lines.append(f" - {name}({{{param_str}}}): {doc}")
return "\n".join(lines)
def dispatch_tool(tool_name: str, arguments: dict) -> str:
"""Call a tool by name with the given arguments."""
if tool_name not in TOOL_REGISTRY:
return f"ERROR: unknown tool '{tool_name}'. Available: {list(TOOL_REGISTRY.keys())}"
func = TOOL_REGISTRY[tool_name]
try:
return func(**arguments)
except TypeError as e:
return f"ERROR calling {tool_name}: {e}"
except Exception as e: except Exception as e:
return f"ERROR in {tool_name}: {type(e).__name__}: {e}" logger.exception("Error calling tool '%s' with argument: %s", tool_name, arguments)
return f"Error calling tool '{tool_name}': {e}"
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# PART C SYSTEM PROMPT # SYSTEM PROMPT
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
SYSTEM_PROMPT = f"""\ SYSTEM_PROMPT = f"""\
You are a coding agent that helps users with Python programming tasks. You are a coding agent that helps users with Python programming tasks.
You work inside a workspace directory and have access to tools. You work inside a workspace directory and have access to tools.
You solve tasks by interacting with a workspace through a
dynamic set of tools via the Model Context Protocol (MCP).
<capabilities> <capabilities>
You can: You have access to a workspace where you can manage files,
- Read .py and .txt files from the workspace analyze code, and execute Python scripts.
- Write .py and .txt files to the workspace You can call tools to interact with the workspace and get feedback.
- List files in the workspace You can write and read files, list directory contents, search for patterns,
- Search for patterns in files using grep validate Python syntax, and run Python code.
- Execute Python files and see their output You can access web search and page fetching tools to gather information from the internet.
- Validate Python syntax using ast.parse You can use these capabilities to iteratively work towards completing the user's task.
- Signal completion when the task is done
</capabilities> </capabilities>
<tools> <tools>
{build_tool_description()} {build_all_tool_description()}
</tools> </tools>
<workflow> <workflow>
For every user request, follow this workflow: For every user request, follow this workflow:
1. PLAN: Think about what steps are needed. List them in "thought". 1. PLAN: Think about what steps are needed. List them in "thought".
@ -244,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.
@ -252,11 +176,12 @@ Example:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# PART D CODING AGENT CLASS # CODING AGENT CLASS
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
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
@ -268,20 +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 non-anchor messages first until we are under the limit.
while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS: while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
tail.pop(0) tail.pop(0)
# After trimming, inject a reminder so the agent doesn't lose track of its goal.
# Without this the agent might restart the task or repeat work it already did.
reminder = { reminder = {
"role": "user", "role": "user",
"content": ( "content": (
@ -293,12 +233,128 @@ def trim_messages(messages: list) -> list:
} }
return head + [reminder] + tail return head + [reminder] + tail
def _repair_json_strings(text: str) -> str:
"""Replace unescaped control characters inside JSON string values.
LLMs frequently emit literal newlines, tabs, or carriage-returns inside
long string values (e.g. code content), which is invalid JSON. This
function replaces those characters with their proper ``\\n`` / ``\\t`` /
``\\r`` escape sequences without touching structural whitespace that lives
outside of string literals.
The parser is a simple state-machine that tracks whether the current
character is inside a quoted string, respecting backslash escapes.
Args:
text: Raw JSON text that may contain unescaped control characters.
Returns:
Repaired JSON text with control characters properly escaped inside strings.
"""
result: list[str] = []
in_string = False
escape = False
_escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'}
for ch in text:
if escape:
# The previous character was a backslash — emit this char literally
# and reset the escape flag.
result.append(ch)
escape = False
continue
if ch == '\\' and in_string:
result.append(ch)
escape = True
continue
if ch == '"':
# Toggle string-mode on every unescaped double quote.
in_string = not in_string
result.append(ch)
continue
if in_string and ch in _escapes:
# Replace the bare control character with its escape sequence.
result.append(_escapes[ch])
continue
result.append(ch)
return ''.join(result)
def extract_json(text: str) -> str:
"""
Extract and repair a JSON object or array from an LLM response that may
contain extra prose, markdown code fences, or unescaped control characters.
Strategy:
1. Strip markdown ```json ... ``` or ``` ... ``` fences.
2. Find the first '{' or '[' and extract to the matching closing bracket.
3. Repair unescaped newlines/tabs inside string values.
Returns the cleaned JSON string, or the original text as a fallback
(so json.loads can raise a meaningful error with context).
"""
if text is None:
return ""
# 1. Strip markdown fences
fenced = re.sub(r"```(?:json)?\s*([\s\S]*?)\s*```", r"\1", text.strip())
if fenced != text.strip():
return _repair_json_strings(fenced.strip())
# 2. Find first JSON container and extract to matching close
extracted = text
for start_char, end_char in [('{', '}'), ('[', ']')]:
idx = text.find(start_char)
if idx == -1:
continue
depth = 0
in_string = False
escape = False
for i, ch in enumerate(text[idx:], start=idx):
if escape:
escape = False
continue
if ch == '\\' and in_string:
escape = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == start_char:
depth += 1
elif ch == end_char:
depth -= 1
if depth == 0:
extracted = text[idx: i + 1]
break
break
# 3. Repair unescaped control characters inside string values
return _repair_json_strings(extracted)
def _strip_code_fences(text: str) -> str: def _strip_code_fences(text: str) -> str:
"""Remove markdown code fences (```json ... ```) from a string.""" """Remove a single wrapping markdown code fence from a string.
Handles both `` ```json `` and plain `` ``` `` opening fences. If the text
does not start with a fence the string is returned unchanged.
Args:
text: Raw LLM response that may be wrapped in a markdown code block.
Returns:
The text with the opening fence line and optional closing `` ``` `` line
removed, stripped of surrounding whitespace.
"""
if text is None:
return ""
text = text.strip() 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()
@ -337,6 +393,7 @@ class CodingAgent:
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"}
if self.api_key and self.api_key != "EMPTY": if self.api_key and self.api_key != "EMPTY":
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
@ -344,20 +401,33 @@ class CodingAgent:
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
# Low temperature keeps the agent's tool selections deterministic and
# reduces the chance of hallucinated tool names or argument formats.
"temperature": 0.2, "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) try:
response = requests.post(
if response.status_code != 200: self.api_url,
raise Exception(f"API Error {response.status_code}: {response.text}") 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 ──────────────────────────────────────────────────────
@ -370,8 +440,9 @@ 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")
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.
Returns the parsed action dict without executing anything. Returns the parsed action dict without executing anything.
@ -387,14 +458,16 @@ class CodingAgent:
if self.iteration >= MAX_ITERATIONS: if self.iteration >= MAX_ITERATIONS:
return {"thought": "Max iterations reached.", "tool": "done", return {"thought": "Max iterations reached.", "tool": "done",
"arguments": {"summary": "Stopped: max iterations reached."}} "arguments": {"summary": "Stopped: max iterations reached."}}
self.iteration += 1 self.iteration += 1
self.messages = trim_messages(self.messages) self.messages = trim_messages(self.messages)
try: try:
raw = self._call_api(self.messages) raw = self._call_api(self.messages)
raw = _strip_code_fences(raw) raw = _strip_code_fences(raw)
action = json.loads(raw) cleaned = extract_json(raw)
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.",
@ -402,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}",
@ -409,11 +483,12 @@ 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
def approve(self) -> dict: async def approve(self) -> dict:
"""Execute the pending action and return the result. """Execute the pending action and return the result.
Returns: Returns:
@ -431,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
@ -442,10 +519,13 @@ class CodingAgent:
} }
# Execute the tool # Execute the tool
result = 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")
# Build feedback nudge agent to replan on errors # Wrap the tool output in an XML tag so the LLM can easily find it.
# Append a <replan> tag on errors to force the agent to reconsider
# its plan rather than blindly retrying the same failing action.
feedback = f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>' feedback = f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>'
if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"): if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"):
feedback += ( feedback += (
@ -453,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})
@ -480,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.
@ -506,3 +588,4 @@ class CodingAgent:
), ),
}) })
self.pending_action = None self.pending_action = None
logger.info("Rejection message appended.")

View File

@ -0,0 +1,195 @@
"""Adapter layer between the CodingAgent and one or more MCP tool servers.
MCPToolAdapter reads a JSON config file that lists MCP server processes, spawns
each process via stdio, queries its available tools, and stores them in a flat
registry. At call time it re-spawns the appropriate server process, executes
the requested tool, and returns the raw MCP result object.
Design note: connections are opened per-call (not kept alive) because Streamlit
reruns make it impractical to maintain long-lived async context managers across
the synchronous/asynchronous boundary.
"""
import asyncio
import json
import sys
from typing import List, Dict, Any
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
class MCPToolAdapter:
"""Discovers and dispatches MCP tools from one or more stdio-based MCP servers.
Workflow:
1. Call ``initialize_all_servers()`` once at startup to populate the
tool registry from every server listed in the config file.
2. Call ``get_all_tools()`` to retrieve the registry for building the
system-prompt tool description.
3. Call ``call_tool(name, arguments)`` whenever the agent wants to
execute a tool. The adapter resolves the owning server, opens a
fresh connection, and returns the MCP result object.
Attributes:
config_path: Path (relative to this file) of the JSON server config.
servers: Dict mapping server name raw config params dict.
tool_registry: Flat list of registered tool dicts, each containing
"server", "tool_name", and "tool_description".
"""
def __init__(self, config_path: str = "mcp_server_config.json"):
self.config_path = config_path
self.servers: Dict[str, Dict] = {}
self.tool_registry: List[Dict[str, Any]] = []
def _load_config(self) -> Dict[str, Any]:
"""Load the MCP server configuration from the JSON file next to this module.
Returns:
Parsed config dict, or an empty dict if the file is missing or invalid.
"""
path = Path(__file__).parent / self.config_path
if not path.exists():
logger.warning("Config file not found: %s", path)
return {}
try:
with open(path, 'r') as f:
config_file = json.load(f)
logger.info("MCP-Server config loaded successfully")
return config_file
except json.JSONDecodeError as e:
logger.critical("Error decoding JSON from server config: %s", e)
return {}
async def initialize_all_servers(self):
"""Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren."""
config = self._load_config()
logger.info("Loaded config for servers: %s", list(config.keys()))
for server_name, params in config.items():
logger.info("Initializing connection to %s", server_name)
self.servers[server_name] = params
server_script = str(Path(__file__).parent / params["args"][0])
# Always use the current Python interpreter so the server runs in the
# same virtual environment as the adapter, regardless of the literal
# command string in the config ("py", "python", "python3").
if params.get("command") in ["py", "python", "python3"]:
server_command = sys.executable
else:
server_command = params["command"]
server_params = StdioServerParameters(
command=server_command,
args=[server_script],
)
try:
async with stdio_client(server_params) as (read_stream, write_stream):
logger.info("Connected to %s. Initializing session...", server_name)
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.info("Session initialized for %s. Requesting tools...", server_name)
result = await session.list_tools()
tools = result.tools
logger.info("Tools received from %s: %s Tools", server_name, str(len(tools)))
for tool in tools:
# Build a human-readable parameter description for the system prompt.
t_params = tool.inputSchema.get("properties", {})
if t_params:
param_lines = []
for pname, pinfo in t_params.items():
ptype = pinfo.get("type", "any")
pdesc = pinfo.get("description", "")
param_lines.append(f" - {pname} ({ptype}): {pdesc}")
param_str = "\n".join(param_lines)
else:
param_str = " (none)"
t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}"
self.tool_registry.append({
"server": server_name,
"tool_name": tool.name,
"tool_description": t_definition
})
logger.info("Registered tool '%s' from %s.", tool.name, server_name)
except Exception as e:
logger.exception("Failed to initialize %s: %s", server_name, str(e))
def get_all_tools(self) -> List[Dict[str, Any]]:
"""Return the full list of registered tools across all servers.
Returns:
List of dicts, each with keys "server", "tool_name", "tool_description".
"""
return self.tool_registry
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]):
"""Look up a tool in the registry, connect to its server, and execute it.
Opens a fresh stdio connection for every call. This is intentionally
stateless so that server crashes or restarts are fully transparent.
Args:
tool_name: Name of the tool to call (must be in the registry).
arguments: Key-value arguments passed verbatim to the MCP server.
Returns:
The raw MCP ``CallToolResult`` object on success, or an error string
if the tool is not found or the server raises an exception.
"""
# Look up which server owns this tool.
tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None)
if not tool_entry:
logger.warning("Tool '%s' not found in MCP adapter registry.", tool_name)
return f"Error: Tool '{tool_name}' not found in registry."
server_name = tool_entry["server"]
s_params = self.servers.get(server_name)
if s_params:
server_script = str(Path(__file__).parent / s_params["args"][0])
# Normalise the interpreter command the same way as in initialize_all_servers().
if s_params.get("command") in ["py", "python", "python3"]:
server_command = sys.executable
else:
server_command = s_params["command"]
server_params = StdioServerParameters(
command=server_command,
args=[server_script],
)
try:
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.info("Session successfully initialized, calling tool '%s' on server '%s", tool_name, server_name)
result = await session.call_tool(tool_name, arguments)
return result
except Exception as e:
logger.exception("Error calling tool '%s' on server '%s': %s", tool_name, server_name, str(e))
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
def main():
"""Debug Function for Tool-Registry"""
adapter = MCPToolAdapter()
asyncio.run(adapter.initialize_all_servers())
print("All servers initialized. Registered tools:")
for tool in adapter.get_all_tools():
print(f"- {tool['tool_name']} (from {tool['server']})")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,16 @@
{"FileSearchServer": {
"command": "py",
"args": ["servers/mcp_server_file_search.py"]
},
"WebSearchServer": {
"command": "py",
"args": ["servers/mcp_server_web_search.py"]
},
"CodeExecutionServer": {
"command": "py",
"args": ["servers/mcp_server_code_execution.py"]
}
}

View File

@ -0,0 +1,351 @@
"""MCP server that provides sandboxed Python code execution tools.
Exposes the following MCP tools to the CodingAgent:
- analyse_structure AST-based structural summary of Python code
- lint_code pyflakes static analysis
- list_sandbox_packages list packages installed in the sandbox venv
- install_package_into_sandbox pip install into the sandbox venv
- reset_sandbox wipe and recreate the sandbox venv
- run_python_code_sandboxed execute Python code inside the sandbox
- python_code_validation syntax + safety check without execution
The sandbox is an isolated virtual environment created on first use.
All code submitted for execution is first checked by a static analyser that
blocks dangerous imports and builtins before spawning any subprocess.
"""
import ast
import os
import subprocess
import sys
import io
from pyflakes.api import check # For linting Code
from pyflakes.reporter import Reporter # For linting Code
from mcp.server.fastmcp import FastMCP
#from backend.managers.debug_logger import get_logger
#logger = get_logger(__name__)
# ── Configuration ────────────────────────────────────────────────────────────
EXEC_TIMEOUT = 15 # seconds before killing the subprocess
MAX_OUTPUT_LENGTH = 3000 # max characters of stdout+stderr to return
# ── Create the MCP server ────────────────────────────────────────────────────
mcp = FastMCP("CodeExecutionServer")
# ── Blocked Imports and Builtins ────────────────────────────────────────────────────
BLOCKED_IMPORTS = {
# Filesystem access:
"os", "pathlib", "shutil", "glob", "tempfile", "fileinput",
# Process execution:
"subprocess", "multiprocessing", "threading",
# Network access:
"socket", "http", "urllib", "requests", "ftplib", "smtplib","xmlrpc", "asyncio",
# System internals:
"sys", "ctypes", "importlib", "code", "codeop", "compileall",
# Serialization exploits:
"pickle", "shelve", "marshal",
# Other dangerous:
"signal", "resource", "pty", "fcntl", "termios", "webbrowser", "antigravity"
}
BLOCKED_BUILTINS = {
# Code execution:
"exec", "eval", "compile", "__import__",
# File access:
"open",
# Process control:
"exit", "quit", "breakpoint",
# Attribute manipulation:
"getattr", "setattr", "delattr",
# Introspection escapes:
"globals", "locals", "vars", "memoryview", "type"
}
FORBIDDEN_SEQUENCES = ["../", "..\\", "/etc/", "/dev/",
"C:\\Windows", "C:\\Program Files", "C:\\Users",
"compile(", "__import__", "os.", "sys.", "subprocess."]
"""
Pre-installed Packages in Sandbox: "pygame", "numpy", "pandas"
"""
# ── Static Analysis ────────────────────────────────────────────────────
def check_code_safety(code: str) -> str | None:
"""
Statically analyze Python code for forbidden imports and builtins with ast.
Args:
code: The Python code to analyze.
Returns:
str or None
Error message if forbidden code found, None if safe.
"""
#logger.info("Checking code safety.")
try:
tree = ast.parse(code)
#logger.info("Code has valid Syntax")
except SyntaxError as e:
#logger.exception("SyntaxError: %s", e)
return f"SyntaxError: {e}"
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
top_level_module = alias.name.split('.')[0]
if top_level_module in BLOCKED_IMPORTS:
#logger.warning("Blocked import '%s'", alias.name)
return (f"Blocked import: Import of '{alias.name}' is not allowed."
f"line {node.lineno}")
elif isinstance(node, ast.ImportFrom):
if node.module:
top_level = node.module.split(".")[0]
if top_level in BLOCKED_IMPORTS:
#logger.warning("Blocked import from '%s'", alias.name)
return (f"Blocked import: Import from '{node.module}' is not allowed."
f"(module '{top_level}' is blocked) line {node.lineno}")
elif isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
if node.func.id in BLOCKED_BUILTINS:
#logger.warning("Blocked ubiltin '%s'", node.func.id)
return f"Blocked builtin: Use of builtin '{node.func.id}' is not allowed."
for seq in FORBIDDEN_SEQUENCES:
if seq in code:
#logger.warning("Suspect path sequence '%s' detected.", seq)
return f"Blocked: Suspect path sequence '{seq}' detected."
return None # No violations found
@mcp.tool()
def analyse_structure(code: str) -> str:
"""
Analyze the structure of Python code and return a summary of its components.
Args:
code: The Python code to analyze in str format.
Returns:
A summary of the code's structure, including functions, classes, and imports.
"""
#logger.info("Tool analyse_structure is being executed on MCP code execution server")
try:
tree = ast.parse(code)
#logger.info("Code tree parsed successfully")
except SyntaxError as e:
#logger.warning("Syntax Error in provided code. Line %s : %s", e.lineno, e.msg)
return f"Syntax Error: Invalid Python code provided. Line {e.lineno}: {e.msg}"
except Exception as e:
#logger.exception("Error parsing code: %s", str(e))
return f"Error parsing code: {str(e)}"
analysis = {
"imports": [],
"classes": [],
"functions": []
}
for node in tree.body:
if isinstance(node, ast.Import):
for alias in node.names:
analysis["imports"].append(f"import {alias.name}")
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
for alias in node.names:
analysis["imports"].append(f"from {module} import {alias.name}")
elif isinstance(node, ast.ClassDef):
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef)]
analysis["classes"].append({
"name": node.name,
"methods": methods
})
elif isinstance(node, ast.FunctionDef):
args = [arg.arg for arg in node.args.args]
analysis["functions"].append({
"name": node.name,
"args": args
})
# Zusammenfassung als String formatieren
lines = ["--- Code Structure Analysis ---"]
if analysis["imports"]:
lines.append("\n[Imports]")
lines.extend([f" - {imp}" for imp in analysis["imports"]])
if analysis["classes"]:
lines.append("\n[Classes]")
for cls in analysis["classes"]:
lines.append(f" - class {cls['name']}:")
if cls["methods"]:
lines.extend([f" * method: {m}" for m in cls["methods"]])
else:
lines.append(" * (no methods)")
if analysis["functions"]:
lines.append("\n[Top-Level Functions]")
for func in analysis["functions"]:
args_str = ", ".join(func["args"])
lines.append(f" - def {func['name']}({args_str})")
if not any([analysis["imports"], analysis["classes"], analysis["functions"]]):
#logger.info("Code analysis successfull but no top-level items found")
return "Analysis complete: No top-level imports, classes, or functions found."
return "\n".join(lines)
@mcp.tool()
def lint_code(code: str) -> str:
"""
Runs a fast static analysis check to catch syntax errors, unused imports,
or undefined variables without executing the code.
Args:
code: The Python code to lint in str format.
Returns:
A report of linting issues or a success message if the code is clean.
"""
#logger.info("Tool lint_code is being executed on MCP code execution server")
error_buffer = io.StringIO()
warning_buffer = io.StringIO()
reporter = Reporter(warning_buffer, error_buffer)
try:
check(code, filename="<agent_code>", reporter=reporter)
#logger.info("Linting successfull")
except Exception as e:
#logger.exception("Critical error during linting: %s", str(e))
return f"Critical error during linting: {str(e)}"
errors = error_buffer.getvalue().strip()
warnings = warning_buffer.getvalue().strip()
# Ergebnis-String zusammenbauen
if not errors and not warnings:
#logger.info("No issues found")
return "Linting complete: No issues found. The code is syntactically sound."
report = ["--- Linting Report ---"]
if errors:
report.append("\n[Syntax Errors / Critical Issues]")
report.append(errors)
if warnings:
report.append("\n[Logical Issues (Unused imports, Undefined names, etc.)]")
report.append(warnings)
report.append("\nAdvice: Please fix these issues before attempting to execute the code.")
#logger.info("There are issues with provided code. Check Report: %s", "\n".join(report))
return "\n".join(report)
@mcp.tool()
def run_python_sandboxed(code: str) -> str:
"""
Run Python code in a sandboxed environment.
The sandbox blocks dangerous operations (filesystem, network, process
control). Code is killed after 10 seconds. Use print() to produce
output, which is captured and returned (up to 3000 chars). If the code
is deemed unsafe by static analysis, it will not be executed and an error
message will be returned instead.
Args:
code: The Python code to execute in str format.
Returns:
Combined stdout+stderr, or an error message in str format.
"""
#logger.info("Tool run_python_sandboxed is being executed on MCP code execution server")
# Reject code that references blocked modules or builtins before spawning a process.
static_safety = check_code_safety(code)
if static_safety:
return f"Code rejected:{static_safety}"
try:
# Force UTF-8 I/O so the subprocess can print unicode on Windows
# (default console encoding is cp1252 which cannot encode emoji).
utf8_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
result = subprocess.run(
[sys.executable, "-c", code],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
timeout=EXEC_TIMEOUT,
env=utf8_env,
)
# Merge stdout and stderr so the agent sees all output in one block.
output = result.stdout + result.stderr
if len(output) > MAX_OUTPUT_LENGTH:
output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..."
if not output.strip():
return "Code executed successfully (no output)."
#logger.info("Code ran successfully")
return output
except subprocess.TimeoutExpired:
#logger.warning("Code execution exceeded time limit of %s seconds", EXEC_TIMEOUT)
return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
except Exception as e:
#logger.exception("Error during code execution: %s", str(e))
return f"Error during code execution: {e}"
@mcp.tool()
def python_code_validation(code: str) -> str:
"""Validate Python code for syntax correctness and sandbox safety without executing it.
Performs two checks in sequence:
1. AST parsing to catch syntax errors.
2. check_code_safety() to detect blocked imports/builtins/path sequences.
Args:
code: The Python source code to validate.
Returns:
A message indicating whether the code is valid and safe, or describing
the first violation found. Returns None implicitly when the code is
both syntactically valid and safe (no safety concerns found).
"""
#logger.info("Tool python_code_validation is being executed on MCP code execution server")
try:
ast.parse(code)
#logger.info("Ast parsing successfull")
except SyntaxError as e:
#logger.warning("Syntax Error while ast parsing code: %s", str(e))
return f"SyntaxError: {e}"
try:
static_analysis_result = check_code_safety(code)
if static_analysis_result:
#logger.info("Code safety issues detected")
return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed."
except Exception as e:
#logger.exception("Error during code safety analysis: %e", str(e))
return f"Error during code safety analysis: {e}"
return "Code is valid and can be executed in the sandbox"
# ── Run the server ───────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

@ -0,0 +1,287 @@
"""MCP server that provides file system read/write tools for the workspace directory.
All operations are restricted to ALLOWED_DIR (the project workspace). Paths
that resolve outside this boundary are rejected with a ValueError so the agent
cannot accidentally read or write arbitrary host-filesystem locations.
Exposes the following MCP tools:
- list_files flat list of all workspace files
- get_file_tree tree-formatted directory listing
- search_files search file names and content
- read_file read a single file
- write_new_file create a new file (no overwrite)
- create_new_directory create a new directory
"""
from pathlib import Path
from mcp.server.fastmcp import FastMCP
#from backend.managers.debug_logger import get_logger
#logger = get_logger(__name__)
# ── Configuration ────────────────────────────────────────────────────────────
# Navigate four levels up from servers/ to the project root, then into workspace/.
project_dir = Path(__file__).resolve().parent.parent.parent.parent
ALLOWED_DIR = project_dir / "workspace"
ALLOWED_FILE_TYPES = [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml",
".sh", ".md", ".txt", ".tex", ".c", ".cpp", ".java"]
# ── Create the MCP server ────────────────────────────────────────────────────
mcp = FastMCP("FileSearchServer")
# ── Helper: path validation ──────────────────────────────────────────────────
def _safe_path(requested: str) -> Path:
"""Resolve and validate a path is inside ALLOWED_DIR."""
resolved = (ALLOWED_DIR / requested).resolve()
if not str(resolved).startswith(str(ALLOWED_DIR)):
#logger.warning("Access denied: '%s' resolves outside allowed directory.", str(requested))
raise ValueError(
f"Access denied: '{requested}' resolves outside "
f"the allowed directory '{ALLOWED_DIR}'"
)
#logger.info("Requested path is safe")
return resolved
# ── MCP Tools ────────────────────────────────────────────────────────────────
@mcp.tool()
def list_files() -> str:
"""List all files in the project directory (recursively).
Returns a newline-separated list of relative file paths.
"""
#logger.info("Tool list_files is being executed on MCP file search server")
files = sorted(
f.relative_to(ALLOWED_DIR)
for f in ALLOWED_DIR.rglob("*")
if f.is_file() and "__pycache__" not in f.parts
)
if not files:
return "No files found in the project directory."
return "\n".join(str(f) for f in files)
@mcp.tool()
def get_file_tree(dir_path: str="") -> str:
"""Get a tree representation of the project directory.
Args:
path: The directory path to display (default is the allowed directory).
Returns:
A string representing the directory structure, similar to 'tree' command output.
"""
#logger.info("Tool get_file_tree is being executed on MCP file search server")
try:
safe_dir = _safe_path(dir_path)
if not safe_dir.exists():
#logger.warning("Directory '%s' does not exist.", dir_path)
return f"Error: Directory '{dir_path}' does not exist."
elif not safe_dir.is_dir():
#logger.warning("'%s' is not a valid directory", dir_path)
return f"Error: '{dir_path}' is not a valid directory within the allowed path."
except ValueError as e:
#logger.exception("Error while checking directory and its path: %s", str(e))
return f"Error: {e}"
#logger.info("Generating file tree.")
def _tree(dir_path: Path, prefix="") -> str:
"""Recursively build a tree string for the given directory.
Directories are sorted before files (``key=lambda x: (x.is_file(), x.name)``
puts dirs first because False < True). __pycache__ entries are hidden.
Args:
dir_path: The directory to render.
prefix: Indentation prefix accumulated during recursion.
Returns:
Multi-line string representing the subtree.
"""
# Exclude __pycache__ at every level to keep output readable for the agent.
entries = sorted(
[e for e in dir_path.iterdir() if "__pycache__" not in e.parts],
key=lambda x: (x.is_file(), x.name) # directories first, then files
)
lines = []
for i, entry in enumerate(entries):
# Use └── for the last entry to close the branch visually.
connector = "└── " if i == len(entries) - 1 else "├── "
lines.append(f"{prefix}{connector}{entry.name}")
if entry.is_dir():
# Extend prefix with a blank column (last item) or │ (more items follow).
extension = " " if i == len(entries) - 1 else ""
lines.append(_tree(entry, prefix + extension))
return "\n".join(lines)
return _tree(Path(safe_dir))
@mcp.tool()
def search_files(query: str) -> str:
"""Search for files whose name or content contains the query string.
Args:
query: The search term (case-insensitive).
Returns:
A formatted string of search results, or a message if no matches found.
"""
#logger.info("Tool search_files is being executed on MCP file search server")
query_lower = query.lower()
results = []
for f in sorted(ALLOWED_DIR.rglob("*")):
if not f.is_file() or "__pycache__" in f.parts:
continue
rel = f.relative_to(ALLOWED_DIR)
if query_lower in str(rel).lower():
results.append(f"[name match] {rel}")
try:
content = f.read_text(encoding="utf-8")
for i, line in enumerate(content.splitlines(), 1):
if query_lower in line.lower():
snippet = line.strip()[:100]
results.append(f"[content] {rel}:{i} -- {snippet}")
except (UnicodeDecodeError, PermissionError):
pass
if not results:
#logger.info("No matches found for user query")
return f"No matches found for '{query}'."
#logger.info("Result returned, limited to 30 matches.")
return "\n".join(results[:30]) # limit to 30 matches
@mcp.tool()
def read_file(path: str) -> str:
"""Read the contents of a file.
Args:
path: Relative path to the file within the project directory.
Returns:
The file content as a string, or an error message if the file cannot be read.
"""
#logger.info("Tool read_file is being executed on MCP file search server")
try:
resolved = _safe_path(path)
except ValueError as e:
return f"Error: {e}"
if not resolved.exists():
#logger.warning("File '%s' does not exist.", path)
return f"Error: File '{path}' does not exist."
if not resolved.is_file():
#logger.warning("'%s' is not a valid file.", path)
return f"Error: '{path}' is not a file."
try:
text = resolved.read_text(encoding="utf-8")
#logger.info("File read successfully.")
return text
except UnicodeDecodeError:
#logger.warning("'%s' is not a text file (binary content).", path)
return f"Error: '{path}' is not a text file (binary content)."
except PermissionError:
#logger.warning(f"Permission denied when trying to read '%s'.", path)
return f"Error: Permission denied when trying to read '{path}'."
except Exception as e:
#logger.exception("Error reading file '%s': %s", path, e)
return f"Error reading file '{path}': {e}"
@mcp.tool()
def write_new_file(path: str, content: str) -> str:
"""Write content to a new file in the allowed directory.
Existing files cannot be overwritten with this tool.
Args:
path: Relative path to the file within the allowed directory.
content: The content to write to the file.
Returns:
A success or error message.
"""
#logger.info("Tool write_new_file is being executed on MCP file search server")
try:
resolved = _safe_path(path)
except ValueError as e:
return f"Error: {e}"
if resolved.exists():
#logger.warning("Requested file path '%s' already exists, overwriting not allowed.", path)
return (f"ERROR: File '{path}' already exists."
f"Overwriting is not allowed with this tool."
f"Use a different path or filename to create a new file.")
if resolved.suffix not in ALLOWED_FILE_TYPES:
#logger.warning("File type not allowed: %s", resolved.suffix)
return f"ERROR: can only write {', '.join(ALLOWED_FILE_TYPES)} types, got '{resolved.suffix}'."
try:
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(content, encoding="utf-8")
#logger.info("File written successfully.")
return f"OK: wrote {len(content)} chars to {path}."
except FileNotFoundError as e:
#logger.warning("FileNotFoundError for '%s': %s", path, e)
return f"Error: {e}"
except PermissionError as e:
#logger.warning("PermissionError for '%s': %s", path, e)
return f"Error: {e}"
except Exception as e:
#logger.exception("Error writing file: %s", e)
return f"Error: {e}"
@mcp.tool()
def create_new_directory(path: str) -> str:
"""Create a new empty directory in the allowed directory.
Args:
path: Relative path to the directory within the allowed directory.
Returns:
A success or error message.
"""
#logger.info("Tool create_new_directory is being executed on MCP file search server")
try:
resolved = _safe_path(path)
except ValueError as e:
return f"Error: {e}"
if resolved.exists():
#logger.warning("Requested path '%s' already exists, overwriting not allowed.", path)
return f"Error: File '{path}' already exists."
if resolved.suffix != "":
#logger.warning("Can only create directories, got '%s'.", resolved.suffix)
return f"Error: can only create directories, got '{resolved.suffix}'."
try:
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.mkdir()
#logger.info("Directory '%s' created successfully.", path)
return f"OK: created empty directory at {path}."
except Exception as e:
#logger.exception("Error creating directory '%s': %s", path, e)
return f"Error creating directory '{path}': {e}"
# ── Run the server ───────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

@ -0,0 +1,179 @@
"""MCP server that provides web search and page-fetching tools.
Exposes two MCP tools:
- web_search keyword search via DuckDuckGo, returns titles, URLs, snippets
- fetch_page fetch and extract readable text from a URL
All outbound requests are guarded by _validate_url() which blocks non-HTTP
schemes and private/loopback IP ranges to prevent SSRF vulnerabilities.
"""
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
from ddgs import DDGS
from mcp.server.fastmcp import FastMCP
#from backend.managers.debug_logger import get_logger
#logger = get_logger(__name__)
# ── Configuration ────────────────────────────────────────────────────────────
MAX_PAGE_LENGTH = 4000 # max characters to return from a fetched page
REQUEST_TIMEOUT = 10 # seconds
# ── Bolcked prefixes & Hosts ────────────────────────────────────────────────────
PRIVATE_PREFIXES = [
"10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
"172.30.", "172.31.", "192.168.",
]
BLOCKED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"]
# ── Create the MCP server ────────────────────────────────────────────────────
mcp = FastMCP("WebSearchServer")
# ── Helper: URL validation (SSRF prevention) ─────────────────────────────────
def _validate_url(url: str) -> str:
"""Validate a URL and raise ValueError if it could be used for an SSRF attack.
Blocks:
- Non-HTTP(S) schemes (file://, ftp://, etc.)
- Loopback and metadata addresses (localhost, 127.0.0.1, 169.254.169.254)
- RFC-1918 private IP ranges (10.x, 172.16-31.x, 192.168.x)
Args:
url: The URL string to validate.
Returns:
The original URL string unchanged if it passes all checks.
Raises:
ValueError: If the URL fails any of the security checks.
"""
parsed = urlparse(url)
#logger.info("Validateing URL")
if parsed.scheme not in ("http", "https"):
#logger.warning("Blocked scheme '%s'. Only http and https are allowed.", parsed.scheme)
raise ValueError(
f"Blocked scheme '{parsed.scheme}'. Only http and https are allowed."
)
hostname = parsed.hostname or ""
if hostname in BLOCKED_HOSTS:
#logger.warning("Blocked internal host: %s", hostname)
raise ValueError(f"Blocked internal host: {hostname}")
for prefix in PRIVATE_PREFIXES:
if hostname.startswith(prefix):
#logger.warning("Blocked private IP range: %s", hostname)
raise ValueError(f"Blocked private IP range: {hostname}")
return url
# ── MCP Tools ────────────────────────────────────────────────────────────────
@mcp.tool()
def web_search(query: str, max_results: int = 5) -> str:
"""Search the web using DuckDuckGo.
Args:
query: The search query.
max_results: Maximum number of results to return (default 5).
Returns:
A formatted string of search results, or a message if no matches found.
"""
#logger.info("Tool web_search is being executed on MCP web search server")
try:
results = DDGS().text(query, max_results=max_results)
if not results:
#logger.info("DDGS API call successful, no web search results found.")
return f"No results found for: {query}"
#logger.info("DDGS API call successfull, web search results returned.")
formatted = []
for r in results:
formatted.append(
f"Title: {r['title']}\n"
f"URL: {r['href']}\n"
f"Snippet: {r['body']}"
)
return "\n---\n".join(formatted)
except Exception as e:
#logger.exception("DDGS API call failed, web search error: %s", e)
return f"Search error: {e}"
@mcp.tool()
def fetch_page(url: str) -> str:
"""Fetch a web page and extract its text content.
Args:
url: The URL to fetch.
Returns:
The text content of the fetched page, or an error message.
"""
#logger.info("Tool fetch_page is being executed on MCP web search server")
try:
url = _validate_url(url)
except ValueError as e:
return f"URL blocked: {e}"
try:
response = requests.get(
url,
timeout=REQUEST_TIMEOUT,
headers={"User-Agent": "Mozilla/5.0 (Lightweight Web Search MCP Server)"},
)
response.raise_for_status()
if response.status_code != 200:
#logger.warning("HTTP error %s while fetching %s", response.status_code, url)
return f"HTTP error {response.status_code} fetching {url}"
#logger.info("DDGS API call successfull")
except requests.RequestException as e:
#logger.warning("HTTP-Fehler: %s", e)
return f"HTTP-Fehler: {e}"
except Exception as e:
#logger.exception("Error fetching page: %s", e)
return f"Error fetching page: {e}"
try:
soup = BeautifulSoup(response.text, "html.parser")
# Remove boilerplate elements that add noise without informational value.
for tag in soup(["script", "style", "nav", "footer"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
#logger.info("HTML parsing with BeautifulSoup successfull")
if len(text) > MAX_PAGE_LENGTH:
text = text[:MAX_PAGE_LENGTH] + "\n\n[... truncated ...]"
return text if text else "Page fetched but no text content found."
except Exception as e:
#logger.exception("Error parsing HTML: %s", e)
return f"Error parsing html: {e}"
# ── Run the server ───────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

@ -1,19 +1,30 @@
"""Chat Manager - Handles chat history and AI communication""" """Manages the chat history and communication with the AI model API."""
import os import os
from dotenv import load_dotenv 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()
class ChatManager: class ChatManager:
"""Handles sending messages and maintaining conversation history.
Connects to an OpenAI-compatible REST endpoint configured via environment
variables. All messages (user, assistant, system) are kept in memory so
the full conversation is sent with every request.
"""
def __init__(self): def __init__(self):
self.api_host = os.getenv("HOST") self.api_host = os.getenv("HOST")
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"
@ -21,37 +32,64 @@ 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."""
self.chat_history.append({"role": role, "content": content}) self.chat_history.append({"role": role, "content": content})
def get_history(self) -> list: def get_history(self) -> list:
return self.chat_history """Return a copy of the conversation history."""
return list(self.chat_history)
def clear_history(self) -> None: def clear_history(self) -> None:
"""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:
"""Send a user message to the AI and return its reply.
Adds the user message to history, calls the API with the full history
as context, and appends the AI reply to history before returning it.
"""
# Add user message to history # 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}"
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
@ -59,39 +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 [
{"role": msg["role"], "content": msg["content"]}
for msg in self.chat_history
]

View File

@ -1,9 +1,123 @@
"""
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:
def __init__(self):
self.logs = [] _initialized = False
_error_log: list[str] = []
def log(self, message):
self.logs.append(message) @classmethod
def setup(cls):
def get_logs(self): # prevents multiple setup
return self.logs if cls._initialized:
return
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] [%(name)s: Line %(lineno)d] %(message)s"
)
# Main log file
file_handler = RotatingFileHandler(
LOG_DIR / "app.log",
maxBytes=5_000_000,
backupCount=5,
encoding="utf-8"
)
file_handler.setFormatter(formatter)
# Separate Error-Log
error_handler = RotatingFileHandler(
LOG_DIR / "errors.log",
maxBytes=5_000_000,
backupCount=3,
encoding="utf-8"
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(file_handler)
root_logger.addHandler(error_handler)
#root_logger.propagate = False
cls._initialized = True
@classmethod
def get_logger(cls, name: str):
cls.setup()
return logging.getLogger(name)
@classmethod
def log_error(cls, error_message: str) -> None:
cls.setup()
logging.error(error_message)
cls._error_log.append(error_message)
@classmethod
def get_errors(cls) -> list[str]:
return cls._error_log
@classmethod
def clear_errors(cls) -> None:
cls._error_log.clear()
@classmethod
def format_debug_output(cls, output: dict) -> str:
stdout = output.get("stdout", "").strip() or "(none)"
stderr = output.get("stderr", "").strip() or "(none)"
return_code = output.get("return_code", "")
return (
"=== Execution Result ===\n"
f"Exit Code: {return_code}\n"
"--- stdout ---\n"
f"{stdout}\n"
"--- stderr ---\n"
f"{stderr}"
)
# praktische shortcut function
def get_logger(name: str):
return DebugLogger.get_logger(name)

View File

@ -1,41 +1,85 @@
"""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
RUN_TIMEOUT = 30 # seconds from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# Maximum time (seconds) a subprocess is allowed to run before being killed.
RUN_TIMEOUT = 30
class ExecutionEngine: class ExecutionEngine:
"""Runs files from the editor in a subprocess and returns the output.
Currently supports Python (.py) and LaTeX (.tex) files.
Returns a dict with keys: stdout, stderr, rc (return code).
"""
def __init__(self): def __init__(self):
pass pass
def run_code(self, active_file: Path) -> dict: def run_code(self, active_file: Path) -> dict:
"""Execute the given file and return its output.
Args:
active_file: Absolute path to the file that should be run.
Returns:
{"stdout": str, "stderr": str, "rc": int}
rc == 0 means success, anything else is an error.
"""
suffix = active_file.suffix suffix = active_file.suffix
current_dir = active_file.parent.resolve() current_dir = active_file.parent.resolve()
# 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":
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(
cmd, cmd,
cwd=current_dir, cwd=current_dir, # run inside the file's own directory
capture_output=True, capture_output=True,
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
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:
return {"stdout": "", "stderr": str(e), "rc": -1} logger.exception("Error while running %s: %s", active_file.name, e)
return {"stdout": "", "stderr": str(e), "rc": -1}
def capture_output(self, proc: subprocess.CompletedProcess) -> dict:
"""Extract stdout, stderr, and return code from a completed subprocess.
Args:
proc: The CompletedProcess returned by subprocess.run().
Returns:
{"stdout": str, "stderr": str, "rc": int} with whitespace stripped.
"""
return {
"stdout": proc.stdout.strip(),
"stderr": proc.stderr.strip(),
"rc": proc.returncode,
}

View File

@ -1,23 +1,65 @@
"""Manages all file and folder operations inside the workspace directory.
Every method validates that the target path stays inside the workspace before
touching the filesystem, preventing path-traversal attacks.
"""
import streamlit as st 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.
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)
def create_folder(self, relative_path: str, name: str) -> bool: def create_folder(self, relative_path: str, name: str) -> bool:
"""
Creates a new folder at the specified relative path.
The relative_path should be the path to the folder relative to the base path,
and name should be the name of the new folder (without any slashes).
Args:
relative_path (str): The relative path (without base path) where the new folder should be created.
name (str): The name of the new folder to create (should not contain slashes).
Returns:
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.
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
name = Path(name) name = Path(name)
if relative_path: if relative_path:
relative_path = Path(relative_path) relative_path = Path(relative_path)
@ -26,29 +68,48 @@ class FileManager:
folder_path = (self.base_path / relative_path / name).resolve() folder_path = (self.base_path / relative_path / name).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(folder_path).startswith(str(self.base_path.resolve())): 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}")
return False return False
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
def create_file(self, relative_path: str, name: str) -> bool: def create_file(self, relative_path: str, name: str) -> bool:
"""
Creates a new file at the specified relative path.
The relative_path should be the path to the folder relative to the base path where the file should be created,
and name should be the name of the new file (without any slashes).
Args:
relative_path (str): The relative path (without base path) where the new file should be created.
name (str): The name of the new file to create (should not contain slashes).
Returns:
bool: True if file was created successfully, False otherwise.
"""
logger.info("Creating file at %s named %s", relative_path, name)
if not name or name.strip() == "" : 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)
else: else:
@ -56,131 +117,235 @@ class FileManager:
file_path = (self.base_path / relative_path / name).resolve() file_path = (self.base_path / relative_path / name).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())): 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}")
return False return False
try: try:
file_path.touch(exist_ok=False) file_path.touch(exist_ok=False)
logger.info("File created successfully.")
return True return True
except FileExistsError: except FileExistsError:
logger.warning("Folder already exists.")
st.warning(f"File already exists: {relative_path}") st.warning(f"File already exists: {relative_path}")
return False return False
except Exception as e: except Exception as e:
logger.exception("Error creating file %s: %s", relative_path, str(e))
st.error(f"Error creating file {relative_path}: {str(e)}") st.error(f"Error creating file {relative_path}: {str(e)}")
return False return False
def read_file(self, relative_path: Path) -> str: def read_file(self, relative_path: Path) -> str:
"""
Reads the content of a file.
Accepts an absolute Path object (as stored in st.session_state.open_files).
The path is validated to ensure it stays inside the workspace.
Args:
relative_path (Path): Absolute path to the file to read.
Returns:
str: The content of the file, or an empty string if there was an error.
"""
logger.info("Reading file at %s.", relative_path)
file_path = (relative_path).resolve() 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).
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): def save_file(self, relative_path: str, content: str) -> bool:
"""
Saves content to a file.
Accepts an absolute path string (as stored in st.session_state.open_files).
The path is validated to ensure it stays inside the workspace.
Args:
relative_path (str): Absolute path to the file to save, including the file name.
content (str): The content to write to the file.
Returns:
bool: True if save was successful, False otherwise.
"""
logger.info("Saving file at %s.", relative_path)
file_path = (Path(relative_path)).resolve() file_path = (Path(relative_path)).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())): 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:
"""
Renames a file while keeping the same extension.
The new_name should not include the extension, as it will be preserved from the old name.
Args:
old_relative_path (str): The current relative path (without base path) of the file to rename, including the file name.
new_name (str): The new name for the file, without extension.
Returns:
bool: True if rename was successful, False otherwise.
"""
logger.info("Rename file at %s to %s.", old_relative_path, new_name)
if not new_name or new_name.strip() == "": 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
new_name = Path(new_name) new_name = Path(new_name)
# Force the original extension so the file type cannot be changed by renaming.
if not Path(new_name).suffix == file_type: if not Path(new_name).suffix == file_type:
new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same
old_file_path = (Path(old_relative_path)).resolve() old_file_path = (Path(self.base_path / old_relative_path)).resolve()
new_file_path = old_file_path.parent / new_name new_file_path = old_file_path.parent / new_name
# Both old and new paths must stay inside the workspace.
if not str(old_file_path).startswith(str(self.base_path.resolve())) or not str(new_file_path).startswith(str(self.base_path.resolve())): 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}")
return False 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
def delete_folder(self, relative_path) -> bool: def delete_folder(self, relative_path: str) -> bool:
"""Deletes a folder and all its contents.
The relative_path should be the path to the folder relative to the base path.
Args:
relative_path (str): The relative path (without base path) to the folder to delete.
Returns:
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).
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): def delete_file(self, relative_path: str) -> bool:
file_path = Path(relative_path).resolve() """Deletes a file.
The relative_path should be the path to the file relative to the base path.
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return False
Args:
relative_path (str): The relative path (without base path) to the file to delete, including the file name.
Returns:
bool: True if deletion was successful, False otherwise.
"""
logger.info("Deleting file %s.", relative_path)
file_path = Path(relative_path)
abs_file_path = (Path(self.base_path) / file_path).resolve()
if not str(abs_file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
logger.warning("Access denied, file outside WORKSPACE.")
return False
try: try:
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:
def build_tree(path: Path): """Builds a nested dictionary representing the file tree.
Directories are represented as keys with dictionary values,
files as keys with None.
Args:
filter_extensions: When True (default), only files whose suffix is
in CODE_EXTENSIONS are included. Directories are always shown,
even when they are empty after filtering.
Returns:
dict: A nested dictionary representing the file tree.
"""
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) tree[item.name] = build_tree(item)
else: else:
if filter_extensions and item.suffix not in CODE_EXTENSIONS:
continue
tree[item.name] = None 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__":
FileManager() FileManager()

View File

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

View File

@ -1,38 +1,139 @@
"""System Prompter - Builds system prompts with optional file context""" """Builds the system prompt that is sent to the AI at the start of each chat session."""
MAX_FILE_CHARS = 4000 # Limit file context to avoid token overflow import ast
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# Prevents very large files from flooding the context window with tokens.
MAX_FILE_CHARS = 4000
# Per-task base prompts — selected via the task_type parameter.
# Appended to every prompt — ensures generated code is safe to run on Windows
# where the console encoding is cp1252 and cannot handle emojis or non-ASCII chars.
_CODE_SAFETY_NOTE = (
" When writing or suggesting code, never use emojis, umlauts (ä, ö, ü, Ä, Ö, Ü, ß), "
"or any non-ASCII characters in string literals or print statements, "
"as the execution environment uses cp1252 encoding which cannot handle them."
)
_TASK_PROMPTS: dict[str, str] = {
"debug": (
"You are a debugging expert integrated into a lightweight code editor. "
"Focus on identifying and fixing errors. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
),
"explain": (
"You are a code explainer integrated into a lightweight code editor. "
"Use simple language and examples. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
),
"optimize": (
"You are a code optimization expert integrated into a lightweight code editor. "
"Focus on performance and readability. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
),
"default": (
"You are an expert code assistant integrated into a lightweight code editor. "
"Help the user with code suggestions, debugging, explanations, and improvements. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
),
}
def _extract_relevant_context(content: str, user_message: str) -> str:
"""Return the most relevant part of a Python file for the given user message.
Parses the file with ast and checks whether any top-level function or class
name appears in the user message. If a match is found only that definition
is returned, keeping the context focused. Falls back to simple truncation
when parsing fails or no name matches.
"""
try:
tree = ast.parse(content)
except SyntaxError:
# Not valid Python (or not Python at all) — fall back to truncation.
if len(content) > MAX_FILE_CHARS:
return content[:MAX_FILE_CHARS] + "\n... [truncated]"
return content
lower_msg = user_message.lower()
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name.lower() in lower_msg:
segment = ast.get_source_segment(content, node)
if segment:
return segment
# No specific symbol matched — fall back to truncation.
if len(content) > MAX_FILE_CHARS:
return content[:MAX_FILE_CHARS] + "\n... [truncated]"
return content
class SystemPrompter: class SystemPrompter:
"""Generates system prompts for the chat assistant.
When a file is open in the editor it can be embedded in the prompt so the
AI has direct context of the code the user is currently working on.
"""
@staticmethod @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' and 'content', or None. user_message: The current user input used for task-type detection
and selective context extraction. Reserved for future
task-specific prompt tuning beyond what task_type covers.
file_context: dict with keys 'name' (filename) and 'content' (raw text),
or None if no file should be included.
search_context: list of {"title", "url", "snippet"} dicts from SearchManager,
or None if no search results should be included.
task_type: One of "debug", "explain", "optimize", "default".
Selects the matching base prompt from _TASK_PROMPTS.
Returns: Returns:
A 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 token limits
if len(content) > MAX_FILE_CHARS: # Extract only the relevant function/class when the user mentions one;
content = content[:MAX_FILE_CHARS] + "\n... [truncated]" # otherwise fall back to simple truncation at MAX_FILE_CHARS.
file_section = ( content = _extract_relevant_context(content, user_message)
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

View File

@ -1,41 +1,60 @@
"""Entry point for the Streamlit app.
Runs with: streamlit run frontend/app.py
Responsibilities:
- Configure the page layout
- Inject global CSS tweaks
- Render the sidebar (navigation + file explorer)
- Delegate to the correct view (Chat or Code Editor) based on the radio selection
"""
import streamlit as st import streamlit as st
import sys import sys
from pathlib import Path from pathlib import Path
# Add project root to Python path for imports # Add the project root to sys.path so backend imports work regardless of
# 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
from frontend.state import init_state from frontend.state import init_state
# Initialise all session-state keys before any widget is rendered
init_state() init_state()
def main(): def main():
st.set_page_config(page_title="Lightweight code editor", layout="wide") st.set_page_config(page_title="Lightweight code editor", layout="wide")
# Small spacing corrections applied globally:
# - Reduce the default top padding of the main content area
# - Pull the sidebar content up so the logo sits at the very top
st.markdown( 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")
init_state()
render_sidebar() render_sidebar()
# 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()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,17 +1,46 @@
"""Chat view — renders both the normal chat interface and the Coding Agent mode."""
import asyncio
import json
import re
from pathlib import Path
import streamlit as st import streamlit as st
from backend.managers.chat_manager import ChatManager from backend.managers.chat_manager import ChatManager
from backend.managers.system_prompter import SystemPrompter from backend.managers.system_prompter import SystemPrompter
from backend.managers.search_manager import SearchManager
from backend.agent.coding_agent import CodingAgent
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# ── Agent Mode helpers ──────────────────────────────────────────────────────── # ── Agent Mode helpers ────────────────────────────────────────────────────────
def _run_async(coro):
"""Execute an async coroutine from synchronous Streamlit code.
Streamlit always runs in a plain synchronous thread with no running event
loop, so we always create a fresh loop here.
Args:
coro: The coroutine to run.
Returns:
The return value of the coroutine.
"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def _start_agent(task: str): def _start_agent(task: str):
"""Initialise a fresh CodingAgent, start the task, """Create a new CodingAgent, feed it the task, and propose the first action.
and propose the first action.""" Stores the agent and its state in session_state so Streamlit can reference
from backend.agent.coding_agent import CodingAgent them across reruns without losing progress.
"""
logger.info("Starting coding agent.")
agent = CodingAgent() agent = CodingAgent()
agent.start_task(task) agent.start_task(task)
action = agent.propose_next_action() action = _run_async(agent.propose_next_action())
st.session_state.coding_agent = agent st.session_state.coding_agent = agent
st.session_state.agent_pending_action = action st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval" st.session_state.agent_status = "waiting_approval"
@ -19,13 +48,14 @@ def _start_agent(task: str):
def _approve_action(): def _approve_action():
"""Execute the pending action, """Execute the pending action, log it, then immediately propose the next step."""
append it to the log, then propose the next step."""
agent = st.session_state.coding_agent agent = st.session_state.coding_agent
pending = st.session_state.agent_pending_action pending = st.session_state.agent_pending_action
result = 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.
st.session_state.agent_log.append({ st.session_state.agent_log.append({
"thought": pending.get("thought", ""), "thought": pending.get("thought", ""),
"tool": result["tool"], "tool": result["tool"],
@ -34,34 +64,47 @@ def _approve_action():
}) })
if result["is_done"]: if result["is_done"]:
# Agent called the "done" tool — task is fully complete.
st.session_state.agent_status = "done" st.session_state.agent_status = "done"
st.session_state.agent_pending_action = None st.session_state.agent_pending_action = None
else: else:
next_action = 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 _reject_action(feedback: str): def _reject_action(feedback: str):
"""Reject the pending action with optional feedback, then replan.""" """Reject the pending action with feedback so the agent replans.
The pending action is discarded; the agent receives the user's feedback and
proposes a different approach on the next call to propose_next_action().
"""
logger.info("Rejecting proposed action.")
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 = 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):
"""Inject a follow-up question into the finished agent and resume 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 = agent.propose_next_action() action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = action st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval" st.session_state.agent_status = "waiting_approval"
def _reset_agent(): def _reset_agent():
"""Reset all agent state back to idle.""" """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 = []
@ -70,29 +113,96 @@ 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():
# Toggle must always be rendered so Streamlit keeps agent_mode=True in session_state """Render the step-by-step agent UI.
Three distinct screens based on agent_status:
- "idle" task description input + Start button
- "waiting_approval" show proposed action, Approve / Reject / Abort
- "done" success message, follow-up input, New Task button
"""
logger.info("Agent mode.")
# The toggle must always render so Streamlit keeps agent_mode=True in session_state.
st.toggle("Agent Mode", key="agent_mode") st.toggle("Agent Mode", key="agent_mode")
agent_status = st.session_state.get("agent_status", "idle") agent_status = st.session_state.get("agent_status", "idle")
agent_log = st.session_state.get("agent_log", []) agent_log = st.session_state.get("agent_log", [])
# ── Agent Log ──────────────────────────────────────────────────────────── # ── Agent Log ────────────────────────────────────────────────────────────
# Collapsed by default so it doesn't clutter the UI during active tasks.
if agent_log: if agent_log:
with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False): with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False):
for i, step in enumerate(agent_log): for i, step in enumerate(agent_log):
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):
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"): result_text = step.get("result", "")
st.error(result_text) # Colour the result based on whether the tool succeeded or failed.
elif result_text.startswith("OK") or result_text.startswith("DONE"): if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
st.success(result_text) st.error(result_text)
else: elif result_text.startswith("OK") or result_text.startswith("DONE"):
st.code(result_text, language=None) st.success(result_text)
else:
st.code(result_text, language=None)
# ── Idle: task input ────────────────────────────────────────────────────── # ── Idle: task input ──────────────────────────────────────────────────────
if agent_status == "idle": if agent_status == "idle":
@ -120,15 +230,12 @@ def render_agent_mode():
args = pending.get("arguments", {}) args = pending.get("arguments", {})
if args: if args:
if "content" in args: _render_arguments(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...",
@ -141,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()
@ -179,54 +287,319 @@ 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():
# Chat history as bubbles """Render the standard multi-turn chat interface.
On the first message the system prompt is injected into the history,
including any active search results as context.
Each subsequent message appends to the same conversation so the AI retains
full context throughout the session. If search results are active when the
user sends a message, they are prepended to that message as a context block.
"""
logger.info("Chat mode")
chat_manager: ChatManager = st.session_state.chat_manager
# Apply model/token overrides from the Settings panel before any API call.
if st.session_state.get("selected_model"):
chat_manager.model = st.session_state.selected_model
if "chat_max_tokens" in st.session_state:
chat_manager.max_tokens = st.session_state.chat_max_tokens
# Consume a debug message forwarded from the editor's "Debug with AI" button.
pending_debug = st.session_state.pop("pending_debug_message", None)
if pending_debug:
_set_system_prompt(chat_manager, pending_debug)
with st.spinner("Sending debug info to AI..."):
try:
ai_response = chat_manager.send_message(pending_debug)
except Exception as e:
ai_response = f"Error: {e}"
st.session_state.chat_history.append({"role": "user", "content": pending_debug})
st.session_state.chat_history.append(
{"role": "assistant", "content": ai_response}
)
st.rerun()
return
# Replay the conversation history as chat bubbles (skip system messages).
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()
# Inject system prompt on the first message # ── /search command ───────────────────────────────────────────────────
if not chat_manager.get_history(): if stripped.lower().startswith("/search"):
system_prompt = SystemPrompter.generate_prompt() arg = stripped[len("/search"):].strip()
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)
# Show response with spinner while API is called
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 ───────────────────────────────────────────────────────────────
def render_chat(): def render_chat():
"""Top-level chat view — switches between Agent Mode and normal chat."""
if st.session_state.get("agent_mode", False): if st.session_state.get("agent_mode", False):
st.subheader("Coding Agent") st.subheader("Coding Agent")
render_agent_mode() render_agent_mode()

View File

@ -1,13 +1,19 @@
"""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.
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"
} }
@ -17,6 +23,10 @@ LANG_MAP = {
@st.dialog("Rename File") @st.dialog("Rename File")
def _rename_dialog(file_path: str): def _rename_dialog(file_path: str):
"""Dialog for renaming the given file.
Updates open_files, files_content and active_file in session_state so
all tabs and the editor reference the new path immediately.
"""
fm = FileManager() fm = FileManager()
st.write(f"Current name: **{Path(file_path).name}**") st.write(f"Current name: **{Path(file_path).name}**")
new_name = st.text_input("New name:", value=Path(file_path).stem) new_name = st.text_input("New name:", value=Path(file_path).stem)
@ -29,159 +39,246 @@ def _rename_dialog(file_path: str):
elif "/" in new_name or "\\" in new_name: elif "/" in new_name or "\\" in new_name:
st.warning("Name must not contain slashes.") st.warning("Name must not contain slashes.")
else: else:
if fm.rename_file(file_path, new_name.strip()): rel_path = str(Path(file_path).relative_to(fm.base_path))
if fm.rename_file(rel_path, new_name.strip()):
ext = Path(file_path).suffix ext = Path(file_path).suffix
new_file_path = str(Path(file_path).parent / (Path(new_name.strip()).stem + ext)) new_file_path = str(Path(file_path).parent / (Path(new_name.strip()).stem + ext))
# Update the open-files list in place so the tab order is preserved.
i = st.session_state.open_files.index(file_path) i = st.session_state.open_files.index(file_path)
st.session_state.open_files[i] = new_file_path st.session_state.open_files[i] = new_file_path
# Transfer cached editor content to the new path key.
st.session_state.files_content[new_file_path] = \ st.session_state.files_content[new_file_path] = \
st.session_state.files_content.pop(file_path) st.session_state.files_content.pop(file_path)
# Update active_file if the renamed file was the active one.
if st.session_state.active_file == file_path: 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()
@st.dialog("Delete File") @st.dialog("Delete File")
def _delete_dialog(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()
st.warning(f"Delete **{Path(file_path).name}**? This cannot be undone.") file_name = Path(abs_file_path).name
relative_path = str(Path(abs_file_path).relative_to(fm.base_path))
st.warning(f"Delete **{file_name}**? This cannot be undone.")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
if st.button("Delete", type="primary", use_container_width=True): if st.button("Delete", type="primary", use_container_width=True):
if fm.delete_file(file_path): if fm.delete_file(relative_path):
st.session_state.open_files.remove(file_path) st.session_state.open_files.remove(abs_file_path)
st.session_state.files_content.pop(file_path, None) st.session_state.files_content.pop(abs_file_path, None)
if st.session_state.active_file == file_path: if st.session_state.active_file == abs_file_path:
st.session_state.active_file = ( st.session_state.active_file = (
st.session_state.open_files[0] st.session_state.open_files[0]
if st.session_state.open_files else None if st.session_state.open_files else None
) )
st.rerun() st.rerun()
logger.info("Deleting file %s successfull.", abs_file_path)
else: else:
st.error("Delete failed. Check that the file still exists.") st.error("Delete failed. Check that the file still exists.")
logger.warning("Deleting file %s failed.", abs_file_path)
with col2: with col2:
if st.button("Cancel", use_container_width=True): if st.button("Cancel", use_container_width=True):
st.rerun() st.rerun()
def run_active_file(): def run_active_file():
"""Execute the currently active file and store the result in exec_results[file_path].
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
if not active_file: if not active_file:
st.warning("No active file to run.") st.warning("No active file to run.")
return return
execution_engine = ExecutionEngine()
debug_logger = DebugLogger()
debug_logger.log(f"Executing code from {active_file}...") execution_engine = ExecutionEngine()
logger.info("Executing code from %s...", active_file)
# ast check — only for Python files
if Path(active_file).suffix == ".py":
source = st.session_state.get("files_content", {}).get(active_file, "")
try:
ast.parse(source)
except SyntaxError as e:
result = {"stdout": "", "stderr": str(e), "return_code": -1, "ast_error": True}
st.session_state.exec_results[active_file] = result
logger.error("Syntax error: %s", e)
return result
with st.spinner(f"Running {Path(active_file).name}..."): 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))
debug_logger.log("Execution completed.") if output["rc"] == 0:
logger.info("Execution completed successfully.")
st.session_state.code_execution_output = { else:
logger.error("Execution failed with exit code %s.", output['rc'])
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:
st.subheader("Code Editor") """UI component that renders the code editor tabs, Ace editor, and run output."""
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:
tab_names = [Path(f).name for f in st.session_state.open_files] st.info("Please select a file to edit.")
tabs = st.tabs(tab_names) return
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]
if file_path not in st.session_state.files_content: tabs = st.tabs(tab_names)
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)
code = st_ace.st_ace( if jump_target and jump_target in st.session_state.open_files:
value=st.session_state.files_content[file_path], idx = st.session_state.open_files.index(jump_target)
language=file_language, st.components.v1.html(
theme="monokai", f"""<script>
key=f"code_editor_{file_path}", (function() {{
auto_update=True, setTimeout(function() {{
height=400, const tabs = window.parent.document
.querySelectorAll('button[data-baseweb="tab"]');
if (tabs[{idx}]) tabs[{idx}].click();
}}, 100);
}})();
</script>""",
height=0,
) )
if code != st.session_state.files_content[file_path]: for idx, file_path in enumerate(st.session_state.open_files):
st.session_state.files_content[file_path] = code with tabs[idx]:
# Load file content from disk on first open; use cached version afterwards.
if file_path not in st.session_state.files_content:
st.session_state.files_content[file_path] = self.fm.read_file(Path(file_path))
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",
st.session_state.active_file = ( key=f"code_editor_{file_path}",
st.session_state.open_files[0] auto_update=True,
if st.session_state.open_files else None height=400,
)
if code != st.session_state.files_content[file_path]:
st.session_state.files_content[file_path] = code
cols = st.columns([1, 1, 1, 1, 1])
with cols[0]:
if st.button("Save Changes", key=f"save_{file_path}"):
if self.fm.save_file(file_path, code):
st.success("File saved successfully!")
with cols[1]:
st.download_button(
label="Download",
data=st.session_state.files_content.get(file_path, ""),
file_name=Path(file_path).name,
mime="text/plain",
key=f"download_{file_path}",
) )
with cols[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 result["return_code"] != 0 or result.get("stderr"):
if st.button("🐛 Debug with AI", key=f"debug_with_ai_{file_path}", type="primary"):
file_name = Path(file_path).name
code_content = st.session_state.files_content.get(file_path, "")
lang = LANG_MAP.get(Path(file_path).suffix, "python")
formatted_output = DebugLogger.format_debug_output(result)
debug_message = (
f"I got an error while running **{file_name}**:\n\n"
f"```\n{formatted_output}\n```\n\n"
f"**Here is the code:**\n```{lang}\n{code_content}\n```\n\n"
f"Can you help me fix this?"
)
st.session_state.pending_debug_message = debug_message
st.session_state["_navigate_to_chat"] = True
st.rerun()
if result.get("stdout"):
st.text_area("Standard Output", value=result["stdout"], height=200,
disabled=True, key=f"run_stdout_{file_path}")
if result.get("stderr"):
st.text_area("Standard Error", value=result["stderr"], height=200,
disabled=True, key=f"run_stderr_{file_path}")
if not result.get("stdout") and not result.get("stderr"):
st.info("No output produced by the code execution.")
def render_editor():
"""Entry point for app.py — delegates to FileViewer."""
FileViewer().render()
if st.button("▶ Run Code", key="run_code"):
result = run_active_file()
if not result:
st.stop()
st.subheader("Execution Output")
if result["return_code"] == 0:
st.success(f"Exit code: {result['return_code']}")
else:
st.error(f"Exit code: {result['return_code']}")
if result["stdout"]:
st.text_area(
"Standard Output",
value=result["stdout"],
height=200,
disabled=True,
key="run_stdout")
if result["stderr"]:
st.text_area(
"Standard Error",
value=result["stderr"],
height=200,
disabled=True,
key="run_stderr")
if not result["stdout"] and not result["stderr"]:
st.info("No output produced by the code execution.")
if __name__ == "__main__": if __name__ == "__main__":
render_editor() render_editor()

View File

@ -1,10 +1,15 @@
"""Sidebar — navigation radio, logo, and the workspace file explorer."""
import streamlit as st import streamlit as st
from streamlit_arborist import tree_view from streamlit_arborist import tree_view
from pathlib import Path from pathlib import Path
from backend.managers.file_manager import FileManager from backend.managers.file_manager import FileManager
# Shared FileManager instance for all sidebar operations.
fm = FileManager() fm = FileManager()
# Maps file extensions (and special keys "folder"/"default") to display emojis
# shown next to each entry in the file tree.
SUFFIX_MAP = { SUFFIX_MAP = {
".py": "🐍", # Python ".py": "🐍", # Python
".js": "🟨", # JavaScript (Gelbes Quadrat/Logo) ".js": "🟨", # JavaScript (Gelbes Quadrat/Logo)
@ -29,11 +34,13 @@ SUFFIX_MAP = {
@st.dialog("Delete Folder") @st.dialog("Delete Folder")
def _delete_folder_dialog(folder_rel: str, folder_name: str): def _delete_folder_dialog(folder_rel: str, folder_name: str):
"""Confirmation dialog before permanently deleting a folder and its contents."""
st.warning(f"Delete **{folder_name}** and all its contents? This cannot be undone.") st.warning(f"Delete **{folder_name}** and all its contents? This cannot be undone.")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
if st.button("Delete", type="primary", use_container_width=True): if st.button("Delete", type="primary", use_container_width=True):
if fm.delete_folder(folder_rel): if fm.delete_folder(folder_rel):
# Clear the selected-folder state so the action bar disappears.
st.session_state.selected_folder = None st.session_state.selected_folder = None
st.session_state.selected_folder_rel = None st.session_state.selected_folder_rel = None
st.rerun() st.rerun()
@ -46,10 +53,22 @@ 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 = ""):
name = st.text_input("File name:", placeholder="e.g. script.py") """Dialog for creating a new file inside the given folder (or workspace root).
col1, col2 = st.columns(2)
with col1: Args:
if st.button("Create", type="primary", use_container_width=True): parent_path: Workspace-relative path of the parent folder. Pass an
empty string to create the file at the workspace root.
"""
with st.form("add_file_form"):
name = st.text_input("File name:", placeholder="e.g. script.py")
col1, col2 = st.columns(2)
with col1:
submitted = st.form_submit_button("Create", type="primary", use_container_width=True)
with col2:
cancel = st.form_submit_button("Cancel", use_container_width=True)
if submitted:
if not name.strip(): if not name.strip():
st.warning("Please enter a file name.") st.warning("Please enter a file name.")
elif "/" in name or "\\" in name: elif "/" in name or "\\" in name:
@ -57,17 +76,29 @@ def _add_file_dialog(parent_path: str = ""):
else: else:
if fm.create_file(parent_path, name.strip()): if fm.create_file(parent_path, name.strip()):
st.rerun() st.rerun()
with col2:
if st.button("Cancel", use_container_width=True): if cancel:
st.rerun() st.rerun()
@st.dialog("Add Folder") @st.dialog("Add Folder")
def _add_folder_dialog(parent_path: str = ""): def _add_folder_dialog(parent_path: str = ""):
name = st.text_input("Folder name:", placeholder="e.g. utils") """Dialog for creating a new subfolder inside the given folder (or workspace root).
col1, col2 = st.columns(2)
with col1: Args:
if st.button("Create", type="primary", use_container_width=True): parent_path: Workspace-relative path of the parent folder. Pass an
empty string to create the folder at the workspace root.
"""
with st.form("add_folder_form"):
name = st.text_input("Folder name:", placeholder="e.g. utils")
col1, col2 = st.columns(2)
with col1:
submitted = st.form_submit_button("Create", type="primary", use_container_width=True)
with col2:
cancel = st.form_submit_button("Cancel", use_container_width=True)
if submitted:
if not name.strip(): if not name.strip():
st.warning("Please enter a folder name.") st.warning("Please enter a folder name.")
elif "/" in name or "\\" in name: elif "/" in name or "\\" in name:
@ -75,27 +106,40 @@ def _add_folder_dialog(parent_path: str = ""):
else: else:
if fm.create_folder(parent_path, name.strip()): if fm.create_folder(parent_path, name.strip()):
st.rerun() st.rerun()
with col2:
if st.button("Cancel", use_container_width=True):
st.rerun()
if cancel:
st.rerun()
# ── File tree ───────────────────────────────────────────────────────────────── # ── File tree ─────────────────────────────────────────────────────────────────
def build_arborist_tree(tree, parent_path=Path()): def build_arborist_tree(tree, parent_path=Path()):
"""Convert the FileManager dict tree into the node format expected by streamlit-arborist.
Folders become nodes with a "children" list; files become leaf nodes
with an emoji prefix derived from their extension.
Args:
tree: Nested dict from FileManager.get_file_tree()
parent_path: Accumulates the relative path while recursing.
Returns:
List of node dicts accepted by tree_view().
"""
nodes = [] nodes = []
for name, content in sorted(tree.items()): for name, content in sorted(tree.items()):
full_path = parent_path / name full_path = parent_path / name
node_id = str(full_path.as_posix()) node_id = str(full_path.as_posix()) # forward-slash IDs work cross-platform
if isinstance(content, dict): if isinstance(content, dict):
# Directory — recurse to build child nodes.
nodes.append({ nodes.append({
"id": node_id, "id": node_id,
"name": f"{name}", "name": f"{name}",
"children": build_arborist_tree(content, full_path) "children": build_arborist_tree(content, full_path)
}) })
else: else:
# File — pick an emoji based on extension, fall back to default.
suffix = Path(name).suffix suffix = Path(name).suffix
icon = SUFFIX_MAP.get(suffix, SUFFIX_MAP["default"]) icon = SUFFIX_MAP.get(suffix, SUFFIX_MAP["default"])
@ -109,15 +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.
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, select_internal_nodes=True, # allow clicking folder names, not just files
open_by_default=False open_by_default=True,
) )
return selected return selected
@ -126,6 +186,21 @@ def render_filetree_arborist(tree):
# ── Sidebar ─────────────────────────────────────────────────────────────────── # ── Sidebar ───────────────────────────────────────────────────────────────────
def render_sidebar(): def render_sidebar():
"""Render the full sidebar: navigation radio and workspace file explorer.
Tree click handling:
- Clicking a file appended to open_files, set as active_file
- Clicking a folder stored in selected_folder so the action bar appears
Navigation flags (_navigate_to_editor, _navigate_to_chat) are consumed here
at the very top before any widget is rendered to avoid StreamlitAPIException.
"""
# Consume navigation flags before any widget renders.
if st.session_state.pop("_navigate_to_editor", False):
st.session_state.radio_interface_options = "Code Editor"
if st.session_state.pop("_navigate_to_chat", False):
st.session_state.radio_interface_options = "Chat with AI Assistant"
st.sidebar.title("Navigation") st.sidebar.title("Navigation")
navigation_section = st.sidebar.container() navigation_section = st.sidebar.container()
@ -155,25 +230,33 @@ def render_sidebar():
if selected: if selected:
selected_path = selected.get("id") selected_path = selected.get("id")
# Only react when the user clicks a *different* node to
# avoid re-running on every Streamlit rerender.
if st.session_state.last_selected != selected_path: if st.session_state.last_selected != selected_path:
st.session_state.last_selected = selected_path st.session_state.last_selected = selected_path
abs_path = fm.base_path / selected_path abs_path = fm.base_path / selected_path
if abs_path.is_file(): if abs_path.is_file():
# Open the file in the editor, jump to its tab,
# 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():
# Select the folder so its action bar appears below.
st.session_state.selected_folder = str(abs_path) st.session_state.selected_folder = str(abs_path)
st.session_state.selected_folder_rel = selected_path st.session_state.selected_folder_rel = selected_path
st.rerun() st.rerun()
# Folder actions — rendered outside the selection block so they persist across reruns # Folder action bar — rendered unconditionally outside the selection
# block so it persists across reruns even when no new click happens.
if st.session_state.get("selected_folder"): if st.session_state.get("selected_folder"):
folder_name = Path(st.session_state.selected_folder).name folder_name = Path(st.session_state.selected_folder).name
folder_rel = st.session_state.selected_folder_rel folder_rel = st.session_state.selected_folder_rel
@ -188,6 +271,7 @@ def render_sidebar():
_delete_folder_dialog(folder_rel, folder_name) _delete_folder_dialog(folder_rel, folder_name)
with add_more: with add_more:
# Popover for workspace-root actions (not tied to any selected folder).
with st.popover("⚙️ Explorer Options", key="popover_options", use_container_width=True): with st.popover("⚙️ Explorer Options", key="popover_options", use_container_width=True):
if st.button("Add File", key="btn_add_file", use_container_width=True): if st.button("Add File", key="btn_add_file", use_container_width=True):
_add_file_dialog("") _add_file_dialog("")
@ -195,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__":

View File

@ -1,64 +1,102 @@
"""Centralised session-state initialisation for the Streamlit app.
All keys used throughout the app are declared here with their default values.
Calling init_state() at the top of app.py ensures every key exists before any
page tries to read it, preventing KeyError on the first load.
"""
import streamlit as st import streamlit as st
from backend.managers.chat_manager import ChatManager 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
# 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.
# 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.
# 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 ──────────────────────────────────────────────────────────
# 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:
st.session_state.open_files = [] st.session_state.open_files = []
# Dict mapping absolute file path → current editor content (may differ from
# disk if the user has unsaved changes).
if "files_content" not in st.session_state: if "files_content" not in st.session_state:
st.session_state.files_content = {} st.session_state.files_content = {}
# Absolute path of the file whose tab is currently active in the editor.
# Must always be one of the paths in open_files, or None if no file is open.
if "active_file" not in st.session_state: if "active_file" not in st.session_state:
st.session_state.active_file = None st.session_state.active_file = None
if "active_tab" not in st.session_state:
st.session_state.active_tab = 0
if "is_editing" not in st.session_state: # Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}}
st.session_state.is_editing = False if "exec_results" not in st.session_state:
st.session_state.exec_results = {}
if "code_suggestions" not in st.session_state: # ── Chat state ────────────────────────────────────────────────────────────
st.session_state.code_suggestions = []
if "code_execution_output" not in st.session_state: # Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles.
st.session_state.code_execution_output = "" # System messages are stored here too but skipped during display.
# Chat state initialization
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 ──────────────────────────────────────────────────────
# 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.
# Set by _start_agent(), cleared by _reset_agent().
if "coding_agent" not in st.session_state: if "coding_agent" not in st.session_state:
st.session_state.coding_agent = None st.session_state.coding_agent = None
# Lifecycle state of the agent: "idle" | "waiting_approval" | "done".
# Controls which sub-screen render_agent_mode() displays.
if "agent_status" not in st.session_state: if "agent_status" not in st.session_state:
st.session_state.agent_status = "idle" st.session_state.agent_status = "idle"
# Chronological list of completed step records shown in the Agent Log expander.
# Each entry: {"thought": str, "tool": str, "arguments": dict, "result": str}
if "agent_log" not in st.session_state: if "agent_log" not in st.session_state:
st.session_state.agent_log = [] st.session_state.agent_log = []
# The action the agent has proposed but that has not yet been approved or
# 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.

View File

@ -1,8 +1,11 @@
# 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
mcp>=0.1.0
ddgs>=0.1.0
# Web & API # Web & API
requests>=2.31.0 requests>=2.31.0
@ -15,9 +18,14 @@ pandas>=2.0.0
# Testing # Testing
pytest>=7.0.0 pytest>=7.0.0
pytest-cov>=4.0.0 pytest-cov>=4.0.0
pytest-asyncio>=0.23.0
# Development & Utilities # Development & Utilities
python-dotenv>=1.0.0 python-dotenv>=1.0.0
#For code editor functionality #For code editor functionality
streamlit-ace>=0.1.0 streamlit-ace>=0.1.0
#MCP-Code execution tools
pyflakes>=0.1.0
pygame>=0.1.0

View File

@ -1,78 +0,0 @@
"""
Temporäres Test-Script für den CodingAgent kann danach gelöscht werden.
Ausführen:
python run_agent.py
Steuerung:
Enter Aktion ausführen (approve)
Text + Enter Feedback geben (reject + replan)
stop Abbrechen
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from backend.agent.coding_agent import CodingAgent, WORKSPACE
def run():
print("\n" + "=" * 60)
print(" CodingAgent Interaktiver Test")
print("=" * 60)
print(f" Workspace: {WORKSPACE}")
print(" [Enter] = Aktion ausführen | Text = Feedback | 'stop' = Abbruch")
print("=" * 60 + "\n")
task = input("Aufgabe eingeben: ").strip()
if not task:
print("Keine Aufgabe eingegeben. Beende.")
return
agent = CodingAgent()
agent.start_task(task)
print(f"\nAgent gestartet für: '{task}'\n")
step = 0
while not agent.is_done:
step += 1
print(f"\n{'' * 60}")
print(f" Schritt {step} Agent überlegt...")
action = agent.propose_next_action()
print(f"\n Thought : {action.get('thought', '')}")
print(f" Tool : {action.get('tool', '')}")
print(f" Arguments: {action.get('arguments', {})}")
print()
user_input = input(" [Enter]=ausführen | Text=Feedback | stop=Abbruch: ").strip()
if user_input.lower() in ("stop", "abort"):
print("\nAbgebrochen.")
break
if user_input:
agent.reject(user_input)
print(f" → Feedback injiziert. Agent plant neu.\n")
continue
result = agent.approve()
print(f"\n Resultat ({result['tool']}):")
print(f" {result['result'][:300]}{'...' if len(result['result']) > 300 else ''}")
if result["is_done"]:
print("\n" + "=" * 60)
print(" FERTIG!")
print(f" {result['result']}")
print("=" * 60)
if __name__ == "__main__":
try:
run()
except KeyboardInterrupt:
print("\n\nUnterbrochen.")

24
tests/conftest.py Normal file
View File

@ -0,0 +1,24 @@
"""Shared pytest configuration — runs before any test module is imported.
Patches MCPToolAdapter at the sys.modules level so that importing
backend.agent.coding_agent never tries to start real MCP subprocess servers.
"""
import sys
from unittest.mock import AsyncMock, MagicMock
# Build a fake adapter instance whose async methods return immediately.
_mock_adapter = MagicMock()
_mock_adapter.initialize_all_servers = AsyncMock(return_value=None)
_mock_adapter.get_all_tools = MagicMock(return_value=[])
_mock_adapter.call_tool = AsyncMock(return_value=MagicMock(isError=False, content=[]))
# Wrap as a class mock: calling MCPToolAdapter() returns _mock_adapter.
_mock_adapter_cls = MagicMock(return_value=_mock_adapter)
# Inject before any test imports coding_agent so the module-level
# asyncio.run(adapter.initialize_all_servers()) uses the mock.
sys.modules.setdefault(
"backend.agent.mcp_server_adapter",
MagicMock(MCPToolAdapter=_mock_adapter_cls),
)

View File

@ -1,242 +1,136 @@
"""Test script for ChatManager - Pytest compatible tests""" """Tests for ChatManager (backend/managers/chat_manager.py)."""
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
import requests
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
# Add project root to Python path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.managers.chat_manager import ChatManager from backend.managers.chat_manager import ChatManager
class TestChatManager: # ── Helpers ──────────────────────────────────────────────────────────────────
"""Test suite for ChatManager functionality."""
def _mock_ok(content="AI reply"):
"""Return a mocked 200 response with a single assistant choice."""
mock = MagicMock()
mock.status_code = 200
mock.json.return_value = {
"choices": [{"message": {"role": "assistant", "content": content}}]
}
mock.text = ""
return mock
# ── History management ────────────────────────────────────────────────────────
class TestHistory:
"""Tests for add_message and clear_history."""
@pytest.fixture @pytest.fixture
def chat_manager(self): def cm(self):
return ChatManager() return ChatManager()
def test_initialization(self, chat_manager): def test_add_message_appends_correct_entry(self, cm):
"""Test that ChatManager initializes correctly.""" cm.add_message("user", "Hello")
assert chat_manager.api_url is not None assert cm.chat_history == [{"role": "user", "content": "Hello"}]
assert chat_manager.model is not None
assert chat_manager.chat_history == []
def test_add_message(self, chat_manager): def test_add_multiple_messages_preserves_order(self, cm):
"""Test adding messages to chat history.""" cm.add_message("user", "Hi")
chat_manager.add_message("user", "Hello") cm.add_message("assistant", "Hello!")
assert len(chat_manager.chat_history) == 1 assert cm.chat_history[0]["role"] == "user"
assert chat_manager.chat_history[0]["role"] == "user" assert cm.chat_history[1]["role"] == "assistant"
assert chat_manager.chat_history[0]["content"] == "Hello"
def test_get_history(self, chat_manager): def test_clear_history_empties_list(self, cm):
"""Test retrieving chat history.""" cm.add_message("user", "Hi")
chat_manager.add_message("user", "Hello") cm.clear_history()
chat_manager.add_message("assistant", "Hi there!") assert cm.chat_history == []
history = chat_manager.get_history()
assert len(history) == 2
assert history[0]["role"] == "user"
assert history[1]["role"] == "assistant"
def test_clear_history(self, chat_manager):
"""Test clearing chat history."""
chat_manager.add_message("user", "Hello")
assert len(chat_manager.chat_history) == 1
chat_manager.clear_history()
assert len(chat_manager.chat_history) == 0
def test_send_message_integration(self, chat_manager):
"""
Integration test for sending message to AI.
This test actually communicates with the API.
"""
try:
# Send a simple test message
response = chat_manager.send_message("Hello, what is 2+2?")
# Verify response is not empty
assert isinstance(response, str)
assert len(response) > 0
# Verify message was added to history
assert len(chat_manager.chat_history) == 2 # user + assistant
assert chat_manager.chat_history[0]["role"] == "user"
assert chat_manager.chat_history[1]["role"] == "assistant"
print(f"API Test Passed")
print(f"Response: {response}")
except Exception as e:
# If API is not reachable, mark as skipped
pytest.skip(f"API not reachable: {str(e)}")
def test_multiple_messages(self, chat_manager):
"""Test sending multiple messages in a conversation."""
try:
# Send first message
response1 = chat_manager.send_message("What is your name?")
assert len(response1) > 0
# Send follow-up message
response2 = chat_manager.send_message("Tell me more")
assert len(response2) > 0
# Verify full conversation is in history
assert len(chat_manager.chat_history) == 4 # 2 user + 2 assistant
print(f"Conversation Test Passed")
print(f"Messages: {len(chat_manager.chat_history)}")
except Exception as e:
pytest.skip(f"API not reachable: {str(e)}")
class TestChatManagerSendMessage: # ── send_message (mocked HTTP) ────────────────────────────────────────────────
"""Unit tests for send_message using mocked HTTP requests."""
class TestSendMessage:
"""Tests for send_message: history updates, HTTP payload, error handling, and auth headers."""
@pytest.fixture @pytest.fixture
def chat_manager(self): def cm(self):
return ChatManager() return ChatManager()
def _mock_response(self, content="AI reply", status_code=200): def test_user_message_added_to_history(self, cm):
mock = MagicMock() with patch("requests.post", return_value=_mock_ok()):
mock.status_code = status_code cm.send_message("Hello")
mock.json.return_value = { assert cm.chat_history[0] == {"role": "user", "content": "Hello"}
"choices": [{"message": {"role": "assistant", "content": content}}]
}
mock.text = "error text"
return mock
def test_send_message_adds_user_message_to_history(self, chat_manager): def test_assistant_reply_added_to_history(self, cm):
with patch("requests.post", return_value=self._mock_response()): with patch("requests.post", return_value=_mock_ok("Hi there")):
chat_manager.send_message("Hello") cm.send_message("Hello")
assert chat_manager.chat_history[0] == {"role": "user", "content": "Hello"} assert cm.chat_history[1] == {"role": "assistant", "content": "Hi there"}
def test_send_message_adds_assistant_response_to_history(self, chat_manager): def test_returns_assistant_content_string(self, cm):
with patch("requests.post", return_value=self._mock_response("Hi there")): with patch("requests.post", return_value=_mock_ok("Answer")):
chat_manager.send_message("Hello") result = cm.send_message("Question")
assert chat_manager.chat_history[1] == {"role": "assistant", "content": "Hi there"} assert result == "Answer"
def test_send_message_returns_ai_content(self, chat_manager): def test_full_history_sent_in_request_payload(self, cm):
with patch("requests.post", return_value=self._mock_response("Answer")): """All prior messages must be forwarded so the model has conversation context."""
response = chat_manager.send_message("Question") cm.add_message("system", "You are helpful.")
assert response == "Answer" with patch("requests.post", return_value=_mock_ok()) as mock_post:
cm.send_message("Hello")
payload = mock_post.call_args.kwargs["json"]
assert payload["messages"][0]["role"] == "system"
assert payload["messages"][1]["role"] == "user"
def test_send_message_history_grows_with_each_call(self, chat_manager): def test_connection_error_raises_and_adds_error_to_history(self, cm):
with patch("requests.post", return_value=self._mock_response()):
chat_manager.send_message("First")
chat_manager.send_message("Second")
assert len(chat_manager.chat_history) == 4 # 2 user + 2 assistant
def test_send_message_connection_error_raises(self, chat_manager):
import requests
with patch("requests.post", side_effect=requests.exceptions.ConnectionError("refused")): with patch("requests.post", side_effect=requests.exceptions.ConnectionError("refused")):
with pytest.raises(Exception, match="Connection Error"): with pytest.raises(Exception, match="Connection Error"):
chat_manager.send_message("Hello") cm.send_message("Hello")
assert any("Error" in msg["content"] for msg in cm.chat_history)
def test_send_message_api_error_status_raises(self, chat_manager): def test_api_error_status_raises(self, cm):
mock = self._mock_response(status_code=500) mock = MagicMock()
mock.status_code = 500
mock.text = "Internal Server Error"
with patch("requests.post", return_value=mock): with patch("requests.post", return_value=mock):
with pytest.raises(Exception, match="API Error 500"): with pytest.raises(Exception, match="API Error 500"):
chat_manager.send_message("Hello") cm.send_message("Hello")
def test_send_message_empty_choices_raises(self, chat_manager): def test_timeout_raises(self, cm):
with patch("requests.post", side_effect=requests.exceptions.Timeout()):
with pytest.raises(Exception):
cm.send_message("Hello")
def test_empty_choices_raises(self, cm):
mock = MagicMock() mock = MagicMock()
mock.status_code = 200 mock.status_code = 200
mock.json.return_value = {"choices": []} mock.json.return_value = {"choices": []}
with patch("requests.post", return_value=mock): with patch("requests.post", return_value=mock):
with pytest.raises(Exception, match="Invalid API response format"): with pytest.raises(Exception, match="Invalid API response format"):
chat_manager.send_message("Hello") cm.send_message("Hello")
def test_send_message_missing_choices_key_raises(self, chat_manager): def test_api_key_included_in_header_when_set(self, cm):
cm.api_key = "test-key-123"
with patch("requests.post", return_value=_mock_ok()) as mock_post:
cm.send_message("Hello")
headers = mock_post.call_args.kwargs["headers"]
assert headers.get("Authorization") == "Bearer test-key-123"
def test_api_key_excluded_from_header_when_empty_sentinel(self, cm):
# "EMPTY" is the sentinel string the UI writes when the user leaves the key field blank.
cm.api_key = "EMPTY"
with patch("requests.post", return_value=_mock_ok()) as mock_post:
cm.send_message("Hello")
headers = mock_post.call_args.kwargs["headers"]
assert "Authorization" not in headers
def test_json_decode_error_raises(self, cm):
import json
mock = MagicMock() mock = MagicMock()
mock.status_code = 200 mock.status_code = 200
mock.json.return_value = {} mock.json.side_effect = json.JSONDecodeError("bad json", "", 0)
with patch("requests.post", return_value=mock): with patch("requests.post", return_value=mock):
with pytest.raises(Exception): with pytest.raises(Exception, match="JSON Decode Error"):
chat_manager.send_message("Hello") cm.send_message("Hello")
def test_send_message_timeout_raises(self, chat_manager):
import requests
with patch("requests.post", side_effect=requests.exceptions.Timeout()):
with pytest.raises(Exception):
chat_manager.send_message("Hello")
class TestChatManagerGetChatDisplay:
"""Tests for get_chat_display()."""
@pytest.fixture
def chat_manager(self):
return ChatManager()
def test_empty_history_returns_empty_list(self, chat_manager):
assert chat_manager.get_chat_display() == []
def test_display_contains_role_and_content_keys(self, chat_manager):
chat_manager.add_message("user", "Hello")
display = chat_manager.get_chat_display()
assert "role" in display[0]
assert "content" in display[0]
def test_display_preserves_message_order(self, chat_manager):
chat_manager.add_message("user", "First")
chat_manager.add_message("assistant", "Second")
display = chat_manager.get_chat_display()
assert display[0]["role"] == "user"
assert display[1]["role"] == "assistant"
def test_display_matches_history(self, chat_manager):
chat_manager.add_message("user", "Hi")
chat_manager.add_message("assistant", "Hello!")
assert chat_manager.get_chat_display() == chat_manager.get_history()
def test_system_message_included_in_display(self, chat_manager):
chat_manager.add_message("system", "You are a helper.")
display = chat_manager.get_chat_display()
assert display[0]["role"] == "system"
def test_chat_manager_demo():
"""Demo test - Shows interactive chat (can be run manually)."""
print("\n" + "=" * 60)
print("ChatManager Demo - Interactive Test")
print("=" * 60 + "\n")
chat_manager = ChatManager()
print(f"Connected to API: {chat_manager.api_url}")
print(f"Model: {chat_manager.model}\n")
# Demo conversation
test_messages = ["Hello! What can you do?", "Tell me a joke", "What is Python?"]
print("Starting conversation...\n")
for message in test_messages:
print(f"User: {message}")
try:
response = chat_manager.send_message(message)
print(f"Assistant: {response}\n")
except Exception as e:
print(f"Error: {str(e)}\n")
pytest.skip(f"API not reachable: {str(e)}")
# Display full chat history
print("=" * 60)
print("Chat History:")
print("=" * 60)
for msg in chat_manager.get_history():
print(f"{msg['role'].upper()}: {msg['content']}\n")
if __name__ == "__main__":
# Run with: pytest tests/test_chat_manager.py -v -s
pytest.main([__file__, "-v", "-s"])

View File

@ -3,13 +3,16 @@ Tests for CodingAgent (backend/agent/coding_agent.py)
Structure: Structure:
TestHelpers truncate_result, trim_messages, _strip_code_fences TestHelpers truncate_result, trim_messages, _strip_code_fences
TestDispatcher dispatch_tool routing
TestTools tool functions (read_file, write_file, ) using tmp workspace
TestCodingAgentInit __init__ and start_task TestCodingAgentInit __init__ and start_task
TestProposeNextAction propose_next_action with mocked API TestProposeNextAction propose_next_action with mocked API
TestApprove approve with mocked API + real tool execution TestApprove approve with mocked API + real tool execution
TestReject reject injects feedback correctly TestReject reject injects feedback correctly
TestFullLoop integration: real API, skipped if unreachable TestFullLoop integration: real API, skipped if unreachable
Note: TestTools (write_file, read_file, etc.) and TestDispatcher were removed
because those tool functions are now MCP server tools, not standalone functions
in coding_agent.py. They will be tested via test_mcp_server_*.py once the MCP
servers are finalised.
""" """
import json import json
@ -22,20 +25,12 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.agent.coding_agent import ( from backend.agent.coding_agent import (
MAX_HISTORY_CHARS, MAX_ITERATIONS,
MAX_RESULT_LENGTH, MAX_RESULT_LENGTH,
CodingAgent, CodingAgent,
_strip_code_fences, _strip_code_fences,
dispatch_tool,
done,
grep_search,
list_files,
read_file,
run_python,
truncate_result, truncate_result,
trim_messages, trim_messages,
validate_python,
write_file,
) )
@ -55,6 +50,8 @@ def _make_api_response(content: str, status_code: int = 200):
def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) -> str: def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) -> str:
"""Return a JSON string in the exact format the agent expects from the LLM:
{"thought": "...", "tool": "<name>", "arguments": {...}}."""
return json.dumps({"thought": thought, "tool": tool, "arguments": arguments}) return json.dumps({"thought": thought, "tool": tool, "arguments": arguments})
@ -63,6 +60,7 @@ def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) ->
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestTruncateResult: class TestTruncateResult:
"""Tests for truncate_result(): ensures long tool outputs are capped before entering the message history."""
def test_short_result_unchanged(self): def test_short_result_unchanged(self):
assert truncate_result("hello") == "hello" assert truncate_result("hello") == "hello"
@ -73,10 +71,6 @@ class TestTruncateResult:
assert len(result) < len(long) assert len(result) < len(long)
assert "TRUNCATED" in result assert "TRUNCATED" in result
def test_exact_limit_not_truncated(self):
text = "a" * MAX_RESULT_LENGTH
assert truncate_result(text) == text
def test_truncated_keeps_start_and_end(self): def test_truncated_keeps_start_and_end(self):
text = "START" + "x" * MAX_RESULT_LENGTH + "END" text = "START" + "x" * MAX_RESULT_LENGTH + "END"
result = truncate_result(text) result = truncate_result(text)
@ -85,8 +79,11 @@ class TestTruncateResult:
class TestTrimMessages: class TestTrimMessages:
"""Tests for trim_messages(): keeps system + original task, drops old turns when history grows too large."""
def _make_messages(self, n_extra: int, chars_each: int = 100) -> list: def _make_messages(self, n_extra: int, chars_each: int = 100) -> list:
"""Build a message list with a fixed system + user header followed by
n_extra assistant/user pairs, each pair consuming 2*chars_each characters."""
msgs = [ msgs = [
{"role": "system", "content": "sys"}, {"role": "system", "content": "sys"},
{"role": "user", "content": "original task"}, {"role": "user", "content": "original task"},
@ -105,8 +102,6 @@ class TestTrimMessages:
original_total = sum(len(m["content"]) for m in msgs) original_total = sum(len(m["content"]) for m in msgs)
trimmed = trim_messages(msgs) trimmed = trim_messages(msgs)
trimmed_total = sum(len(m["content"]) for m in trimmed) trimmed_total = sum(len(m["content"]) for m in trimmed)
# Must be significantly shorter than the original
# (slightly above MAX_HISTORY_CHARS is acceptable due to the injected reminder message)
assert trimmed_total < original_total assert trimmed_total < original_total
assert len(trimmed) < len(msgs) assert len(trimmed) < len(msgs)
@ -121,6 +116,8 @@ class TestTrimMessages:
assert trimmed[1]["content"] == "original task" assert trimmed[1]["content"] == "original task"
def test_reminder_injected_when_trimmed(self): def test_reminder_injected_when_trimmed(self):
# trim_messages inserts a "system_note" message so the agent knows that
# earlier turns were dropped and it should not reference missing context.
msgs = self._make_messages(n_extra=500, chars_each=200) msgs = self._make_messages(n_extra=500, chars_each=200)
trimmed = trim_messages(msgs) trimmed = trim_messages(msgs)
contents = [m["content"] for m in trimmed] contents = [m["content"] for m in trimmed]
@ -128,6 +125,7 @@ class TestTrimMessages:
class TestStripCodeFences: class TestStripCodeFences:
"""Tests for _strip_code_fences(): the LLM sometimes wraps its JSON in markdown fences — this strips them."""
def test_plain_text_unchanged(self): def test_plain_text_unchanged(self):
assert _strip_code_fences("hello") == "hello" assert _strip_code_fences("hello") == "hello"
@ -140,171 +138,13 @@ class TestStripCodeFences:
text = "```\nhello\n```" text = "```\nhello\n```"
assert _strip_code_fences(text) == "hello" assert _strip_code_fences(text) == "hello"
def test_strips_whitespace(self):
assert _strip_code_fences(" hello ") == "hello"
# ═════════════════════════════════════════════════════════════════════════════
# TestDispatcher
# ═════════════════════════════════════════════════════════════════════════════
class TestDispatcher:
def test_unknown_tool_returns_error(self):
result = dispatch_tool("nonexistent_tool", {})
assert "ERROR" in result
assert "nonexistent_tool" in result
def test_done_tool_dispatched(self):
result = dispatch_tool("done", {"summary": "finished"})
assert "finished" in result
def test_wrong_arguments_returns_error(self):
result = dispatch_tool("read_file", {"wrong_param": "x"})
assert "ERROR" in result
# ═════════════════════════════════════════════════════════════════════════════
# TestTools (patched WORKSPACE → tmp_path)
# ═════════════════════════════════════════════════════════════════════════════
class TestWriteFile:
def test_write_creates_file(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = write_file("hello.py", "print('hi')")
assert result.startswith("OK:")
assert (tmp_path / "hello.py").read_text() == "print('hi')"
def test_write_outside_workspace_blocked(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = write_file("../evil.py", "bad")
assert "ERROR" in result
def test_write_unsupported_extension_blocked(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = write_file("script.sh", "echo hi")
assert "ERROR" in result
class TestReadFile:
def test_read_existing_file(self, tmp_path):
(tmp_path / "data.txt").write_text("hello world")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = read_file("data.txt")
assert result == "hello world"
def test_read_nonexistent_file(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = read_file("ghost.py")
assert "ERROR" in result
def test_read_outside_workspace_blocked(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = read_file("../secret.py")
assert "ERROR" in result
def test_read_unsupported_extension(self, tmp_path):
(tmp_path / "data.csv").write_text("a,b")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = read_file("data.csv")
assert "ERROR" in result
class TestListFiles:
def test_empty_workspace(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = list_files()
assert "No files" in result
def test_lists_existing_files(self, tmp_path):
(tmp_path / "a.py").touch()
(tmp_path / "b.txt").touch()
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = list_files()
assert "a.py" in result
assert "b.txt" in result
def test_glob_filter(self, tmp_path):
(tmp_path / "a.py").touch()
(tmp_path / "b.txt").touch()
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = list_files("*.py")
assert "a.py" in result
assert "b.txt" not in result
class TestGrepSearch:
def test_finds_pattern(self, tmp_path):
(tmp_path / "code.py").write_text("def hello():\n pass\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = grep_search("def hello")
assert "code.py" in result
assert "def hello" in result
def test_no_match_returns_message(self, tmp_path):
(tmp_path / "code.py").write_text("x = 1\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = grep_search("nonexistent_pattern")
assert "No matches" in result
def test_returns_line_number(self, tmp_path):
(tmp_path / "code.py").write_text("x = 1\ndef foo():\n pass\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = grep_search("def foo")
assert ":2:" in result
class TestValidatePython:
def test_valid_syntax(self, tmp_path):
(tmp_path / "good.py").write_text("def f(x):\n return x * 2\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = validate_python("good.py")
assert result == "OK: syntax is valid."
def test_invalid_syntax(self, tmp_path):
(tmp_path / "bad.py").write_text("def f(x)\n return x\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = validate_python("bad.py")
assert "SYNTAX ERROR" in result
def test_file_not_found(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = validate_python("ghost.py")
assert "ERROR" in result
class TestRunPython:
def test_successful_execution(self, tmp_path):
(tmp_path / "hello.py").write_text("print('hello world')\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = run_python("hello.py")
assert "hello world" in result
assert "Exit code: 0" in result
def test_runtime_error_captured(self, tmp_path):
(tmp_path / "bad.py").write_text("raise ValueError('oops')\n")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = run_python("bad.py")
assert "ValueError" in result
assert "Exit code: 1" in result
def test_file_not_found(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
result = run_python("ghost.py")
assert "ERROR" in result
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# TestCodingAgentInit # TestCodingAgentInit
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestCodingAgentInit: class TestCodingAgentInit:
"""Tests for CodingAgent.__init__ and start_task(): state is clean before and after task setup."""
def test_initial_state_is_clean(self): def test_initial_state_is_clean(self):
agent = CodingAgent() agent = CodingAgent()
@ -313,11 +153,6 @@ class TestCodingAgentInit:
assert agent.is_done is False assert agent.is_done is False
assert agent.iteration == 0 assert agent.iteration == 0
def test_api_url_is_set(self):
agent = CodingAgent()
assert agent.api_url.startswith("http://")
assert "/v1/chat/completions" in agent.api_url
def test_start_task_sets_messages(self): def test_start_task_sets_messages(self):
agent = CodingAgent() agent = CodingAgent()
agent.start_task("Write fibonacci.py") agent.start_task("Write fibonacci.py")
@ -348,6 +183,7 @@ class TestCodingAgentInit:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestProposeNextAction: class TestProposeNextAction:
"""Tests for propose_next_action(): API is mocked so no real HTTP calls are made."""
@pytest.fixture @pytest.fixture
def agent(self): def agent(self):
@ -359,55 +195,72 @@ class TestProposeNextAction:
payload = _agent_action_json(tool, thought, **args) payload = _agent_action_json(tool, thought, **args)
agent._call_api = MagicMock(return_value=payload) agent._call_api = MagicMock(return_value=payload)
def test_returns_dict_with_required_keys(self, agent): @pytest.mark.asyncio
async def test_returns_dict_with_required_keys(self, agent):
self._mock_api(agent) self._mock_api(agent)
action = agent.propose_next_action() action = await agent.propose_next_action()
assert "thought" in action assert "thought" in action
assert "tool" in action assert "tool" in action
assert "arguments" in action assert "arguments" in action
def test_increments_iteration(self, agent): @pytest.mark.asyncio
async def test_increments_iteration(self, agent):
self._mock_api(agent) self._mock_api(agent)
agent.propose_next_action() await agent.propose_next_action()
assert agent.iteration == 1 assert agent.iteration == 1
def test_stores_pending_action(self, agent): @pytest.mark.asyncio
async def test_stores_pending_action(self, agent):
self._mock_api(agent) self._mock_api(agent)
agent.propose_next_action() await agent.propose_next_action()
assert agent.pending_action is not None assert agent.pending_action is not None
def test_returns_correct_tool(self, agent): @pytest.mark.asyncio
async def test_returns_correct_tool(self, agent):
self._mock_api(agent, tool="list_files") self._mock_api(agent, tool="list_files")
action = agent.propose_next_action() action = await agent.propose_next_action()
assert action["tool"] == "list_files" assert action["tool"] == "list_files"
def test_handles_json_parse_error_gracefully(self, agent): @pytest.mark.asyncio
async def test_handles_json_parse_error_gracefully(self, agent):
agent._call_api = MagicMock(return_value="this is not json {{") agent._call_api = MagicMock(return_value="this is not json {{")
action = agent.propose_next_action() action = await agent.propose_next_action()
assert action["tool"] == "done" assert action["tool"] == "done"
def test_handles_api_exception_gracefully(self, agent): @pytest.mark.asyncio
async def test_handles_api_exception_gracefully(self, agent):
agent._call_api = MagicMock(side_effect=Exception("connection refused")) agent._call_api = MagicMock(side_effect=Exception("connection refused"))
action = agent.propose_next_action() action = await agent.propose_next_action()
assert action["tool"] == "done" assert action["tool"] == "done"
def test_strips_code_fences_from_response(self, agent): @pytest.mark.asyncio
async def test_strips_code_fences_from_response(self, agent):
payload = "```json\n" + _agent_action_json("list_files", "thinking") + "\n```" payload = "```json\n" + _agent_action_json("list_files", "thinking") + "\n```"
agent._call_api = MagicMock(return_value=payload) agent._call_api = MagicMock(return_value=payload)
action = agent.propose_next_action() action = await agent.propose_next_action()
assert action["tool"] == "list_files" assert action["tool"] == "list_files"
def test_already_done_returns_done_action(self, agent): @pytest.mark.asyncio
async def test_already_done_returns_done_action(self, agent):
agent.is_done = True agent.is_done = True
action = agent.propose_next_action() action = await agent.propose_next_action()
assert action["tool"] == "done" assert action["tool"] == "done"
@pytest.mark.asyncio
async def test_max_iterations_returns_done_without_api_call(self, agent):
agent.iteration = MAX_ITERATIONS
agent._call_api = MagicMock(side_effect=AssertionError("API must not be called"))
action = await agent.propose_next_action()
assert action["tool"] == "done"
agent._call_api.assert_not_called()
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# TestApprove (mocked API + real tool execution via tmp_path) # TestApprove (mocked API + mocked dispatch_tool)
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestApprove: class TestApprove:
"""Tests for approve(): dispatch_tool is mocked so no filesystem or subprocess side-effects occur."""
@pytest.fixture @pytest.fixture
def agent(self): def agent(self):
@ -416,56 +269,68 @@ class TestApprove:
return a return a
def _set_pending(self, agent, tool: str, **arguments): def _set_pending(self, agent, tool: str, **arguments):
"""Inject a pending_action into the agent as if propose_next_action() had just run.
'raw' holds the original JSON string; 'action' holds the parsed dict."""
raw = _agent_action_json(tool, "thought", **arguments) raw = _agent_action_json(tool, "thought", **arguments)
agent.pending_action = { agent.pending_action = {
"raw": raw, "raw": raw,
"action": {"thought": "thought", "tool": tool, "arguments": arguments}, "action": {"thought": "thought", "tool": tool, "arguments": arguments},
} }
def test_approve_without_pending_raises(self, agent): @pytest.mark.asyncio
async def test_approve_without_pending_raises(self, agent):
with pytest.raises(Exception): with pytest.raises(Exception):
agent.approve() await agent.approve()
def test_approve_done_sets_is_done(self, agent): @pytest.mark.asyncio
async def test_approve_done_sets_is_done(self, agent):
self._set_pending(agent, "done", summary="all done") self._set_pending(agent, "done", summary="all done")
result = agent.approve() result = await agent.approve()
assert result["is_done"] is True assert result["is_done"] is True
assert agent.is_done is True assert agent.is_done is True
def test_approve_done_returns_summary(self, agent): @pytest.mark.asyncio
async def test_approve_done_returns_summary(self, agent):
self._set_pending(agent, "done", summary="finished successfully") self._set_pending(agent, "done", summary="finished successfully")
result = agent.approve() result = await agent.approve()
assert "finished successfully" in result["result"] assert "finished successfully" in result["result"]
def test_approve_clears_pending_action(self, agent): @pytest.mark.asyncio
async def test_approve_clears_pending_action(self, agent):
self._set_pending(agent, "done", summary="x") self._set_pending(agent, "done", summary="x")
agent.approve() await agent.approve()
assert agent.pending_action is None assert agent.pending_action is None
def test_approve_appends_assistant_message(self, agent): @pytest.mark.asyncio
async def test_approve_appends_assistant_message(self, agent):
self._set_pending(agent, "done", summary="x") self._set_pending(agent, "done", summary="x")
before = len(agent.messages) before = len(agent.messages)
agent.approve() await agent.approve()
assert len(agent.messages) > before assert len(agent.messages) > before
def test_approve_tool_result_appended_to_messages(self, agent, tmp_path): @pytest.mark.asyncio
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path): async def test_approve_tool_result_appended_to_messages(self, agent):
with patch("backend.agent.coding_agent.dispatch_tool", return_value="file list"):
self._set_pending(agent, "list_files") self._set_pending(agent, "list_files")
agent.approve() await agent.approve()
tool_results = [m for m in agent.messages if "tool_result" in m["content"]] tool_results = [m for m in agent.messages if "tool_result" in m["content"]]
assert len(tool_results) == 1 assert len(tool_results) == 1
def test_approve_error_result_adds_replan_tag(self, agent, tmp_path): @pytest.mark.asyncio
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path): async def test_approve_error_result_adds_replan_tag(self, agent):
# When a tool returns an error, approve() adds a "replan" tag to the message
# so the LLM knows the last action failed and must choose a different approach.
with patch("backend.agent.coding_agent.dispatch_tool", return_value="ERROR: file not found"):
self._set_pending(agent, "read_file", path="nonexistent.py") self._set_pending(agent, "read_file", path="nonexistent.py")
agent.approve() await agent.approve()
last_msg = agent.messages[-1]["content"] last_msg = agent.messages[-1]["content"]
assert "replan" in last_msg assert "replan" in last_msg
def test_approve_returns_tool_name_in_result(self, agent, tmp_path): @pytest.mark.asyncio
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path): async def test_approve_returns_tool_name_in_result(self, agent):
with patch("backend.agent.coding_agent.dispatch_tool", return_value="(empty)"):
self._set_pending(agent, "list_files") self._set_pending(agent, "list_files")
result = agent.approve() result = await agent.approve()
assert result["tool"] == "list_files" assert result["tool"] == "list_files"
assert result["is_done"] is False assert result["is_done"] is False
@ -475,6 +340,7 @@ class TestApprove:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestReject: class TestReject:
"""Tests for reject(): user feedback is injected into the history and the pending action is discarded."""
@pytest.fixture @pytest.fixture
def agent(self): def agent(self):
@ -508,66 +374,12 @@ class TestReject:
def test_reject_without_pending_does_not_crash(self, agent): def test_reject_without_pending_does_not_crash(self, agent):
agent.pending_action = None agent.pending_action = None
agent.reject("no pending action") # should not raise agent.reject("no pending action")
def test_reject_does_not_execute_tool(self, agent, tmp_path): def test_reject_does_not_execute_tool(self, agent, tmp_path):
self._set_pending(agent, "write_file") self._set_pending(agent, "write_file")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path): with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
agent.reject("Do not write anything") agent.reject("Do not write anything")
assert not list(tmp_path.glob("*")) # no files created assert not list(tmp_path.glob("*"))
# ═════════════════════════════════════════════════════════════════════════════
# TestFullLoop (integration real API, skipped if unreachable)
# ═════════════════════════════════════════════════════════════════════════════
class TestFullLoop:
"""End-to-end test: agent runs a real task against the live API.
Skipped automatically if the API is not reachable.
"""
MAX_STEPS = 15 # safety limit for the test loop
def _run_until_done(self, agent) -> list:
"""Drive the agent loop until done or MAX_STEPS reached."""
steps = []
for _ in range(self.MAX_STEPS):
action = agent.propose_next_action()
result = agent.approve()
steps.append(result)
if result["is_done"]:
break
return steps
def test_agent_completes_hello_world_task(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
agent = CodingAgent()
try:
agent.start_task(
"Write a Python file called hello.py that prints 'Hello World'. "
"Validate it and run it."
)
steps = self._run_until_done(agent)
except Exception as e:
pytest.skip(f"API not reachable: {e}")
assert agent.is_done, "Agent did not reach done state"
tools_used = [s["tool"] for s in steps]
assert "write_file" in tools_used
assert "done" in tools_used
def test_agent_creates_file_on_disk(self, tmp_path):
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
agent = CodingAgent()
try:
agent.start_task("Write a file called output.txt containing the text 'test passed'.")
self._run_until_done(agent)
except Exception as e:
pytest.skip(f"API not reachable: {e}")
py_files = list(tmp_path.glob("*.txt")) + list(tmp_path.glob("*.py"))
assert len(py_files) > 0, "Agent did not create any file"
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

109
tests/test_debug_logger.py Normal file
View File

@ -0,0 +1,109 @@
"""Tests for DebugLogger (backend/managers/debug_logger.py).
DebugLogger is a classmethod-based utility. Its _error_log class variable
persists across tests, so every test that modifies it must call
DebugLogger.clear_errors() in teardown (handled by the autouse fixture).
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.managers.debug_logger import DebugLogger
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def clear_error_log():
"""Reset the shared _error_log class variable before and after each test."""
DebugLogger.clear_errors()
yield
DebugLogger.clear_errors()
# ── log_error() ───────────────────────────────────────────────────────────────
class TestLogError:
"""Tests for log_error(): appends the message to the in-memory error list."""
def test_log_error_appends_to_error_log(self):
DebugLogger.log_error("something broke")
assert "something broke" in DebugLogger.get_errors()
def test_log_error_multiple_messages_all_stored(self):
DebugLogger.log_error("first error")
DebugLogger.log_error("second error")
errors = DebugLogger.get_errors()
assert "first error" in errors
assert "second error" in errors
def test_log_error_preserves_order(self):
DebugLogger.log_error("alpha")
DebugLogger.log_error("beta")
errors = DebugLogger.get_errors()
assert errors.index("alpha") < errors.index("beta")
# ── get_errors() ──────────────────────────────────────────────────────────────
class TestGetErrors:
"""Tests for get_errors(): returns the current in-memory error list."""
def test_get_errors_empty_initially(self):
assert DebugLogger.get_errors() == []
def test_get_errors_reflects_logged_errors(self):
DebugLogger.log_error("boom")
assert len(DebugLogger.get_errors()) == 1
# ── clear_errors() ────────────────────────────────────────────────────────────
class TestClearErrors:
"""Tests for clear_errors(): wipes the in-memory error list."""
def test_clear_errors_empties_list(self):
DebugLogger.log_error("will be cleared")
DebugLogger.clear_errors()
assert DebugLogger.get_errors() == []
def test_clear_errors_on_empty_list_does_not_raise(self):
DebugLogger.clear_errors() # already empty from autouse fixture
# ── format_debug_output() ─────────────────────────────────────────────────────
class TestFormatDebugOutput:
"""Tests for format_debug_output(): renders return_code, stdout, and stderr."""
def test_contains_execution_result_header(self):
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
assert "=== Execution Result ===" in result
def test_exit_code_zero_appears_in_output(self):
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
assert "Exit Code: 0" in result
def test_nonzero_exit_code_appears_in_output(self):
result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": ""})
assert "Exit Code: 1" in result
def test_stdout_included_when_present(self):
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "Hello", "stderr": ""})
assert "Hello" in result
def test_stderr_included_when_present(self):
result = DebugLogger.format_debug_output({"return_code": 1, "stdout": "", "stderr": "NameError"})
assert "NameError" in result
def test_empty_stdout_shows_none_placeholder(self):
result = DebugLogger.format_debug_output({"return_code": 0, "stdout": "", "stderr": ""})
assert "(none)" in result
def test_missing_keys_do_not_raise(self):
# format_debug_output uses .get() so absent keys fall back to defaults.
result = DebugLogger.format_debug_output({})
assert isinstance(result, str)

View File

@ -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 (110)
# =========================================================
# ---------------------------------------------------------
# 1. Python-Datei wird korrekt ausgeführt
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_python_file_success(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print('hello')")
mock_run.return_value = Mock(
stdout="hello\n",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0
assert "hello" in result["stdout"]
# ---------------------------------------------------------
# 2. Python-Datei mit Fehler
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_python_file_error(mock_run, engine, tmp_path):
file = tmp_path / "broken.py"
file.write_text("1/0")
mock_run.return_value = Mock(
stdout="",
stderr="ZeroDivisionError",
returncode=1
)
result = engine.run_code(file)
assert result["rc"] == 1
assert "ZeroDivisionError" in result["stderr"]
# ---------------------------------------------------------
# 4. Unsupported File Type
# ---------------------------------------------------------
def test_run_unsupported_file(engine, tmp_path):
file = tmp_path / "test.js"
file.write_text("console.log('x')")
result = engine.run_code(file)
assert result["rc"] == 1
assert "Unsupported file type" in result["stderr"]
# ---------------------------------------------------------
# 5. Timeout wird behandelt
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_timeout(mock_run, engine, tmp_path):
file = tmp_path / "slow.py"
file.write_text("while True: pass")
mock_run.side_effect = subprocess.TimeoutExpired(
cmd=["py"],
timeout=30
)
result = engine.run_code(file)
assert result["rc"] == -1
assert "Timed out" in result["stderr"]
# ---------------------------------------------------------
# 6. Fehlender Interpreter
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_missing_interpreter(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.side_effect = FileNotFoundError("py not found")
result = engine.run_code(file)
assert result["rc"] == -1
assert "py not found" in result["stderr"]
# ---------------------------------------------------------
# 7. Allgemeine Exception
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_general_exception(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.side_effect = RuntimeError("unexpected")
result = engine.run_code(file)
assert result["rc"] == -1
assert "unexpected" in result["stderr"]
# ---------------------------------------------------------
# 8. subprocess.run wird mit cwd ausgeführt
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_uses_correct_cwd(mock_run, engine, tmp_path):
folder = tmp_path / "project"
folder.mkdir()
file = folder / "main.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
_, kwargs = mock_run.call_args
assert kwargs["cwd"] == folder.resolve()
# ---------------------------------------------------------
# 9. subprocess.run nutzt capture_output
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_capture_output_enabled(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
_, kwargs = mock_run.call_args
assert kwargs["capture_output"] is True
# ---------------------------------------------------------
# 10. subprocess.run nutzt text=True
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_text_mode_enabled(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
_, kwargs = mock_run.call_args
assert kwargs["text"] is True
# =========================================================
# EDGE CASE TESTS (1120)
# =========================================================
# ---------------------------------------------------------
# 11. Unicode Output
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_unicode_output(mock_run, engine, tmp_path):
file = tmp_path / "unicode.py"
file.write_text("print('🔥 Grüezi 世界')", encoding="utf-8")
mock_run.return_value = Mock(
stdout="🔥 Grüezi 世界\n",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert "🔥 Grüezi 世界" in result["stdout"]
# ---------------------------------------------------------
# 12. Leerer stdout/stderr
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_empty_output(mock_run, engine, tmp_path):
file = tmp_path / "empty.py"
file.write_text("x = 1")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["stdout"] == ""
assert result["stderr"] == ""
# ---------------------------------------------------------
# 13. Sehr langer stdout
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_large_output(mock_run, engine, tmp_path):
file = tmp_path / "large.py"
file.write_text("print('A')")
mock_run.return_value = Mock(
stdout="A" * 100000,
stderr="",
returncode=0
)
result = engine.run_code(file)
assert len(result["stdout"]) == 100000
# ---------------------------------------------------------
# 14. Dateiname mit Leerzeichen
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_filename_with_spaces(mock_run, engine, tmp_path):
file = tmp_path / "my script.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="ok",
stderr="",
returncode=0
)
engine.run_code(file)
args, _ = mock_run.call_args
assert "my script.py" in args[0]
# ---------------------------------------------------------
# 15. Dateiname mit Unicode
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_unicode_filename(mock_run, engine, tmp_path):
file = tmp_path / "🔥_test.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="ok",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0
# ---------------------------------------------------------
# 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

View File

@ -0,0 +1,220 @@
"""Tests for FileManager (backend/managers/file_manager.py)."""
import sys
from pathlib import Path
import pytest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.managers.file_manager import FileManager
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_streamlit():
"""Suppress all st.error / st.warning calls — they require a running Streamlit app."""
with patch("backend.managers.file_manager.st"):
yield
@pytest.fixture
def fm(tmp_path):
"""Return a FileManager whose workspace is an isolated pytest temp directory."""
return FileManager(base_path=tmp_path)
# ── create_folder ─────────────────────────────────────────────────────────────
class TestCreateFolder:
"""Tests for create_folder(): name validation, path-traversal protection, and nested creation."""
def test_creates_folder_successfully(self, fm, tmp_path):
result = fm.create_folder("", "myfolder")
assert result is True
assert (tmp_path / "myfolder").is_dir()
def test_empty_name_returns_false(self, fm):
assert fm.create_folder("", "") is False
def test_slash_in_name_returns_false(self, fm):
assert fm.create_folder("", "a/b") is False
def test_backslash_in_name_returns_false(self, fm):
assert fm.create_folder("", "a\\b") is False
def test_duplicate_folder_returns_false(self, fm, tmp_path):
(tmp_path / "existing").mkdir()
assert fm.create_folder("", "existing") is False
def test_path_traversal_returns_false(self, fm):
assert fm.create_folder("../../", "evil") is False
def test_nested_folder_created_inside_base(self, fm, tmp_path):
(tmp_path / "sub").mkdir()
result = fm.create_folder("sub", "child")
assert result is True
assert (tmp_path / "sub" / "child").is_dir()
# ── create_file ───────────────────────────────────────────────────────────────
class TestCreateFile:
"""Tests for create_file(): name validation, auto .txt extension, and path-traversal protection."""
def test_creates_file_successfully(self, fm, tmp_path):
result = fm.create_file("", "test.py")
assert result is True
assert (tmp_path / "test.py").is_file()
def test_empty_name_returns_false(self, fm):
assert fm.create_file("", "") is False
def test_whitespace_only_name_returns_false(self, fm):
assert fm.create_file("", " ") is False
def test_no_extension_defaults_to_txt(self, fm, tmp_path):
fm.create_file("", "notes")
assert (tmp_path / "notes.txt").is_file()
def test_duplicate_file_returns_false(self, fm, tmp_path):
(tmp_path / "existing.py").touch()
assert fm.create_file("", "existing.py") is False
def test_path_traversal_returns_false(self, fm):
assert fm.create_file("../../", "evil.py") is False
# ── read_file ─────────────────────────────────────────────────────────────────
class TestReadFile:
"""Tests for read_file(): accepts an absolute Path, validates workspace boundary, returns content or ""."""
def test_reads_file_content(self, fm, tmp_path):
f = tmp_path / "hello.py"
f.write_text("print('hello')")
assert fm.read_file(f) == "print('hello')"
def test_nonexistent_file_returns_empty_string(self, fm, tmp_path):
assert fm.read_file(tmp_path / "ghost.py") == ""
def test_file_outside_workspace_returns_empty_string(self, fm, tmp_path):
outside = tmp_path.parent / "outside.py"
outside.write_text("secret")
assert fm.read_file(outside) == ""
# ── save_file ─────────────────────────────────────────────────────────────────
class TestSaveFile:
"""Tests for save_file(): accepts an absolute path string, overwrites content, and blocks path traversal."""
def test_saves_content_to_file(self, fm, tmp_path):
f = tmp_path / "output.py"
f.touch()
result = fm.save_file(str(f), "x = 1")
assert result is True
assert f.read_text() == "x = 1"
def test_overwrites_existing_content(self, fm, tmp_path):
f = tmp_path / "script.py"
f.write_text("old content")
fm.save_file(str(f), "new content")
assert f.read_text() == "new content"
def test_path_traversal_returns_false(self, fm, tmp_path):
outside = str(tmp_path.parent / "evil.py")
assert fm.save_file(outside, "bad") is False
# ── rename_file ───────────────────────────────────────────────────────────────
class TestRenameFile:
"""Tests for rename_file(): renames by stem only — the original extension is always preserved."""
def test_renames_file_successfully(self, fm, tmp_path):
(tmp_path / "old.py").touch()
result = fm.rename_file("old.py", "new")
assert result is True
assert (tmp_path / "new.py").exists()
assert not (tmp_path / "old.py").exists()
def test_preserves_original_extension(self, fm, tmp_path):
# Even if the caller passes a different extension (.txt), rename_file
# silently replaces it with the original (.py) to prevent accidental type changes.
(tmp_path / "script.py").touch()
fm.rename_file("script.py", "renamed.txt")
assert (tmp_path / "renamed.py").exists()
def test_empty_new_name_returns_false(self, fm, tmp_path):
(tmp_path / "file.py").touch()
assert fm.rename_file("file.py", "") is False
def test_nonexistent_file_returns_false(self, fm):
assert fm.rename_file("ghost.py", "new_name") is False
def test_path_traversal_returns_false(self, fm):
assert fm.rename_file("../../evil.py", "new_name") is False
# ── delete_file ───────────────────────────────────────────────────────────────
class TestDeleteFile:
"""Tests for delete_file(): accepts a relative path, validates workspace boundary, removes the file."""
def test_deletes_file_successfully(self, fm, tmp_path):
f = tmp_path / "todelete.py"
f.touch()
result = fm.delete_file("todelete.py")
assert result is True
assert not f.exists()
def test_nonexistent_file_returns_false(self, fm):
assert fm.delete_file("ghost.py") is False
def test_path_traversal_returns_false(self, fm):
assert fm.delete_file("../../evil.py") is False
# ── delete_folder ─────────────────────────────────────────────────────────────
class TestDeleteFolder:
"""Tests for delete_folder(): recursively removes a folder and all its contents."""
def test_deletes_folder_and_contents(self, fm, tmp_path):
sub = tmp_path / "todelete"
sub.mkdir()
(sub / "file.py").touch()
result = fm.delete_folder("todelete")
assert result is True
assert not sub.exists()
def test_path_traversal_returns_false(self, fm):
assert fm.delete_folder("../../") is False
# ── get_file_tree ─────────────────────────────────────────────────────────────
class TestGetFileTree:
"""Tests for get_file_tree(): returns a nested dict where files map to None and dirs map to dicts."""
def test_empty_workspace_returns_empty_dict(self, fm):
assert fm.get_file_tree() == {}
def test_file_is_represented_as_none(self, fm, tmp_path):
(tmp_path / "main.py").touch()
tree = fm.get_file_tree()
assert tree["main.py"] is None
def test_directory_is_represented_as_dict(self, fm, tmp_path):
(tmp_path / "src").mkdir()
tree = fm.get_file_tree()
assert isinstance(tree["src"], dict)
def test_nested_structure_is_correct(self, fm, tmp_path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").touch()
tree = fm.get_file_tree()
assert tree["src"]["app.py"] is None

View File

@ -1,6 +0,0 @@
"""Test cases for the main module."""
def test_placeholder():
"""Placeholder test."""
assert True

View File

@ -0,0 +1,265 @@
from backend.agent.servers import mcp_server_code_execution as server
# =========================================================
# BASIC TESTS (110)
# =========================================================
# ---------------------------------------------------------
# 1. Erlaubter Code besteht Safety Check
# ---------------------------------------------------------
def test_check_code_safety_valid():
code = "print('hello')"
result = server.check_code_safety(code)
assert result is None
# ---------------------------------------------------------
# 2. Blockierter Import wird erkannt
# ---------------------------------------------------------
def test_check_code_safety_blocked_import():
code = "import os"
result = server.check_code_safety(code)
assert "Blocked import" in result
# ---------------------------------------------------------
# 3. Blockierter Builtin wird erkannt
# ---------------------------------------------------------
def test_check_code_safety_blocked_builtin():
code = "eval('2+2')"
result = server.check_code_safety(code)
assert "Blocked builtin" in result
# ---------------------------------------------------------
# 4. analyse_structure erkennt Funktionen
# ---------------------------------------------------------
def test_analyse_structure_function():
code = """
def hello(name):
return name
"""
result = server.analyse_structure(code)
assert "def hello(name)" in result
# ---------------------------------------------------------
# 5. analyse_structure erkennt Klassen
# ---------------------------------------------------------
def test_analyse_structure_class():
code = """
class User:
def login(self):
pass
"""
result = server.analyse_structure(code)
assert "class User" in result
assert "method: login" in result
# ---------------------------------------------------------
# 6. lint_code erkennt Undefined Variable
# ---------------------------------------------------------
def test_lint_code_undefined_variable():
code = "print(x)"
result = server.lint_code(code)
assert "undefined name 'x'" in result.lower()
# ---------------------------------------------------------
# 7. lint_code erkennt sauberen Code
# ---------------------------------------------------------
def test_lint_code_clean():
code = """
x = 1
print(x)
"""
result = server.lint_code(code)
assert "No issues found" in result
# ---------------------------------------------------------
# 8. python_code_validation validiert sicheren Code
# ---------------------------------------------------------
def test_python_code_validation_safe():
code = "print('safe')"
result = server.python_code_validation(code)
assert "can be executed" in result
# ---------------------------------------------------------
# 9. run_python_sandboxed führt Code aus
# ---------------------------------------------------------
def test_run_python_sandboxed_success():
code = "print('hello world')"
result = server.run_python_sandboxed(code)
assert "hello world" in result
# ---------------------------------------------------------
# 10. run_python_sandboxed ohne Output
# ---------------------------------------------------------
def test_run_python_sandboxed_no_output():
code = "x = 5"
result = server.run_python_sandboxed(code)
assert "no output" in result.lower()
# =========================================================
# EDGE CASE TESTS (1120)
# =========================================================
# ---------------------------------------------------------
# 11. Syntaxfehler erkennen
# ---------------------------------------------------------
def test_check_code_safety_syntax_error():
code = "def broken("
result = server.check_code_safety(code)
assert "SyntaxError" in result
# ---------------------------------------------------------
# 12. ImportFrom blockieren
# ---------------------------------------------------------
def test_check_code_safety_import_from():
code = "from os import path"
result = server.check_code_safety(code)
assert "Blocked import" in result
# ---------------------------------------------------------
# 13. Gefährliche Path-Sequenzen erkennen
# ---------------------------------------------------------
def test_check_code_safety_path_traversal():
code = "print('../etc/passwd')"
result = server.check_code_safety(code)
assert "Suspect path sequence" in result
# ---------------------------------------------------------
# 14. __import__ erkennen
# ---------------------------------------------------------
def test_check_code_safety_import_escape():
code = "__import__('os')"
result = server.check_code_safety(code)
assert "Blocked" in result
# ---------------------------------------------------------
# 15. subprocess Escape erkennen
# ---------------------------------------------------------
def test_check_code_safety_subprocess_escape():
code = "subprocess.run(['ls'])"
result = server.check_code_safety(code)
assert "Suspect path sequence" in result
# ---------------------------------------------------------
# 16. Endlosschleife Timeout
# ---------------------------------------------------------
def test_run_python_sandboxed_timeout():
code = """
while True:
pass
"""
result = server.run_python_sandboxed(code)
assert "time limit" in result.lower()
# ---------------------------------------------------------
# 17. Sehr großer Output wird gekürzt
# ---------------------------------------------------------
def test_run_python_sandboxed_large_output():
code = "print('A' * 10000)"
result = server.run_python_sandboxed(code)
assert "truncated" in result.lower()
# ---------------------------------------------------------
# 18. Unicode Output funktioniert
# ---------------------------------------------------------
def test_run_python_sandboxed_unicode():
code = "print('🔥 Grüezi 世界')"
result = server.run_python_sandboxed(code)
assert "🔥 Grüezi 世界" in result
# ---------------------------------------------------------
# 19. analyse_structure bei leerem Code
# ---------------------------------------------------------
def test_analyse_structure_empty():
code = ""
result = server.analyse_structure(code)
assert "No top-level imports" in result
# ---------------------------------------------------------
# 20. Sandbox behandelt Runtime Errors
# ---------------------------------------------------------
def test_run_python_sandboxed_runtime_error():
code = "1 / 0"
result = server.run_python_sandboxed(code)
assert "ZeroDivisionError" in result

View File

@ -0,0 +1,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 (110)
# =========================================================
# ---------------------------------------------------------
# 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 (1120)
# =========================================================
# ---------------------------------------------------------
# 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

View File

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

View 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")

View File

@ -5,30 +5,17 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS
class TestSystemPrompterBasePrompt: class TestSystemPrompterBasePrompt:
"""Tests for generate_prompt() without file context.""" """Tests for generate_prompt() without file context."""
def test_returns_non_empty_string(self):
prompt = SystemPrompter.generate_prompt()
assert isinstance(prompt, str)
assert len(prompt) > 0
def test_describes_code_assistant(self):
prompt = SystemPrompter.generate_prompt()
assert "code assistant" in prompt.lower()
def test_contains_no_file_xml_tag(self): def test_contains_no_file_xml_tag(self):
prompt = SystemPrompter.generate_prompt() prompt = SystemPrompter.generate_prompt()
assert "<file" not in prompt assert "<file" not in prompt
assert "<code>" not in prompt assert "<code>" not in prompt
def test_none_equals_no_argument(self):
assert SystemPrompter.generate_prompt(file_context=None) == SystemPrompter.generate_prompt()
class TestSystemPrompterWithFileContext: class TestSystemPrompterWithFileContext:
"""Tests for generate_prompt() with file_context provided.""" """Tests for generate_prompt() with file_context provided."""
@ -49,15 +36,6 @@ class TestSystemPrompterWithFileContext:
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": "pass"}) prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": "pass"})
assert "<code>" in prompt assert "<code>" in prompt
def test_with_context_is_longer_than_base(self):
base = SystemPrompter.generate_prompt()
with_ctx = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": "x=1"})
assert len(with_ctx) > len(base)
def test_missing_name_key_uses_unknown(self):
prompt = SystemPrompter.generate_prompt(file_context={"content": "some code"})
assert "unknown" in prompt
def test_missing_content_key_does_not_raise(self): def test_missing_content_key_does_not_raise(self):
prompt = SystemPrompter.generate_prompt(file_context={"name": "empty.py"}) prompt = SystemPrompter.generate_prompt(file_context={"name": "empty.py"})
assert "empty.py" in prompt assert "empty.py" in prompt
@ -77,12 +55,19 @@ class TestSystemPrompterTruncation:
assert "[truncated]" not in prompt assert "[truncated]" not in prompt
assert content in prompt assert content in prompt
def test_file_exactly_at_limit_is_not_truncated(self):
content = "x" * MAX_FILE_CHARS
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
assert "[truncated]" not in prompt
def test_file_one_over_limit_is_truncated(self): def test_file_one_over_limit_is_truncated(self):
content = "x" * (MAX_FILE_CHARS + 1) content = "x" * (MAX_FILE_CHARS + 1)
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content}) prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
assert "[truncated]" in prompt assert "[truncated]" in prompt
class TestSystemPrompterSpecialCharacters:
"""Tests that XML special characters in file content are handled without breaking the prompt."""
def test_xml_tags_in_content_are_preserved_literally(self):
# User code often contains HTML or XML. The prompt builder must embed it
# verbatim — escaping or stripping tags would corrupt the file content.
prompt = SystemPrompter.generate_prompt(
file_context={"name": "template.html", "content": "<div>hello</div>"}
)
assert "<div>hello</div>" in prompt