docs: merge docstrings and inline comments from agent
This commit is contained in:
commit
0751be8e61
@ -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.")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user