From 241c6580f35b27e229646ece67650cc2699b285a Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Fri, 22 May 2026 14:25:22 +0200 Subject: [PATCH 1/9] some implementetd stuff --- backend/managers/chat_manager.py | 3 +- frontend/chat.py | 152 ++++++++++++++++++++++++++----- frontend/editor.py | 113 ++++++++++++++++------- frontend/sidebar.py | 17 +++- frontend/state.py | 15 +++ 5 files changed, 241 insertions(+), 59 deletions(-) diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index a10faab..e39d911 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -21,6 +21,7 @@ class ChatManager: self.api_port = os.getenv("PORT") self.api_key = os.getenv("API_KEY") self.model = os.getenv("MODEL") + self.max_tokens = 2000 # API endpoint URL (OpenAI-compatible format) self.api_url = f"http://{self.api_host}:{self.api_port}/v1/chat/completions" @@ -64,7 +65,7 @@ class ChatManager: "model": self.model, "messages": self.chat_history, "temperature": 0.7, - "max_tokens": 2000, + "max_tokens": self.max_tokens, "stream": False, } diff --git a/frontend/chat.py b/frontend/chat.py index 43ab8bc..5db855e 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -1,11 +1,12 @@ """Chat view — renders both the normal chat interface and the Coding Agent mode.""" +import asyncio +from pathlib import Path + import streamlit as st from backend.managers.chat_manager import ChatManager from backend.managers.system_prompter import SystemPrompter -import asyncio - # ── Agent Mode helpers ──────────────────────────────────────────────────────── def _run_async(coro): @@ -210,39 +211,119 @@ def render_agent_mode(): st.rerun() +# ── Normal Chat helpers ─────────────────────────────────────────────────────── + +def _build_file_context() -> dict | None: + """Return file context for the system prompt if a file is open and context is enabled. + + Reads from files_content cache first; falls back to FileManager if the file + has not been loaded into the editor yet. + """ + if not st.session_state.get("include_file_context", True): + return None + active_file = st.session_state.get("active_file") + if not active_file: + return None + content = st.session_state.get("files_content", {}).get(active_file, "") + if not content: + try: + from backend.managers.file_manager import FileManager + fm = FileManager() + content = fm.read_file(Path(active_file)) or "" + except Exception: + return None + return {"name": Path(active_file).name, "content": content} + + +@st.dialog("Clear Chat") +def _clear_chat_dialog(): + """Confirmation dialog before wiping the full conversation history.""" + st.warning("All messages will be deleted. This cannot be undone.") + col1, col2 = st.columns(2) + with col1: + if st.button("Clear", type="primary", use_container_width=True): + st.session_state.chat_manager.clear_history() + st.session_state.chat_history = [] + st.rerun() + with col2: + if st.button("Cancel", use_container_width=True): + st.rerun() + + # ── Normal Chat ─────────────────────────────────────────────────────────────── def render_normal_chat(): """Render the standard multi-turn chat interface. - On the first message the system prompt is injected into the history. - Each subsequent message appends to the same conversation so the AI retains - full context throughout the session. + Execution order on every rerun: + 1. Apply model/token settings from the Settings panel (5e) + 2. Consume any pending debug message from the editor (5f) + 3. Replay chat history + 4. Handle chat input with updated system-prompt logic (5g) + 5. Render Clear Chat button and Settings expander (5d, 5h) """ + chat_manager: ChatManager = st.session_state.chat_manager + + # 5e — 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 + + # 5f — 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: + if not chat_manager.get_history(): + custom_prompt = st.session_state.get("custom_system_prompt", "").strip() + if custom_prompt: + chat_manager.add_message("system", custom_prompt) + else: + file_ctx = _build_file_context() + system_prompt = SystemPrompter.generate_prompt(file_ctx) + chat_manager.add_message("system", system_prompt) + + 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: - role = message["role"] - if role == "system": + if message["role"] == "system": continue - with st.chat_message(role): + with st.chat_message(message["role"]): st.markdown(message["content"]) - # Chat input — Enter to send, no extra button needed + # Chat input — Enter to send, no extra button needed. user_input = st.chat_input("Type your message here...") if user_input: - chat_manager = st.session_state.chat_manager - # On the very first user message, prepend the system prompt so the AI - # knows it is a code assistant embedded in an editor. + # 5g — System-prompt logic: inject on first message, update on file change. if not chat_manager.get_history(): - system_prompt = SystemPrompter.generate_prompt() - chat_manager.add_message("system", system_prompt) + custom_prompt = st.session_state.get("custom_system_prompt", "").strip() + if custom_prompt: + chat_manager.add_message("system", custom_prompt) + else: + file_ctx = _build_file_context() + system_prompt = SystemPrompter.generate_prompt(file_ctx) + chat_manager.add_message("system", system_prompt) + elif st.session_state.get("active_file") and st.session_state.get("include_file_context", True): + # Follow-up messages: refresh the system prompt when the active file changes. + history = chat_manager.get_history() + if history and history[0]["role"] == "system": + file_ctx = _build_file_context() + if file_ctx: + history[0]["content"] = SystemPrompter.generate_prompt(file_ctx) - # Show user message immediately without waiting for response. with st.chat_message("user"): st.markdown(user_input) - # Call the AI and show its response with a spinner while waiting. with st.chat_message("assistant"): with st.spinner("Thinking..."): try: @@ -251,15 +332,44 @@ def render_normal_chat(): ai_response = f"Error: {e}" st.markdown(ai_response) - st.session_state.chat_history.append({"role": "user", "content": user_input}) + st.session_state.chat_history.append({"role": "user", "content": user_input}) st.session_state.chat_history.append({"role": "assistant", "content": ai_response}) st.rerun() - # Rendered in the normal flow; JS above clones them to fixed positions - # and hides these originals. + # 5d — Clear Chat opens a confirmation dialog instead of deleting immediately. + if st.button("🗑️ Clear Chat"): + _clear_chat_dialog() + 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) + + # 5h — Settings expander: file context toggle, model, token limit, custom prompt. + with st.expander("⚙️ Settings", expanded=False): + 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 [] + for m in ["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307", "gpt-4o", "gpt-4o-mini"]: + if m not in model_options: + model_options.append(m) + 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.", + ) # ── Entry point ─────────────────────────────────────────────────────────────── diff --git a/frontend/editor.py b/frontend/editor.py index 9f9c8b8..805e1d7 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -1,5 +1,7 @@ """Code Editor view — renders the Ace editor, file tabs, and execution output.""" +import ast + import streamlit as st import streamlit_ace as st_ace from pathlib import Path @@ -85,9 +87,13 @@ def _delete_dialog(abs_file_path: str): def run_active_file(): - """Execute the currently active file and store the result in session_state. - Returns the execution result dict {stdout, stderr, return_code}, or None - if no active file is set. + """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 @@ -97,10 +103,20 @@ def run_active_file(): execution_engine = ExecutionEngine() debug_logger = DebugLogger() - debug_logger.clear() debug_logger.log(f"Executing code from {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 + debug_logger.log_error(f"Syntax error: {e}") + return result + with st.spinner(f"Running {Path(active_file).name}..."): output = execution_engine.run_code(Path(active_file)) @@ -109,12 +125,13 @@ def run_active_file(): else: debug_logger.log_error(f"Execution failed with exit code {output['rc']}.") - st.session_state.code_execution_output = { + result = { "stdout": output["stdout"], "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 def render_editor(): @@ -132,6 +149,24 @@ def render_editor(): tab_names = [Path(f).name for f in st.session_state.open_files] tabs = st.tabs(tab_names) + # Tab-Sprung via JavaScript — pop() verhindert Loop bei jedem Rerun. + # Wenn _jump_to_tab gesetzt ist, klickt das Script den richtigen Tab an. + jump_target = st.session_state.pop("_jump_to_tab", None) + if jump_target and jump_target in st.session_state.open_files: + idx = st.session_state.open_files.index(jump_target) + st.components.v1.html( + f"""""", + height=0, + ) + for idx, file_path in enumerate(st.session_state.open_files): with tabs[idx]: # Load file content from disk on first open; afterwards use the cached version. @@ -179,36 +214,48 @@ def render_editor(): if st.button("Delete File", key=f"delete_{file_path}"): _delete_dialog(file_path) - if st.button("▶ Run Code", key="run_code"): - result = run_active_file() - if not result: - st.stop() + # ── Run + Output ────────────────────────────────────────────────── + if st.button("▶ Run Code", key=f"run_code_{file_path}", type="primary"): + run_active_file() + st.rerun() - st.subheader("Execution Output") + result = st.session_state.get("exec_results", {}).get(file_path) + if result: + st.subheader("Execution Output") - # Green on exit code 0 (success), red on anything else (error/crash). - if result["return_code"] == 0: - st.success(f"Exit code: {result['return_code']}") - else: - st.error(f"Exit code: {result['return_code']}") + if result.get("ast_error"): + st.warning("⚠️ Syntax Error detected before execution — code was not run.") + elif result["return_code"] == 0: + st.success(f"✅ Exit code: 0") + 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") + # Debug with AI — only shown when there is an error or stderr output. + 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 + error_text = result.get("stderr", "") or f"Exit code: {result['return_code']}" + code_content = st.session_state.files_content.get(file_path, "") + lang = LANG_MAP.get(Path(file_path).suffix, "python") + debug_message = ( + f"I got an error while running **{file_name}**:\n\n" + f"**Error:** {error_text.strip()}\n" + f"**Exit Code:** {result['return_code']}\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["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 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.") diff --git a/frontend/sidebar.py b/frontend/sidebar.py index 1a6b9d4..f496190 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -173,9 +173,6 @@ def _delete_file_dialog(relative_file_path: str, file_name: str): if st.button("Delete", type="primary", use_container_width=True): if fm.delete_file(relative_file_path): abs_file_path = str(Path(fm.base_path) / relative_file_path) - print(f"Deleting file at absolute path: {abs_file_path}") # Debugging info - print(f"Current open files before deletion: {st.session_state.open_files}") # Debugging info - st.session_state.open_files.remove(abs_file_path) st.session_state.files_content.pop(abs_file_path, None) if st.session_state.active_file == abs_file_path: @@ -257,7 +254,16 @@ def render_sidebar(): Tree click handling: - Clicking a file → appended to open_files, set as active_file - Clicking a folder → stored in selected_folder so the action bar appears + + Navigation flags (_navigate_to_editor, _navigate_to_chat) are consumed here + at the very top — before any widget is rendered — to avoid StreamlitAPIException. """ + # Consume navigation flags before any widget renders. + if st.session_state.pop("_navigate_to_editor", False): + st.session_state.radio_interface_options = "Code Editor" + if st.session_state.pop("_navigate_to_chat", False): + st.session_state.radio_interface_options = "Chat with AI Assistant" + st.sidebar.title("Navigation") navigation_section = st.sidebar.container() @@ -294,13 +300,16 @@ def render_sidebar(): abs_path = fm.base_path / selected_path if abs_path.is_file(): - # Open the file in the editor. + # Open the file in the editor, jump to its tab, + # and switch the view to the Editor pane. st.session_state.selected_folder = None st.session_state.selected_folder_rel = None file_str = str(abs_path) if file_str not in st.session_state.open_files: st.session_state.open_files.append(file_str) st.session_state.active_file = file_str + st.session_state._jump_to_tab = file_str + st.session_state._navigate_to_editor = True st.rerun() elif abs_path.is_dir(): diff --git a/frontend/state.py b/frontend/state.py index 2a4b25d..b5c7b7b 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -88,6 +88,21 @@ def init_state(): if "agent_pending_action" not in st.session_state: st.session_state.agent_pending_action = None + # Whether to inject the currently open file as context into the system prompt + if "include_file_context" not in st.session_state: + st.session_state.include_file_context = True + + # Optional custom system prompt entered by the user in Settings (overrides default) + if "custom_system_prompt" not in st.session_state: + st.session_state.custom_system_prompt = "" + + # Holds a pre-built debug message to be sent to the AI on the next chat render + if "pending_debug_message" not in st.session_state: + st.session_state.pending_debug_message = None + + # Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}} + if "exec_results" not in st.session_state: + st.session_state.exec_results = {} if __name__ == "__main__": -- 2.30.2 From 21b0b3f9dc2d04246522e4c1fe432bb622a3edc8 Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Sun, 24 May 2026 10:28:28 +0200 Subject: [PATCH 2/9] docs: add docstrings and inline comments to all Python files --- backend/agent/coding_agent.py | 118 ++++++++++++++---- backend/agent/mcp_server_adapter.py | 99 ++++++++++++--- .../servers/mcp_server_code_execution.py | 97 ++++++++++---- .../agent/servers/mcp_server_file_search.py | 39 +++++- .../agent/servers/mcp_server_web_search.py | 31 ++++- backend/managers/execution_engine.py | 7 ++ backend/managers/file_manager.py | 9 ++ frontend/chat.py | 15 ++- frontend/editor.py | 8 ++ frontend/sidebar.py | 22 ++++ frontend/state.py | 67 ++++++---- 11 files changed, 413 insertions(+), 99 deletions(-) diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index 93d51f9..ff98386 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -49,8 +49,15 @@ MAX_HISTORY_CHARS = 80_000 # ═════════════════════════════════════════════════════════════════════════════ def build_all_tool_description() -> str: - """Get relevant tools from the MCP servers based on the query.""" + """Build a formatted string listing every registered MCP tool. + The returned string is embedded verbatim in the SYSTEM_PROMPT so the LLM + knows which tools exist and what arguments they expect. + + Returns: + Newline-separated list of tool descriptions in the format + ``"- : "``. + """ all_tools = adapter.get_all_tools() print(f"Building tool description for {len(all_tools)} tools.") @@ -58,17 +65,30 @@ def build_all_tool_description() -> str: for tool in all_tools: pprint.pprint(f"{tool}") descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}") - + return "\n".join(descriptions) async def dispatch_tool(tool_name: str, arguments: dict) -> str: - """Call a tool by name with the given arguments using the MCP adapter.""" + """Execute a named tool and return its output as a plain string. + Handles the special "done" pseudo-tool locally (it signals completion and + is never forwarded to an MCP server). All other tools are forwarded to the + MCPToolAdapter which routes them to the correct MCP server process. + + Args: + tool_name: Name of the tool to execute (e.g. "write_file", "done"). + arguments: Dict of arguments for the tool. + + Returns: + The tool's text output, a "DONE: ..." completion message, or an error + string beginning with "Tool error:" / "Error calling tool:" on failure. + """ if tool_name == "done": - # Handle the "done" tool locally since it's not an MCP tool + # The "done" tool is a sentinel — it lives only in the agent protocol, + # not in any MCP server, so we resolve it directly here. summary = arguments.get("summary", "Task completed.") return f"DONE: {summary}" - + try: print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...") result = await adapter.call_tool(tool_name, arguments) @@ -76,12 +96,14 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str: print(f"Raw result from tool '{tool_name}': {result}") if result.isError: + # MCP servers signal tool-level errors via the isError flag rather + # than raising exceptions, so we surface them explicitly. texts = [block.text for block in result.content if block.type == "text"] return f"Tool error: {' '.join(texts)}" - + texts = [block.text for block in result.content if block.type == "text"] - return "\n".join(texts) - + return "\n".join(texts) + except Exception as e: return f"Error calling tool '{tool_name}': {e}" @@ -167,23 +189,33 @@ def truncate_result(result: str) -> str: 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). + """Kürzt die Konversations-History wenn sie das Kontextfenster überschreitet. + + Behält immer den System-Prompt (Index 0) und die ursprüngliche Aufgabe (Index 1). + Entfernt die ältesten Nachrichten zuerst und injiziert danach einen Erinnerungs- + Hinweis damit der Agent den Überblick behält. + + Args: + messages: Vollständige Konversations-History als Liste von {role, content} Dicts. + + Returns: + Gekürzte History mit maximal MAX_HISTORY_CHARS Zeichen, immer mit Head + Reminder + Tail. """ total = sum(len(m["content"]) for m in messages) if total <= MAX_HISTORY_CHARS: return messages + # Protect the two anchor messages that must never be discarded. head = messages[:2] tail = messages[2:] original_task = messages[1]["content"] if len(messages) > 1 else "" - # Drop the oldest messages first (index 2 onwards) until we are under the limit. - # The system prompt (0) and original task (1) are never dropped. + # Drop the oldest non-anchor messages first until we are under the limit. while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS: tail.pop(0) - # Inject a reminder so the agent doesn't lose track of its goal after trimming. + # After trimming, inject a reminder so the agent doesn't lose track of its goal. + # Without this the agent might restart the task or repeat work it already did. reminder = { "role": "user", "content": ( @@ -196,13 +228,22 @@ def trim_messages(messages: list) -> list: return head + [reminder] + tail def _repair_json_strings(text: str) -> str: - """ - Replace unescaped control characters (newline, tab, carriage return) - inside JSON string values with their proper escape sequences. + """Replace unescaped control characters inside JSON string values. - LLMs frequently emit literal newlines inside long string values, which - is invalid JSON. This function fixes that without touching structural - whitespace outside strings. + 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 @@ -210,6 +251,8 @@ def _repair_json_strings(text: str) -> str: _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 @@ -218,10 +261,12 @@ def _repair_json_strings(text: str) -> str: 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) @@ -285,13 +330,25 @@ def extract_json(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() if text.startswith("```"): lines = text.split("\n") + # Omit the last line only if it is a closing fence; otherwise keep everything. end = -1 if lines[-1].strip() == "```" else len(lines) text = "\n".join(lines[1:end]) return text.strip() @@ -328,11 +385,18 @@ class CodingAgent: self.api_key = os.getenv("API_KEY") self.model = os.getenv("MODEL") - #async def _call_api(self, messages: list) -> str: def _call_api(self, messages: list) -> str: + """Send the conversation history to the LLM and return its reply text. - """Make a raw API call and return the response content string.""" - + Args: + messages: Full conversation history as a list of {role, content} dicts. + + Returns: + The raw string content of the assistant's reply. + + Raises: + Exception: On non-200 HTTP status or an unexpected response schema. + """ headers = {"Content-Type": "application/json"} if self.api_key and self.api_key != "EMPTY": headers["Authorization"] = f"Bearer {self.api_key}" @@ -340,11 +404,13 @@ class CodingAgent: payload = { "model": self.model, "messages": messages, - "temperature": 0.2, # low temperature → deterministic, more reliable tool calls + # Low temperature keeps the agent's tool selections deterministic and + # reduces the chance of hallucinated tool names or argument formats. + "temperature": 0.2, "max_tokens": 4096, "stream": False, } - + response = requests.post(self.api_url, headers=headers, json=payload, timeout=60) if response.status_code != 200: diff --git a/backend/agent/mcp_server_adapter.py b/backend/agent/mcp_server_adapter.py index 8df0885..e3be379 100644 --- a/backend/agent/mcp_server_adapter.py +++ b/backend/agent/mcp_server_adapter.py @@ -1,3 +1,15 @@ +"""Adapter layer between the CodingAgent and one or more MCP tool servers. + +MCPToolAdapter reads a JSON config file that lists MCP server processes, spawns +each process via stdio, queries its available tools, and stores them in a flat +registry. At call time it re-spawns the appropriate server process, executes +the requested tool, and returns the raw MCP result object. + +Design note: connections are opened per-call (not kept alive) because Streamlit +reruns make it impractical to maintain long-lived async context managers across +the synchronous/asynchronous boundary. +""" + import asyncio import json import sys @@ -7,20 +19,42 @@ from pathlib import Path from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client + 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.exit_stack: Dict[str, Any] = {} self.tool_registry: List[Dict[str, Any]] = [] def _load_config(self) -> Dict[str, Any]: - """Lädt die Server-Konfiguration aus der JSON-Datei.""" + """Load the MCP server configuration from the JSON file next to this module. + + Returns: + Parsed config dict, or an empty dict if the file is missing or invalid. + """ path = Path(__file__).parent / self.config_path if not path.exists(): print(f"Config file not found: {path}") return {} - + try: with open(path, 'r') as f: return json.load(f) @@ -29,7 +63,13 @@ class MCPToolAdapter: return {} async def initialize_all_servers(self): - """Lädt die Konfiguration und fragt alle Server ab, um die Tools zu registrieren.""" + """Connect to every configured MCP server and register their tools. + + Opens a short-lived stdio connection to each server, calls list_tools(), + and stores each discovered tool in ``self.tool_registry``. Servers that + fail to connect are skipped with a warning so a single broken server does + not prevent the others from loading. + """ print("Initializing MCP sessions...") config = self._load_config() print(f"Loaded config for servers: {list(config.keys())}") @@ -39,18 +79,21 @@ class MCPToolAdapter: 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: - # Verbindung aufbauen async with stdio_client(server_params) as (read_stream, write_stream): print(f"Connected to {server_name}. Initializing session...") async with ClientSession(read_stream, write_stream) as session: @@ -60,9 +103,9 @@ class MCPToolAdapter: print(f"Tools received from {server_name}: {result}") tools = result.tools print(f"Tools received from {server_name}: {result}") - #tools = getattr(result, '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 = [] @@ -76,7 +119,6 @@ class MCPToolAdapter: t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}" - self.tool_registry.append({ "server": server_name, "tool_name": tool.name, @@ -84,22 +126,37 @@ class MCPToolAdapter: }) print(f"Registered tool '{tool.name}' from {server_name}.") - - print(f"Session for {server_name} ready. {len(tools)} tools found.") + print(f"Session for {server_name} ready. {len(tools)} tools found.") except Exception as e: print(f"Failed to initialize {server_name}: {e}") def get_all_tools(self) -> List[Dict[str, Any]]: - """Gibt alle gesammelten Tools zurück.""" + """Return the full list of registered tools across all servers. + + Returns: + List of dicts, each with keys "server", "tool_name", "tool_description". + """ return self.tool_registry async def call_tool(self, tool_name: str, arguments: Dict[str, Any]): - """Findet den richtigen Server für ein Tool und führt es aus.""" - # Suche in der Registry nach dem passenden Server + """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: print(f"Tool '{tool_name}' not found in MCP adapter registry.") return f"Error: Tool '{tool_name}' not found in registry." @@ -109,11 +166,12 @@ class MCPToolAdapter: 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], @@ -127,11 +185,16 @@ class MCPToolAdapter: return result except Exception as 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.""" + """Close all open server connections gracefully. + + Note: This method references ``self.exit_stack`` which is not currently + populated (connections are opened per-call). It is kept as a placeholder + for a future persistent-connection implementation. + """ for server_name, (transport_gen, session) in self.exit_stack.items(): try: await session.__aexit__(None, None, None) diff --git a/backend/agent/servers/mcp_server_code_execution.py b/backend/agent/servers/mcp_server_code_execution.py index e04308a..ff02b9e 100644 --- a/backend/agent/servers/mcp_server_code_execution.py +++ b/backend/agent/servers/mcp_server_code_execution.py @@ -1,3 +1,19 @@ +"""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 from datetime import datetime import subprocess @@ -12,18 +28,30 @@ import shutil # ── Sandbox venv ──────────────────────────────────────────────────────────── SERVER_BASE_DIR = Path(__file__).parent.resolve() SANDBOX_DIR = SERVER_BASE_DIR / ".mcp_sandbox" +# Navigate four levels up from servers/ to reach the project root, then into workspace/. WORKSPACE_DIR = SERVER_BASE_DIR.parent.parent.parent.parent / "workspace" + def get_sandbox_paths(): - """Bestimmt die Executables innerhalb der Venv ohne os-Modul.""" + """Locate (and lazily create) the sandbox venv and return its executable paths. + + Creates the venv on first call if it does not yet exist. Uses a simple + filesystem check to determine whether we are on Windows (``Scripts/``) or + POSIX (``bin/``), avoiding the ``os`` module which is blocked inside the + sandbox itself. + + Returns: + Tuple of (python_exe_path, pip_exe_path) as strings. + """ if not SANDBOX_DIR.exists(): venv.create(SANDBOX_DIR, with_pip=True) - - bin_folder = "Scripts" if Path("C:/").exists() else "bin" # Einfacher Check für Windows - + + # Path("C:/").exists() is True on Windows, False on Linux/macOS. + bin_folder = "Scripts" if Path("C:/").exists() else "bin" + python_exe = SANDBOX_DIR / bin_folder / "python" pip_exe = SANDBOX_DIR / bin_folder / "pip" - + return str(python_exe), str(pip_exe) PYTHON_EXE, PIP_EXE = get_sandbox_paths() @@ -300,9 +328,17 @@ def install_package_into_sandbox(package_name: str) -> str: @mcp.tool() def reset_sandbox() -> str: - """Löscht die gesamte Sandbox und erstellt sie neu (Full Reset).""" + """Delete and recreate the sandbox virtual environment (full reset). + + Useful when a package installation went wrong or the venv became corrupted. + Calling get_sandbox_paths() after deletion triggers the lazy creation logic. + + Returns: + Confirmation string after the reset completes. + """ if SANDBOX_DIR.exists(): shutil.rmtree(SANDBOX_DIR) + # Re-calling get_sandbox_paths() triggers venv creation for the fresh sandbox. get_sandbox_paths() return "Sandbox wurde komplett zurückgesetzt." @@ -325,22 +361,26 @@ def run_python_code_sandboxed(code: str) -> str: Combined stdout+stderr, or an error message in str format. """ + # 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}" + # Each run gets its own temporary directory so concurrent runs don't interfere. run_id = datetime.now().strftime("%Y%m%d_%H%M%S") jail_dir = WORKSPACE_DIR / f"sandbox_run_{run_id}" - + try: jail_dir.mkdir(parents=True, exist_ok=True) + # Minimal environment: only the sandbox Python is on PATH, HOME and TMPDIR + # point to the per-run jail directory so the subprocess cannot access user files. custom_env = { - "PYTHONPATH": str(WORKSPACE_DIR), - "PATH": str(Path(PYTHON_EXE).parent), - "HOME": str(jail_dir), - "TMPDIR": str(jail_dir) - } + "PYTHONPATH": str(WORKSPACE_DIR), + "PATH": str(Path(PYTHON_EXE).parent), + "HOME": str(jail_dir), + "TMPDIR": str(jail_dir) + } result = subprocess.run( [PYTHON_EXE, "-c", code], @@ -349,47 +389,54 @@ def run_python_code_sandboxed(code: str) -> str: capture_output=True, text=True, timeout=EXEC_TIMEOUT) - + + # 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)." - + return output - + except subprocess.TimeoutExpired: return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated." except Exception as e: return f"Error during code execution: {e}" - + finally: + # Always clean up the per-run jail directory, even if execution failed. if jail_dir.exists(): shutil.rmtree(jail_dir) @mcp.tool() def python_code_validation(code: str) -> str: - """ - Validate Python code for syntax and safety without executing it. - This tool performs static analysis to check for syntax errors. - + """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 code to validate in str format. + code: The Python source code to validate. + Returns: - A message indicating the validation result. - And if sandboxed test execution is allowed. + 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). """ try: ast.parse(code) except SyntaxError as e: return f"SyntaxError: {e}" - try: + try: static_analysis_result = check_code_safety(code) if static_analysis_result: + # Inform the agent that the code would be rejected by run_python_code_sandboxed. return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed." except Exception as e: return f"Error during code safety analysis: {e}" diff --git a/backend/agent/servers/mcp_server_file_search.py b/backend/agent/servers/mcp_server_file_search.py index 65180bc..dbcbdb1 100644 --- a/backend/agent/servers/mcp_server_file_search.py +++ b/backend/agent/servers/mcp_server_file_search.py @@ -1,10 +1,27 @@ +"""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 # ── 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"] +ALLOWED_FILE_TYPES = [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml", + ".sh", ".md", ".txt", ".tex", ".c", ".cpp", ".java"] # ── Create the MCP server ──────────────────────────────────────────────────── mcp = FastMCP("FileSearchServer") @@ -63,12 +80,30 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str: def _tree(dir_path: Path, prefix="") -> str: - entries = sorted([e for e in dir_path.iterdir() if "__pycache__" not in e.parts], key=lambda x: (x.is_file(), x.name)) + """Recursively build a tree string for the given directory. + + Directories are sorted before files (``key=lambda x: (x.is_file(), x.name)`` + puts dirs first because False < True). __pycache__ entries are hidden. + + Args: + dir_path: The directory to render. + prefix: Indentation prefix accumulated during recursion. + + Returns: + Multi-line string representing the subtree. + """ + # Exclude __pycache__ at every level to keep output readable for the agent. + entries = sorted( + [e for e in dir_path.iterdir() if "__pycache__" not in e.parts], + key=lambda x: (x.is_file(), x.name) # directories first, then files + ) lines = [] 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) diff --git a/backend/agent/servers/mcp_server_web_search.py b/backend/agent/servers/mcp_server_web_search.py index 39c9fb4..810c285 100644 --- a/backend/agent/servers/mcp_server_web_search.py +++ b/backend/agent/servers/mcp_server_web_search.py @@ -1,3 +1,13 @@ +"""MCP server that provides web search and page-fetching tools. + +Exposes two MCP tools: + - web_search — keyword search via DuckDuckGo, returns titles, URLs, snippets + - fetch_page — fetch and extract readable text from a URL + +All outbound requests are guarded by _validate_url() which blocks non-HTTP +schemes and private/loopback IP ranges to prevent SSRF vulnerabilities. +""" + from urllib.parse import urlparse from mcp.server.fastmcp import FastMCP @@ -12,7 +22,22 @@ mcp = FastMCP("WebSearchServer") # ── Helper: URL validation (SSRF prevention) ───────────────────────────────── def _validate_url(url: str) -> str: - """Validate a URL to prevent SSRF attacks.""" + """Validate a URL and raise ValueError if it could be used for an SSRF attack. + + Blocks: + - Non-HTTP(S) schemes (file://, ftp://, etc.) + - Loopback and metadata addresses (localhost, 127.0.0.1, 169.254.169.254) + - RFC-1918 private IP ranges (10.x, 172.16-31.x, 192.168.x) + + Args: + url: The URL string to validate. + + Returns: + The original URL string unchanged if it passes all checks. + + Raises: + ValueError: If the URL fails any of the security checks. + """ parsed = urlparse(url) if parsed.scheme not in ("http", "https"): @@ -22,10 +47,13 @@ def _validate_url(url: str) -> str: hostname = parsed.hostname or "" + # Block well-known loopback and cloud-metadata addresses. blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"} if hostname in blocked_hosts: raise ValueError(f"Blocked internal host: {hostname}") + # Block all private RFC-1918 ranges by checking the string prefix. + # This is a best-effort check; a full implementation would resolve DNS first. private_prefixes = ( "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", @@ -100,6 +128,7 @@ def fetch_page(url: str) -> str: 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() diff --git a/backend/managers/execution_engine.py b/backend/managers/execution_engine.py index b4a449a..caeb603 100644 --- a/backend/managers/execution_engine.py +++ b/backend/managers/execution_engine.py @@ -1,3 +1,10 @@ +"""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 from pathlib import Path diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index add29f3..62360b0 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -12,6 +12,15 @@ WORKSPACE = Path("workspace") WORKSPACE.mkdir(exist_ok=True) class FileManager: + """Manages all file and folder operations inside the workspace directory. + + Every public method resolves the given path and verifies that the result + stays within ``base_path`` before touching the filesystem. This prevents + path-traversal attacks where a caller might pass ``../../etc/passwd``. + + The workspace directory is created on first use if it does not yet exist. + """ + def __init__(self, base_path=Path("workspace")) -> None: self.base_path = Path(base_path) self.base_path.mkdir(exist_ok=True) diff --git a/frontend/chat.py b/frontend/chat.py index 43ab8bc..f5b785f 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -9,10 +9,23 @@ import asyncio # ── Agent Mode helpers ──────────────────────────────────────────────────────── def _run_async(coro): - """Hilfsfunktion um async Code in sync Streamlit auszuführen""" + """Execute an async coroutine from synchronous Streamlit code. + + Streamlit runs in a synchronous context, but the CodingAgent uses async + methods (for MCP tool calls). This helper bridges the gap by reusing an + already-running event loop when one exists, or creating a new one otherwise. + + Args: + coro: The coroutine to run. + + Returns: + The return value of the coroutine. + """ try: + # Reuse the loop that is already running (e.g. inside pytest-asyncio). loop = asyncio.get_running_loop() except RuntimeError: + # No running loop in this thread — create a fresh one. loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) return loop.run_until_complete(coro) diff --git a/frontend/editor.py b/frontend/editor.py index 9f9c8b8..119f2f3 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -60,6 +60,14 @@ def _rename_dialog(file_path: str): @st.dialog("Delete File") def _delete_dialog(abs_file_path: str): + """Confirmation dialog before permanently deleting the given file. + + Removes the file from disk and also cleans up the editor's open-files list, + content cache, and active_file pointer so the UI stays consistent. + + Args: + abs_file_path: Absolute path to the file that should be deleted. + """ fm = FileManager() file_name = Path(abs_file_path).name relative_path = str(Path(abs_file_path).relative_to(fm.base_path)) diff --git a/frontend/sidebar.py b/frontend/sidebar.py index 1a6b9d4..0f8bd2e 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -53,6 +53,12 @@ def _delete_folder_dialog(folder_rel: str, folder_name: str): @st.dialog("Add File") def _add_file_dialog(parent_path: str = ""): + """Dialog for creating a new file inside the given folder (or workspace root). + + Args: + parent_path: Workspace-relative path of the parent folder. Pass an + empty string to create the file at the workspace root. + """ with st.form("add_file_form"): name = st.text_input("File name:", placeholder="e.g. script.py") col1, col2 = st.columns(2) @@ -77,6 +83,12 @@ def _add_file_dialog(parent_path: str = ""): @st.dialog("Add Folder") def _add_folder_dialog(parent_path: str = ""): + """Dialog for creating a new subfolder inside the given folder (or workspace root). + + Args: + parent_path: Workspace-relative path of the parent folder. Pass an + empty string to create the folder at the workspace root. + """ with st.form("add_folder_form"): name = st.text_input("Folder name:", placeholder="e.g. utils") col1, col2 = st.columns(2) @@ -166,6 +178,15 @@ def _rename_file_dialog(relative_file_path: str, file_name: str): @st.dialog("Delete File") def _delete_file_dialog(relative_file_path: str, file_name: str): + """Confirmation dialog before permanently deleting a file. + + After a successful delete the file is also removed from the editor's + open-files list and content cache so it cannot be saved back to disk. + + Args: + relative_file_path: Workspace-relative path to the file (used by FileManager). + file_name: Display name shown in the warning message. + """ st.warning(f"Delete **{file_name}**? This cannot be undone.") col1, col2 = st.columns(2) @@ -178,6 +199,7 @@ def _delete_file_dialog(relative_file_path: str, file_name: str): st.session_state.open_files.remove(abs_file_path) st.session_state.files_content.pop(abs_file_path, None) + # Fall back to the first remaining open file, or None if all tabs are closed. if st.session_state.active_file == abs_file_path: st.session_state.active_file = ( st.session_state.open_files[0] diff --git a/frontend/state.py b/frontend/state.py index 2a4b25d..7469725 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -10,45 +10,52 @@ from backend.managers.chat_manager import ChatManager 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: st.session_state.last_selected = None - # Absolute path and workspace-relative path of the currently highlighted folder + # Absolute path and workspace-relative path of the currently highlighted folder. + # Both are set together; both are cleared together when a folder is deselected. if "selected_folder" not in st.session_state: st.session_state.selected_folder = None if "selected_folder_rel" not in st.session_state: st.session_state.selected_folder_rel = None - # Chat manager (persists across reruns) - # ChatManager keeps the full conversation history in memory 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: st.session_state.chat_manager = ChatManager() - # Editor state initialization - # List of absolute file paths that are currently open as tabs + # ── 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: - """A list of currently open file paths - absolute paths only. The order determines the tab order in the UI. - Format: [ "path/to/file1.py", "path/to/file2.js", ... ] - """ st.session_state.open_files = [] - # Dict mapping file path → current editor content (may be unsaved) + # Dict mapping absolute file path → current editor content (may differ from + # disk if the user has unsaved changes). if "files_content" not in st.session_state: - """A dictionary mapping file paths to their current content in the editor. - Format: { "path/to/file.py": "file content as string", ... } - """ st.session_state.files_content = {} - # Absolute path of the file whose tab is currently active + # Absolute path of the file whose tab is currently active in the editor. + # Must always be one of the paths in open_files, or None if no file is open. if "active_file" not in st.session_state: - """The currently active file in the editor (absolute path in string e.g. "/workspace/path/to/file.py"). - Should be one of the paths in open_files or None if no file is open.""" st.session_state.active_file = None - # Index of the active tab (used by st.tabs) + # Index of the active tab — kept in sync with active_file for st.tabs(). if "active_tab" not in st.session_state: st.session_state.active_tab = 0 @@ -58,33 +65,41 @@ def init_state(): if "code_suggestions" not in st.session_state: st.session_state.code_suggestions = [] - # Output dict from the last code run: {stdout, stderr, return_code} + # Output dict from the last code execution: {stdout, stderr, return_code}. + # Initialised as empty string so the editor view can safely check falsyness. if "code_execution_output" not in st.session_state: st.session_state.code_execution_output = "" - # Chat state initialization - # Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles + # ── Chat state ──────────────────────────────────────────────────────────── + + # Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles. + # System messages are stored here too but skipped during display. if "chat_history" not in st.session_state: st.session_state.chat_history = [] - # Agent Mode state - # Whether the UI is currently in Agent Mode (vs normal chat) + # ── Agent Mode state ────────────────────────────────────────────────────── + + # Boolean toggle — True while the UI is in Coding Agent mode. if "agent_mode" not in st.session_state: st.session_state.agent_mode = False - # The live CodingAgent instance while a task is running + # The live CodingAgent instance while a task is running. + # Set by _start_agent(), cleared by _reset_agent(). if "coding_agent" not in st.session_state: st.session_state.coding_agent = None - # Current status of the agent: "idle" | "waiting_approval" | "done" + # Lifecycle state of the agent: "idle" | "waiting_approval" | "done". + # Controls which sub-screen render_agent_mode() displays. if "agent_status" not in st.session_state: st.session_state.agent_status = "idle" - # List of completed steps shown in the collapsible Agent Log + # Chronological list of completed step records shown in the Agent Log expander. + # Each entry: {"thought": str, "tool": str, "arguments": dict, "result": str} if "agent_log" not in st.session_state: st.session_state.agent_log = [] - # The action the agent proposed but has not yet been approved or rejected + # The action the agent has proposed but that has not yet been approved or + # rejected by the user. Stored as the raw dict returned by propose_next_action(). if "agent_pending_action" not in st.session_state: st.session_state.agent_pending_action = None -- 2.30.2 From 7afa3452792ad7882dc8a264abdd0db1f8d83be8 Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Sun, 24 May 2026 10:28:35 +0200 Subject: [PATCH 3/9] docs: update README.md and READMEnew.md with full project documentation --- README.md | 201 ++++++++++++++------------- READMEnew.md | 383 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 491 insertions(+), 93 deletions(-) create mode 100644 READMEnew.md diff --git a/README.md b/README.md index 97f4062..135e3e2 100644 --- a/README.md +++ b/README.md @@ -2,106 +2,102 @@ AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) -## Project Structure +## Projektstruktur ``` AISE_AIAgent/ -├── frontend/ # Streamlit UI Components -│ ├── __init__.py -│ ├── app.py # Main Streamlit application entry point -│ ├── sidebar.py # File navigation sidebar component -│ ├── editor.py # Code editor pane component -│ └── chat.py # Chat interface component +├── frontend/ # Streamlit UI-Komponenten +│ ├── app.py # Haupteinstiegspunkt der Streamlit-App +│ ├── state.py # Session-State-Verwaltung +│ ├── sidebar.py # Datei-Navigation (Sidebar) +│ ├── editor.py # Code-Editor-Pane +│ └── chat.py # Chat-Interface │ -├── backend/ # Backend Logic Modules -│ ├── __init__.py -│ ├── managers/ # Business logic for UI operations -│ │ ├── __init__.py -│ │ ├── file_manager.py # File I/O operations for UI (read, write, list files) -│ │ ├── chat_manager.py # AI chat management and history -│ │ ├── system_prompter.py # System prompts and context injection -│ │ ├── search_manager.py # Internet search functionality -│ │ ├── execution_engine.py # Code execution and sandboxing -│ │ └── debug_logger.py # Logging, error handling, debug messages +├── backend/ # Backend-Logik +│ ├── managers/ # Business-Logik für UI-Operationen +│ │ ├── file_manager.py # Datei-CRUD (lesen, schreiben, listen) +│ │ ├── chat_manager.py # AI-Chat-Verwaltung und -History +│ │ ├── system_prompter.py # System-Prompts und Kontext-Injektion +│ │ ├── search_manager.py # Web-Suche (DuckDuckGo) +│ │ ├── execution_engine.py # Code-Ausführung und Sandboxing +│ │ └── debug_logger.py # Logging, Fehlerbehandlung, Debug-Ausgaben │ │ -│ ├── agents/ # AI Agent System -│ │ ├── __init__.py -│ │ ├── coding_agent.py # Main agent loop (plan-act-observe cycle) -│ │ └── tools.py # Tools available to agent (7 functions + dispatcher) -│ │ -│ └── utils/ # Helper Utilities -│ ├── __init__.py -│ └── server_utils.py # LLM client init, chat functions, formatters +│ └── agent/ # Autonomes AI-Agent-System (MCP-basiert) +│ ├── coding_agent.py # Haupt-Agent-Loop (Plan-Act-Observe) +│ ├── mcp_server_adapter.py # MCP-Adapter: verbindet Agent mit MCP-Servern +│ ├── mcp_server_adapter_RAG.py # MCP-Adapter mit RAG-basierter Tool-Auswahl +│ ├── mcp_server_config.json # Konfiguration der MCP-Server (Startbefehle) +│ └── servers/ # MCP-Server-Implementierungen +│ ├── mcp_server_code_execution.py # Tool: Python-Code ausführen +│ ├── mcp_server_file_search.py # Tool: Dateien suchen und lesen +│ └── mcp_server_web_search.py # Tool: Web-Suche via DuckDuckGo │ -├── tests/ # Unit Tests -│ ├── __init__.py -│ ├── test_file_manager.py # Tests for file operations -│ ├── test_chat_manager.py # Tests for chat functionality -│ ├── test_execution_engine.py # Tests for code execution -│ └── test_main.py # Integration tests +├── tests/ # Unit-Tests (pytest) +│ ├── conftest.py # Globale Test-Fixtures und MCP-Mocks +│ ├── test_file_manager.py +│ ├── test_chat_manager.py +│ ├── test_execution_engine.py +│ ├── test_coding_agent.py +│ ├── test_debug_logger.py +│ ├── test_system_prompter.py +│ ├── test_mcp_server_code_execution.py +│ ├── test_mcp_server_file_search.py +│ └── test_mcp_server_web_search.py │ -├── workspace/ # Agent Sandbox Directory -│ └── .gitkeep # Placeholder for agent to work safely in isolation -│ -├── .gitignore # Git exclusions (venv, .env, __pycache__, etc.) -├── .env # Local environment variables (NOT committed) -├── .env.example # Template for environment variables (IS committed) -├── requirements.txt # Python dependencies -├── README.md # This file -└── project_exercise.pdf # Project specification +├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis) +├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent +└── .env.example # Vorlage für Umgebungsvariablen ``` -## Component Responsibilities +## Komponenten ### Frontend (`frontend/`) -- **app.py**: Main Streamlit application, layout orchestration -- **sidebar.py**: File browser and project navigation -- **editor.py**: Code editing interface with syntax highlighting -- **chat.py**: AI assistant chat interface +- **app.py**: Streamlit-Applikation, Layout-Orchestrierung +- **state.py**: Zentralisierte Session-State-Verwaltung +- **sidebar.py**: Datei-Browser und Projekt-Navigation +- **editor.py**: Code-Editor mit Syntax-Highlighting +- **chat.py**: AI-Assistent Chat-Interface -### Backend Managers (`backend/managers/`) -Used directly by Frontend for UI operations: -- **file_manager.py**: CRUD operations on project files -- **chat_manager.py**: Chat history, message management -- **system_prompter.py**: System prompt generation and file context -- **execution_engine.py**: Safe code execution with output capture -- **debug_logger.py**: Error tracking and log formatting -- **search_manager.py**: Web search integration +### Backend Manager (`backend/managers/`) +Werden direkt vom Frontend für UI-Operationen genutzt: +- **file_manager.py**: CRUD-Operationen auf Projektdateien +- **chat_manager.py**: Chat-History, Nachrichten-Verwaltung +- **system_prompter.py**: System-Prompt-Generierung und Datei-Kontext +- **execution_engine.py**: Sichere Code-Ausführung mit Output-Capture +- **debug_logger.py**: Fehler-Tracking und Log-Formatierung +- **search_manager.py**: Web-Suche via DuckDuckGo (`ddgs`-Bibliothek) -### 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/`) -- **server_utils.py**: LLM client initialization, chat helpers, message formatters +### Backend Agent (`backend/agent/`) +Autonomes AI-Agent-System für komplexe Coding-Aufgaben: +- **coding_agent.py**: Agent-Loop (Plan → Act → Observe → Wiederholen) +- **mcp_server_adapter.py**: Verbindet den Agent mit MCP-Servern via Konfigurationsdatei +- **mcp_server_adapter_RAG.py**: Erweiterter Adapter mit semantischer Tool-Auswahl (RAG) +- **mcp_server_config.json**: Definiert welche MCP-Server gestartet werden und mit welchen Argumenten +- **servers/**: Die eigentlichen MCP-Tool-Server (Code-Ausführung, Datei-Suche, Web-Suche) ### Workspace (`workspace/`) -- Sandbox directory where agent executes and stores files -- Prevents agent from accessing files outside this directory +- Sandbox-Verzeichnis, in dem der Agent Dateien erstellt und ausführt +- Verhindert, dass der Agent auf Dateien ausserhalb dieses Verzeichnisses zugreift ## 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 +- **Datei-Verwaltung**: Dateien im Workspace durchsuchen und bearbeiten +- **Chat-Interface**: KI-gestützter Code-Assistent +- **Code-Ausführung**: Python-Code sicher ausführen mit Debug-Output +- **Web-Suche**: Dokumentation und Beispiele via DuckDuckGo abrufen +- **Autonomer Agent**: MCP-basierter Coding-Agent mit Plan-Act-Observe-Loop +- **RAG Tool-Auswahl**: Semantische Tool-Selektion via Sentence Transformers ## Setup -### 1. Project Clonen +### 1. Repository klonen -1. In den Zielordner wechseln -cd /pfad/zum/zielordner - -2. Repository klonen +```bash git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git - -3. In das Projekt wechseln cd AISE1_Project +``` -### 2. Activate Virtual Environment +### 2. Virtuelle Umgebung aktivieren ```bash # Windows @@ -111,43 +107,62 @@ cd AISE1_Project source .venv/bin/activate ``` -### 3. Install Dependencies +### 3. Abhängigkeiten installieren ```bash pip install -r requirements.txt ``` -### 4. Run Application +### 4. Umgebungsvariablen konfigurieren + +```bash +cp .env.example .env +# .env mit API-Keys befüllen +``` + +### 5. Applikation starten ```bash streamlit run frontend/app.py ``` -### 5. Run Tests +### 6. Tests ausführen ```bash -pytest tests/ +pytest tests/ -v ``` -## Architecture +## Konfiguration: `mcp_server_config.json` -The application follows a frontend-backend split: +Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Agent starten soll. Jeder Eintrag enthält den Servernamen, den Startbefehl (`command`) und optionale Argumente (`args`) sowie Umgebungsvariablen (`env`): -- **Frontend**: Streamlit UI components (sidebar, editor, chat) -- **Backend**: Specialized manager modules - - FileManager: File operations - - ChatManager: AI interaction - - SystemPrompter: Prompt management - - SearchManager: Internet search - - ExecutionEngine: Code execution - - DebugLogger: Error handling & logging +```json +{ + "FileSearchServer": { + "command": "py", + "args": ["servers/mcp_server_file_search.py"] + }, + "WebSearchServer": { + "command": "py", + "args": ["servers/mcp_server_web_search.py"], + "env": { "DDGS_API_KEY": "your_key_here" } + } +} +``` -## Development +## Architektur -Use Git to track changes: +``` +Frontend (Streamlit) ──► Backend Manager ──► AI API + │ + └──► Coding Agent ──► MCP-Adapter ──► MCP-Server + (Code / File / Web) +``` + +## Entwicklung ```bash git add . -git commit -m "Your message" -git push origin sturcture +git commit -m "Deine Nachricht" +git push origin main ``` diff --git a/READMEnew.md b/READMEnew.md new file mode 100644 index 0000000..e8a4a09 --- /dev/null +++ b/READMEnew.md @@ -0,0 +1,383 @@ +# AISE AI Code Editor — Technische Dokumentation + +AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) + +Diese Datei enthält die ausführliche technische Dokumentation des Projekts. Für eine Kurzübersicht siehe `README.md`. + +--- + +## Inhaltsverzeichnis + +1. [Projektstruktur](#projektstruktur) +2. [Frontend](#frontend) +3. [Backend Manager](#backend-manager) +4. [Backend Agent (MCP-System)](#backend-agent-mcp-system) +5. [MCP-Server-Konfiguration](#mcp-server-konfiguration) +6. [Tests](#tests) +7. [Setup](#setup) +8. [Architektur-Übersicht](#architektur-übersicht) + +--- + +## Projektstruktur + +``` +AISE_AIAgent/ +├── frontend/ # Streamlit UI-Komponenten +│ ├── app.py # Haupteinstiegspunkt der Streamlit-App +│ ├── state.py # Session-State-Verwaltung +│ ├── sidebar.py # Datei-Navigation (Sidebar) +│ ├── editor.py # Code-Editor-Pane +│ └── chat.py # Chat-Interface +│ +├── backend/ # Backend-Logik +│ ├── managers/ # Business-Logik für UI-Operationen +│ │ ├── file_manager.py # Datei-CRUD (lesen, schreiben, listen) +│ │ ├── chat_manager.py # AI-Chat-Verwaltung und -History +│ │ ├── system_prompter.py # System-Prompts und Kontext-Injektion +│ │ ├── search_manager.py # Web-Suche (DuckDuckGo) +│ │ ├── execution_engine.py # Code-Ausführung und Sandboxing +│ │ └── debug_logger.py # Logging, Fehlerbehandlung, Debug-Ausgaben +│ │ +│ └── agent/ # Autonomes AI-Agent-System (MCP-basiert) +│ ├── coding_agent.py # Haupt-Agent-Loop (Plan-Act-Observe) +│ ├── mcp_server_adapter.py # MCP-Adapter: verbindet Agent mit MCP-Servern +│ ├── mcp_server_adapter_RAG.py # MCP-Adapter mit RAG-basierter Tool-Auswahl +│ ├── mcp_server_config.json # Konfiguration der MCP-Server (Startbefehle) +│ └── servers/ # MCP-Server-Implementierungen +│ ├── mcp_server_code_execution.py # Tool: Python-Code ausführen +│ ├── mcp_server_file_search.py # Tool: Dateien suchen und lesen +│ └── mcp_server_web_search.py # Tool: Web-Suche via DuckDuckGo +│ +├── tests/ # Unit-Tests (pytest) +│ ├── conftest.py # Globale Test-Fixtures und MCP-Mocks +│ ├── test_file_manager.py +│ ├── test_chat_manager.py +│ ├── test_execution_engine.py +│ ├── test_coding_agent.py +│ ├── test_debug_logger.py +│ ├── test_system_prompter.py +│ ├── test_mcp_server_code_execution.py +│ ├── test_mcp_server_file_search.py +│ └── test_mcp_server_web_search.py +│ +├── workspace/ # Agent-Sandbox (isoliertes Arbeitsverzeichnis) +├── run_agent.py # CLI-Einstiegspunkt für den Coding-Agent +└── .env.example # Vorlage für Umgebungsvariablen +``` + +--- + +## Frontend + +Das Frontend besteht aus Streamlit-Komponenten, die zusammen eine interaktive Code-Editor-Oberfläche bilden. + +### `app.py` +Haupteinstiegspunkt der Applikation. Orchestriert das Layout und initialisiert alle UI-Komponenten (Sidebar, Editor, Chat). + +### `state.py` +Zentralisierte Verwaltung des Streamlit Session-State. Stellt sicher, dass alle Komponenten denselben Zustand (geöffnete Datei, Chat-History, Agent-Status) teilen. + +### `sidebar.py` +Datei-Browser und Projekt-Navigation. Erlaubt das Durchsuchen des Workspaces und das Öffnen von Dateien im Editor. + +### `editor.py` +Code-Editor-Pane mit Syntax-Highlighting. Ermöglicht das Bearbeiten und Speichern von Code-Dateien direkt im Browser. + +### `chat.py` +Chat-Interface für den KI-Assistenten. Zeigt die Konversations-History und ermöglicht Eingaben an das AI-Modell. + +--- + +## Backend Manager + +Die Manager-Klassen kapseln die Business-Logik und werden direkt vom Frontend aufgerufen. + +### `file_manager.py` +Stellt CRUD-Operationen auf dem Workspace-Verzeichnis bereit: +- Dateien lesen, schreiben, umbenennen, löschen +- Verzeichnisstruktur auflisten +- Sichere Pfadvalidierung (verhindert Path-Traversal) + +### `chat_manager.py` +Verwaltet AI-Chat-Interaktionen: +- Aufbau und Verwaltung der Chat-History +- Senden von Nachrichten an das AI-Modell +- Formatierung von System- und User-Nachrichten + +### `system_prompter.py` +Generiert kontextreiche System-Prompts für den AI-Assistenten: +- Injektion von aktuellem Dateiinhalt als Kontext +- Steuerung des AI-Verhaltens (Coding-Assistent-Persona) + +### `execution_engine.py` +Führt Python-Code sicher aus: +- Subprocess-basierte Code-Ausführung +- Timeout-Schutz und Output-Capture +- Fehler- und Exception-Handling + +### `debug_logger.py` +Logging und Fehler-Tracking: +- Formatierte Log-Ausgaben für Debugging +- Fehler-Aggregation für die UI-Darstellung + +### `search_manager.py` +Web-Suche für den KI-Assistenten via DuckDuckGo: +- Nutzt die `ddgs`-Bibliothek (DuckDuckGo Search) für API-freie Websuche +- Gibt strukturierte Suchergebnisse zurück (Titel, URL, Snippet) +- Wird vom Chat-Manager aufgerufen, wenn der Assistent externe Dokumentation oder Code-Beispiele benötigt +- Keine API-Key-Konfiguration notwendig (da DuckDuckGo öffentlich zugänglich ist) + +--- + +## Backend Agent (MCP-System) + +Der Agent ist ein autonomes System, das komplexe Coding-Aufgaben selbstständig löst. Er kommuniziert mit externen Tool-Servern über das **Model Context Protocol (MCP)**. + +### `coding_agent.py` +Implementiert den Plan-Act-Observe-Loop: +1. **Plan**: Das AI-Modell wählt das nächste Tool und Argumente +2. **Act**: Das Tool wird via MCP-Adapter aufgerufen (nach User-Bestätigung) +3. **Observe**: Das Ergebnis wird in die Message-History eingefügt +4. Der Loop wiederholt sich bis zur Fertigstellung oder einem `done`-Tool-Aufruf + +Wichtige Klassen und Funktionen: +- `CodingAgent`: Haupt-Klasse mit `start_task()`, `propose_next_action()`, `approve()`, `reject()` +- `truncate_result()`: Kürzt lange Tool-Outputs bevor sie in die History gehen +- `trim_messages()`: Entfernt alte Turns aus der History wenn das Kontextfenster voll wird +- `_strip_code_fences()`: Bereinigt Markdown-Fences aus LLM-JSON-Antworten + +Konstanten: `MAX_ITERATIONS`, `MAX_RESULT_LENGTH`, `MAX_HISTORY_CHARS` + +### `mcp_server_adapter.py` +Verbindet den Coding-Agent mit den MCP-Tool-Servern: +- Liest `mcp_server_config.json` und startet die konfigurierten Server als Subprozesse +- Baut MCP-Sessions via `stdio_client` auf +- Registriert alle verfügbaren Tools aus allen Servern in einem zentralen `tool_registry` +- Delegiert Tool-Aufrufe an den richtigen Server via `call_tool()` + +Hauptmethoden: +- `initialize_all_servers()`: Startet alle Server und baut Sessions auf +- `get_all_tools()`: Gibt alle registrierten Tool-Definitionen zurück +- `call_tool(tool_name, arguments)`: Führt ein Tool auf dem zuständigen Server aus + +### `mcp_server_adapter_RAG.py` +Erweiterter MCP-Adapter mit semantischer Tool-Auswahl via Retrieval-Augmented Generation (RAG): + +**Motivation**: Bei vielen MCP-Tools kann das LLM-Kontextfenster überfüllt werden, wenn alle Tool-Definitionen mitgesendet werden. Der RAG-Adapter löst dies durch semantische Vorauswahl. + +**Funktionsweise**: +1. Beim Initialisieren werden alle Tool-Beschreibungen mit `SentenceTransformer('all-MiniLM-L6-v2')` in Embeddings umgewandelt +2. Bei jedem Agent-Schritt wird der aktuelle Task als Query kodiert +3. Cosine-Similarity zwischen Query- und Tool-Embeddings bestimmt die `top_k` relevantesten Tools +4. Nur diese Tools werden dem LLM als verfügbare Aktionen präsentiert + +Hauptmethoden: +- `initialize_all_sessions()`: Startet Server, baut Sessions auf, erstellt Embedding-Index +- `get_relevant_tools(query, top_k=5)`: Gibt die `top_k` semantisch ähnlichsten Tools zurück +- `call_tool(tool_name, arguments)`: Findet den zuständigen Server und führt das Tool aus +- `shutdown_all_sessions()`: Schliesst alle offenen MCP-Sessions sauber + +Abhängigkeit: `sentence-transformers`, `numpy` + +**Hinweis**: Diese Klasse befindet sich noch in der Entwicklung (Work in Progress). Es gibt bekannte Bugs (z.B. Tippfehler `commanf` statt `command`, falsche Verwendung von `result.get()` vs. `result.tools`). + +--- + +## MCP-Server-Konfiguration + +### Format: `mcp_server_config.json` + +Die Datei `backend/agent/mcp_server_config.json` definiert, welche MCP-Server der Adapter starten soll. Das Format ist ein JSON-Objekt, wobei jeder Key ein frei wählbarer Servername ist: + +```json +{ + "ServerName": { + "command": "py", + "args": ["servers/mcp_server_datei.py"], + "env": { + "API_KEY": "optional_key" + } + } +} +``` + +| Feld | Pflicht | Beschreibung | +|-----------|---------|--------------| +| `command` | Ja | Ausführbares Programm (z.B. `py`, `python3`, `node`) | +| `args` | Ja | Argumente als Array (Pfad zum Server-Script) | +| `env` | Nein | Umgebungsvariablen für den Serverprozess | + +### Aktuelle Server + +```json +{ + "FileSearchServer": { + "command": "py", + "args": ["servers/mcp_server_file_search.py"] + }, + "WebSearchServer": { + "command": "py", + "args": ["servers/mcp_server_web_search.py"], + "env": { "DDGS_API_KEY": "your_ddgs_api_key_here" } + }, + "CodeExecutionServer": { + "command": "py", + "args": ["servers/mcp_server_code_execution.py"] + } +} +``` + +### Neuen MCP-Server hinzufügen + +1. Neues Server-Script in `backend/agent/servers/` erstellen (MCP-konformes Python-Script) +2. Eintrag in `mcp_server_config.json` ergänzen: + ```json + "MeinNeuerServer": { + "command": "py", + "args": ["servers/mcp_server_mein_tool.py"] + } + ``` +3. Der Adapter erkennt den neuen Server beim nächsten Start automatisch und registriert seine Tools + +--- + +## Tests + +### Tests ausführen + +```bash +# Alle Tests ausführen +pytest tests/ -v + +# Einzelnes Test-Modul ausführen +pytest tests/test_coding_agent.py -v + +# Tests mit Kurzausgabe +pytest tests/ +``` + +### Teststruktur + +Die Tests liegen in `tests/` und folgen dem Muster `test_.py`. + +#### `conftest.py` +Globale Pytest-Konfiguration. Patcht den `MCPToolAdapter` auf `sys.modules`-Ebene, bevor irgendein Test-Modul importiert wird. Dadurch werden beim Import von `coding_agent` keine echten MCP-Subprozesse gestartet. Der Mock-Adapter liefert sofort leere Ergebnisse zurück. + +#### Was wird getestet + +| Test-Datei | Getestetes Modul | Schwerpunkt | +|---|---|---| +| `test_file_manager.py` | `backend/managers/file_manager.py` | Datei-CRUD, Pfad-Validierung | +| `test_chat_manager.py` | `backend/managers/chat_manager.py` | Chat-History, Nachrichtenformatierung | +| `test_execution_engine.py` | `backend/managers/execution_engine.py` | Code-Ausführung, Timeouts, Fehlerbehandlung | +| `test_system_prompter.py` | `backend/managers/system_prompter.py` | Prompt-Generierung, Kontext-Injektion | +| `test_debug_logger.py` | `backend/managers/debug_logger.py` | Log-Formatierung, Fehler-Aggregation | +| `test_coding_agent.py` | `backend/agent/coding_agent.py` | Agent-Loop, Tool-Dispatch, History-Trimming | +| `test_mcp_server_code_execution.py` | `backend/agent/servers/mcp_server_code_execution.py` | MCP Code-Execution-Tool | +| `test_mcp_server_file_search.py` | `backend/agent/servers/mcp_server_file_search.py` | MCP Datei-Such-Tool | +| `test_mcp_server_web_search.py` | `backend/agent/servers/mcp_server_web_search.py` | MCP Web-Such-Tool | + +#### Testklassen in `test_coding_agent.py` + +- **`TestTruncateResult`**: Prüft, dass lange Tool-Outputs korrekt gekürzt werden +- **`TestTrimMessages`**: Prüft, dass alte History-Turns entfernt werden wenn der Kontext zu gross wird; System-Message und Original-Task bleiben immer erhalten +- **`TestStripCodeFences`**: Prüft, dass Markdown-Codeblöcke aus LLM-Antworten entfernt werden +- **`TestCodingAgentInit`**: Prüft initialen Zustand und `start_task()`-Reset-Verhalten +- **`TestProposeNextAction`**: Prüft den API-Aufruf-Zyklus mit gemockter API; testet Fehler-Handling (JSON-Parse-Fehler, API-Exceptions, Max-Iterations) +- **`TestApprove`**: Prüft `approve()` mit gemocktem `dispatch_tool`; testet Tool-Ergebnis-Injektion und Error-Replan-Tagging +- **`TestReject`**: Prüft, dass `reject()` das Feedback korrekt in die History injiziert und kein Tool ausführt + +#### Test-Konventionen + +- MCP-Server werden in Tests **nicht** als echte Subprozesse gestartet (via `conftest.py`-Mock) +- Streamlit-Aufrufe werden mit `patch("modul.st")` gemockt +- Dateisystem-Tests nutzen `tmp_path` (pytest-Fixture) für isolierte temporäre Verzeichnisse +- Async-Tests verwenden `@pytest.mark.asyncio` (benötigt `pytest-asyncio`) + +--- + +## Setup + +### 1. Repository klonen + +```bash +git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git +cd AISE1_Project +``` + +### 2. Virtuelle Umgebung aktivieren + +```bash +# Windows +.\.venv\Scripts\Activate.ps1 + +# macOS/Linux +source .venv/bin/activate +``` + +### 3. Abhängigkeiten installieren + +```bash +pip install -r requirements.txt +``` + +### 4. Umgebungsvariablen konfigurieren + +```bash +cp .env.example .env +# .env mit API-Keys befüllen +``` + +### 5. Applikation starten + +```bash +streamlit run frontend/app.py +``` + +### 6. Tests ausführen + +```bash +pytest tests/ -v +``` + +--- + +## Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (Streamlit) │ +│ app.py → sidebar.py / editor.py / chat.py │ +│ │ │ +│ state.py (Session-State) │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend Manager │ +│ FileManager / ChatManager / SystemPrompter / │ +│ SearchManager / ExecutionEngine / DebugLogger │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Coding Agent (backend/agent/) │ +│ coding_agent.py ←→ mcp_server_adapter.py │ +│ │ │ +│ mcp_server_config.json │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ mcp_server_file_search mcp_server_web mcp_server_code │ +│ │ +│ (Optional: mcp_server_adapter_RAG.py für semantische │ +│ Tool-Auswahl via Sentence Transformers) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ workspace/ │ +│ Isoliertes Sandbox-Verzeichnis für Agent-Dateien │ +└─────────────────────────────────────────────────────────────────┘ +``` -- 2.30.2 From 45ec8f8208a589046643cb47f56b08d32fe33913 Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Sun, 24 May 2026 10:28:44 +0200 Subject: [PATCH 4/9] chore: prior changes on func_improvments before agent merge --- DEBUG_LOGGER_USAGE.md | 46 +++++ README.md | 331 ++++++++++++++++++++++-------------- READMEnew.md | 381 ++++++++++++++++++++++++++++++++++++++++++ run_agent.py | 78 --------- 4 files changed, 628 insertions(+), 208 deletions(-) create mode 100644 DEBUG_LOGGER_USAGE.md create mode 100644 READMEnew.md delete mode 100644 run_agent.py diff --git a/DEBUG_LOGGER_USAGE.md b/DEBUG_LOGGER_USAGE.md new file mode 100644 index 0000000..965b61f --- /dev/null +++ b/DEBUG_LOGGER_USAGE.md @@ -0,0 +1,46 @@ +# DebugLogger Usage + +## Import +```python +from backend.managers.debug_logger import DebugLogger +logger = DebugLogger() +``` + +## Methoden +```python +logger.clear() # vor jeder neuen Ausführung aufrufen +logger.log("Nachricht") # INFO-Eintrag +logger.log_error("Fehler") # ERROR-Eintrag +logger.get_logs() # gibt Liste aller Einträge zurück +logger.format_debug_output({ # gibt formatierten String zurück + "rc": 0, + "stdout": "...", + "stderr": "..." +}) +``` + +## Eintrag-Format +```python +{ + "level": "INFO", # oder "ERROR" + "message": "Nachricht", + "timestamp": "14:23:01" +} +``` + +## Beispiel +```python +logger = DebugLogger() +logger.clear() +logger.log("Starte Ausführung...") + +try: + result = run_something() + logger.log("Erfolgreich abgeschlossen.") +except Exception as e: + logger.log_error(f"Fehler: {e}") + +# Logs anzeigen +for entry in logger.get_logs(): + print(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}") +``` diff --git a/README.md b/README.md index 97f4062..7fc8b13 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,224 @@ -# AISE AI Code Editor +# AISE AI Agent — Code Editor & Coding Assistant -AI-Supported Lightweight Code Editor built with Streamlit (AISE501 Spring 2026) +Ein browserbasierter Code-Editor mit integriertem AI-Chat und autonomem Coding-Agent, gebaut mit Streamlit und dem Model Context Protocol (MCP). -## Project Structure +--- + +## Installation & Setup + +**Voraussetzungen:** Python 3.13+, pip + +```bash +# 1. Repository klonen und ins Verzeichnis wechseln +cd AISE_AIAgent + +# 2. Virtuelle Umgebung erstellen und aktivieren +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS/Linux + +# 3. Abhängigkeiten installieren +pip install -r requirements.txt + +# 4. Umgebungsvariablen konfigurieren +copy .env.example .env +# .env öffnen und HOST, PORT, API_KEY, MODEL eintragen +``` + +**`.env` Konfiguration:** +``` +HOST=silicon.fhgr.ch +PORT=7080 +API_KEY=EMPTY +MODEL=qwen3.5-35b-a3b +``` + +--- + +## Starten + +```bash +# Streamlit App (Hauptinterface) +streamlit run frontend/app.py + +# Coding Agent direkt im Terminal testen +python run_agent.py +``` + +--- + +## Projektstruktur ``` AISE_AIAgent/ -├── frontend/ # Streamlit UI Components -│ ├── __init__.py -│ ├── app.py # Main Streamlit application entry point -│ ├── sidebar.py # File navigation sidebar component -│ ├── editor.py # Code editor pane component -│ └── chat.py # Chat interface component -│ -├── backend/ # Backend Logic Modules -│ ├── __init__.py -│ ├── managers/ # Business logic for UI operations -│ │ ├── __init__.py -│ │ ├── file_manager.py # File I/O operations for UI (read, write, list files) -│ │ ├── chat_manager.py # AI chat management and history -│ │ ├── system_prompter.py # System prompts and context injection -│ │ ├── search_manager.py # Internet search functionality -│ │ ├── execution_engine.py # Code execution and sandboxing -│ │ └── debug_logger.py # Logging, error handling, debug messages -│ │ -│ ├── agents/ # AI Agent System -│ │ ├── __init__.py -│ │ ├── coding_agent.py # Main agent loop (plan-act-observe cycle) -│ │ └── tools.py # Tools available to agent (7 functions + dispatcher) -│ │ -│ └── utils/ # Helper Utilities -│ ├── __init__.py -│ └── server_utils.py # LLM client init, chat functions, formatters -│ -├── tests/ # Unit Tests -│ ├── __init__.py -│ ├── test_file_manager.py # Tests for file operations -│ ├── test_chat_manager.py # Tests for chat functionality -│ ├── test_execution_engine.py # Tests for code execution -│ └── test_main.py # Integration tests -│ -├── workspace/ # Agent Sandbox Directory -│ └── .gitkeep # Placeholder for agent to work safely in isolation -│ -├── .gitignore # Git exclusions (venv, .env, __pycache__, etc.) -├── .env # Local environment variables (NOT committed) -├── .env.example # Template for environment variables (IS committed) -├── requirements.txt # Python dependencies -├── README.md # This file -└── project_exercise.pdf # Project specification +├── frontend/ # Streamlit UI +├── backend/ +│ ├── agent/ # Coding Agent + MCP-Adapter +│ │ └── servers/ # MCP-Server (Tools für den Agent) +│ └── managers/ # Business-Logik (Chat, Dateien, Ausführung) +├── workspace/ # Arbeitsverzeichnis des Agents & Editors +└── run_agent.py # CLI-Test für den Coding Agent ``` -## Component Responsibilities +--- -### Frontend (`frontend/`) -- **app.py**: Main Streamlit application, layout orchestration -- **sidebar.py**: File browser and project navigation -- **editor.py**: Code editing interface with syntax highlighting -- **chat.py**: AI assistant chat interface +## Frontend -### Backend Managers (`backend/managers/`) -Used directly by Frontend for UI operations: -- **file_manager.py**: CRUD operations on project files -- **chat_manager.py**: Chat history, message management -- **system_prompter.py**: System prompt generation and file context -- **execution_engine.py**: Safe code execution with output capture -- **debug_logger.py**: Error tracking and log formatting -- **search_manager.py**: Web search integration +### `frontend/app.py` — Einstiegspunkt +Initialisiert die Streamlit-App, setzt das Layout und routet zwischen Code-Editor und Chat-Ansicht basierend auf der Sidebar-Navigation. -### 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) +### `frontend/state.py` — Session State +Initialisiert alle Streamlit `session_state`-Variablen beim App-Start (offene Dateien, Chat-Verlauf, Agent-Status etc.). Verhindert `KeyError` beim ersten Laden. -### Backend Utils (`backend/utils/`) -- **server_utils.py**: LLM client initialization, chat helpers, message formatters +### `frontend/sidebar.py` — Sidebar & File Explorer +- **Navigation:** Radio-Button zum Wechseln zwischen "Code Editor" und "Chat with AI Assistant" +- **File Tree:** Interaktiver Dateibaum (via `streamlit-arborist`) zeigt den `workspace/`-Ordner +- **Datei-Aktionen:** Datei öffnen, umbenennen, löschen +- **Ordner-Aktionen:** Ordner erstellen, Dateien in Ordner hinzufügen, Ordner löschen -### Workspace (`workspace/`) -- Sandbox directory where agent executes and stores files -- Prevents agent from accessing files outside this directory +### `frontend/editor.py` — Code-Editor +- **Ace Editor:** Syntax-Highlighting für Python, JS, HTML, CSS, JSON, YAML, LaTeX u.a. +- **Tabs:** Mehrere Dateien gleichzeitig offen, Tab-Wechsel per Klick +- **Datei-Operationen:** Speichern, Schliessen, Umbenennen, Löschen direkt im Tab +- **Code ausführen:** Button "▶ Run Code" führt die aktive Datei aus, Output wird darunter angezeigt +- **Debug with AI:** Bei Fehlern erscheint ein Button der den Fehler + Code automatisch an den AI-Chat weiterleitet -## Features +### `frontend/chat.py` — Chat & Agent Mode -- **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 +**Normaler Chat (`render_normal_chat`):** +- Multi-Turn Konversation mit dem konfigurierten LLM +- Injiziert die aktuell offene Datei automatisch als Kontext in den System-Prompt +- Einstellungen: Modell wählen, Max Tokens, eigener System-Prompt +- "Clear Chat" mit Bestätigungsdialog -## Setup +**Agent Mode (`render_agent_mode`):** +- Autonomer Coding-Agent im Step-by-Step Modus +- Benutzer gibt eine Aufgabe ein, der Agent schlägt Aktionen vor +- Jede Aktion muss einzeln **genehmigt (Approve)** oder **abgelehnt (Reject)** werden +- **Agent Log:** Zeigt alle ausgeführten Schritte mit Tool, Gedanken und Resultat +- **Follow-up:** Nach Abschluss einer Aufgabe können Korrekturen eingegeben werden -### 1. Project Clonen +--- -1. In den Zielordner wechseln -cd /pfad/zum/zielordner +## Backend — Managers -2. Repository klonen -git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git +### `backend/managers/chat_manager.py` — ChatManager +Verwaltet den Konversationsverlauf und kommuniziert mit dem LLM über eine OpenAI-kompatible REST-API. Sendet bei jedem Request den gesamten Verlauf als Kontext mit. -3. In das Projekt wechseln -cd AISE1_Project +| Methode | Beschreibung | +|---|---| +| `send_message(user_message)` | Nachricht senden, Antwort zurückgeben, History updaten | +| `add_message(role, content)` | Nachricht manuell zur History hinzufügen | +| `clear_history()` | Gesamten Chat-Verlauf löschen | +| `get_history()` | Kopie des aktuellen Verlaufs zurückgeben | -### 2. Activate Virtual Environment +### `backend/managers/file_manager.py` — FileManager +Alle Datei- und Ordneroperationen innerhalb des `workspace/`-Verzeichnisses. Jede Operation validiert den Pfad gegen Path-Traversal-Angriffe. + +| Methode | Beschreibung | +|---|---| +| `create_file(relative_path, name)` | Neue Datei im Workspace erstellen | +| `create_folder(relative_path, name)` | Neuen Ordner erstellen | +| `read_file(path)` | Dateiinhalt lesen | +| `save_file(path, content)` | Datei speichern/überschreiben | +| `rename_file(old_path, new_name)` | Datei umbenennen (Extension bleibt erhalten) | +| `delete_file(relative_path)` | Datei löschen | +| `delete_folder(relative_path)` | Ordner inkl. Inhalt löschen | +| `get_file_tree()` | Verschachteltes Dict des gesamten Workspace-Baums | + +### `backend/managers/execution_engine.py` — ExecutionEngine +Führt Dateien aus dem Editor in einem Subprocess aus. Unterstützt Python (`.py`) und LaTeX (`.tex`). Timeout: 30 Sekunden. + +| Methode | Beschreibung | +|---|---| +| `run_code(active_file)` | Datei ausführen, gibt `{stdout, stderr, rc}` zurück | + +### `backend/managers/system_prompter.py` — SystemPrompter +Generiert den System-Prompt für den AI-Chat. Wenn eine Datei im Editor offen ist, wird ihr Inhalt (max. 4000 Zeichen) automatisch eingebettet. + +| Methode | Beschreibung | +|---|---| +| `generate_prompt(file_context)` | System-Prompt mit optionalem Datei-Kontext bauen | + +### `backend/managers/debug_logger.py` — DebugLogger +Einfacher In-Memory-Logger für den Code-Editor (Ausführungsstatus, Fehler). + +--- + +## Backend — Coding Agent + +### `backend/agent/coding_agent.py` — CodingAgent +Autonomer Agent nach dem **Plan → Act → Observe → Fix → Done** Loop. Kommuniziert mit dem LLM und ruft MCP-Tools über den Adapter auf. + +| Methode | Beschreibung | +|---|---| +| `start_task(task)` | Neue Aufgabe initialisieren, State zurücksetzen | +| `propose_next_action()` | LLM fragen was als nächstes zu tun ist (führt nichts aus) | +| `approve()` | Vorgeschlagene Aktion ausführen, nächsten Schritt vorschlagen | +| `reject(feedback)` | Aktion ablehnen, Feedback injizieren, Agent plant neu | +| `follow_up(question)` | Folgefrage nach abgeschlossener Aufgabe stellen | + +**Hilfsfunktionen:** +- `dispatch_tool(tool_name, arguments)` — Leitet Tool-Aufrufe an den MCP-Adapter weiter +- `build_all_tool_description()` — Generiert die Tool-Beschreibung für den System-Prompt +- `extract_json(text)` / `_repair_json_strings(text)` — Parst und repariert LLM-JSON-Antworten +- `trim_messages(messages)` — Kürzt alte History wenn Kontextfenster voll wird + +### `backend/agent/mcp_server_adapter.py` — MCPToolAdapter +Verbindet den Coding Agent mit den MCP-Servern. Liest die Konfiguration aus `mcp_server_config.json`, registriert alle verfügbaren Tools und startet bei jedem Tool-Aufruf eine frische stdio-Verbindung zum zuständigen Server. + +| Methode | Beschreibung | +|---|---| +| `initialize_all_servers()` | Alle Server verbinden und Tools registrieren | +| `call_tool(tool_name, arguments)` | Tool auf dem zuständigen Server aufrufen | +| `get_all_tools()` | Liste aller registrierten Tools zurückgeben | + +--- + +## MCP-Server (Tools für den Agent) + +Alle Server laufen als eigenständige Prozesse und kommunizieren über `stdio`. + +### `backend/agent/servers/mcp_server_code_execution.py` — CodeExecutionServer + +| Tool | Beschreibung | +|---|---| +| `run_python_sandboxed(code)` | Python-Code sicher ausführen (statische Analyse + Subprocess mit isoliertem stdin) | +| `python_code_validation(code)` | Syntax und Sicherheit prüfen ohne auszuführen | +| `lint_code(code)` | Statische Analyse mit pyflakes (ungenutzte Imports, undefinierte Variablen) | +| `analyse_structure(code)` | Struktur-Zusammenfassung (Klassen, Funktionen, Imports) | + +**Sicherheitsmechanismen:** Blockierte Imports (`os`, `sys`, `subprocess` etc.), blockierte Builtins (`exec`, `eval`, `open` etc.), verbotene Pfadsequenzen, Timeout nach 45 Sekunden. + +### `backend/agent/servers/mcp_server_file_search.py` — FileSearchServer + +| Tool | Beschreibung | +|---|---| +| `list_files()` | Alle Dateien im Workspace auflisten | +| `get_file_tree(dir_path)` | Verzeichnisbaum anzeigen | +| `search_files(query)` | Dateien nach Name oder Inhalt durchsuchen | +| `read_file(path)` | Dateiinhalt lesen | +| `write_new_file(path, content)` | Neue Datei erstellen (keine Überschreibung bestehender Dateien) | +| `create_new_directory(path)` | Neues Verzeichnis erstellen | + +Alle Zugriffe sind auf den `workspace/`-Ordner beschränkt (Path-Traversal-Schutz). + +### `backend/agent/servers/mcp_server_web_search.py` — WebSearchServer + +| Tool | Beschreibung | +|---|---| +| `web_search(query, max_results)` | DuckDuckGo-Suche, gibt formatierte Ergebnisse zurück | +| `fetch_page(url)` | Webseite abrufen und Text extrahieren (max. 4000 Zeichen) | + +SSRF-Schutz: Blockiert `localhost`, private IP-Ranges und nicht-HTTP/HTTPS-Schemata. + +--- + +## CLI-Test + +`run_agent.py` ermöglicht den Coding Agent direkt im Terminal zu testen ohne die Streamlit-App zu starten: ```bash -# Windows -.\.venv\Scripts\Activate.ps1 - -# macOS/Linux -source .venv/bin/activate -``` - -### 3. Install Dependencies - -```bash -pip install -r requirements.txt -``` - -### 4. Run Application - -```bash -streamlit run frontend/app.py -``` - -### 5. Run Tests - -```bash -pytest tests/ -``` - -## Architecture - -The application follows a frontend-backend split: - -- **Frontend**: Streamlit UI components (sidebar, editor, chat) -- **Backend**: Specialized manager modules - - FileManager: File operations - - ChatManager: AI interaction - - SystemPrompter: Prompt management - - SearchManager: Internet search - - ExecutionEngine: Code execution - - DebugLogger: Error handling & logging - -## Development - -Use Git to track changes: - -```bash -git add . -git commit -m "Your message" -git push origin sturcture +python run_agent.py +# Aufgabe eingeben → Enter zum Genehmigen, Text zum Ablehnen, "stop" zum Abbrechen ``` diff --git a/READMEnew.md b/READMEnew.md new file mode 100644 index 0000000..080690f --- /dev/null +++ b/READMEnew.md @@ -0,0 +1,381 @@ +# AISE AI Agent — Code Editor & Coding Assistant + +Ein browserbasierter Code-Editor mit integriertem AI-Chat und autonomem Coding-Agent. Das Projekt kombiniert eine Streamlit-Oberfläche mit einem LLM-Backend und dem Model Context Protocol (MCP), um einen vollständigen AI-gestützten Entwicklungsworkflow zu ermöglichen. + +--- + +## Was kann das Projekt? + +- **Code schreiben und bearbeiten** im Browser mit Syntax-Highlighting (Ace Editor) +- **Code direkt ausführen** und Output anzeigen, ohne die App zu verlassen +- **Mit einem AI-Assistenten chatten**, der den aktuell geöffneten Code als Kontext kennt +- **Fehler mit AI debuggen** — ein Klick schickt den Fehler + Code automatisch an den Chat +- **Einen autonomen Coding-Agent starten**, der selbstständig Aufgaben plant und umsetzt (Dateien lesen/schreiben, Code ausführen, im Web suchen) — jeder Schritt wird dem Benutzer zur Genehmigung vorgelegt + +--- + +## Voraussetzungen + +- Python 3.13 oder neuer +- pip +- Zugang zu einem OpenAI-kompatiblen LLM-Endpoint (z.B. FHGR Silicon Server) + +--- + +## Installation + +```bash +# 1. Repository klonen +git clone +cd AISE_AIAgent + +# 2. Virtuelle Umgebung erstellen und aktivieren +python -m venv .venv + +# Windows: +.venv\Scripts\activate +# macOS / Linux: +source .venv/bin/activate + +# 3. Abhängigkeiten installieren +pip install -r requirements.txt + +# 4. Umgebungsvariablen konfigurieren +copy .env.example .env # Windows +# cp .env.example .env # macOS/Linux +``` + +Dann `.env` öffnen und die Werte anpassen: + +```env +HOST=silicon.fhgr.ch # Hostname des LLM-Servers +PORT=7080 # Port des LLM-Servers +API_KEY=EMPTY # API-Key (EMPTY wenn kein Key benötigt) +MODEL=qwen3.5-35b-a3b # Modell-Name +``` + +--- + +## Starten + +```bash +# Streamlit Web-App starten (Hauptinterface) +streamlit run frontend/app.py + +# Coding Agent im Terminal testen (ohne Streamlit) +python run_agent.py +``` + +Nach `streamlit run frontend/app.py` öffnet sich die App automatisch im Browser unter `http://localhost:8501`. + +--- + +## Projektstruktur + +``` +AISE_AIAgent/ +│ +├── frontend/ # Gesamte Streamlit-Benutzeroberfläche +│ ├── app.py # Einstiegspunkt, App-Routing +│ ├── state.py # Session-State Initialisierung +│ ├── sidebar.py # Navigation + File Explorer +│ ├── editor.py # Code-Editor Ansicht +│ └── chat.py # Chat + Agent Mode Ansicht +│ +├── backend/ +│ ├── agent/ # Coding Agent Logik +│ │ ├── coding_agent.py # Agent Klasse (Plan→Act→Observe Loop) +│ │ ├── mcp_server_adapter.py # MCP-Client (verbindet Agent mit Servern) +│ │ ├── mcp_server_adapter_RAG.py +│ │ ├── mcp_server_config.json +│ │ └── servers/ # MCP-Server (laufen als eigene Prozesse) +│ │ ├── mcp_server_code_execution.py +│ │ ├── mcp_server_file_search.py +│ │ └── mcp_server_web_search.py +│ │ +│ └── managers/ # Business-Logik Module +│ ├── chat_manager.py # LLM-API Kommunikation + Chat-History +│ ├── file_manager.py # Datei/Ordner-Operationen im Workspace +│ ├── execution_engine.py # Code-Ausführung via Subprocess +│ ├── system_prompter.py # System-Prompt Generierung +│ └── debug_logger.py # Einfacher In-Memory Logger +│ └── search_manager.py # Einfacher In-Memory Such-Manager +├── tests/ +| ├── conftest.py +| ├── test_file_manager.py # Unit-Tests für FileManager +| ├── test_chat_manager.py # Unit-Tests für ChatManager +| ├── test_execution_engine.py # Unit-Tests für ExecutionEngine +| ├── test_coding_agent.py # Unit-Tests für CodingAgent +| ├── test_search_manager.py # Unit-Tests für SearchManager +| ├── test_debug_logger.py # Unit-Tests für DebugLogger +| ├── test_mcp_server_code_execution.py +| ├── test_mcp_server_web_search.py +| ├── test_system_prompter.py +| └── test_mcp_server_file_search.py +| +├── workspace/ # Arbeitsverzeichnis (Dateien des Editors/Agents) +└── .env.example # Vorlage für Umgebungsvariablen +``` + +--- + +## Frontend + +### `frontend/app.py` — Einstiegspunkt der App + +Dieser File ist der Startpunkt der gesamten Streamlit-Applikation. Er wird direkt mit `streamlit run` aufgerufen und übernimmt drei Aufgaben: Er setzt das globale Seitenlayout (Titel, breites Layout, minimales CSS-Padding), ruft `init_state()` auf um alle Session-State-Variablen zu initialisieren, und leitet den Benutzer basierend auf der Sidebar-Auswahl entweder zur Editor-Ansicht oder zur Chat-Ansicht weiter. + +--- + +### `frontend/state.py` — Session State Verwaltung + +Streamlit rendert die gesamte App bei jeder Benutzerinteraktion neu. Um Daten zwischen diesen Reruns zu erhalten (offene Dateien, Chat-Verlauf, Agent-Status etc.), nutzt Streamlit `session_state`. Dieser File definiert und initialisiert alle verwendeten Keys mit ihren Standardwerten an einem zentralen Ort — damit kein anderer Teil der App auf einen nicht-existierenden Key trifft. + +**Wichtige State-Keys:** +- `open_files` — Liste aller aktuell geöffneten Dateipfade (bestimmt die Tab-Reihenfolge) +- `files_content` — Dict `{Dateipfad: aktueller Editorinhalt}` (ungespeicherte Änderungen inklusive) +- `active_file` — Absoluter Pfad der aktuell aktiven Datei +- `chat_history` — Flache Liste aller Chat-Nachrichten `[{role, content}, ...]` +- `agent_mode` — Boolean ob der Agent Mode aktiv ist +- `agent_status` — Aktueller Agent-Zustand: `"idle"` | `"waiting_approval"` | `"done"` +- `agent_log` — Liste aller abgeschlossenen Agent-Schritte +- `agent_pending_action` — Die vom Agent vorgeschlagene, noch nicht ausgeführte Aktion + +--- + +### `frontend/sidebar.py` — Navigation & File Explorer + +Die Sidebar ist in zwei Bereiche aufgeteilt: + +**Navigation:** Ein Radio-Button schaltet zwischen "Code Editor" und "Chat with AI Assistant" um. Die aktuelle Auswahl wird in `session_state.radio_interface_options` gespeichert und von `app.py` ausgewertet. + +**File Explorer:** Ein interaktiver Dateibaum zeigt den gesamten `workspace/`-Ordner an. Implementiert mit `streamlit-arborist`, das einen klickbaren Baum mit Ordner-Icons rendert. Ein Klick auf eine Datei öffnet sie im Editor und wechselt automatisch zur Editor-Ansicht. Ein Klick auf einen Ordner zeigt eine Aktionsleiste mit Buttons zum Erstellen von Dateien/Unterordnern und zum Löschen des Ordners. + +**Modale Dialoge** (via `@st.dialog`): +- `_add_file_dialog` — Neuen Dateinamen eingeben und Datei erstellen +- `_add_folder_dialog` — Neuen Ordnernamen eingeben und Ordner erstellen +- `_rename_file_dialog` — Datei umbenennen (Extension wird automatisch beibehalten) +- `_delete_file_dialog` — Löschbestätigung für Dateien +- `_delete_folder_dialog` — Löschbestätigung für Ordner inkl. Inhalt + +--- + +### `frontend/editor.py` — Code-Editor + +Der Code-Editor ist die zentrale Arbeitsfläche für das direkte Bearbeiten von Dateien. + +**Ace Editor (`streamlit-ace`):** Jede offene Datei wird in einem Tab mit dem Ace-Editor angezeigt. Der Editor erkennt die Dateiendung automatisch und stellt das passende Syntax-Highlighting ein (Python, JavaScript, HTML, CSS, JSON, YAML, LaTeX, Bash). Das Theme ist "Monokai". `auto_update=True` bedeutet, dass Änderungen sofort in `session_state.files_content` landen — ohne expliziten Submit. + +**Tab-Verwaltung:** Jede offene Datei erscheint als Tab. Wenn eine Datei aus dem File Explorer geöffnet wird, springt ein JavaScript-Snippet automatisch auf den richtigen Tab (da `st.tabs` keinen programmatischen Tab-Wechsel unterstützt). + +**Aktions-Buttons pro Datei:** +- **Save Changes** — Schreibt den aktuellen Editorinhalt auf Disk +- **Close File** — Entfernt die Datei aus den offenen Tabs +- **Rename File** — Öffnet Rename-Dialog +- **Delete File** — Öffnet Lösch-Bestätigungsdialog +- **▶ Run Code** — Führt die Datei aus (Python via `py`, LaTeX via `pdflatex`) + +**Ausführungs-Output:** Nach dem Ausführen erscheinen stdout, stderr und der Exit-Code unterhalb des Editors. Bei einem Fehler erscheint zusätzlich der Button **"🐛 Debug with AI"** — dieser baut automatisch eine Fehlernachricht zusammen (Fehlertext + kompletter Code) und schickt sie an den Chat-Assistenten. + +--- + +### `frontend/chat.py` — Chat & Agent Mode + +Dieser File enthält zwei grundlegend verschiedene Interfaces, die über einen Toggle umgeschaltet werden. + +#### Normaler Chat + +Ein klassischer Multi-Turn-Chatbot. Bei der ersten Nachricht wird automatisch ein System-Prompt generiert. Falls eine Datei im Editor geöffnet ist und "Include current file as context" aktiviert ist, wird der Dateiinhalt in den System-Prompt eingebettet — der AI-Assistent "sieht" also den Code und kann gezielt darauf eingehen. + +Bei jeder Folgenachricht wird der System-Prompt aktualisiert falls eine andere Datei aktiv ist. Das Modell, die maximale Tokenzahl und ein eigener System-Prompt können in einem aufklappbaren Settings-Panel konfiguriert werden. Der "Clear Chat" Button öffnet einen Bestätigungsdialog. + +#### Agent Mode + +Der Agent Mode verwandelt den Chat in ein Step-by-Step Kontrollinterface für den autonomen `CodingAgent`. + +**Ablauf:** +1. Benutzer gibt eine Aufgabe ein (z.B. "Schreibe eine Funktion die eine Liste sortiert und speichere sie als sorted.py") +2. Der Agent analysiert die Aufgabe und schlägt einen ersten Schritt vor (z.B. `write_new_file` mit dem generierten Code) +3. Die UI zeigt den Gedankengang des Agents ("Thought"), das gewählte Tool und die Argumente an +4. Benutzer klickt **Approve** → Aktion wird ausgeführt, nächster Schritt wird vorgeschlagen +5. Oder **Reject** → Benutzer gibt Feedback ein, Agent plant neu ohne die Aktion auszuführen +6. Oder **Abort Task** → Agent wird sofort gestoppt +7. Nach Abschluss kann eine Follow-up Frage gestellt werden ohne den Kontext zu verlieren + +Der **Agent Log** zeigt alle abgeschlossenen Schritte in einem aufklappbaren Bereich. + +--- + +## Backend — Managers + +### `backend/managers/chat_manager.py` — ChatManager + +Kapselt die gesamte Kommunikation mit dem LLM. Verbindet sich mit einem OpenAI-kompatiblen REST-Endpoint (`/v1/chat/completions`) dessen Adresse und Key aus der `.env` gelesen werden. + +Der Chat-Verlauf wird als Liste von `{role, content}`-Dicts in-memory gehalten. Bei jedem `send_message`-Aufruf wird die vollständige History mitgeschickt, sodass das Modell immer den gesamten Gesprächskontext kennt. + +| Methode | Beschreibung | +|---|---| +| `send_message(user_message)` | Nachricht zur History hinzufügen, API-Call machen, Antwort zurückgeben und in History speichern | +| `add_message(role, content)` | Nachricht direkt zur History hinzufügen (z.B. für System-Prompt) | +| `get_history()` | Kopie der aktuellen History zurückgeben | +| `clear_history()` | Gesamten Verlauf löschen (neuer Chat) | + +--- + +### `backend/managers/file_manager.py` — FileManager + +Abstrahiert alle Dateioperationen und stellt sicher, dass jeder Zugriff innerhalb des `workspace/`-Verzeichnisses bleibt. Jede Methode löst den angegebenen Pfad zu einem absoluten Pfad auf und prüft mit `startswith(workspace.resolve())` ob der Pfad im erlaubten Bereich liegt — damit sind Path-Traversal-Angriffe wie `../../etc/passwd` ausgeschlossen. + +| Methode | Beschreibung | +|---|---| +| `create_file(relative_path, name)` | Neue leere Datei erstellen. Ohne Extension wird `.txt` ergänzt. Schlägt fehl wenn Datei existiert. | +| `create_folder(relative_path, name)` | Neuen Ordner erstellen. Schlägt fehl wenn Ordner existiert. | +| `read_file(path)` | Dateiinhalt als String lesen. Gibt leeren String bei Fehler zurück. | +| `save_file(path, content)` | Datei mit neuem Inhalt überschreiben (erstellt falls nicht vorhanden). | +| `rename_file(old_path, new_name)` | Datei umbenennen. Die Dateiendung wird immer vom Original übernommen. | +| `delete_file(relative_path)` | Einzelne Datei löschen. | +| `delete_folder(relative_path)` | Ordner und gesamten Inhalt rekursiv löschen. | +| `get_file_tree()` | Gesamten Workspace als verschachteltes Dict zurückgeben: Ordner als `{name: dict}`, Dateien als `{name: None}`. | + +--- + +### `backend/managers/execution_engine.py` — ExecutionEngine + +Führt Dateien aus dem Editor als Subprocess aus. Die Datei wird immer aus ihrem eigenen Verzeichnis heraus gestartet (`cwd=file.parent`), damit relative Imports und Pfade korrekt funktionieren. + +Unterstützte Dateitypen: +- **Python (`.py`)** → `py ` (nutzt den Windows Python Launcher) +- **LaTeX (`.tex`)** → `pdflatex -interaction=nonstopmode ` + +Timeout: 30 Sekunden. Gibt immer ein Dict `{stdout, stderr, rc}` zurück. + +--- + +### `backend/managers/system_prompter.py` — SystemPrompter + +Baut den System-Prompt für den normalen Chat zusammen. Ohne Datei-Kontext enthält er nur eine allgemeine Beschreibung des AI-Assistenten. Mit Datei-Kontext wird der Dateiname und der Inhalt (max. 4000 Zeichen, danach abgeschnitten) in XML-ähnliche Tags eingebettet: + +``` + + +def fib(n): ... + + +``` + +Das Modell wird angewiesen sich auf diese Datei zu beziehen wenn es Fragen zum Code beantwortet. + +--- + +### `backend/managers/debug_logger.py` — DebugLogger + +Einfacher In-Memory-Logger der Ausführungsmeldungen für den Code-Editor speichert. Wird von `editor.py` genutzt um den Status einer Code-Ausführung (`log()`, `log_error()`, `clear()`) zu protokollieren. + +--- + +## Backend — Coding Agent + +### `backend/agent/coding_agent.py` — CodingAgent + +Das Herzstück des autonomen Agents. Implementiert den **Plan → Act → Observe → Fix → Done** Loop. + +**Wie der Loop funktioniert:** +1. `start_task(task)` initialisiert den Agenten mit dem System-Prompt (enthält Beschreibungen aller verfügbaren MCP-Tools) und der Aufgabe +2. `propose_next_action()` schickt den bisherigen Konversationsverlauf an das LLM. Das Modell antwortet immer mit einem JSON-Objekt `{"thought": "...", "tool": "...", "arguments": {...}}`. Die Antwort wird geparsed und als `pending_action` gespeichert — aber **noch nicht ausgeführt** +3. `approve()` führt die pending_action aus, fügt das Resultat als User-Nachricht zur History hinzu und ruft sofort `propose_next_action()` auf +4. `reject(feedback)` verwirft die pending_action ohne sie auszuführen und injiziert das Feedback als User-Nachricht +5. Wenn das LLM `"done"` als Tool wählt, setzt der Agent `is_done = True` + +**Robustheit:** LLM-Antworten die kein valides JSON enthalten werden durch `extract_json()` und `_repair_json_strings()` bereinigt (entfernt Markdown-Fences, repariert unescapte Newlines in Strings). Wenn die History das Limit von 80'000 Zeichen überschreitet, werden ältere Nachrichten entfernt und ein Erinnerungs-Hinweis injiziert. + +| Methode | Beschreibung | +|---|---| +| `start_task(task)` | Agent neu initialisieren, System-Prompt + Aufgabe setzen | +| `propose_next_action()` | LLM-Aufruf → JSON-Antwort parsen → als `pending_action` speichern | +| `approve()` | `pending_action` ausführen, Resultat zur History hinzufügen, nächste Aktion vorschlagen | +| `reject(feedback)` | `pending_action` verwerfen, Feedback injizieren | +| `follow_up(question)` | Nach Abschluss: Folgefrage stellen ohne Kontext zu verlieren | + +--- + +### `backend/agent/mcp_server_adapter.py` — MCPToolAdapter + +Der Adapter ist die Brücke zwischen dem `CodingAgent` und den MCP-Servern. Er liest beim Start die Konfigurationsdatei `mcp_server_config.json`, verbindet sich zu jedem Server, fragt dessen Tool-Liste ab und speichert alle Tools in einer internen Registry. + +Bei jedem `call_tool`-Aufruf öffnet der Adapter eine neue `stdio`-Verbindung zum zuständigen Server, führt das Tool aus und schliesst die Verbindung wieder. Das heisst: Die Server laufen nicht permanent, sondern werden für jeden Aufruf frisch gestartet. Das macht das System robuster (kein veralteter State in einem Server) aber etwas langsamer. + +| Methode | Beschreibung | +|---|---| +| `initialize_all_servers()` | Alle konfigurierten Server starten, Tool-Liste abfragen, in Registry speichern | +| `call_tool(tool_name, arguments)` | Passenden Server für das Tool finden, Verbindung aufbauen, Tool aufrufen, Ergebnis zurückgeben | +| `get_all_tools()` | Komplette Tool-Registry zurückgeben | + +--- + +## MCP-Server + +Die drei MCP-Server sind eigenständige Python-Prozesse die über das `stdio`-Transportprotokoll kommunizieren. Sie werden vom Adapter gestartet und stellen dem Coding Agent Tools zur Verfügung. + +--- + +### `backend/agent/servers/mcp_server_code_execution.py` — CodeExecutionServer + +Ermöglicht dem Agent das sichere Ausführen von Python-Code. Bevor Code ausgeführt wird, durchläuft er eine **zweistufige Sicherheitsprüfung**: + +1. **Statische AST-Analyse** (`check_code_safety`): Der Code wird mit Pythons `ast`-Modul geparst. Jeder Import-Node und jeder Funktionsaufruf wird gegen Blocklisten geprüft. Blockierte Imports umfassen u.a. `os`, `sys`, `subprocess`, `socket`, `pickle`. Blockierte Builtins umfassen `exec`, `eval`, `open`, `compile`. + +2. **String-Suche nach verbotenen Sequenzen**: Zusätzlich wird der Code-String direkt nach gefährlichen Mustern durchsucht (`../`, `os.`, `sys.`, `subprocess.` etc.). + +Erst wenn beide Prüfungen bestanden sind, wird der Code via `subprocess.run([sys.executable, "-c", code], stdin=subprocess.DEVNULL, ...)` ausgeführt. `stdin=subprocess.DEVNULL` ist entscheidend: Da der MCP-Server über asyncio-verwaltetes `stdio` läuft, würde der Kind-Prozess sonst stdin erben und blockieren. + +| Tool | Beschreibung | +|---|---| +| `run_python_sandboxed(code)` | Code nach Sicherheitsprüfung ausführen. Gibt stdout+stderr zurück (max. 3000 Zeichen). Timeout: 45s. | +| `python_code_validation(code)` | Nur prüfen (Syntax + Sicherheit), nicht ausführen. | +| `lint_code(code)` | Pyflakes-Analyse: ungenutzte Imports, undefinierte Variablen, Syntax-Fehler. | +| `analyse_structure(code)` | Imports, Klassen (mit Methoden) und Top-Level-Funktionen als strukturierte Zusammenfassung. | + +--- + +### `backend/agent/servers/mcp_server_file_search.py` — FileSearchServer + +Gibt dem Agent Lese- und Schreibzugriff auf den `workspace/`-Ordner. Alle Pfade werden über `_safe_path()` gegen Path-Traversal validiert — ein Zugriff ausserhalb des Workspace ist nicht möglich. + +| Tool | Beschreibung | +|---|---| +| `list_files()` | Alle Dateien im Workspace rekursiv auflisten (ohne `__pycache__`). | +| `get_file_tree(dir_path)` | Verzeichnisstruktur als formatierter Text (ähnlich `tree`-Befehl). | +| `search_files(query)` | Dateien deren Name oder Inhalt den Suchbegriff enthält (case-insensitive). | +| `read_file(path)` | Vollständigen Inhalt einer Datei lesen. | +| `write_new_file(path, content)` | Neue Datei erstellen und Inhalt schreiben. Bestehende Dateien können **nicht** überschrieben werden. | +| `create_new_directory(path)` | Neues Verzeichnis im Workspace erstellen. | + +--- + +### `backend/agent/servers/mcp_server_web_search.py` — WebSearchServer + +Gibt dem Agent Zugriff auf das Internet. Enthält einen SSRF-Schutz der verhindert, dass der Agent interne Adressen abruft. + +**SSRF-Schutz** (`_validate_url`): Nur `http://` und `https://`-URLs sind erlaubt. Hostnamen wie `localhost`, `127.0.0.1`, `0.0.0.0`, `169.254.169.254` (AWS Metadata) sowie alle privaten IP-Ranges (`10.x`, `172.16-31.x`, `192.168.x`) sind blockiert. + +| Tool | Beschreibung | +|---|---| +| `web_search(query, max_results)` | DuckDuckGo-Suche. Gibt Titel, URL und Snippet für jedes Ergebnis zurück (Standard: 5 Ergebnisse). | +| `fetch_page(url)` | URL abrufen, HTML parsen, Fliesstext extrahieren (max. 4000 Zeichen). | + +--- + +## CLI-Test mit `run_agent.py` + +Für schnelles Testen des Coding Agents ohne die Streamlit-App: + +```bash +python run_agent.py +``` + +Das Script startet eine interaktive Konsolen-Session: +- **Aufgabe eingeben** → Agent startet +- **Enter drücken** → Vorgeschlagene Aktion genehmigen und ausführen +- **Text eingeben + Enter** → Feedback geben, Agent plant neu +- **`stop` eingeben** → Abbrechen diff --git a/run_agent.py b/run_agent.py deleted file mode 100644 index 4e5e26e..0000000 --- a/run_agent.py +++ /dev/null @@ -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.") -- 2.30.2 From cc14f25e6944bf1370e6ebe51d21b84bbdaf6ebb Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Sun, 24 May 2026 10:38:59 +0200 Subject: [PATCH 5/9] refactor: add REVIEW annotations for dead/debug code across frontend and backend Co-Authored-By: Claude Sonnet 4.6 --- backend/agent/coding_agent.py | 14 +++++++++++++- backend/agent/mcp_server_adapter.py | 5 +++++ backend/agent/servers/mcp_server_code_execution.py | 6 ++++++ backend/agent/servers/mcp_server_file_search.py | 8 ++++++++ backend/managers/chat_manager.py | 5 +++++ backend/managers/debug_logger.py | 1 + backend/managers/file_manager.py | 5 ++++- backend/managers/system_prompter.py | 4 ++++ frontend/app.py | 2 ++ frontend/chat.py | 5 +++++ frontend/editor.py | 5 +++++ frontend/sidebar.py | 1 + frontend/state.py | 10 ++++++++++ 13 files changed, 69 insertions(+), 2 deletions(-) diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index ff98386..2fa8bf0 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -18,17 +18,22 @@ import os import re from pathlib import Path import asyncio +# REVIEW: pprint is only used for a single debug print in build_all_tool_description(); +# replace with a plain print() call and remove this import. import pprint import requests from dotenv import load_dotenv +# REVIEW: commented-out import — remove once the package import above is confirmed stable. #from mcp_server_adapter import MCPToolAdapter # Import from current directory for easier testing without package structure from backend.agent.mcp_server_adapter import MCPToolAdapter # ── mcp server initialization ──────────────────────────────────────────────────────────────── adapter = MCPToolAdapter() +# REVIEW: debug print — remove before shipping. print("MCPToolAdapter created. Listing all tools from servers...") asyncio.run(adapter.initialize_all_servers()) +# REVIEW: debug print — remove before shipping. print("listed tools from all servers") load_dotenv() @@ -59,10 +64,12 @@ def build_all_tool_description() -> str: ``"- : "``. """ all_tools = adapter.get_all_tools() + # REVIEW: debug print — remove before shipping. print(f"Building tool description for {len(all_tools)} tools.") descriptions = [] for tool in all_tools: + # REVIEW: debug print via pprint — remove before shipping; replace pprint import with plain print if kept. pprint.pprint(f"{tool}") descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}") @@ -90,9 +97,11 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str: return f"DONE: {summary}" try: + # REVIEW: debug print — remove before shipping. print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...") result = await adapter.call_tool(tool_name, arguments) + # REVIEW: debug print — remove before shipping. print(f"Raw result from tool '{tool_name}': {result}") if result.isError: @@ -572,6 +581,8 @@ class CodingAgent: }) self.pending_action = None +# REVIEW: dead code — this module is always imported, never run as a script. +# The __main__ guard below is unreachable in normal use. Move this to run_agent.py or delete it. def main(): """Example of how to use the CodingAgent in a simple loop.""" agent = CodingAgent() @@ -598,7 +609,8 @@ def main(): feedback = input("Enter feedback for the agent: ") agent.reject(feedback) - + # REVIEW: unreachable when action["tool"] == "done" (we break above); also `result` is + # unbound when the elif branch runs — this will raise UnboundLocalError at runtime. if result["is_done"]: print("Task completed.") break diff --git a/backend/agent/mcp_server_adapter.py b/backend/agent/mcp_server_adapter.py index e3be379..21ab3d7 100644 --- a/backend/agent/mcp_server_adapter.py +++ b/backend/agent/mcp_server_adapter.py @@ -100,8 +100,10 @@ class MCPToolAdapter: await session.initialize() print(f"Session initialized for {server_name}. Requesting tools...") result = await session.list_tools() + # REVIEW: debug print — remove before shipping. print(f"Tools received from {server_name}: {result}") tools = result.tools + # REVIEW: duplicate print — identical message already printed inside the `async with` block above. print(f"Tools received from {server_name}: {result}") for tool in tools: @@ -195,6 +197,9 @@ class MCPToolAdapter: populated (connections are opened per-call). It is kept as a placeholder for a future persistent-connection implementation. """ + # REVIEW: self.exit_stack is never assigned in __init__ — calling this method will + # always raise AttributeError. Either remove this method or initialise exit_stack + # in __init__ as an empty dict. for server_name, (transport_gen, session) in self.exit_stack.items(): try: await session.__aexit__(None, None, None) diff --git a/backend/agent/servers/mcp_server_code_execution.py b/backend/agent/servers/mcp_server_code_execution.py index ff02b9e..262a6dd 100644 --- a/backend/agent/servers/mcp_server_code_execution.py +++ b/backend/agent/servers/mcp_server_code_execution.py @@ -15,6 +15,9 @@ blocks dangerous imports and builtins before spawning any subprocess. """ import ast +# REVIEW: dead code — datetime is imported but only used to generate run_id in +# run_python_code_sandboxed(). That is legitimate, but note the import is unused in all +# other tools; it would be cleaner as a local import inside run_python_code_sandboxed(). from datetime import datetime import subprocess import io @@ -440,6 +443,9 @@ def python_code_validation(code: str) -> str: return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed." except Exception as e: return f"Error during code safety analysis: {e}" + # REVIEW: unreachable code — python_code_validation() falls off the end of the function + # without an explicit `return` when static_analysis_result is None (safe code); the function + # implicitly returns None instead of returning a success message to the caller. # ── Run the server ─────────────────────────────────────────────────────────── diff --git a/backend/agent/servers/mcp_server_file_search.py b/backend/agent/servers/mcp_server_file_search.py index dbcbdb1..4ad5f5a 100644 --- a/backend/agent/servers/mcp_server_file_search.py +++ b/backend/agent/servers/mcp_server_file_search.py @@ -69,6 +69,8 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str: """ try: safe_dir = _safe_path(dir_path) + # REVIEW: unreachable code — _safe_path() always returns a Path object (never None/falsy) + # or raises ValueError; this check can never be True. if not safe_dir: return f"Error: Invalid directory path '{dir_path}'." elif not safe_dir.exists(): @@ -108,6 +110,10 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str: lines.append(_tree(entry, prefix + extension)) return "\n".join(lines) + # REVIEW: redundant — get_file_tree() passes `dir_path` (original str argument) to `_tree()` + # rather than the validated `safe_dir` (resolved Path). If dir_path is a relative string, + # the inner `_tree()` will call `dir_path.iterdir()` on a str, causing an AttributeError. + # Should pass `safe_dir` instead. return _tree(dir_path) @@ -235,6 +241,8 @@ def create_new_directory(path: str) -> str: if resolved.exists(): return f"Error: File '{path}' already exists." + # REVIEW: redundant — `resolved.suffix != None` is always True (Path.suffix always returns str); + # the None check is unnecessary. Simplify to `if resolved.suffix != "":`. if resolved.suffix != None and resolved.suffix != "": return f"Error: can only create directories, got '{resolved.suffix}'." diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index e39d911..a4363fe 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -37,6 +37,7 @@ class ChatManager: """Return a copy of the conversation history.""" return list(self.chat_history) + # REVIEW: dead code — clear_history() is never called anywhere in the codebase. def clear_history(self) -> None: """Wipe the conversation history (starts a fresh chat).""" self.chat_history = [] @@ -107,6 +108,10 @@ class ChatManager: self.add_message("assistant", f"Error: {error_msg}") raise Exception(error_msg) + # REVIEW: dead code — get_chat_display() is never called anywhere in the codebase. + # The UI renders st.session_state.chat_history directly. This method also does the + # same thing as get_history() (returns a copy of chat_history with the same fields), + # making it redundant even if it were used. def get_chat_display(self) -> list: """Return a copy of the history suitable for display in the UI.""" return [ diff --git a/backend/managers/debug_logger.py b/backend/managers/debug_logger.py index b681f05..946ccf3 100644 --- a/backend/managers/debug_logger.py +++ b/backend/managers/debug_logger.py @@ -35,6 +35,7 @@ class DebugLogger: """Reset the log — call before each new execution.""" self.logs = [] + # REVIEW: dead code — format_debug_output() is never called anywhere in the codebase. def format_debug_output(self, output: dict) -> str: """Format an ExecutionEngine result dict into a human-readable string. diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index 62360b0..58bc1cc 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -7,7 +7,8 @@ touching the filesystem, preventing path-traversal attacks. import streamlit as st from pathlib import Path -# The workspace folder is created at module load so it always exists. +# REVIEW: dead code — module-level WORKSPACE constant is never used anywhere in this file or +# the rest of the codebase. FileManager.__init__ creates the workspace via self.base_path.mkdir(). WORKSPACE = Path("workspace") WORKSPACE.mkdir(exist_ok=True) @@ -139,6 +140,8 @@ class FileManager: with open(file_path, "r") as f: return f.read() except FileNotFoundError: + # REVIEW: unreachable code — FileNotFoundError cannot be raised here because + # `file_path.exists()` is already checked above and returns "" on failure. st.error(f"File not found: {relative_path}") return "" except Exception as e: diff --git a/backend/managers/system_prompter.py b/backend/managers/system_prompter.py index d9610b7..b5e50d3 100644 --- a/backend/managers/system_prompter.py +++ b/backend/managers/system_prompter.py @@ -12,6 +12,10 @@ class SystemPrompter: """ @staticmethod + # REVIEW: unused parameter (in production) — `file_context` is never passed by the only + # production call site (frontend/chat.py line 242 calls generate_prompt() with no args), + # so the file-embedding branch (lines 31-46) is dead in production. It is tested in + # tests/test_system_prompter.py but the feature is not wired up in the UI. def generate_prompt(file_context: dict | None = None) -> str: """Build a system prompt, optionally embedding a file's content. diff --git a/frontend/app.py b/frontend/app.py index 140f7c9..c05ace7 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -44,6 +44,8 @@ def main(): st.title("Lightweight code editor") + # REVIEW: redundant — init_state() is already called at module level (line 26) before main() runs; + # calling it again here is unnecessary since Streamlit reruns the whole module on each reload. # Re-run init_state to cover any keys that might have been missed on cold start init_state() diff --git a/frontend/chat.py b/frontend/chat.py index af7e236..fd8207c 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -22,6 +22,8 @@ def _run_async(coro): Returns: The return value of the coroutine. """ + # REVIEW: asyncio.get_running_loop() always raises RuntimeError in a Streamlit context; + # the try branch is dead code. The except branch always runs. try: # Reuse the loop that is already running (e.g. inside pytest-asyncio). loop = asyncio.get_running_loop() @@ -144,6 +146,7 @@ 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): + # REVIEW: commented-out code — remove if not needed. #loop = asyncio.new_event_loop() #asyncio.set_event_loop(loop) if task.strip(): @@ -353,6 +356,8 @@ def render_normal_chat(): if st.button("🗑️ Clear Chat"): _clear_chat_dialog() + # REVIEW: duplicate widget key — "agent_mode" toggle is already rendered inside render_agent_mode(); + # having two st.toggle calls with the same key on the same page will raise a DuplicateWidgetID error. st.toggle("Agent Mode", key="agent_mode") # 5h — Settings expander: file context toggle, model, token limit, custom prompt. diff --git a/frontend/editor.py b/frontend/editor.py index 19fe6b9..995ce1e 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -8,6 +8,8 @@ from pathlib import Path from backend.managers.file_manager import FileManager from backend.managers.execution_engine import ExecutionEngine +# REVIEW: DebugLogger is imported and used to log execution steps, but its output is never +# surfaced in the UI — DebugLogger writes to an in-memory buffer that nothing reads or renders. from backend.managers.debug_logger import DebugLogger # Maps file extensions to Ace editor language modes for syntax highlighting. @@ -194,6 +196,9 @@ def render_editor(): ) # Keep the in-memory cache in sync with what the editor currently shows. + # REVIEW: redundant round-trip — st_ace returns the same value that was passed as + # `value=` unless the user edited the content; comparing and re-assigning on every + # rerun is a no-op most of the time and adds overhead. if code != st.session_state.files_content[file_path]: st.session_state.files_content[file_path] = code diff --git a/frontend/sidebar.py b/frontend/sidebar.py index 4ee3db7..40f78c2 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -375,6 +375,7 @@ def render_sidebar(): if st.button("Add Folder", key="btn_add_folder", use_container_width=True): _add_folder_dialog("") + # REVIEW: bare `return` at end of void function — no-op; can be removed. return diff --git a/frontend/state.py b/frontend/state.py index d7e5f19..7e2fb9c 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -43,25 +43,33 @@ def init_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: + # REVIEW: dead code — docstrings inside `if` blocks are plain string literals that Python + # evaluates and immediately discards; they are never visible as __doc__ and have no effect. 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: + # REVIEW: dead code — same issue: string literal inside `if` block is never used as a docstring. 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: + # REVIEW: dead code — string literal inside `if` block is never used as a docstring. st.session_state.active_file = None + # REVIEW: dead code — active_tab is initialised here but never read or written anywhere else + # in the codebase; st.tabs() in editor.py does not use this key. # Index of the active tab — kept in sync with active_file for st.tabs(). if "active_tab" not in st.session_state: st.session_state.active_tab = 0 + # REVIEW: dead code — is_editing is initialised here but never read or written anywhere else. if "is_editing" not in st.session_state: st.session_state.is_editing = False + # REVIEW: dead code — code_suggestions is initialised here but never read or written anywhere else. if "code_suggestions" not in st.session_state: st.session_state.code_suggestions = [] @@ -120,5 +128,7 @@ def init_state(): st.session_state.exec_results = {} +# REVIEW: dead code — state.py is never run as a script; this guard is useless here because +# init_state() requires a running Streamlit session (st.session_state) to work. if __name__ == "__main__": init_state() -- 2.30.2 From 59ff3ead93f1edb162111f556bddcc45abf34ed1 Mon Sep 17 00:00:00 2001 From: Irina Rueegg Date: Mon, 25 May 2026 11:02:44 +0200 Subject: [PATCH 6/9] Logger implementation, MCP commented out since logging is blocking server --- .gitignore | 3 + backend/agent/coding_agent.py | 57 ++- backend/agent/mcp_server_adapter.py | 43 +- backend/agent/mcp_server_config.json | 5 +- .../servers/mcp_server_code_execution.py | 40 +- .../agent/servers/mcp_server_file_search.py | 54 ++- .../agent/servers/mcp_server_web_search.py | 65 ++- backend/managers/_debug_logger.py | 71 +++ backend/managers/chat_manager.py | 76 ++-- backend/managers/debug_logger.py | 126 ++++-- backend/managers/execution_engine.py | 9 + backend/managers/file_manager.py | 57 ++- backend/managers/search_manager.py | 2 + backend/managers/system_prompter.py | 5 + frontend/app.py | 5 + frontend/chat.py | 85 +++- frontend/editor.py | 3 +- requirements.txt | 5 +- tests/test_debug_logger.py | 2 +- tests/test_execution_engine.py | 428 ++++++++++++++++++ tests/test_mcp_server_code_execution.py | 269 +++++++++++ 21 files changed, 1246 insertions(+), 164 deletions(-) create mode 100644 backend/managers/_debug_logger.py diff --git a/.gitignore b/.gitignore index 7309b3c..c3aa73e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ data/raw/ # Workspace workspace/ + +# Logs +logs/ diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index fd0a371..9b15f52 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -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 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." ) + 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." ), }) + 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.""" diff --git a/backend/agent/mcp_server_adapter.py b/backend/agent/mcp_server_adapter.py index c87b5ae..80cb4af 100644 --- a/backend/agent/mcp_server_adapter.py +++ b/backend/agent/mcp_server_adapter.py @@ -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:") diff --git a/backend/agent/mcp_server_config.json b/backend/agent/mcp_server_config.json index 76ede4d..ade2521 100644 --- a/backend/agent/mcp_server_config.json +++ b/backend/agent/mcp_server_config.json @@ -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": { diff --git a/backend/agent/servers/mcp_server_code_execution.py b/backend/agent/servers/mcp_server_code_execution.py index c2608e5..7e1dfb2 100644 --- a/backend/agent/servers/mcp_server_code_execution.py +++ b/backend/agent/servers/mcp_server_code_execution.py @@ -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="", 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" diff --git a/backend/agent/servers/mcp_server_file_search.py b/backend/agent/servers/mcp_server_file_search.py index 65180bc..37ba647 100644 --- a/backend/agent/servers/mcp_server_file_search.py +++ b/backend/agent/servers/mcp_server_file_search.py @@ -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 ─────────────────────────────────────────────────────────── diff --git a/backend/agent/servers/mcp_server_web_search.py b/backend/agent/servers/mcp_server_web_search.py index 39c9fb4..03c7a7c 100644 --- a/backend/agent/servers/mcp_server_web_search.py +++ b/backend/agent/servers/mcp_server_web_search.py @@ -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 ─────────────────────────────────────────────────────────── diff --git a/backend/managers/_debug_logger.py b/backend/managers/_debug_logger.py new file mode 100644 index 0000000..af1fc13 --- /dev/null +++ b/backend/managers/_debug_logger.py @@ -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()) \ No newline at end of file diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index a10faab..0b44bb8 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -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.""" diff --git a/backend/managers/debug_logger.py b/backend/managers/debug_logger.py index b681f05..fd14ef9 100644 --- a/backend/managers/debug_logger.py +++ b/backend/managers/debug_logger.py @@ -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) diff --git a/backend/managers/execution_engine.py b/backend/managers/execution_engine.py index b4a449a..8ac9f68 100644 --- a/backend/managers/execution_engine.py +++ b/backend/managers/execution_engine.py @@ -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} diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index add29f3..12d9e65 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -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 = {} diff --git a/backend/managers/search_manager.py b/backend/managers/search_manager.py index e69de29..7a11780 100644 --- a/backend/managers/search_manager.py +++ b/backend/managers/search_manager.py @@ -0,0 +1,2 @@ +from backend.managers.debug_logger import get_logger +logger = get_logger(__name__) \ No newline at end of file diff --git a/backend/managers/system_prompter.py b/backend/managers/system_prompter.py index d9610b7..46e09cd 100644 --- a/backend/managers/system_prompter.py +++ b/backend/managers/system_prompter.py @@ -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", "") diff --git a/frontend/app.py b/frontend/app.py index 140f7c9..4d9dc6c 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -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() diff --git a/frontend/chat.py b/frontend/chat.py index 43ab8bc..8457268 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -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"] diff --git a/frontend/editor.py b/frontend/editor.py index 9f9c8b8..4e1ec5e 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -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 = { diff --git a/requirements.txt b/requirements.txt index 3916858..acaf29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,5 +25,6 @@ python-dotenv>=1.0.0 #For code editor functionality streamlit-ace>=0.1.0 -#Whitelisted Imports from Agent-Sandbox -pygame \ No newline at end of file +#MCP-Code execution tools +pyflakes>=0.1.0 +pygame>=0.1.0 \ No newline at end of file diff --git a/tests/test_debug_logger.py b/tests/test_debug_logger.py index 1ee6eca..d0de98a 100644 --- a/tests/test_debug_logger.py +++ b/tests/test_debug_logger.py @@ -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 ────────────────────────────────────────────────────────────────── diff --git a/tests/test_execution_engine.py b/tests/test_execution_engine.py index e69de29..14e32ce 100644 --- a/tests/test_execution_engine.py +++ b/tests/test_execution_engine.py @@ -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 (1–10) +# ========================================================= + + +# --------------------------------------------------------- +# 1. Python-Datei wird korrekt ausgeführt +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_python_file_success(mock_run, engine, tmp_path): + file = tmp_path / "test.py" + file.write_text("print('hello')") + + mock_run.return_value = Mock( + stdout="hello\n", + stderr="", + returncode=0 + ) + + result = engine.run_code(file) + + assert result["rc"] == 0 + assert "hello" in result["stdout"] + + +# --------------------------------------------------------- +# 2. Python-Datei mit Fehler +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_python_file_error(mock_run, engine, tmp_path): + file = tmp_path / "broken.py" + file.write_text("1/0") + + mock_run.return_value = Mock( + stdout="", + stderr="ZeroDivisionError", + returncode=1 + ) + + result = engine.run_code(file) + + assert result["rc"] == 1 + assert "ZeroDivisionError" in result["stderr"] + + +# --------------------------------------------------------- +# 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 (11–20) +# ========================================================= + + +# --------------------------------------------------------- +# 11. Unicode Output +# --------------------------------------------------------- + +@patch("subprocess.run") +def test_run_unicode_output(mock_run, engine, tmp_path): + file = tmp_path / "unicode.py" + file.write_text("print('🔥 Grüezi 世界')") + + 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 \ No newline at end of file diff --git a/tests/test_mcp_server_code_execution.py b/tests/test_mcp_server_code_execution.py index e69de29..8bef986 100644 --- a/tests/test_mcp_server_code_execution.py +++ b/tests/test_mcp_server_code_execution.py @@ -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 (1–10) +# ========================================================= + + +# --------------------------------------------------------- +# 1. Erlaubter Code besteht Safety Check +# --------------------------------------------------------- + +def test_check_code_safety_valid(): + code = "print('hello')" + + result = server.check_code_safety(code) + + assert result is None + + +# --------------------------------------------------------- +# 2. Blockierter Import wird erkannt +# --------------------------------------------------------- + +def test_check_code_safety_blocked_import(): + code = "import os" + + result = server.check_code_safety(code) + + assert "Blocked import" in result + + +# --------------------------------------------------------- +# 3. Blockierter Builtin wird erkannt +# --------------------------------------------------------- + +def test_check_code_safety_blocked_builtin(): + code = "eval('2+2')" + + result = server.check_code_safety(code) + + assert "Blocked builtin" in result + + +# --------------------------------------------------------- +# 4. analyse_structure erkennt Funktionen +# --------------------------------------------------------- + +def test_analyse_structure_function(): + code = """ +def hello(name): + return name +""" + + result = server.analyse_structure(code) + + assert "def hello(name)" in result + + +# --------------------------------------------------------- +# 5. analyse_structure erkennt Klassen +# --------------------------------------------------------- + +def test_analyse_structure_class(): + code = """ +class User: + def login(self): + pass +""" + + result = server.analyse_structure(code) + + assert "class User" in result + assert "method: login" in result + + +# --------------------------------------------------------- +# 6. lint_code erkennt Undefined Variable +# --------------------------------------------------------- + +def test_lint_code_undefined_variable(): + code = "print(x)" + + result = server.lint_code(code) + + assert "undefined name 'x'" in result.lower() + + +# --------------------------------------------------------- +# 7. lint_code erkennt sauberen Code +# --------------------------------------------------------- + +def test_lint_code_clean(): + code = """ +x = 1 +print(x) +""" + + result = server.lint_code(code) + + assert "No issues found" in result + + +# --------------------------------------------------------- +# 8. python_code_validation validiert sicheren Code +# --------------------------------------------------------- + +def test_python_code_validation_safe(): + code = "print('safe')" + + result = server.python_code_validation(code) + + assert "can be executed" in result + + +# --------------------------------------------------------- +# 9. run_python_sandboxed führt Code aus +# --------------------------------------------------------- + +def test_run_python_sandboxed_success(): + code = "print('hello world')" + + result = server.run_python_sandboxed(code) + + assert "hello world" in result + + +# --------------------------------------------------------- +# 10. run_python_sandboxed ohne Output +# --------------------------------------------------------- + +def test_run_python_sandboxed_no_output(): + code = "x = 5" + + result = server.run_python_sandboxed(code) + + assert "no output" in result.lower() + + +# ========================================================= +# EDGE CASE TESTS (11–20) +# ========================================================= + + +# --------------------------------------------------------- +# 11. Syntaxfehler erkennen +# --------------------------------------------------------- + +def test_check_code_safety_syntax_error(): + code = "def broken(" + + result = server.check_code_safety(code) + + assert "SyntaxError" in result + + +# --------------------------------------------------------- +# 12. ImportFrom blockieren +# --------------------------------------------------------- + +def test_check_code_safety_import_from(): + code = "from os import path" + + result = server.check_code_safety(code) + + assert "Blocked import" in result + + +# --------------------------------------------------------- +# 13. Gefährliche Path-Sequenzen erkennen +# --------------------------------------------------------- + +def test_check_code_safety_path_traversal(): + code = "print('../etc/passwd')" + + result = server.check_code_safety(code) + + assert "Suspect path sequence" in result + + +# --------------------------------------------------------- +# 14. __import__ erkennen +# --------------------------------------------------------- + +def test_check_code_safety_import_escape(): + code = "__import__('os')" + + result = server.check_code_safety(code) + + assert "Blocked" in result + + +# --------------------------------------------------------- +# 15. subprocess Escape erkennen +# --------------------------------------------------------- + +def test_check_code_safety_subprocess_escape(): + code = "subprocess.run(['ls'])" + + result = server.check_code_safety(code) + + assert "Suspect path sequence" in result + + +# --------------------------------------------------------- +# 16. Endlosschleife Timeout +# --------------------------------------------------------- + +def test_run_python_sandboxed_timeout(): + code = """ +while True: + pass +""" + + result = server.run_python_sandboxed(code) + + assert "time limit" in result.lower() + + +# --------------------------------------------------------- +# 17. Sehr großer Output wird gekürzt +# --------------------------------------------------------- + +def test_run_python_sandboxed_large_output(): + code = "print('A' * 10000)" + + result = server.run_python_sandboxed(code) + + assert "truncated" in result.lower() + + +# --------------------------------------------------------- +# 18. Unicode Output funktioniert +# --------------------------------------------------------- + +def test_run_python_sandboxed_unicode(): + code = "print('🔥 Grüezi 世界')" + + result = server.run_python_sandboxed(code) + + assert "🔥 Grüezi 世界" in result + + +# --------------------------------------------------------- +# 19. analyse_structure bei leerem Code +# --------------------------------------------------------- + +def test_analyse_structure_empty(): + code = "" + + result = server.analyse_structure(code) + + assert "No top-level imports" in result + + +# --------------------------------------------------------- +# 20. Sandbox behandelt Runtime Errors +# --------------------------------------------------------- + +def test_run_python_sandboxed_runtime_error(): + code = "1 / 0" + + result = server.run_python_sandboxed(code) + + assert "ZeroDivisionError" in result \ No newline at end of file -- 2.30.2 From d28c0157d8d67e854daf99998b70c45e29d8e296 Mon Sep 17 00:00:00 2001 From: Irina Rueegg Date: Mon, 25 May 2026 11:03:38 +0200 Subject: [PATCH 7/9] logger in editor --- frontend/editor.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/editor.py b/frontend/editor.py index 4e1ec5e..35d5a40 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -52,8 +52,10 @@ def _rename_dialog(file_path: str): if st.session_state.active_file == file_path: st.session_state.active_file = new_file_path st.rerun() + logger.info("Rename file %s to %s successfull", file_path, new_name ) else: - 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: if st.button("Cancel", use_container_width=True): st.rerun() @@ -78,8 +80,10 @@ def _delete_dialog(abs_file_path: str): if st.session_state.open_files else None ) st.rerun() + logger.info("Deleting file %s successfull.", abs_file_path) else: st.error("Delete failed. Check that the file still exists.") + logger.warning("Deleting file %s failed.", abs_file_path) with col2: if st.button("Cancel", use_container_width=True): st.rerun() @@ -97,18 +101,16 @@ def run_active_file(): return execution_engine = ExecutionEngine() - debug_logger = DebugLogger() - - debug_logger.clear() - debug_logger.log(f"Executing code from {active_file}...") + + logger.info("Executing code from %s...", active_file) with st.spinner(f"Running {Path(active_file).name}..."): output = execution_engine.run_code(Path(active_file)) if output["rc"] == 0: - debug_logger.log("Execution completed successfully.") + logger.info("Execution completed successfully.") else: - debug_logger.log_error(f"Execution failed with exit code {output['rc']}.") + logger.error("Execution failed with exit code %s.", output['rc']) st.session_state.code_execution_output = { "stdout": output["stdout"], -- 2.30.2 From efdd9351b3b94302336edaf8d782065230a66ef9 Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Mon, 25 May 2026 19:02:20 +0200 Subject: [PATCH 8/9] improvments --- frontend/app.py | 2 +- frontend/chat.py | 38 +++++++++++++++++++++++++++++++------- frontend/editor.py | 2 +- frontend/sidebar.py | 19 +++++++++++++++++-- frontend/state.py | 4 ++++ 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/frontend/app.py b/frontend/app.py index 0c5c5e9..0625395 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -39,7 +39,7 @@ def main(): """ """, unsafe_allow_html=True, diff --git a/frontend/chat.py b/frontend/chat.py index 4a78f1d..89bb0b6 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -1,18 +1,17 @@ """Chat view — renders both the normal chat interface and the Coding Agent mode.""" import asyncio +import json from pathlib import Path import streamlit as st from backend.managers.chat_manager import ChatManager from backend.managers.system_prompter import SystemPrompter +from backend.managers.search_manager import SearchManager from backend.agent.coding_agent import CodingAgent - from backend.managers.debug_logger import get_logger -logger = get_logger(__name__) -import asyncio -import json +logger = get_logger(__name__) # ── Agent Mode helpers ──────────────────────────────────────────────────────── @@ -377,6 +376,32 @@ def render_normal_chat(): 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: + if not chat_manager.get_history(): + system_prompt = SystemPrompter.generate_prompt(_build_file_context()) + chat_manager.add_message("system", system_prompt) + 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: if message["role"] == "system": @@ -430,7 +455,6 @@ def render_normal_chat(): return # ── Normal chat message ─────────────────────────────────────────────── - chat_manager = st.session_state.chat_manager search_results = st.session_state.get("search_results", []) # 5g — System-prompt logic: inject on first message, update on file change. @@ -473,8 +497,8 @@ def render_normal_chat(): if st.button("🗑️ Clear Chat"): _clear_chat_dialog() - # REVIEW: duplicate widget key — "agent_mode" toggle is already rendered inside render_agent_mode(); - # having two st.toggle calls with the same key on the same page will raise a DuplicateWidgetID error. + # Toggle to switch to Agent Mode (render_normal_chat and render_agent_mode are + # mutually exclusive, so the same key here causes no DuplicateWidgetID conflict). st.toggle("Agent Mode", key="agent_mode") # 5h — Settings expander: file context toggle, model, token limit, custom prompt. diff --git a/frontend/editor.py b/frontend/editor.py index de2af8b..68b2877 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -126,7 +126,7 @@ def run_active_file(): except SyntaxError as e: result = {"stdout": "", "stderr": str(e), "return_code": -1, "ast_error": True} st.session_state.exec_results[active_file] = result - debug_logger.log_error(f"Syntax error: {e}") + logger.error("Syntax error: %s", e) return result with st.spinner(f"Running {Path(active_file).name}..."): diff --git a/frontend/sidebar.py b/frontend/sidebar.py index 373c71d..e265d61 100644 --- a/frontend/sidebar.py +++ b/frontend/sidebar.py @@ -253,14 +253,29 @@ def build_arborist_tree(tree, parent_path=Path()): def render_filetree_arborist(tree): - """Render the interactive file tree and return the currently selected node dict.""" + """Render the interactive file tree and return the currently selected node dict. + + Passes the active file's relative path as ``selection`` so the tree always + highlights whichever file is currently open in the editor, even when the + user switches tabs instead of clicking the tree. + """ data = build_arborist_tree(tree) + # Compute the node-ID of the currently active file (posix relative path) + # so the tree highlights it regardless of how the tab was opened. + active_selection = None + active_file = st.session_state.get("active_file") + if active_file: + try: + active_selection = str(Path(active_file).relative_to(fm.base_path).as_posix()) + except ValueError: + pass + selected = tree_view( data=data, icons={"open": "📂", "closed": "📁"}, height=400, - selection=None, + selection=active_selection, select_internal_nodes=True, # allow clicking folder names, not just files open_by_default=True ) diff --git a/frontend/state.py b/frontend/state.py index 5eda2d6..787dd81 100644 --- a/frontend/state.py +++ b/frontend/state.py @@ -78,6 +78,10 @@ def init_state(): if "code_execution_output" not in st.session_state: st.session_state.code_execution_output = "" + # Per-file execution results: {file_path: {stdout, stderr, return_code, ast_error}} + if "exec_results" not in st.session_state: + st.session_state.exec_results = {} + # ── Chat state ──────────────────────────────────────────────────────────── # Flat list of {"role": ..., "content": ...} dicts rendered as chat bubbles. -- 2.30.2 From 33dd93fbbf2c645f8e4a571dc408b24c18a5aba1 Mon Sep 17 00:00:00 2001 From: Livio Meuli Date: Tue, 26 May 2026 06:31:02 +0200 Subject: [PATCH 9/9] Fixes and Updates --- README.md | 6 +- READMEnew.md | 11 +- backend/managers/chat_manager.py | 38 +-- backend/managers/debug_logger.py | 29 +++ backend/managers/execution_engine.py | 17 +- backend/managers/file_manager.py | 14 ++ backend/managers/system_prompter.py | 79 +++++- frontend/chat.py | 42 +++- frontend/editor.py | 7 +- tests/test_execution_engine.py | 2 +- tests/test_search_manager.py | 362 +++++++++++++++++++++++++++ 11 files changed, 562 insertions(+), 45 deletions(-) create mode 100644 tests/test_search_manager.py diff --git a/README.md b/README.md index 135e3e2..be4f596 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,11 @@ AISE_AIAgent/ ### Backend Manager (`backend/managers/`) Werden direkt vom Frontend für UI-Operationen genutzt: -- **file_manager.py**: CRUD-Operationen auf Projektdateien -- **chat_manager.py**: Chat-History, Nachrichten-Verwaltung +- **file_manager.py**: CRUD-Operationen auf Projektdateien (`get_file_tree()` liefert die verschachtelte Baumstruktur für den File Explorer; eine flache `list_files()` wurde bewusst nicht implementiert, da das Frontend die Baumstruktur benötigt — für den Agent Mode übernimmt `mcp_server_file_search.py` die Dateisuche) +- **chat_manager.py**: Chat-History, Nachrichten-Verwaltung (Fehler aus der Code-Ausführung werden im normalen Chat bewusst per "Debug with AI"-Button manuell an den Chat übergeben — der User entscheidet selbst wann die AI eingeschaltet wird; im Agent Mode geschieht dies automatisch über den Plan-Act-Observe-Loop) - **system_prompter.py**: System-Prompt-Generierung und Datei-Kontext - **execution_engine.py**: Sichere Code-Ausführung mit Output-Capture -- **debug_logger.py**: Fehler-Tracking und Log-Formatierung +- **debug_logger.py**: Fehler-Tracking und Log-Formatierung (`format_debug_output()` formatiert Execution-Output für UI und AI-Chat; `log_error()` wurde bewusst nicht als separate Methode implementiert — Python's Standard-`logging`-Modul mit `logger.error()` deckt diese Funktionalität bereits vollständig ab und wird konsequent im gesamten Code verwendet) - **search_manager.py**: Web-Suche via DuckDuckGo (`ddgs`-Bibliothek) ### Backend Agent (`backend/agent/`) diff --git a/READMEnew.md b/READMEnew.md index e8a4a09..4722fb9 100644 --- a/READMEnew.md +++ b/READMEnew.md @@ -99,12 +99,18 @@ Stellt CRUD-Operationen auf dem Workspace-Verzeichnis bereit: - Verzeichnisstruktur auflisten - Sichere Pfadvalidierung (verhindert Path-Traversal) +> **Designentscheidung — `list_files()` vs. `get_file_tree()`:** +> Die Projektspezifikation nennt `list_files()` als `FileManager`-Methode. Im vorliegenden Design wurde bewusst `get_file_tree()` implementiert, da das Frontend eine verschachtelte Baumstruktur benötigt (für den interaktiven File Explorer in der Sidebar). Eine flache Liste würde die Navigation nicht unterstützen. Für den Agent Mode übernimmt der MCP-Server `mcp_server_file_search.py` die Dateisuche — die Funktionalität ist damit im System vorhanden, nur architektonisch sauber getrennt. + ### `chat_manager.py` Verwaltet AI-Chat-Interaktionen: - Aufbau und Verwaltung der Chat-History - Senden von Nachrichten an das AI-Modell - Formatierung von System- und User-Nachrichten +> **Designentscheidung — Fehler-Output im normalen Chat:** +> Laufzeitfehler und stderr-Output werden im normalen Chat bewusst **nicht automatisch** in den Chat-Kontext injiziert. Stattdessen gibt es den "Debug with AI"-Button im Editor, über den der User selbst entscheidet wann er die AI einschalten möchte. Dies verhindert, dass die Chat-History mit ungewollten Fehlermeldungen geflutet wird. Im Agent Mode wird dies anders gelöst: dort landet jeder Execution-Fehler automatisch als Observation im Plan-Act-Observe-Loop und der Agent replant ohne User-Eingriff. + ### `system_prompter.py` Generiert kontextreiche System-Prompts für den AI-Assistenten: - Injektion von aktuellem Dateiinhalt als Kontext @@ -119,7 +125,10 @@ Führt Python-Code sicher aus: ### `debug_logger.py` Logging und Fehler-Tracking: - Formatierte Log-Ausgaben für Debugging -- Fehler-Aggregation für die UI-Darstellung +- `format_debug_output(output)` formatiert den Execution-Output (`stdout`, `stderr`, `return_code`) in einen einheitlichen String für die UI-Anzeige und den AI-Chat-Kontext + +> **Designentscheidung — `log_error()` nicht implementiert:** +> Die Projektspezifikation nennt `log_error()` als `DebugLogger`-Methode. Diese wurde bewusst nicht als separate Methode implementiert, da Python's eingebautes `logging`-Modul diese Funktionalität mit `logger.error()` bereits vollständig abdeckt. Im gesamten Projekt wird konsistent `logger = get_logger(__name__)` gefolgt von `logger.error(...)` verwendet — eine eigene Wrapper-Methode wäre toter Code ohne Mehrwert. ### `search_manager.py` Web-Suche für den KI-Assistenten via DuckDuckGo: diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index dbf9ff5..65a9114 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -98,32 +98,34 @@ class ChatManager: 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 + 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: - # Parse response response_data = response.json() - - # Extract AI message - if "choices" in response_data and len(response_data["choices"]) > 0: - ai_message = response_data["choices"][0]["message"]["content"] - - # Add AI response to history - self.add_message("assistant", ai_message) - - 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 json.JSONDecodeError as e: error_msg = f"JSON Decode Error: {str(e)}" self.add_message("assistant", f"Error: {error_msg}") logger.exception("JSON Decode Error: %s", e) raise Exception(error_msg) + + if "choices" not in response_data or not response_data["choices"]: + logger.warning("Invalid API response format: %s", response_data) + self.add_message("assistant", "Error: Invalid API response format") + raise Exception("Invalid API response format") + + try: + ai_message = response_data["choices"][0]["message"]["content"] + self.add_message("assistant", ai_message) + logger.info("Assistant response generated") + return ai_message except Exception as e: error_msg = f"Error: {str(e)}" self.add_message("assistant", f"Error: {error_msg}") diff --git a/backend/managers/debug_logger.py b/backend/managers/debug_logger.py index fd14ef9..b221d43 100644 --- a/backend/managers/debug_logger.py +++ b/backend/managers/debug_logger.py @@ -41,6 +41,7 @@ LOG_DIR.mkdir(exist_ok=True) class DebugLogger: _initialized = False + _error_log: list[str] = [] @classmethod def setup(cls): @@ -88,6 +89,34 @@ class DebugLogger: 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): diff --git a/backend/managers/execution_engine.py b/backend/managers/execution_engine.py index 0d4b732..547d4bc 100644 --- a/backend/managers/execution_engine.py +++ b/backend/managers/execution_engine.py @@ -63,7 +63,7 @@ class ExecutionEngine: timeout=RUN_TIMEOUT, ) logger.info("File ran successfully.") - return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode} + return self.capture_output(proc) except subprocess.TimeoutExpired: logger.warning("Time out afte %s s", RUN_TIMEOUT) @@ -75,3 +75,18 @@ class ExecutionEngine: except Exception as e: logger.exception("Error while running %s: %s", active_file.name, e) return {"stdout": "", "stderr": str(e), "rc": -1} + + def capture_output(self, proc: subprocess.CompletedProcess) -> dict: + """Extract stdout, stderr, and return code from a completed subprocess. + + Args: + proc: The CompletedProcess returned by subprocess.run(). + + Returns: + {"stdout": str, "stderr": str, "rc": int} with whitespace stripped. + """ + return { + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + "rc": proc.returncode, + } diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index eab6f3a..0c87dd9 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -340,5 +340,19 @@ class FileManager: return tree return build_tree(self.base_path) + def list_files(self, extensions: list[str] | None = None) -> list[Path]: + """Returns a flat list of all files in the workspace. + + Args: + extensions: Optional list of extensions to filter by, e.g. ['.py', '.js']. + If None, all files are returned. + Returns: + List of absolute Path objects for all matching files. + """ + files = (p for p in self.base_path.rglob("*") if p.is_file()) + if extensions is not None: + files = (p for p in files if p.suffix in extensions) + return sorted(files) + if __name__ == "__main__": FileManager() diff --git a/backend/managers/system_prompter.py b/backend/managers/system_prompter.py index 211e7a3..e2f89e2 100644 --- a/backend/managers/system_prompter.py +++ b/backend/managers/system_prompter.py @@ -1,11 +1,67 @@ """Builds the system prompt that is sent to the AI at the start of each chat session.""" +import ast + from backend.managers.debug_logger import get_logger logger = get_logger(__name__) # Prevents very large files from flooding the context window with tokens. MAX_FILE_CHARS = 4000 +# Per-task base prompts — selected via the task_type parameter. +_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." + ), + "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." + ), + "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." + ), + "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." + ), +} + + +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: """Generates system prompts for the chat assistant. @@ -16,37 +72,38 @@ class SystemPrompter: @staticmethod def generate_prompt( + user_message: str = "", file_context: dict | None = None, search_context: list[dict] | None = None, + task_type: str = "default", ) -> str: """Build a system prompt, optionally embedding a file and/or web search results. Args: + user_message: The current user input — used for task-type detection + and selective context extraction. Reserved for future + task-specific prompt tuning beyond what task_type covers. file_context: dict with keys 'name' (filename) and 'content' (raw text), or None if no file should be included. search_context: list of {"title", "url", "snippet"} dicts from SearchManager, or None if no search results should be included. + task_type: One of "debug", "explain", "optimize", "default". + Selects the matching base prompt from _TASK_PROMPTS. 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. " - "Be concise and precise. Use markdown and fenced code blocks where appropriate." - ) - - prompt = base + logger.info("Generating system prompt (task_type=%s).", task_type) + prompt = _TASK_PROMPTS.get(task_type, _TASK_PROMPTS["default"]) if file_context: logger.info("Appending file context.") name = file_context.get("name", "unknown") content = file_context.get("content", "") - # Truncate large files to avoid exceeding the model's token limit - if len(content) > MAX_FILE_CHARS: - content = content[:MAX_FILE_CHARS] + "\n... [truncated]" + # Extract only the relevant function/class when the user mentions one; + # otherwise fall back to simple truncation at MAX_FILE_CHARS. + content = _extract_relevant_context(content, user_message) prompt += ( f"\n\nThe user currently has the following file open in the editor:\n" diff --git a/frontend/chat.py b/frontend/chat.py index 89bb0b6..3d4c32c 100644 --- a/frontend/chat.py +++ b/frontend/chat.py @@ -285,6 +285,40 @@ def render_agent_mode(): # ── Normal Chat helpers ─────────────────────────────────────────────────────── +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. @@ -387,9 +421,7 @@ def render_normal_chat(): # 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: - if not chat_manager.get_history(): - system_prompt = SystemPrompter.generate_prompt(_build_file_context()) - chat_manager.add_message("system", system_prompt) + _set_system_prompt(chat_manager, pending_debug) with st.spinner("Sending debug info to AI..."): try: ai_response = chat_manager.send_message(pending_debug) @@ -458,9 +490,7 @@ def render_normal_chat(): search_results = st.session_state.get("search_results", []) # 5g — System-prompt logic: inject on first message, update on file change. - if not chat_manager.get_history(): - system_prompt = SystemPrompter.generate_prompt() - chat_manager.add_message("system", system_prompt) + _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. diff --git a/frontend/editor.py b/frontend/editor.py index 68b2877..ba1c13e 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -8,7 +8,7 @@ from pathlib import Path from backend.managers.file_manager import FileManager from backend.managers.execution_engine import ExecutionEngine -from backend.managers.debug_logger import get_logger +from backend.managers.debug_logger import get_logger, DebugLogger logger = get_logger(__name__) # Maps file extensions to Ace editor language modes for syntax highlighting. @@ -249,13 +249,12 @@ def render_editor(): 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 - error_text = result.get("stderr", "") or f"Exit code: {result['return_code']}" 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"**Error:** {error_text.strip()}\n" - f"**Exit Code:** {result['return_code']}\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?" ) diff --git a/tests/test_execution_engine.py b/tests/test_execution_engine.py index 14e32ce..6120ece 100644 --- a/tests/test_execution_engine.py +++ b/tests/test_execution_engine.py @@ -232,7 +232,7 @@ def test_run_text_mode_enabled(mock_run, engine, tmp_path): @patch("subprocess.run") def test_run_unicode_output(mock_run, engine, tmp_path): file = tmp_path / "unicode.py" - file.write_text("print('🔥 Grüezi 世界')") + file.write_text("print('🔥 Grüezi 世界')", encoding="utf-8") mock_run.return_value = Mock( stdout="🔥 Grüezi 世界\n", diff --git a/tests/test_search_manager.py b/tests/test_search_manager.py new file mode 100644 index 0000000..73fbcc9 --- /dev/null +++ b/tests/test_search_manager.py @@ -0,0 +1,362 @@ +"""Tests for SearchManager — no real network calls, all I/O mocked.""" + +import socket +import pytest +from unittest.mock import Mock, patch, MagicMock + +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 = "

Hello World

" + 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 = ( + "" + "

Content

" + ) + 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"{long_text}" + 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 = "

Short

" + 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") -- 2.30.2