diff --git a/backend/agent/coding_agent.py b/backend/agent/coding_agent.py index 12952e8..93d51f9 100644 --- a/backend/agent/coding_agent.py +++ b/backend/agent/coding_agent.py @@ -178,9 +178,12 @@ def trim_messages(messages: list) -> list: 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. 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. reminder = { "role": "user", "content": ( @@ -337,7 +340,7 @@ class CodingAgent: payload = { "model": self.model, "messages": messages, - "temperature": 0.2, + "temperature": 0.2, # low temperature → deterministic, more reliable tool calls "max_tokens": 4096, "stream": False, } @@ -439,7 +442,9 @@ class CodingAgent: result = await dispatch_tool(tool_name, arguments) result = truncate_result(result) - # Build feedback – nudge agent to replan on errors + # Wrap the tool output in an XML tag so the LLM can easily find it. + # Append a tag on errors to force the agent to reconsider + # its plan rather than blindly retrying the same failing action. feedback = f'\n{result}\n' if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"): feedback += ( diff --git a/backend/managers/chat_manager.py b/backend/managers/chat_manager.py index bcfc2e7..a10faab 100644 --- a/backend/managers/chat_manager.py +++ b/backend/managers/chat_manager.py @@ -1,4 +1,4 @@ -"""Chat Manager - Handles chat history and AI communication""" +"""Manages the chat history and communication with the AI model API.""" import os from dotenv import load_dotenv @@ -9,6 +9,13 @@ load_dotenv() class ChatManager: + """Handles sending messages and maintaining conversation history. + + Connects to an OpenAI-compatible REST endpoint configured via environment + variables. All messages (user, assistant, system) are kept in memory so + the full conversation is sent with every request. + """ + def __init__(self): self.api_host = os.getenv("HOST") self.api_port = os.getenv("PORT") @@ -22,15 +29,23 @@ class ChatManager: self.chat_history = [] def add_message(self, role: str, content: str) -> None: + """Append a single message to the conversation history.""" self.chat_history.append({"role": role, "content": content}) def get_history(self) -> list: - return self.chat_history + """Return a copy of the conversation history.""" + return list(self.chat_history) def clear_history(self) -> None: + """Wipe the conversation history (starts a fresh chat).""" self.chat_history = [] def send_message(self, user_message: str) -> str: + """Send a user message to the AI and return its reply. + + Adds the user message to history, calls the API with the full history + as context, and appends the AI reply to history before returning it. + """ # Add user message to history self.add_message("user", user_message) @@ -44,6 +59,7 @@ class ChatManager: 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, @@ -91,6 +107,7 @@ class ChatManager: raise Exception(error_msg) def get_chat_display(self) -> list: + """Return a copy of the history suitable for display in the UI.""" return [ {"role": msg["role"], "content": msg["content"]} for msg in self.chat_history diff --git a/backend/managers/debug_logger.py b/backend/managers/debug_logger.py index 477dc8f..b681f05 100644 --- a/backend/managers/debug_logger.py +++ b/backend/managers/debug_logger.py @@ -1,9 +1,66 @@ +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 log(self, message): - self.logs.append(message) - - def get_logs(self): - return self.logs \ No newline at end of file + + 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) diff --git a/backend/managers/execution_engine.py b/backend/managers/execution_engine.py index 0b1bd92..b4a449a 100644 --- a/backend/managers/execution_engine.py +++ b/backend/managers/execution_engine.py @@ -1,19 +1,38 @@ import subprocess from pathlib import Path -RUN_TIMEOUT = 30 # seconds +# Maximum time (seconds) a subprocess is allowed to run before being killed. +RUN_TIMEOUT = 30 + class ExecutionEngine: + """Runs files from the editor in a subprocess and returns the output. + + Currently supports Python (.py) and LaTeX (.tex) files. + Returns a dict with keys: stdout, stderr, rc (return code). + """ + def __init__(self): pass - + def run_code(self, active_file: Path) -> dict: + """Execute the given file and return its output. + + Args: + active_file: Absolute path to the file that should be run. + + Returns: + {"stdout": str, "stderr": str, "rc": int} + rc == 0 means success, anything else is an error. + """ suffix = active_file.suffix current_dir = active_file.parent.resolve() + # Build the shell command depending on file type if suffix == ".py": cmd = ["py", active_file.name] elif suffix == ".tex": + # pdflatex in non-interactive mode so it never waits for input cmd = [ "pdflatex", "-interaction=nonstopmode", @@ -22,20 +41,21 @@ class ExecutionEngine: ] else: return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1} - + try: proc = subprocess.run( cmd, - cwd=current_dir, + cwd=current_dir, # run inside the file's own directory capture_output=True, text=True, timeout=RUN_TIMEOUT, ) return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode} - + except subprocess.TimeoutExpired: 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 return {"stdout": "", "stderr": str(e), "rc": -1} except Exception as e: - return {"stdout": "", "stderr": str(e), "rc": -1} \ No newline at end of file + return {"stdout": "", "stderr": str(e), "rc": -1} diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index d86cff3..add29f3 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -1,6 +1,13 @@ +"""Manages all file and folder operations inside the workspace directory. + +Every method validates that the target path stays inside the workspace before +touching the filesystem, preventing path-traversal attacks. +""" + import streamlit as st from pathlib import Path +# The workspace folder is created at module load so it always exists. WORKSPACE = Path("workspace") WORKSPACE.mkdir(exist_ok=True) @@ -24,11 +31,12 @@ class FileManager: if not 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: st.error(f"Invalid folder name (no slashes allowed): {name}") return False - + name = Path(name) if relative_path: relative_path = Path(relative_path) @@ -37,10 +45,11 @@ class FileManager: folder_path = (self.base_path / relative_path / name).resolve() + # Ensure the resolved path is still inside the workspace (prevents path traversal). if not str(folder_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") return False - + try: folder_path.mkdir(exist_ok=False) return True @@ -66,11 +75,11 @@ class FileManager: if not name or name.strip() == "" : 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 - + if relative_path: relative_path = Path(relative_path) else: @@ -78,10 +87,11 @@ class FileManager: file_path = (self.base_path / relative_path / name).resolve() + # Ensure the resolved path is still inside the workspace (prevents path traversal). if not str(file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") return False - + try: file_path.touch(exist_ok=False) return True @@ -95,10 +105,11 @@ class FileManager: def read_file(self, relative_path: Path) -> str: """ Reads the content of a file. - The relative_path should be the path to the file relative to the base path. + Accepts an absolute Path object (as stored in st.session_state.open_files). + The path is validated to ensure it stays inside the workspace. Args: - relative_path (str): The relative path (without base path) to the file to read, including the file name + relative_path (Path): Absolute path to the file to read. Returns: str: The content of the file, or an empty string if there was an error. """ @@ -110,10 +121,11 @@ class FileManager: if not file_path.is_file(): st.error(f"Path is not a file: {relative_path}") 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}") return "" - + try: with open(file_path, "r") as f: return f.read() @@ -126,21 +138,23 @@ class FileManager: def save_file(self, relative_path: str, content: str) -> bool: """ - Saves content to a file. - The relative_path should be the path to the file relative to the base path. - + Saves content to a file. + Accepts an absolute path string (as stored in st.session_state.open_files). + The path is validated to ensure it stays inside the workspace. + Args: - relative_path (str): The relative path(without base path) to the file to save, including the file name - content (str): The content to write to the file + relative_path (str): Absolute path to the file to save, including the file name. + content (str): The content to write to the file. Returns: bool: True if save was successful, False otherwise. """ 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}") return False - + try: with open(file_path, "w") as f: f.write(content) @@ -148,7 +162,7 @@ class FileManager: except Exception as e: st.error(f"Error saving file {relative_path}: {str(e)}") return False - + def rename_file(self, old_relative_path: str, new_name: str) -> bool: """ Renames a file while keeping the same extension. @@ -163,20 +177,22 @@ class FileManager: if not new_name or new_name.strip() == "": st.error(f"Invalid file name: {new_name}") return False - + file_type = Path(old_relative_path).suffix new_name = Path(new_name) + # Force the original extension so the file type cannot be changed by renaming. if not Path(new_name).suffix == file_type: new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same old_file_path = (Path(self.base_path / old_relative_path)).resolve() new_file_path = old_file_path.parent / new_name + # Both old and new paths must stay inside the workspace. if not str(old_file_path).startswith(str(self.base_path.resolve())) or not str(new_file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {old_relative_path}") return False - + try: old_file_path.rename(new_file_path) return True @@ -196,6 +212,7 @@ class FileManager: """ 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}") return False @@ -223,7 +240,6 @@ class FileManager: """ file_path = Path(relative_path) abs_file_path = (Path(self.base_path) / file_path).resolve() - print(f"Absolute file path resolved to: {abs_file_path}") # Debugging info if not str(abs_file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") @@ -254,11 +270,11 @@ class FileManager: for item in sorted(path.iterdir()): if item.is_dir(): - tree[item.name] = build_tree(item) + tree[item.name] = build_tree(item) # recurse into sub-folders else: - tree[item.name] = None + tree[item.name] = None # leaf node for files return tree return build_tree(self.base_path) if __name__ == "__main__": - FileManager() \ No newline at end of file + FileManager() diff --git a/backend/managers/system_prompter.py b/backend/managers/system_prompter.py index 4fa880c..d9610b7 100644 --- a/backend/managers/system_prompter.py +++ b/backend/managers/system_prompter.py @@ -1,18 +1,26 @@ -"""System Prompter - Builds system prompts with optional file context""" +"""Builds the system prompt that is sent to the AI at the start of each chat session.""" -MAX_FILE_CHARS = 4000 # Limit file context to avoid token overflow +# Prevents very large files from flooding the context window with tokens. +MAX_FILE_CHARS = 4000 class SystemPrompter: + """Generates system prompts for the chat assistant. + + When a file is open in the editor it can be embedded in the prompt so the + AI has direct context of the code the user is currently working on. + """ + @staticmethod def generate_prompt(file_context: dict | None = None) -> str: """Build a system prompt, optionally embedding a file's content. Args: - file_context: dict with keys 'name' and 'content', or None. + file_context: dict with keys 'name' (filename) and 'content' (raw text), + or None if no file should be included. Returns: - A system prompt string. + A ready-to-use system prompt string. """ base = ( "You are an expert code assistant integrated into a lightweight code editor. " @@ -23,9 +31,11 @@ class SystemPrompter: if file_context: name = file_context.get("name", "unknown") content = file_context.get("content", "") - # Truncate large files to avoid exceeding token limits + + # Truncate large files to avoid exceeding the model's token limit if len(content) > MAX_FILE_CHARS: content = content[:MAX_FILE_CHARS] + "\n... [truncated]" + file_section = ( f"\n\nThe user currently has the following file open in the editor:\n" f"\n" diff --git a/frontend/app.py b/frontend/app.py index 8c07269..140f7c9 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -1,8 +1,20 @@ +"""Entry point for the Streamlit app. + +Runs with: streamlit run frontend/app.py + +Responsibilities: +- Configure the page layout +- Inject global CSS tweaks +- Render the sidebar (navigation + file explorer) +- Delegate to the correct view (Chat or Code Editor) based on the radio selection +""" + import streamlit as st import sys from pathlib import Path -# Add project root to Python path for imports +# Add the project root to sys.path so backend imports work regardless of +# where streamlit is launched from. sys.path.insert(0, str(Path(__file__).parent.parent)) from frontend.sidebar import render_sidebar @@ -10,12 +22,16 @@ from frontend.editor import render_editor from frontend.chat import render_chat from frontend.state import init_state +# Initialise all session-state keys before any widget is rendered init_state() def main(): st.set_page_config(page_title="Lightweight code editor", layout="wide") + # Small spacing corrections applied globally: + # - Reduce the default top padding of the main content area + # - Pull the sidebar content up so the logo sits at the very top st.markdown( """