Logger implementation, MCP commented out since logging is blocking server

This commit is contained in:
Irina Rueegg 2026-05-25 11:02:44 +02:00
parent b98b74dad1
commit 59ff3ead93
21 changed files with 1246 additions and 164 deletions

3
.gitignore vendored
View File

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

View File

@ -18,18 +18,20 @@ import os
import re
from pathlib import Path
import asyncio
import pprint
import requests
from dotenv import load_dotenv
#from mcp_server_adapter import MCPToolAdapter # Import from current directory for easier testing without package structure
from backend.agent.mcp_server_adapter import MCPToolAdapter
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# ── mcp server initialization ────────────────────────────────────────────────────────────────
adapter = MCPToolAdapter()
print("MCPToolAdapter created. Listing all tools from servers...")
logger.info("MCPToolAdapter created. Listing all tools from servers...")
asyncio.run(adapter.initialize_all_servers())
print("listed tools from all servers")
logger.info("Listed tools from all servers")
load_dotenv()
@ -52,11 +54,10 @@ def build_all_tool_description() -> str:
"""Get relevant tools from the MCP servers based on the query."""
all_tools = adapter.get_all_tools()
print(f"Building tool description for {len(all_tools)} tools.")
logger.info("Building tool description for %s tools.", str(len(all_tools)))
descriptions = []
for tool in all_tools:
pprint.pprint(f"{tool}")
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
return "\n".join(descriptions)
@ -70,20 +71,21 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str:
return f"DONE: {summary}"
try:
print(f"Trying to call tool '{tool_name}' with arguments: {arguments}")
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name)
result = await adapter.call_tool(tool_name, arguments)
print(f"Raw result from tool '{tool_name}': {result}")
logger.info(f"Result from tool '%s' recieved", tool_name)
if result.isError:
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)
except Exception as e:
logger.exception(f"Error calling tool '%s' with argument: %s", tool_name, arguments)
return f"Error calling tool '{tool_name}': {e}"
# ═════════════════════════════════════════════════════════════════════════════
@ -157,6 +159,7 @@ Example:
def truncate_result(result: str) -> str:
"""Truncate a tool result that exceeds MAX_RESULT_LENGTH."""
logger.info("Result has been truncated")
if len(result) <= MAX_RESULT_LENGTH:
return result
half = MAX_RESULT_LENGTH // 2
@ -171,6 +174,8 @@ def trim_messages(messages: list) -> list:
"""Drop old messages when history exceeds MAX_HISTORY_CHARS.
Always keeps the system prompt (index 0) and original task (index 1).
"""
logger.info("Message is being trimmed")
total = sum(len(m["content"]) for m in messages)
if total <= MAX_HISTORY_CHARS:
return messages
@ -331,7 +336,6 @@ class CodingAgent:
#async def _call_api(self, messages: list) -> str:
def _call_api(self, messages: list) -> str:
"""Make a raw API call and return the response content string."""
headers = {"Content-Type": "application/json"}
@ -345,16 +349,31 @@ class CodingAgent:
"max_tokens": 4096,
"stream": False,
}
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
if response.status_code != 200:
raise Exception(f"API Error {response.status_code}: {response.text}")
try:
response = requests.post(
self.api_url,
headers=headers,
json=payload,
timeout=60)
response.raise_for_status()
logger.info("LLM API response requested")
if response.status_code != 200:
logger.error("API Error %s: %s", response.status_code, response.text)
raise Exception(f"API Error {response.status_code}: {response.text}")
except requests.RequestException as exc:
logger.exception("API Error; HTTP-Fehler: %s", exc)
raise Exception(f"HTTP-Fehler: {exc}") from exc
data = response.json()
if "choices" in data and len(data["choices"]) > 0:
logger.info("valid API output, data returned")
return data["choices"][0]["message"]["content"]
logger.error("Invalid API response format")
raise Exception("Invalid API response format")
# ── Public interface ──────────────────────────────────────────────────────
@ -367,6 +386,7 @@ class CodingAgent:
self.pending_action = None
self.is_done = False
self.iteration = 0
logger.info("New ask initialized")
async def propose_next_action(self) -> dict:
"""Ask the LLM what to do next.
@ -393,6 +413,7 @@ class CodingAgent:
raw = _strip_code_fences(raw)
cleaned = extract_json(raw)
action = json.loads(cleaned)
logger.info("Propose next action successfull")
except json.JSONDecodeError:
action = {
"thought": "Could not parse LLM response as JSON.",
@ -400,6 +421,7 @@ class CodingAgent:
"arguments": {"summary": "Stopped: JSON parse error."},
}
raw = json.dumps(action)
logger.critical("Parsing API response into valid JASON failed in Step 'propose_next_action'")
except Exception as e:
action = {
"thought": f"API call failed: {e}",
@ -407,6 +429,7 @@ class CodingAgent:
"arguments": {"summary": f"Stopped: {e}"},
}
raw = json.dumps(action)
logger.critical("API call faliled in Step %s: %s", self.iteration, e)
self.pending_action = {"raw": raw, "action": action}
return action
@ -429,6 +452,8 @@ class CodingAgent:
self.messages.append({"role": "assistant", "content": raw})
self.pending_action = None
logger.info("Messages prepared after approval")
# Handle completion
if tool_name == "done":
self.is_done = True
@ -442,6 +467,7 @@ class CodingAgent:
# Execute the tool
result = await dispatch_tool(tool_name, arguments)
result = truncate_result(result)
logger.info("Tool called and result truncated")
# Wrap the tool output in an XML tag so the LLM can easily find it.
# Append a <replan> tag on errors to force the agent to reconsider
@ -453,6 +479,7 @@ class CodingAgent:
"Re-examine your plan: what went wrong and what should you do differently? "
"State your revised plan in your next thought.</replan>"
)
logger.warning("Error Message in the tool result, replan-feedback will appended")
self.messages.append({"role": "user", "content": feedback})
@ -480,6 +507,7 @@ class CodingAgent:
"address their question accordingly.</replan>"
),
})
logger.info("Follow-up message appended.")
def reject(self, feedback: str) -> None:
"""Reject the pending action and inject user feedback.
@ -506,6 +534,7 @@ class CodingAgent:
),
})
self.pending_action = None
logger.info("Rejection message appended.")
def main():
"""Example of how to use the CodingAgent in a simple loop."""

View File

