docs: merge docstrings and inline comments from agent

This commit is contained in:
Livio Meuli 2026-05-24 10:28:54 +02:00
commit 0751be8e61
11 changed files with 413 additions and 99 deletions

View File

@ -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
``"- <tool_name>: <description>"``.
"""
all_tools = adapter.get_all_tools()
print(f"Building tool description for {len(all_tools)} tools.")
@ -62,10 +69,23 @@ def build_all_tool_description() -> str:
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}"
@ -76,6 +96,8 @@ 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)}"
@ -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,7 +404,9 @@ 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,
}

View File

@ -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,15 +19,37 @@ 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}")
@ -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,6 +79,10 @@ 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:
@ -50,7 +94,6 @@ class MCPToolAdapter:
)
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,
@ -87,17 +129,32 @@ class MCPToolAdapter:
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:
@ -109,6 +166,7 @@ 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:
@ -131,7 +189,12 @@ class MCPToolAdapter:
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)

View File

@ -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,14 +28,26 @@ 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"
@ -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,16 +361,20 @@ 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),
@ -350,6 +390,7 @@ def run_python_code_sandboxed(code: str) -> str:
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:
@ -366,21 +407,26 @@ def run_python_code_sandboxed(code: str) -> str:
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)
@ -390,6 +436,7 @@ def python_code_validation(code: str) -> str:
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}"

View File

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

View File

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

View File

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

View File

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

View File

@ -10,10 +10,23 @@ from backend.managers.system_prompter import SystemPrompter
# ── 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)

View File

@ -62,6 +62,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))

View File

@ -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)
@ -175,6 +196,7 @@ def _delete_file_dialog(relative_file_path: str, file_name: str):
abs_file_path = str(Path(fm.base_path) / relative_file_path)
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]

View File

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