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:
|
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()
|
all_tools = adapter.get_all_tools()
|
||||||
print(f"Building tool description for {len(all_tools)} 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:
|
for tool in all_tools:
|
||||||
pprint.pprint(f"{tool}")
|
pprint.pprint(f"{tool}")
|
||||||
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
|
descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}")
|
||||||
|
|
||||||
return "\n".join(descriptions)
|
return "\n".join(descriptions)
|
||||||
|
|
||||||
async def dispatch_tool(tool_name: str, arguments: dict) -> str:
|
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":
|
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.")
|
summary = arguments.get("summary", "Task completed.")
|
||||||
return f"DONE: {summary}"
|
return f"DONE: {summary}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
|
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
|
||||||
result = await adapter.call_tool(tool_name, arguments)
|
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}")
|
print(f"Raw result from tool '{tool_name}': {result}")
|
||||||
|
|
||||||
if result.isError:
|
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"]
|
texts = [block.text for block in result.content if block.type == "text"]
|
||||||
return f"Tool error: {' '.join(texts)}"
|
return f"Tool error: {' '.join(texts)}"
|
||||||
|
|
||||||
texts = [block.text for block in result.content if block.type == "text"]
|
texts = [block.text for block in result.content if block.type == "text"]
|
||||||
return "\n".join(texts)
|
return "\n".join(texts)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error calling tool '{tool_name}': {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:
|
def trim_messages(messages: list) -> list:
|
||||||
"""Drop old messages when history exceeds MAX_HISTORY_CHARS.
|
"""Kürzt die Konversations-History wenn sie das Kontextfenster überschreitet.
|
||||||
Always keeps the system prompt (index 0) and original task (index 1).
|
|
||||||
|
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)
|
total = sum(len(m["content"]) for m in messages)
|
||||||
if total <= MAX_HISTORY_CHARS:
|
if total <= MAX_HISTORY_CHARS:
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
# Protect the two anchor messages that must never be discarded.
|
||||||
head = messages[:2]
|
head = messages[:2]
|
||||||
tail = messages[2:]
|
tail = messages[2:]
|
||||||
original_task = messages[1]["content"] if len(messages) > 1 else ""
|
original_task = messages[1]["content"] if len(messages) > 1 else ""
|
||||||
|
|
||||||
# Drop the oldest messages first (index 2 onwards) until we are under the limit.
|
# Drop the oldest non-anchor messages first 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:
|
while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
|
||||||
tail.pop(0)
|
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 = {
|
reminder = {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": (
|
||||||
@ -196,13 +228,22 @@ def trim_messages(messages: list) -> list:
|
|||||||
return head + [reminder] + tail
|
return head + [reminder] + tail
|
||||||
|
|
||||||
def _repair_json_strings(text: str) -> str:
|
def _repair_json_strings(text: str) -> str:
|
||||||
"""
|
"""Replace unescaped control characters inside JSON string values.
|
||||||
Replace unescaped control characters (newline, tab, carriage return)
|
|
||||||
inside JSON string values with their proper escape sequences.
|
|
||||||
|
|
||||||
LLMs frequently emit literal newlines inside long string values, which
|
LLMs frequently emit literal newlines, tabs, or carriage-returns inside
|
||||||
is invalid JSON. This function fixes that without touching structural
|
long string values (e.g. code content), which is invalid JSON. This
|
||||||
whitespace outside strings.
|
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] = []
|
result: list[str] = []
|
||||||
in_string = False
|
in_string = False
|
||||||
@ -210,6 +251,8 @@ def _repair_json_strings(text: str) -> str:
|
|||||||
_escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'}
|
_escapes = {'\n': '\\n', '\r': '\\r', '\t': '\\t'}
|
||||||
for ch in text:
|
for ch in text:
|
||||||
if escape:
|
if escape:
|
||||||
|
# The previous character was a backslash — emit this char literally
|
||||||
|
# and reset the escape flag.
|
||||||
result.append(ch)
|
result.append(ch)
|
||||||
escape = False
|
escape = False
|
||||||
continue
|
continue
|
||||||
@ -218,10 +261,12 @@ def _repair_json_strings(text: str) -> str:
|
|||||||
escape = True
|
escape = True
|
||||||
continue
|
continue
|
||||||
if ch == '"':
|
if ch == '"':
|
||||||
|
# Toggle string-mode on every unescaped double quote.
|
||||||
in_string = not in_string
|
in_string = not in_string
|
||||||
result.append(ch)
|
result.append(ch)
|
||||||
continue
|
continue
|
||||||
if in_string and ch in _escapes:
|
if in_string and ch in _escapes:
|
||||||
|
# Replace the bare control character with its escape sequence.
|
||||||
result.append(_escapes[ch])
|
result.append(_escapes[ch])
|
||||||
continue
|
continue
|
||||||
result.append(ch)
|
result.append(ch)
|
||||||
@ -285,13 +330,25 @@ def extract_json(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _strip_code_fences(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:
|
if text is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
lines = text.split("\n")
|
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)
|
end = -1 if lines[-1].strip() == "```" else len(lines)
|
||||||
text = "\n".join(lines[1:end])
|
text = "\n".join(lines[1:end])
|
||||||
return text.strip()
|
return text.strip()
|
||||||
@ -328,11 +385,18 @@ class CodingAgent:
|
|||||||
self.api_key = os.getenv("API_KEY")
|
self.api_key = os.getenv("API_KEY")
|
||||||
self.model = os.getenv("MODEL")
|
self.model = os.getenv("MODEL")
|
||||||
|
|
||||||
#async def _call_api(self, messages: list) -> str:
|
|
||||||
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"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if self.api_key and self.api_key != "EMPTY":
|
if self.api_key and self.api_key != "EMPTY":
|
||||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
@ -340,11 +404,13 @@ class CodingAgent:
|
|||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"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,
|
"max_tokens": 4096,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
|
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
|
||||||
|
|
||||||
if response.status_code != 200:
|
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 asyncio
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@ -7,20 +19,42 @@ from pathlib import Path
|
|||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
|
|
||||||
|
|
||||||
class MCPToolAdapter:
|
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"):
|
def __init__(self, config_path: str = "mcp_server_config.json"):
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
self.servers: Dict[str, Dict] = {}
|
self.servers: Dict[str, Dict] = {}
|
||||||
#self.exit_stack: Dict[str, Any] = {}
|
|
||||||
self.tool_registry: List[Dict[str, Any]] = []
|
self.tool_registry: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
def _load_config(self) -> 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
|
path = Path(__file__).parent / self.config_path
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
print(f"Config file not found: {path}")
|
print(f"Config file not found: {path}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@ -29,7 +63,13 @@ class MCPToolAdapter:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def initialize_all_servers(self):
|
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...")
|
print("Initializing MCP sessions...")
|
||||||
config = self._load_config()
|
config = self._load_config()
|
||||||
print(f"Loaded config for servers: {list(config.keys())}")
|
print(f"Loaded config for servers: {list(config.keys())}")
|
||||||
@ -39,18 +79,21 @@ class MCPToolAdapter:
|
|||||||
|
|
||||||
self.servers[server_name] = params
|
self.servers[server_name] = params
|
||||||
server_script = str(Path(__file__).parent / params["args"][0])
|
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"]:
|
if params.get("command") in ["py", "python", "python3"]:
|
||||||
server_command = sys.executable
|
server_command = sys.executable
|
||||||
else:
|
else:
|
||||||
server_command = params["command"]
|
server_command = params["command"]
|
||||||
|
|
||||||
server_params = StdioServerParameters(
|
server_params = StdioServerParameters(
|
||||||
command=server_command,
|
command=server_command,
|
||||||
args=[server_script],
|
args=[server_script],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Verbindung aufbauen
|
|
||||||
async with stdio_client(server_params) as (read_stream, write_stream):
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||||
print(f"Connected to {server_name}. Initializing session...")
|
print(f"Connected to {server_name}. Initializing session...")
|
||||||
async with ClientSession(read_stream, write_stream) as session:
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
@ -60,9 +103,9 @@ class MCPToolAdapter:
|
|||||||
print(f"Tools received from {server_name}: {result}")
|
print(f"Tools received from {server_name}: {result}")
|
||||||
tools = result.tools
|
tools = result.tools
|
||||||
print(f"Tools received from {server_name}: {result}")
|
print(f"Tools received from {server_name}: {result}")
|
||||||
#tools = getattr(result, 'tools', [])
|
|
||||||
|
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
|
# Build a human-readable parameter description for the system prompt.
|
||||||
t_params = tool.inputSchema.get("properties", {})
|
t_params = tool.inputSchema.get("properties", {})
|
||||||
if t_params:
|
if t_params:
|
||||||
param_lines = []
|
param_lines = []
|
||||||
@ -76,7 +119,6 @@ class MCPToolAdapter:
|
|||||||
|
|
||||||
t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}"
|
t_definition = f"- {tool.name}: {tool.description}\nParameters:\n{param_str}"
|
||||||
|
|
||||||
|
|
||||||
self.tool_registry.append({
|
self.tool_registry.append({
|
||||||
"server": server_name,
|
"server": server_name,
|
||||||
"tool_name": tool.name,
|
"tool_name": tool.name,
|
||||||
@ -84,22 +126,37 @@ class MCPToolAdapter:
|
|||||||
})
|
})
|
||||||
|
|
||||||
print(f"Registered tool '{tool.name}' from {server_name}.")
|
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:
|
except Exception as e:
|
||||||
print(f"Failed to initialize {server_name}: {e}")
|
print(f"Failed to initialize {server_name}: {e}")
|
||||||
|
|
||||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
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
|
return self.tool_registry
|
||||||
|
|
||||||
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]):
|
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."""
|
"""Look up a tool in the registry, connect to its server, and execute it.
|
||||||
# Suche in der Registry nach dem passenden Server
|
|
||||||
|
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)
|
tool_entry = next((t for t in self.tool_registry if t["tool_name"] == tool_name), None)
|
||||||
|
|
||||||
if not tool_entry:
|
if not tool_entry:
|
||||||
print(f"Tool '{tool_name}' not found in MCP adapter registry.")
|
print(f"Tool '{tool_name}' not found in MCP adapter registry.")
|
||||||
return f"Error: Tool '{tool_name}' not found in registry."
|
return f"Error: Tool '{tool_name}' not found in registry."
|
||||||
@ -109,11 +166,12 @@ class MCPToolAdapter:
|
|||||||
|
|
||||||
if s_params:
|
if s_params:
|
||||||
server_script = str(Path(__file__).parent / s_params["args"][0])
|
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"]:
|
if s_params.get("command") in ["py", "python", "python3"]:
|
||||||
server_command = sys.executable
|
server_command = sys.executable
|
||||||
else:
|
else:
|
||||||
server_command = s_params["command"]
|
server_command = s_params["command"]
|
||||||
|
|
||||||
server_params = StdioServerParameters(
|
server_params = StdioServerParameters(
|
||||||
command=server_command,
|
command=server_command,
|
||||||
args=[server_script],
|
args=[server_script],
|
||||||
@ -127,11 +185,16 @@ class MCPToolAdapter:
|
|||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
|
return f"Error calling tool '{tool_name}' on server '{server_name}': {str(e)}"
|
||||||
|
|
||||||
return f"Error: Session for server '{server_name}' not active."
|
return f"Error: Session for server '{server_name}' not active."
|
||||||
|
|
||||||
async def shutdown_all_sessions(self):
|
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():
|
for server_name, (transport_gen, session) in self.exit_stack.items():
|
||||||
try:
|
try:
|
||||||
await session.__aexit__(None, None, None)
|
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
|
import ast
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -12,18 +28,30 @@ import shutil
|
|||||||
# ── Sandbox venv ────────────────────────────────────────────────────────────
|
# ── Sandbox venv ────────────────────────────────────────────────────────────
|
||||||
SERVER_BASE_DIR = Path(__file__).parent.resolve()
|
SERVER_BASE_DIR = Path(__file__).parent.resolve()
|
||||||
SANDBOX_DIR = SERVER_BASE_DIR / ".mcp_sandbox"
|
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"
|
WORKSPACE_DIR = SERVER_BASE_DIR.parent.parent.parent.parent / "workspace"
|
||||||
|
|
||||||
|
|
||||||
def get_sandbox_paths():
|
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():
|
if not SANDBOX_DIR.exists():
|
||||||
venv.create(SANDBOX_DIR, with_pip=True)
|
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"
|
python_exe = SANDBOX_DIR / bin_folder / "python"
|
||||||
pip_exe = SANDBOX_DIR / bin_folder / "pip"
|
pip_exe = SANDBOX_DIR / bin_folder / "pip"
|
||||||
|
|
||||||
return str(python_exe), str(pip_exe)
|
return str(python_exe), str(pip_exe)
|
||||||
|
|
||||||
PYTHON_EXE, PIP_EXE = get_sandbox_paths()
|
PYTHON_EXE, PIP_EXE = get_sandbox_paths()
|
||||||
@ -300,9 +328,17 @@ def install_package_into_sandbox(package_name: str) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def reset_sandbox() -> str:
|
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():
|
if SANDBOX_DIR.exists():
|
||||||
shutil.rmtree(SANDBOX_DIR)
|
shutil.rmtree(SANDBOX_DIR)
|
||||||
|
# Re-calling get_sandbox_paths() triggers venv creation for the fresh sandbox.
|
||||||
get_sandbox_paths()
|
get_sandbox_paths()
|
||||||
return "Sandbox wurde komplett zurückgesetzt."
|
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.
|
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)
|
static_safety = check_code_safety(code)
|
||||||
if static_safety:
|
if static_safety:
|
||||||
return f"Code rejected:{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")
|
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
jail_dir = WORKSPACE_DIR / f"sandbox_run_{run_id}"
|
jail_dir = WORKSPACE_DIR / f"sandbox_run_{run_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jail_dir.mkdir(parents=True, exist_ok=True)
|
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 = {
|
custom_env = {
|
||||||
"PYTHONPATH": str(WORKSPACE_DIR),
|
"PYTHONPATH": str(WORKSPACE_DIR),
|
||||||
"PATH": str(Path(PYTHON_EXE).parent),
|
"PATH": str(Path(PYTHON_EXE).parent),
|
||||||
"HOME": str(jail_dir),
|
"HOME": str(jail_dir),
|
||||||
"TMPDIR": str(jail_dir)
|
"TMPDIR": str(jail_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[PYTHON_EXE, "-c", code],
|
[PYTHON_EXE, "-c", code],
|
||||||
@ -349,47 +389,54 @@ def run_python_code_sandboxed(code: str) -> str:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=EXEC_TIMEOUT)
|
timeout=EXEC_TIMEOUT)
|
||||||
|
|
||||||
|
# Merge stdout and stderr so the agent sees all output in one block.
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
if len(output) > MAX_OUTPUT_LENGTH:
|
if len(output) > MAX_OUTPUT_LENGTH:
|
||||||
output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..."
|
output = output[:MAX_OUTPUT_LENGTH] + "\n...[output truncated]..."
|
||||||
|
|
||||||
if not output.strip():
|
if not output.strip():
|
||||||
return "Code executed successfully (no output)."
|
return "Code executed successfully (no output)."
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
return f"Error: Code execution exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error during code execution: {e}"
|
return f"Error during code execution: {e}"
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
# Always clean up the per-run jail directory, even if execution failed.
|
||||||
if jail_dir.exists():
|
if jail_dir.exists():
|
||||||
shutil.rmtree(jail_dir)
|
shutil.rmtree(jail_dir)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def python_code_validation(code: str) -> str:
|
def python_code_validation(code: str) -> str:
|
||||||
"""
|
"""Validate Python code for syntax correctness and sandbox safety without executing it.
|
||||||
Validate Python code for syntax and safety without executing it.
|
|
||||||
This tool performs static analysis to check for syntax errors.
|
Performs two checks in sequence:
|
||||||
|
1. AST parsing to catch syntax errors.
|
||||||
|
2. check_code_safety() to detect blocked imports/builtins/path sequences.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
code: The Python code to validate in str format.
|
code: The Python source code to validate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A message indicating the validation result.
|
A message indicating whether the code is valid and safe, or describing
|
||||||
And if sandboxed test execution is allowed.
|
the first violation found. Returns None implicitly when the code is
|
||||||
|
both syntactically valid and safe (no safety concerns found).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ast.parse(code)
|
ast.parse(code)
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
return f"SyntaxError: {e}"
|
return f"SyntaxError: {e}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
static_analysis_result = check_code_safety(code)
|
static_analysis_result = check_code_safety(code)
|
||||||
if static_analysis_result:
|
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."
|
return f"Valid Syntax, but with safety concerns: {static_analysis_result}; code execution is not allowed."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error during code safety analysis: {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 pathlib import Path
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────────────────────
|
# ── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
# Navigate four levels up from servers/ to the project root, then into workspace/.
|
||||||
project_dir = Path(__file__).resolve().parent.parent.parent.parent
|
project_dir = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
ALLOWED_DIR = project_dir / "workspace"
|
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 ────────────────────────────────────────────────────
|
# ── Create the MCP server ────────────────────────────────────────────────────
|
||||||
mcp = FastMCP("FileSearchServer")
|
mcp = FastMCP("FileSearchServer")
|
||||||
@ -63,12 +80,30 @@ def get_file_tree(dir_path: str=ALLOWED_DIR) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _tree(dir_path: Path, prefix="") -> 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 = []
|
lines = []
|
||||||
for i, entry in enumerate(entries):
|
for i, entry in enumerate(entries):
|
||||||
|
# Use └── for the last entry to close the branch visually.
|
||||||
connector = "└── " if i == len(entries) - 1 else "├── "
|
connector = "└── " if i == len(entries) - 1 else "├── "
|
||||||
lines.append(f"{prefix}{connector}{entry.name}")
|
lines.append(f"{prefix}{connector}{entry.name}")
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
|
# Extend prefix with a blank column (last item) or │ (more items follow).
|
||||||
extension = " " if i == len(entries) - 1 else "│ "
|
extension = " " if i == len(entries) - 1 else "│ "
|
||||||
lines.append(_tree(entry, prefix + extension))
|
lines.append(_tree(entry, prefix + extension))
|
||||||
return "\n".join(lines)
|
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 urllib.parse import urlparse
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
@ -12,7 +22,22 @@ mcp = FastMCP("WebSearchServer")
|
|||||||
# ── Helper: URL validation (SSRF prevention) ─────────────────────────────────
|
# ── Helper: URL validation (SSRF prevention) ─────────────────────────────────
|
||||||
|
|
||||||
def _validate_url(url: str) -> str:
|
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)
|
parsed = urlparse(url)
|
||||||
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
@ -22,10 +47,13 @@ def _validate_url(url: str) -> str:
|
|||||||
|
|
||||||
hostname = parsed.hostname or ""
|
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"}
|
blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254"}
|
||||||
if hostname in blocked_hosts:
|
if hostname in blocked_hosts:
|
||||||
raise ValueError(f"Blocked internal host: {hostname}")
|
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 = (
|
private_prefixes = (
|
||||||
"10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
"10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
"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")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|
||||||
|
# Remove boilerplate elements that add noise without informational value.
|
||||||
for tag in soup(["script", "style", "nav", "footer"]):
|
for tag in soup(["script", "style", "nav", "footer"]):
|
||||||
tag.decompose()
|
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
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,15 @@ WORKSPACE = Path("workspace")
|
|||||||
WORKSPACE.mkdir(exist_ok=True)
|
WORKSPACE.mkdir(exist_ok=True)
|
||||||
|
|
||||||
class FileManager:
|
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:
|
def __init__(self, base_path=Path("workspace")) -> None:
|
||||||
self.base_path = Path(base_path)
|
self.base_path = Path(base_path)
|
||||||
self.base_path.mkdir(exist_ok=True)
|
self.base_path.mkdir(exist_ok=True)
|
||||||
|
|||||||
@ -10,10 +10,23 @@ from backend.managers.system_prompter import SystemPrompter
|
|||||||
|
|
||||||
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
# ── Agent Mode helpers ────────────────────────────────────────────────────────
|
||||||
def _run_async(coro):
|
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:
|
try:
|
||||||
|
# Reuse the loop that is already running (e.g. inside pytest-asyncio).
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
# No running loop in this thread — create a fresh one.
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
return loop.run_until_complete(coro)
|
return loop.run_until_complete(coro)
|
||||||
|
|||||||
@ -62,6 +62,14 @@ def _rename_dialog(file_path: str):
|
|||||||
|
|
||||||
@st.dialog("Delete File")
|
@st.dialog("Delete File")
|
||||||
def _delete_dialog(abs_file_path: str):
|
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()
|
fm = FileManager()
|
||||||
file_name = Path(abs_file_path).name
|
file_name = Path(abs_file_path).name
|
||||||
relative_path = str(Path(abs_file_path).relative_to(fm.base_path))
|
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")
|
@st.dialog("Add File")
|
||||||
def _add_file_dialog(parent_path: str = ""):
|
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"):
|
with st.form("add_file_form"):
|
||||||
name = st.text_input("File name:", placeholder="e.g. script.py")
|
name = st.text_input("File name:", placeholder="e.g. script.py")
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
@ -77,6 +83,12 @@ def _add_file_dialog(parent_path: str = ""):
|
|||||||
|
|
||||||
@st.dialog("Add Folder")
|
@st.dialog("Add Folder")
|
||||||
def _add_folder_dialog(parent_path: str = ""):
|
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"):
|
with st.form("add_folder_form"):
|
||||||
name = st.text_input("Folder name:", placeholder="e.g. utils")
|
name = st.text_input("Folder name:", placeholder="e.g. utils")
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
@ -166,6 +178,15 @@ def _rename_file_dialog(relative_file_path: str, file_name: str):
|
|||||||
|
|
||||||
@st.dialog("Delete File")
|
@st.dialog("Delete File")
|
||||||
def _delete_file_dialog(relative_file_path: str, file_name: str):
|
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.")
|
st.warning(f"Delete **{file_name}**? This cannot be undone.")
|
||||||
|
|
||||||
col1, col2 = st.columns(2)
|
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)
|
abs_file_path = str(Path(fm.base_path) / relative_file_path)
|
||||||
st.session_state.open_files.remove(abs_file_path)
|
st.session_state.open_files.remove(abs_file_path)
|
||||||
st.session_state.files_content.pop(abs_file_path, None)
|
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:
|
if st.session_state.active_file == abs_file_path:
|
||||||
st.session_state.active_file = (
|
st.session_state.active_file = (
|
||||||
st.session_state.open_files[0]
|
st.session_state.open_files[0]
|
||||||
|
|||||||
@ -10,45 +10,52 @@ from backend.managers.chat_manager import ChatManager
|
|||||||
|
|
||||||
|
|
||||||
def init_state():
|
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
|
# 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:
|
if "last_selected" not in st.session_state:
|
||||||
st.session_state.last_selected = None
|
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:
|
if "selected_folder" not in st.session_state:
|
||||||
st.session_state.selected_folder = None
|
st.session_state.selected_folder = None
|
||||||
|
|
||||||
if "selected_folder_rel" not in st.session_state:
|
if "selected_folder_rel" not in st.session_state:
|
||||||
st.session_state.selected_folder_rel = None
|
st.session_state.selected_folder_rel = None
|
||||||
|
|
||||||
# Chat manager (persists across reruns)
|
# ── Chat manager ──────────────────────────────────────────────────────────
|
||||||
# ChatManager keeps the full conversation history in memory across reruns
|
|
||||||
|
# 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:
|
if "chat_manager" not in st.session_state:
|
||||||
st.session_state.chat_manager = ChatManager()
|
st.session_state.chat_manager = ChatManager()
|
||||||
|
|
||||||
# Editor state initialization
|
# ── Editor state ──────────────────────────────────────────────────────────
|
||||||
# List of absolute file paths that are currently open as tabs
|
|
||||||
|
# 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:
|
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 = []
|
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:
|
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 = {}
|
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:
|
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
|
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:
|
if "active_tab" not in st.session_state:
|
||||||
st.session_state.active_tab = 0
|
st.session_state.active_tab = 0
|
||||||
|
|
||||||
@ -58,33 +65,41 @@ def init_state():
|
|||||||
if "code_suggestions" not in st.session_state:
|
if "code_suggestions" not in st.session_state:
|
||||||
st.session_state.code_suggestions = []
|
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:
|
if "code_execution_output" not in st.session_state:
|
||||||
st.session_state.code_execution_output = ""
|
st.session_state.code_execution_output = ""
|
||||||
|
|
||||||
# Chat state initialization
|
# ── Chat state ────────────────────────────────────────────────────────────
|
||||||
# Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles
|
|
||||||
|
# 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:
|
if "chat_history" not in st.session_state:
|
||||||
st.session_state.chat_history = []
|
st.session_state.chat_history = []
|
||||||
|
|
||||||
# Agent Mode state
|
# ── Agent Mode state ──────────────────────────────────────────────────────
|
||||||
# Whether the UI is currently in Agent Mode (vs normal chat)
|
|
||||||
|
# Boolean toggle — True while the UI is in Coding Agent mode.
|
||||||
if "agent_mode" not in st.session_state:
|
if "agent_mode" not in st.session_state:
|
||||||
st.session_state.agent_mode = False
|
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:
|
if "coding_agent" not in st.session_state:
|
||||||
st.session_state.coding_agent = None
|
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:
|
if "agent_status" not in st.session_state:
|
||||||
st.session_state.agent_status = "idle"
|
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:
|
if "agent_log" not in st.session_state:
|
||||||
st.session_state.agent_log = []
|
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:
|
if "agent_pending_action" not in st.session_state:
|
||||||
st.session_state.agent_pending_action = None
|
st.session_state.agent_pending_action = None
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user