@ -7,6 +7,9 @@ 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:
def __init__(self, config_path: str = "mcp_server_config.json"):
self.config_path = config_path
@ -17,24 +20,25 @@ class MCPToolAdapter:
"""Lädt die Server-Konfiguration aus der JSON-Datei."""
path = Path(__file__).parent / self.config_path
if not path.exists():
print(f"Config file not found: {path}")
logger.warning("Config file not found: %s", path)
return {}
try:
with open(path, 'r') as f:
return json.load(f)
config_file = json.load(f)
logger.info("MCP-Server config loaded successfully")
return config_file
except json.JSONDecodeError as e:
print(f"Error decoding JSON config: {e}")
logger.critical("Error decoding JSON from server config: %s", e)
return {}
async def initialize_all_servers(self):
"""Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren."""
print("Initializing MCP sessions...")
config = self._load_config()
print(f"Loaded config for servers: {list(config.keys())}")
logger.info("Loaded config for servers: %s", list(config.keys()))
for server_name, params in config.items():
print(f"Testing connection to {server_name}...")
logger.info("Initializing connection to %s", server_name)
self.servers[server_name] = params
server_script = str(Path(__file__).parent / params["args"][0])
@ -51,13 +55,13 @@ class MCPToolAdapter:
try:
# Verbindung aufbauen
async with stdio_client(server_params) as (read_stream, write_stream):
print(f"Connected to {server_name}. Initializing session...")
logger.info("Connected to %s. Initializing session...", server_name)
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
print(f"Session initialized for {server_name}. Requesting tools...")
logger.info("Session initialized for %s. Requesting tools...", server_name)
result = await session.list_tools()
tools = result.tools
print(f"Tools received from {server_name}: {len(tools)} Tools")
logger.info(f"Tools received from %s: %s Tools", server_name, str(len(tools)))
for tool in tools:
t_params = tool.inputSchema.get("properties", {})
@ -80,10 +84,10 @@ class MCPToolAdapter:
"tool_description": t_definition
})
print(f"Registered tool '{tool.name}' from {server_name}.")
logger.info("Registered tool '%s' from %s.", tool.name, server_name)
except Exception as e:
print(f"Failed to initialize {server_name}: {e}")
logger.exception("Failed to initialize %s: %s", server_name, str(e))
def get_all_tools(self) -> List[Dict[str, Any]]:
"""Gibt alle gesammelten Tools zurück."""
@ -95,7 +99,7 @@ class MCPToolAdapter:
tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None)
if not tool_entry:
print(f"Tool '{tool_name}' not found in MCP adapter registry.")
logger.warning("Tool '%s' not found in MCP adapter registry.", tool_name)
return f"Error: Tool '{tool_name}' not found in registry."
server_name = tool_entry["server"]
@ -117,24 +121,15 @@ class MCPToolAdapter:
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.info("Session successfully initialized, calling tool '%s' on server '%s", tool_name, server_name)
result = await session.call_tool(tool_name, arguments)
return result
except Exception as e:
logger.exception("Error calling tool '%s' on server '%s': %s", tool_name, server_name, str(e))
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
return f"Error: Session for server '{server_name}' not active."
async def shutdown_all_sessions(self):
"""Schließt alle offenen Verbindungen sauber."""
for server_name, (transport_gen, session) in self.exit_stack.items():
try:
await session.__aexit__(None, None, None)
await transport_gen.__aexit__(None, None, None)
print(f"Session for {server_name} shut down.")
except Exception as e:
print(f"Error during shutdown of {server_name}: {e}")
def main():
"""Debug Function for Tool-Registry"""
adapter = MCPToolAdapter()
asyncio.run(adapter.initialize_all_servers())
print("All servers initialized. Registered tools:")

View File

@ -5,10 +5,7 @@
"WebSearchServer": {
"command": "py",
"args": ["servers/mcp_server_web_search.py"],
"env": {
"DDGS_API_KEY": "your_ddgs_api_key_here"
}
"args": ["servers/mcp_server_web_search.py"]
},
"CodeExecutionServer": {

View File

@ -8,8 +8,11 @@ from pyflakes.reporter import Reporter # For linting Code
from mcp.server.fastmcp import FastMCP
from pathlib import Path
#from backend.managers.debug_logger import get_logger
#logger = get_logger(__name__)
# ── Configuration ────────────────────────────────────────────────────────────
EXEC_TIMEOUT = 45 # seconds before killing the subprocess
EXEC_TIMEOUT = 15 # seconds before killing the subprocess
MAX_OUTPUT_LENGTH = 3000 # max characters of stdout+stderr to return
# ── Create the MCP server ────────────────────────────────────────────────────
@ -48,8 +51,9 @@ FORBIDDEN_SEQUENCES = ["../", "..\\", "/etc/", "/dev/",
"C:\\Windows", "C:\\Program Files", "C:\\Users",
"compile(", "__import__", "os.", "sys.", "subprocess."]
ALLOWED_PACKAGES = ["pygame", "numpy", "pandas"]
"""
Pre-installed Packages in Sandbox: "pygame", "numpy", "pandas"
"""
# ── Static Analysis ────────────────────────────────────────────────────
def check_code_safety(code: str) -> str | None:
"""
@ -62,10 +66,13 @@ def check_code_safety(code: str) -> str | None:
str or None
Error message if forbidden code found, None if safe.
"""
#logger.info("Checking code safety.")
try:
tree = ast.parse(code)
#logger.info("Code has valid Syntax")
except SyntaxError as e:
#logger.exception("SyntaxError: %s", e)
return f"SyntaxError: {e}"
for node in ast.walk(tree):
@ -73,6 +80,7 @@ def check_code_safety(code: str) -> str | None:
for alias in node.names:
top_level_module = alias.name.split('.')[0]
if top_level_module in BLOCKED_IMPORTS:
#logger.warning("Blocked import '%s'", alias.name)
return (f"Blocked import: Import of '{alias.name}' is not allowed."
f"line {node.lineno}")
@ -80,16 +88,19 @@ def check_code_safety(code: str) -> str | None:
if node.module:
top_level = node.module.split(".")[0]
if top_level in BLOCKED_IMPORTS:
#logger.warning("Blocked import from '%s'", alias.name)
return (f"Blocked import: Import from '{node.module}' is not allowed."
f"(module '{top_level}' is blocked) line {node.lineno}")
elif isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
if node.func.id in BLOCKED_BUILTINS:
#logger.warning("Blocked ubiltin '%s'", node.func.id)
return f"Blocked builtin: Use of builtin '{node.func.id}' is not allowed."
for seq in FORBIDDEN_SEQUENCES:
if seq in code:
#logger.warning("Suspect path sequence '%s' detected.", seq)
return f"Blocked: Suspect path sequence '{seq}' detected."
return None # No violations found
@ -104,11 +115,16 @@ def analyse_structure(code: str) -> str:
Returns:
A summary of the code's structure, including functions, classes, and imports.
"""
#logger.info("Tool analyse_structure is being executed on MCP code execution server")
try:
tree = ast.parse(code)
#logger.info("Code tree parsed successfully")
except SyntaxError as e:
#logger.warning("Syntax Error in provided code. Line %s : %s", e.lineno, e.msg)
return f"Syntax Error: Invalid Python code provided. Line {e.lineno}: {e.msg}"
except Exception as e:
#logger.exception("Error parsing code: %s", str(e))
return f"Error parsing code: {str(e)}"
analysis = {
@ -164,6 +180,7 @@ def analyse_structure(code: str) -> str:
lines.append(f" - def {func['name']}({args_str})")
if not any([analysis["imports"], analysis["classes"], analysis["functions"]]):
#logger.info("Code analysis successfull but no top-level items found")
return "Analysis complete: No top-level imports, classes, or functions found."
return "\n".join(lines)
@ -180,6 +197,8 @@ def lint_code(code: str) -> str:
Returns:
A report of linting issues or a success message if the code is clean.
"""
#logger.info("Tool lint_code is being executed on MCP code execution server")
error_buffer = io.StringIO()
warning_buffer = io.StringIO()
@ -187,7 +206,9 @@ def lint_code(code: str) -> str:
try:
check(code, filename="<agent_code>", reporter=reporter)
#logger.info("Linting successfull")
except Exception as e:
#logger.exception("Critical error during linting: %s", str(e))
return f"Critical error during linting: {str(e)}"
errors = error_buffer.getvalue().strip()
@ -195,6 +216,7 @@ def lint_code(code: str) -> str:
# Ergebnis-String zusammenbauen
if not errors and not warnings:
#logger.info("No issues found")
return "Linting complete: No issues found. The code is syntactically sound."
report = ["--- Linting Report ---"]
@ -209,6 +231,7 @@ def lint_code(code: str) -> str:
report.append("\nAdvice: Please fix these issues before attempting to execute the code.")
#logger.info("There are issues with provided code. Check Report: %s", "\n".join(report))
return "\n".join(report)
@mcp.tool()
@ -228,6 +251,7 @@ def run_python_sandboxed(code: str) -> str:
Returns:
Combined stdout+stderr, or an error message in str format.
"""
#logger.info("Tool run_python_sandboxed is being executed on MCP code execution server")
static_safety = check_code_safety(code)
if static_safety:
@ -249,11 +273,15 @@ def run_python_sandboxed(code: str) -> str:
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()
@ -268,16 +296,22 @@ def python_code_validation(code: str) -> str:
A message indicating the validation result.
And if sandboxed test execution is allowed.
"""
#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"

View File

@ -1,6 +1,9 @@
from pathlib import Path
from mcp.server.fastmcp import FastMCP
#from backend.managers.debug_logger import get_logger
#logger = get_logger(__name__)
# ── Configuration ────────────────────────────────────────────────────────────
project_dir = Path(__file__).resolve().parent.parent.parent.parent
ALLOWED_DIR = project_dir / "workspace"
@ -15,10 +18,12 @@ def _safe_path(requested: str) -> Path:
"""Resolve and validate a path is inside ALLOWED_DIR."""
resolved = (ALLOWED_DIR / requested).resolve()
if not str(resolved).startswith(str(ALLOWED_DIR)):
#logger.warning("Access denied: '%s' resolves outside allowed directory.", str(requested))
raise ValueError(
f"Access denied: '{requested}' resolves outside "
f"the allowed directory '{ALLOWED_DIR}'"
)
#logger.info("Requested path is safe")
return resolved
@ -30,6 +35,8 @@ def list_files() -> str:
Returns a newline-separated list of relative file paths.
"""
#logger.info("Tool list_files is being executed on MCP file search server")
files = sorted(
f.relative_to(ALLOWED_DIR)
for f in ALLOWED_DIR.rglob("*")
@ -50,18 +57,22 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str:
Returns:
A string representing the directory structure, similar to 'tree' command output.
"""
#logger.info("Tool get_file_tree is being executed on MCP file search server")
try:
safe_dir = _safe_path(dir_path)
if not safe_dir:
return f"Error: Invalid directory path '{dir_path}'."
elif not safe_dir.exists():
#logger.warning("Directory '%s' does not exist.", dir_path)
return f"Error: Directory '{dir_path}' does not exist."
elif not safe_dir.is_dir():
#logger.warning("'%s' is not a valid directory", dir_path)
return f"Error: '{dir_path}' is not a valid directory within the allowed path."
except ValueError as e:
#logger.exception("Error while checking directory and its path: %s", str(e))
return f"Error: {e}"
#logger.info("Generating file tree.")
def _tree(dir_path: Path, prefix="") -> str:
entries = sorted([e for e in dir_path.iterdir() if "__pycache__" not in e.parts], key=lambda x: (x.is_file(), x.name))
lines = []
@ -73,7 +84,7 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str:
lines.append(_tree(entry, prefix + extension))
return "\n".join(lines)
return _tree(dir_path)
return _tree(Path(dir_path))
@mcp.tool()
@ -86,6 +97,8 @@ def search_files(query: str) -> str:
Returns:
A formatted string of search results, or a message if no matches found.
"""
#logger.info("Tool search_files is being executed on MCP file search server")
query_lower = query.lower()
results = []
@ -103,11 +116,17 @@ def search_files(query: str) -> str:
if query_lower in line.lower():
snippet = line.strip()[:100]
results.append(f"[content] {rel}:{i} -- {snippet}")
except (UnicodeDecodeError, PermissionError):
except UnicodeDecodeError as e:
#logger.warning("Decode error in file/folder '%s': %s", f, e)
pass
except PermissionError as e:
#logger.warning("Permission error in file/folder: '%s': %s", f, e)
pass
if not results:
#logger.info("No matches found for user query")
return f"No matches found for '{query}'."
#logger.info("Result returned, limited to 30 matches.")
return "\n".join(results[:30]) # limit to 30 matches
@ -121,23 +140,33 @@ def read_file(path: str) -> str:
Returns:
The file content as a string, or an error message if the file cannot be read.
"""
#logger.info("Tool read_file is being executed on MCP file search server")
try:
resolved = _safe_path(path)
except ValueError as e:
return f"Error: {e}"
if not resolved.exists():
#logger.warning("File '%s' does not exist.", path)
return f"Error: File '{path}' does not exist."
if not resolved.is_file():
#logger.warning("'%s' is not a valid file.", path)
return f"Error: '{path}' is not a file."
try:
return resolved.read_text(encoding="utf-8")
text = resolved.read_text(encoding="utf-8")
#logger.info("File read successfully.")
return text
except UnicodeDecodeError:
#logger.warning("'%s' is not a text file (binary content).", path)
return f"Error: '{path}' is not a text file (binary content)."
except PermissionError:
#logger.warning(f"Permission denied when trying to read '%s'.", path)
return f"Error: Permission denied when trying to read '{path}'."
except Exception as e:
#logger.exception("Error reading file '%s': %s", path, e)
return f"Error reading file '{path}': {e}"
@mcp.tool()
@ -152,6 +181,7 @@ def write_new_file(path: str, content: str) -> str:
Returns:
A success or error message.
"""
#logger.info("Tool write_new_file is being executed on MCP file search server")
try:
resolved = _safe_path(path)
@ -159,26 +189,30 @@ def write_new_file(path: str, content: str) -> str:
return f"Error: {e}"
if resolved.exists():
#logger.warning("Requested file path '%s' already exists, overwriting not allowed.", path)
return (f"ERROR: File '{path}' already exists."
f"Overwriting is not allowed with this tool."
f"Use a different path or filename to create a new file.")
if resolved.suffix not in ALLOWED_FILE_TYPES:
#logger.warning("File type not allowed: %s", resolved.suffix)
return f"ERROR: can only write {', '.join(ALLOWED_FILE_TYPES)} types, got '{resolved.suffix}'."
try:
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(content, encoding="utf-8")
#logger.info("File written successfully.")
return f"OK: wrote {len(content)} chars to {path}."
except FileNotFoundError as e:
print(f"FileNotFoundError for {path}: {e}")
#logger.warning("FileNotFoundError for '%s': %s", path, e)
return f"Error: {e}"
except PermissionError as e:
print(f"PermissionError for {path}: {e}")
#logger.warning("PermissionError for '%s': %s", path, e)
return f"Error: {e}"
except Exception as e:
#logger.exception("Error writing file: %s", e)
return f"Error: {e}"
@ -192,23 +226,29 @@ def create_new_directory(path: str) -> str:
Returns:
A success or error message.
"""
#logger.info("Tool create_new_directory is being executed on MCP file search server")
try:
resolved = _safe_path(path)
except ValueError as e:
return f"Error: {e}"
if resolved.exists():
#logger.warning("Requested path '%s' already exists, overwriting not allowed.", path)
return f"Error: File '{path}' already exists."
if resolved.suffix != None and resolved.suffix != "":
#logger.warning("Can only create directories, got '%s'.", resolved.suffix)
return f"Error: can only create directories, got '{resolved.suffix}'."
try:
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.mkdir()
#logger.info("Directory '%s' created successfully.", path)
return f"OK: created empty directory at {path}."
except Exception as e:
return f"Error creating dictionary file '{path}': {e}"
#logger.exception("Error creating directory '%s': %s", path, e)
return f"Error creating directory '{path}': {e}"
# ── Run the server ───────────────────────────────────────────────────────────

View File

@ -1,10 +1,26 @@
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")
@ -14,26 +30,23 @@ mcp = FastMCP("WebSearchServer")
def _validate_url(url: str) -> str:
"""Validate a URL to prevent SSRF attacks."""
parsed = urlparse(url)
#logger.info("Validateing URL")
if parsed.scheme not in ("http", "https"):
#logger.warning("Blocked scheme '%s'. Only http and https are allowed.", parsed.scheme)
raise ValueError(
f"Blocked scheme '{parsed.scheme}'. Only http and https are allowed."
)
hostname = parsed.hostname or ""
blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"}
if hostname in blocked_hosts:
if hostname in BLOCKED_HOSTS:
#logger.warning("Blocked internal host: %s", hostname)
raise ValueError(f"Blocked internal host: {hostname}")
private_prefixes = (
"10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
"172.30.", "172.31.", "192.168.",
)
for prefix in private_prefixes:
for prefix in PRIVATE_PREFIXES:
if hostname.startswith(prefix):
#logger.warning("Blocked private IP range: %s", hostname)
raise ValueError(f"Blocked private IP range: {hostname}")
return url
@ -51,12 +64,16 @@ def web_search(query: str, max_results: int = 5) -> str:
Returns:
A formatted string of search results, or a message if no matches found.
"""
#logger.info("Tool web_search is being executed on MCP web search server")
try:
from ddgs import DDGS
results = DDGS().text(query, max_results=max_results)
if not results:
#logger.info("DDGS API call successful, no web search results found.")
return f"No results found for: {query}"
#logger.info("DDGS API call successfull, web search results returned.")
formatted = []
for r in results:
@ -68,6 +85,7 @@ def web_search(query: str, max_results: int = 5) -> str:
return "\n---\n".join(formatted)
except Exception as e:
#logger.exception("DDGS API call failed, web search error: %s", e)
return f"Search error: {e}"
@ -80,24 +98,36 @@ def fetch_page(url: str) -> str:
Returns:
The text content of the fetched page, or an error message.
"""
#logger.info("Tool fetch_page is being executed on MCP web search server")
try:
url = _validate_url(url)
except ValueError as e:
return f"URL blocked: {e}"
try:
import requests
from bs4 import BeautifulSoup
response = requests.get(
url,
timeout=REQUEST_TIMEOUT,
headers={"User-Agent": "Mozilla/5.0 (Lightweight Web Search MCP Server)"},
)
response.raise_for_status()
if response.status_code != 200:
#logger.warning("HTTP error %s while fetching %s", response.status_code, url)
return f"HTTP error {response.status_code} fetching {url}"
#logger.info("DDGS API call successfull")
except requests.RequestException as e:
#logger.warning("HTTP-Fehler: %s", e)
return f"HTTP-Fehler: {e}"
except Exception as e:
#logger.exception("Error fetching page: %s", e)
return f"Error fetching page: {e}"
try:
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer"]):
@ -105,13 +135,16 @@ def fetch_page(url: str) -> str:
text = soup.get_text(separator="\n", strip=True)
#logger.info("HTML parsing with BeautifulSoup successfull")
if len(text) > MAX_PAGE_LENGTH:
text = text[:MAX_PAGE_LENGTH] + "\n\n[... truncated ...]"
return text if text else "Page fetched but no text content found."
except Exception as e:
return f"Error fetching page: {e}"
#logger.exception("Error parsing HTML: %s", e)
return f"Error parsing html: {e}"
# ── Run the server ───────────────────────────────────────────────────────────

View File

@ -0,0 +1,71 @@
from datetime import datetime
class DebugLogger:
"""In-memory logger for code execution events.
Collects timestamped INFO and ERROR entries during a single run.
Call clear() before each new execution to start fresh.
"""
def __init__(self):
self.logs: list[dict] = []
def log(self, message: str) -> None:
"""Append a general info message."""
self.logs.append({
"level": "INFO",
"message": message,
"timestamp": datetime.now().strftime("%H:%M:%S"),
})
def log_error(self, error_message: str) -> None:
"""Append an error message."""
self.logs.append({
"level": "ERROR",
"message": error_message,
"timestamp": datetime.now().strftime("%H:%M:%S"),
})
def get_logs(self) -> list[dict]:
"""Return a copy of all collected log entries."""
return list(self.logs)
def clear(self) -> None:
"""Reset the log — call before each new execution."""
self.logs = []
def format_debug_output(self, output: dict) -> str:
"""Format an ExecutionEngine result dict into a human-readable string.
Args:
output: dict with keys 'stdout', 'stderr', and 'rc'.
Returns:
A formatted string ready for display in the UI.
"""
lines = []
status = "SUCCESS" if output.get("rc") == 0 else "FAILED"
lines.append(f"[{status}] Exit code: {output.get('rc')}")
if output.get("stdout"):
lines.append("\n--- stdout ---")
lines.append(output["stdout"].rstrip())
if output.get("stderr"):
lines.append("\n--- stderr ---")
lines.append(output["stderr"].rstrip())
if not output.get("stdout") and not output.get("stderr"):
lines.append("No output produced.")
for entry in self.logs:
lines.append(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}")
return "\n".join(lines)
if __name__ == "__main__":
logger = DebugLogger()
print(logger.get_logs())

View File

@ -5,6 +5,9 @@ from dotenv import load_dotenv
import requests
import json
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
load_dotenv()
@ -38,6 +41,7 @@ class ChatManager:
def clear_history(self) -> None:
"""Wipe the conversation history (starts a fresh chat)."""
logger.info("Chat history was cleared")
self.chat_history = []
def send_message(self, user_message: str) -> str:
@ -49,25 +53,27 @@ class ChatManager:
# Add user message to history
self.add_message("user", user_message)
logger.info("Sending message to LLM API")
# Prepare request to OpenAI-compatible API
headers = {
"Content-Type": "application/json",
}
# Add API key if available
if self.api_key and self.api_key != "EMPTY":
headers["Authorization"] = f"Bearer {self.api_key}"
# Full history is sent so the model has multi-turn conversation context
payload = {
"model": self.model,
"messages": self.chat_history,
"temperature": 0.7,
"max_tokens": 2000,
"stream": False,
}
try:
# Prepare request to OpenAI-compatible API
headers = {
"Content-Type": "application/json",
}
# Add API key if available
if self.api_key and self.api_key != "EMPTY":
headers["Authorization"] = f"Bearer {self.api_key}"
# Full history is sent so the model has multi-turn conversation context
payload = {
"model": self.model,
"messages": self.chat_history,
"temperature": 0.7,
"max_tokens": 2000,
"stream": False,
}
# Make API request
response = requests.post(
self.api_url, headers=headers, json=payload, timeout=30
@ -75,9 +81,24 @@ class ChatManager:
# Check if request was successful
if response.status_code != 200:
error_msg = f"API Error {response.status_code}: {response.text}"
raise Exception(error_msg)
logger.warning("API HTTP status error %s: %s", response.status_code, response.text)
raise Exception(f"API Error {response.status_code}")
logger.info("Response recieved from API")
except requests.exceptions.Timeout as e:
error_msg = f"Timeout Error: {str(e)}"
self.add_message("assistant", f"Error: {error_msg}")
logger.exception("LLM API timeout: %s", e)
raise RuntimeError("LLM API timeout") from e
except requests.exceptions.RequestException as e:
error_msg = f"Connection Error: {str(e)}"
self.add_message("assistant", f"Error: {error_msg}")
logger.exception("LLM API connection failed: %s", e)
raise RuntimeError("LLM API connection failed") from e
try:
# Parse response
response_data = response.json()
@ -88,23 +109,24 @@ class ChatManager:
# Add AI response to history
self.add_message("assistant", ai_message)
logger.info("Assistant response generated")
return ai_message
else:
logger.warning("Invalid API response format: %s", response_data)
raise Exception("Invalid API response format")
except requests.exceptions.RequestException as e:
error_msg = f"Connection Error: {str(e)}"
# Add error message to history so user sees it
self.add_message("assistant", f"Error: {error_msg}")
raise Exception(error_msg)
except json.JSONDecodeError as e:
error_msg = f"JSON Decode Error: {str(e)}"
self.add_message("assistant", f"Error: {error_msg}")
logger.exception("JSON Decode Error: %s", e)
raise Exception(error_msg)
except Exception as e:
error_msg = f"Error: {str(e)}"
self.add_message("assistant", f"Error: {error_msg}")
raise Exception(error_msg)
logger.exception("JSON parsing and message formatting failed: %s", e)
raise RuntimeError("JSON parsing and message formatting failed") from e
def get_chat_display(self) -> list:
"""Return a copy of the history suitable for display in the UI."""

View File

@ -1,66 +1,94 @@
from datetime import datetime
"""
Central logging setup for the application.
- Provides a unified logger via get_logger(__name__)
- Writes all logs to a central rotating log file (logs/app.log)
- Writes errors separately to logs/errors.log
- Automatically includes the module name in each log entry
- Supports standard logging levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
Usage:
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
logger.info("Service started")
logger.debug("Debug details")
logger.error("Something went wrong")
try:
...
except Exception:
logger.exception("Unexpected error")
Logging levels (use consistently):
DEBUG: Detailed technical info for developers (variables, flow, internal state).
INFO: Normal application events (start/stop, successful operations, key milestones).
WARNING: Something unexpected happened, but the program continues normally.
ERROR: A specific operation failed, but the application is still running.
CRITICAL: A severe failure that may stop the application or make it unusable.
EXCEPTION: Same as ERROR, but used inside an `except` block and includes stacktrace
(via logger.exception()).
"""
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
class DebugLogger:
"""In-memory logger for code execution events.
Collects timestamped INFO and ERROR entries during a single run.
Call clear() before each new execution to start fresh.
"""
_initialized = False
def __init__(self):
self.logs: list[dict] = []
@classmethod
def setup(cls):
# prevents multiple setup
if cls._initialized:
return
def log(self, message: str) -> None:
"""Append a general info message."""
self.logs.append({
"level": "INFO",
"message": message,
"timestamp": datetime.now().strftime("%H:%M:%S"),
})
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] [%(name)s: Line %(lineno)d] %(message)s"
)
def log_error(self, error_message: str) -> None:
"""Append an error message."""
self.logs.append({
"level": "ERROR",
"message": error_message,
"timestamp": datetime.now().strftime("%H:%M:%S"),
})
# Main log file
file_handler = RotatingFileHandler(
LOG_DIR / "app.log",
maxBytes=5_000_000,
backupCount=5,
encoding="utf-8"
)
def get_logs(self) -> list[dict]:
"""Return a copy of all collected log entries."""
return list(self.logs)
file_handler.setFormatter(formatter)
def clear(self) -> None:
"""Reset the log — call before each new execution."""
self.logs = []
# Separate Error-Log
error_handler = RotatingFileHandler(
LOG_DIR / "errors.log",
maxBytes=5_000_000,
backupCount=3,
encoding="utf-8"
)
def format_debug_output(self, output: dict) -> str:
"""Format an ExecutionEngine result dict into a human-readable string.
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
Args:
output: dict with keys 'stdout', 'stderr', and 'rc'.
root_logger = logging.getLogger()
Returns:
A formatted string ready for display in the UI.
"""
lines = []
root_logger.setLevel(logging.DEBUG)
status = "SUCCESS" if output.get("rc") == 0 else "FAILED"
lines.append(f"[{status}] Exit code: {output.get('rc')}")
root_logger.addHandler(file_handler)
root_logger.addHandler(error_handler)
#root_logger.propagate = False
if output.get("stdout"):
lines.append("\n--- stdout ---")
lines.append(output["stdout"].rstrip())
cls._initialized = True
if output.get("stderr"):
lines.append("\n--- stderr ---")
lines.append(output["stderr"].rstrip())
@classmethod
def get_logger(cls, name: str):
cls.setup()
return logging.getLogger(name)
if not output.get("stdout") and not output.get("stderr"):
lines.append("No output produced.")
for entry in self.logs:
lines.append(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}")
return "\n".join(lines)
# praktische shortcut function
def get_logger(name: str):
return DebugLogger.get_logger(name)

View File

@ -1,6 +1,9 @@
import subprocess
from pathlib import Path
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# Maximum time (seconds) a subprocess is allowed to run before being killed.
RUN_TIMEOUT = 30
@ -41,6 +44,8 @@ class ExecutionEngine:
]
else:
return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1}
logger.info("Running file %s with suffix %s", active_file.name, suffix)
try:
proc = subprocess.run(
@ -50,12 +55,16 @@ class ExecutionEngine:
text=True,
timeout=RUN_TIMEOUT,
)
logger.info("File ran successfully.")
return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode}
except subprocess.TimeoutExpired:
logger.warning("Time out afte %s s", RUN_TIMEOUT)
return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1}
except FileNotFoundError as e:
# Raised when the interpreter/compiler binary is not found on PATH
logger.warning("Interpreter/compiler binary is not found on PATH: %s", e)
return {"stdout": "", "stderr": str(e), "rc": -1}
except Exception as e:
logger.exception("Error while running %s: %s", active_file.name, e)
return {"stdout": "", "stderr": str(e), "rc": -1}

View File

@ -7,6 +7,9 @@ touching the filesystem, preventing path-traversal attacks.
import streamlit as st
from pathlib import Path
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# The workspace folder is created at module load so it always exists.
WORKSPACE = Path("workspace")
WORKSPACE.mkdir(exist_ok=True)
@ -28,12 +31,16 @@ class FileManager:
Returns:
bool: True if folder was created successfully, False otherwise.
"""
logger.info("Creating folder at %s named %s", relative_path, name)
if not name:
logger.warning("Invalid folder name")
st.error(f"Invalid folder name: {name}")
return False
# Slashes in the name would silently create nested paths — reject them.
if "/" in name or "\\" in name:
logger.warning("'/' or '\\' in foldername not allowed")
st.error(f"Invalid folder name (no slashes allowed): {name}")
return False
@ -52,11 +59,14 @@ class FileManager:
try:
folder_path.mkdir(exist_ok=False)
logger.info("Folder created successfully.")
return True
except FileExistsError:
logger.warning("Folder already exists.")
st.warning(f"Folder already exists: {relative_path}")
return False
except Exception as e:
logger.exception("Error creating folder %s: %s", relative_path, str(e))
st.error(f"Error creating folder {relative_path}: {str(e)}")
return False
@ -71,14 +81,18 @@ class FileManager:
name (str): The name of the new file to create (should not contain slashes).
Returns:
bool: True if file was created successfully, False otherwise.
"""
"""
logger.info("Creating file at %s named %s", relative_path, name)
if not name or name.strip() == "" :
logger.warning("Invalid folder name")
st.error(f"Invalid file name: {name}")
return False
name = Path(name)
if not name.suffix:
name = name.with_suffix(".txt") # Default to .txt if no extension provided
logger.info("No suffix was provided, creating .txt file")
if relative_path:
relative_path = Path(relative_path)
@ -94,11 +108,14 @@ class FileManager:
try:
file_path.touch(exist_ok=False)
logger.info("File created successfully.")
return True
except FileExistsError:
logger.warning("Folder already exists.")
st.warning(f"File already exists: {relative_path}")
return False
except Exception as e:
logger.exception("Error creating file %s: %s", relative_path, str(e))
st.error(f"Error creating file {relative_path}: {str(e)}")
return False
@ -113,27 +130,35 @@ class FileManager:
Returns:
str: The content of the file, or an empty string if there was an error.
"""
logger.info("Reading file at %s.", relative_path)
file_path = (relative_path).resolve()
if not file_path.exists():
st.error(f"File not found: {relative_path}")
logger.warning("Filepath does not exist.")
return ""
if not file_path.is_file():
st.error(f"Path is not a file: {relative_path}")
logger.warning("Path is not a file.")
return ""
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
logger.warning("Access denied. File ist outside WORKSPACE")
return ""
try:
with open(file_path, "r") as f:
return f.read()
content = f.read()
logger.info("File read successfully.")
return content
except FileNotFoundError:
st.error(f"File not found: {relative_path}")
logger.warning("File not found")
return ""
except Exception as e:
st.error(f"Error reading file {relative_path}: {str(e)}")
logger.exception("Error reading file at %s: %s", relative_path, e)
return ""
def save_file(self, relative_path: str, content: str) -> bool:
@ -148,19 +173,24 @@ class FileManager:
Returns:
bool: True if save was successful, False otherwise.
"""
logger.info("Saving file at %s.", relative_path)
file_path = (Path(relative_path)).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
logger.warning("Access denied. File outside WORKSPACE.")
return False
try:
with open(file_path, "w") as f:
f.write(content)
logger.info("File written successfully.")
return True
except Exception as e:
st.error(f"Error saving file {relative_path}: {str(e)}")
logger.exception("Error saving file %s: %s", relative_path, e)
return False
def rename_file(self, old_relative_path: str, new_name: str) -> bool:
@ -174,8 +204,11 @@ class FileManager:
Returns:
bool: True if rename was successful, False otherwise.
"""
logger.info("Rename file at %s to %s.", old_relative_path, new_name)
if not new_name or new_name.strip() == "":
st.error(f"Invalid file name: {new_name}")
logger.warning("New Name is empty.")
return False
file_type = Path(old_relative_path).suffix
@ -191,13 +224,20 @@ class FileManager:
# Both old and new paths must stay inside the workspace.
if not str(old_file_path).startswith(str(self.base_path.resolve())) or not str(new_file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {old_relative_path}")
logger.warning("Access denied, file outside WORKSPACE.")
return False
try:
old_file_path.rename(new_file_path)
logger.info("Renamed successfully.")
return True
except FileNotFoundError:
st.error(f"File not found: {old_relative_path}")
logger.warning("Original file not found.")
return False
except Exception as e:
st.error(f"Error renaming file {old_relative_path} to {new_name}: {str(e)}")
logger.exception("Error deleting folder %s to %s: %s", old_relative_path, new_name, str(e))
return False
@ -210,23 +250,29 @@ class FileManager:
Returns:
bool: True if deletion was successful, False otherwise.
"""
logger.info("Deleting folder %s.", relative_path)
folder_path = (self.base_path / relative_path).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(folder_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
logger.warning("Access denied, folder outside WORKSPACE.")
return False
if not folder_path.exists():
st.error(f"Folder not found: {relative_path}")
logger.warning("Folder path not found.")
return False
try:
import shutil
shutil.rmtree(folder_path)
logger.info("Folder deleted successfully.")
return True
except Exception as e:
st.error(f"Error deleting folder {relative_path}: {str(e)}")
logger.exception("Error deleting folder %s: %s", relative_path, str(e))
return False
def delete_file(self, relative_path: str) -> bool:
@ -238,21 +284,26 @@ class FileManager:
Returns:
bool: True if deletion was successful, False otherwise.
"""
logger.info("Deleting file %s.", relative_path)
file_path = Path(relative_path)
abs_file_path = (Path(self.base_path) / file_path).resolve()
if not str(abs_file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
logger.warning("Access denied, file outside WORKSPACE.")
return False
try:
abs_file_path.unlink()
logger.info("File deleted successfully.")
return True
except FileNotFoundError:
st.error(f"File not found: {relative_path}")
logger.warning("File not found")
return False
except Exception as e:
st.error(f"Error deleting file {relative_path}: {str(e)}")
logger.exception("Error deleting folder %s: %s", relative_path, str(e))
return False
def get_file_tree(self):
@ -264,6 +315,8 @@ class FileManager:
Returns:
dict: A nested dictionary representing the file tree.
"""
logger.info("Getting file tree ...")
def build_tree(path: Path):
tree = {}

View File

@ -0,0 +1,2 @@
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)

View File

@ -1,5 +1,8 @@
"""Builds the system prompt that is sent to the AI at the start of each chat session."""
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
@ -22,6 +25,7 @@ class SystemPrompter:
Returns:
A ready-to-use system prompt string.
"""
logger.info("Generating system prompt.")
base = (
"You are an expert code assistant integrated into a lightweight code editor. "
"Help the user with code suggestions, debugging, explanations, and improvements. "
@ -29,6 +33,7 @@ class SystemPrompter:
)
if file_context:
logger.info("Appending file context.")
name = file_context.get("name", "unknown")
content = file_context.get("content", "")

View File

@ -17,6 +17,9 @@ from pathlib import Path
# where streamlit is launched from.
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
from frontend.sidebar import render_sidebar
from frontend.editor import render_editor
from frontend.chat import render_chat
@ -51,8 +54,10 @@ def main():
# Switch between the two main views based on the sidebar radio button
if st.session_state.get("radio_interface_options") == "Code Editor":
logger.info("Editor mode")
render_editor()
elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant":
logger.info("Chat/Agent mode")
render_chat()

View File

@ -3,8 +3,13 @@
import streamlit as st
from backend.managers.chat_manager import ChatManager
from backend.managers.system_prompter import SystemPrompter
from backend.agent.coding_agent import CodingAgent
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
import asyncio
import json
# ── Agent Mode helpers ────────────────────────────────────────────────────────
@ -22,7 +27,7 @@ def _start_agent(task: str):
Stores the agent and its state in session_state so Streamlit can reference
them across reruns without losing progress.
"""
from backend.agent.coding_agent import CodingAgent
logger.info("Starting coding agent.")
agent = CodingAgent()
agent.start_task(task)
action = _run_async(agent.propose_next_action())
@ -38,6 +43,7 @@ def _approve_action():
pending = st.session_state.agent_pending_action
result = _run_async(agent.approve())
logger.info("Approve action and propose next step.")
# Append a record to the log so the user can review every completed step.
st.session_state.agent_log.append({
@ -62,6 +68,7 @@ def _reject_action(feedback: str):
The pending action is discarded; the agent receives the user's feedback and
proposes a different approach on the next call to propose_next_action().
"""
logger.info("Rejecting proposed action.")
agent = st.session_state.coding_agent
agent.reject(feedback or "Please try a different approach.")
next_action = _run_async(agent.propose_next_action())
@ -71,6 +78,7 @@ def _reject_action(feedback: str):
def _followup_agent(question: str):
"""Continue a finished task by injecting a follow-up question and resuming the loop."""
logger.info("Asking follow up question")
agent = st.session_state.coding_agent
agent.follow_up(question)
action = _run_async(agent.propose_next_action())
@ -80,6 +88,7 @@ def _followup_agent(question: str):
def _reset_agent():
"""Clear all agent state and return to the idle (task input) screen."""
logger.info("Resetting Agent")
st.session_state.coding_agent = None
st.session_state.agent_status = "idle"
st.session_state.agent_log = []
@ -88,6 +97,62 @@ def _reset_agent():
# ── Agent Mode UI ─────────────────────────────────────────────────────────────
def _render_arguments(args: dict):
if not args:
return
with st.expander("📦 Arguments", expanded=False):
if args.get("path"):
st.markdown("##### 📁 Path")
st.code(args["path"])
if args.get("dir_path"):
st.markdown("##### 🌳 Directory")
st.code(args["dir_path"])
if args.get("query"):
st.markdown("##### 🔎 Query")
st.code(args["query"])
if args.get("url"):
st.markdown("##### 🌐 URL")
st.code(args["url"])
if args.get("content"):
st.markdown("##### 📝 Content")
st.code(args["content"])
if args.get("code"):
st.markdown("##### 🐍 Python Code")
st.code(args["code"], language="python")
if args.get("max_results") is not None:
st.markdown("##### 📊 Max Results")
st.code(str(args["max_results"]))
known_keys = {
"path",
"dir_path",
"query",
"content",
"url",
"code",
"max_results",
}
extra_args = {
k: v for k, v in args.items()
if k not in known_keys
}
if extra_args:
st.markdown("##### ⚙️ Other")
st.code(
json.dumps(extra_args, indent=2),
language="json"
)
def render_agent_mode():
"""Render the step-by-step agent UI.
@ -96,6 +161,7 @@ def render_agent_mode():
- "waiting_approval" show proposed action, Approve / Reject / Abort
- "done" success message, follow-up input, New Task button
"""
logger.info("Agent mode.")
# The toggle must always render so Streamlit keeps agent_mode=True in session_state.
st.toggle("Agent Mode", key="agent_mode")
@ -110,8 +176,8 @@ def render_agent_mode():
with st.chat_message("assistant"):
st.markdown(f"**Step {i + 1} — `{step['tool']}`**")
st.caption(f"Thought: {step['thought']}")
if step.get("arguments"):
st.json(step["arguments"])
if step.get("arguments"):
_render_arguments(step["arguments"])
result_text = step.get("result", "")
# Colour the result based on whether the tool succeeded or failed.
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
@ -130,8 +196,6 @@ def render_agent_mode():
placeholder="e.g. Write a function that sorts a list and saves it to sorted.py",
)
if st.button("Start Agent", type="primary", use_container_width=True):
#loop = asyncio.new_event_loop()
#asyncio.set_event_loop(loop)
if task.strip():
with st.spinner("Agent is thinking..."):
_start_agent(task.strip())
@ -149,15 +213,7 @@ def render_agent_mode():
args = pending.get("arguments", {})
if args:
# Show file content separately as a code block for readability;
# other arguments are displayed as JSON.
if "content" in args:
display_args = {k: v for k, v in args.items() if k != "content"}
if display_args:
st.json(display_args)
st.code(args["content"], language="python")
else:
st.json(args)
_render_arguments(args)
feedback = st.text_input(
"Rejection feedback (optional):",
@ -219,6 +275,7 @@ def render_normal_chat():
Each subsequent message appends to the same conversation so the AI retains
full context throughout the session.
"""
logger.info("Chat mode")
# Replay the conversation history as chat bubbles (skip system messages).
for message in st.session_state.chat_history:
role = message["role"]

View File

@ -6,7 +6,8 @@ from pathlib import Path
from backend.managers.file_manager import FileManager
from backend.managers.execution_engine import ExecutionEngine
from backend.managers.debug_logger import DebugLogger
from backend.managers.debug_logger import get_logger
logger = get_logger(__name__)
# Maps file extensions to Ace editor language modes for syntax highlighting.
LANG_MAP = {

View File

@ -25,5 +25,6 @@ python-dotenv>=1.0.0
#For code editor functionality
streamlit-ace>=0.1.0
#Whitelisted Imports from Agent-Sandbox
pygame
#MCP-Code execution tools
pyflakes>=0.1.0
pygame>=0.1.0

View File

@ -6,7 +6,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.managers.debug_logger import DebugLogger
from backend.managers._debug_logger import DebugLogger
# ── Fixtures ──────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,428 @@
import pytest
import subprocess
from pathlib import Path
from unittest.mock import Mock, patch
from backend.managers.execution_engine import ExecutionEngine
# =========================================================
# FIXTURE
# =========================================================
@pytest.fixture()
def engine():
return ExecutionEngine()
# =========================================================
# BASIC TESTS (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"]
# ---------------------------------------------------------
# 3. LaTeX-Datei wird kompiliert
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_tex_file_success(mock_run, engine, tmp_path):
file = tmp_path / "doc.tex"
file.write_text("\\documentclass{article}")
mock_run.return_value = Mock(
stdout="PDF created",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0
assert "PDF created" in result["stdout"]
# ---------------------------------------------------------
# 4. Unsupported File Type
# ---------------------------------------------------------
def test_run_unsupported_file(engine, tmp_path):
file = tmp_path / "test.js"
file.write_text("console.log('x')")
result = engine.run_code(file)
assert result["rc"] == 1
assert "Unsupported file type" in result["stderr"]
# ---------------------------------------------------------
# 5. Timeout wird behandelt
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_timeout(mock_run, engine, tmp_path):
file = tmp_path / "slow.py"
file.write_text("while True: pass")
mock_run.side_effect = subprocess.TimeoutExpired(
cmd=["py"],
timeout=30
)
result = engine.run_code(file)
assert result["rc"] == -1
assert "Timed out" in result["stderr"]
# ---------------------------------------------------------
# 6. Fehlender Interpreter
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_missing_interpreter(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.side_effect = FileNotFoundError("py not found")
result = engine.run_code(file)
assert result["rc"] == -1
assert "py not found" in result["stderr"]
# ---------------------------------------------------------
# 7. Allgemeine Exception
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_general_exception(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.side_effect = RuntimeError("unexpected")
result = engine.run_code(file)
assert result["rc"] == -1
assert "unexpected" in result["stderr"]
# ---------------------------------------------------------
# 8. subprocess.run wird mit cwd ausgeführt
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_uses_correct_cwd(mock_run, engine, tmp_path):
folder = tmp_path / "project"
folder.mkdir()
file = folder / "main.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
_, kwargs = mock_run.call_args
assert kwargs["cwd"] == folder.resolve()
# ---------------------------------------------------------
# 9. subprocess.run nutzt capture_output
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_capture_output_enabled(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
_, kwargs = mock_run.call_args
assert kwargs["capture_output"] is True
# ---------------------------------------------------------
# 10. subprocess.run nutzt text=True
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_text_mode_enabled(mock_run, engine, tmp_path):
file = tmp_path / "test.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
_, kwargs = mock_run.call_args
assert kwargs["text"] is True
# =========================================================
# EDGE CASE TESTS (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 世界')")
mock_run.return_value = Mock(
stdout="🔥 Grüezi 世界\n",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert "🔥 Grüezi 世界" in result["stdout"]
# ---------------------------------------------------------
# 12. Leerer stdout/stderr
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_empty_output(mock_run, engine, tmp_path):
file = tmp_path / "empty.py"
file.write_text("x = 1")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["stdout"] == ""
assert result["stderr"] == ""
# ---------------------------------------------------------
# 13. Sehr langer stdout
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_large_output(mock_run, engine, tmp_path):
file = tmp_path / "large.py"
file.write_text("print('A')")
mock_run.return_value = Mock(
stdout="A" * 100000,
stderr="",
returncode=0
)
result = engine.run_code(file)
assert len(result["stdout"]) == 100000
# ---------------------------------------------------------
# 14. Dateiname mit Leerzeichen
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_filename_with_spaces(mock_run, engine, tmp_path):
file = tmp_path / "my script.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="ok",
stderr="",
returncode=0
)
engine.run_code(file)
args, _ = mock_run.call_args
assert "my script.py" in args[0]
# ---------------------------------------------------------
# 15. Dateiname mit Unicode
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_unicode_filename(mock_run, engine, tmp_path):
file = tmp_path / "🔥_test.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="ok",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0
# ---------------------------------------------------------
# 16. .tex nutzt pdflatex
# ---------------------------------------------------------
@patch("subprocess.run")
def test_tex_uses_pdflatex(mock_run, engine, tmp_path):
file = tmp_path / "doc.tex"
file.write_text("x")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
args, _ = mock_run.call_args
assert args[0][0] == "pdflatex"
# ---------------------------------------------------------
# 17. .py nutzt py Interpreter
# ---------------------------------------------------------
@patch("subprocess.run")
def test_python_uses_py_interpreter(mock_run, engine, tmp_path):
file = tmp_path / "main.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
args, _ = mock_run.call_args
assert args[0][0] == "py"
# ---------------------------------------------------------
# 18. Relative Pfade funktionieren
# ---------------------------------------------------------
@patch("subprocess.run")
def test_relative_paths(mock_run, engine, tmp_path):
sub = tmp_path / "src"
sub.mkdir()
file = sub / "main.py"
file.write_text("print(1)")
mock_run.return_value = Mock(
stdout="ok",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0
# ---------------------------------------------------------
# 19. Großgeschriebenes Suffix blockiert
# ---------------------------------------------------------
def test_uppercase_suffix_not_supported(engine, tmp_path):
file = tmp_path / "SCRIPT.PY"
file.write_text("print(1)")
result = engine.run_code(file)
assert result["rc"] == 1
# ---------------------------------------------------------
# 20. Leere Datei ausführen
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_empty_file(mock_run, engine, tmp_path):
file = tmp_path / "empty.py"
file.write_text("")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0

View File

@ -0,0 +1,269 @@
import pytest
from unittest.mock import Mock, patch
import subprocess
from backend.agent.servers import mcp_server_code_execution as server
# =========================================================
# BASIC TESTS (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