async mcp implementation in agentloop - buggy

This commit is contained in:
Irina Rueegg 2026-05-06 21:19:03 +02:00
parent 2a37ae3aa4
commit 0a329139ca
4 changed files with 235 additions and 159 deletions

View File

@ -22,7 +22,12 @@ import sys
from pathlib import Path
import requests
import httpx
from dotenv import load_dotenv
from backend.agent.mcp_server_adapter import MCPToolRAGAdapter
# ── mcp server initialization ────────────────────────────────────────────────────────────────
adapter = MCPToolRAGAdapter()
load_dotenv()
@ -44,173 +49,216 @@ MAX_HISTORY_CHARS = 80_000
# Each tool is a plain Python function decorated with @register_tool.
# The decorator adds the function to TOOL_REGISTRY so the dispatcher
# can call it by name at runtime.
#
#TOOL_REGISTRY: dict[str, callable] = {}
#
#
#def register_tool(func):
# """Decorator adds a function to the global tool registry."""
# TOOL_REGISTRY[func.__name__] = func
# return func
#
#
#@register_tool
#def read_file(path: str) -> str:
# """Read a .py or .txt file from the workspace and return its contents."""
# target = (WORKSPACE / path).resolve()
# if not str(target).startswith(str(WORKSPACE.resolve())):
# return "ERROR: path is outside the workspace."
# if not target.exists():
# return f"ERROR: file '{path}' not found."
# if target.suffix not in (".py", ".txt"):
# return f"ERROR: can only read .py and .txt files, got '{target.suffix}'."
# return target.read_text()
#
#
#@register_tool
#def write_file(path: str, content: str) -> str:
# """Write content to a .py or .txt file in the workspace."""
# target = (WORKSPACE / path).resolve()
# if not str(target).startswith(str(WORKSPACE.resolve())):
# return "ERROR: path is outside the workspace."
# if target.suffix not in (".py", ".txt"):
# return f"ERROR: can only write .py and .txt files, got '{target.suffix}'."
# target.parent.mkdir(parents=True, exist_ok=True)
# target.write_text(content)
# return f"OK: wrote {len(content)} chars to {path}."
#
#
#@register_tool
#def list_files(file_glob: str = "*") -> str:
# """List files in the workspace matching the glob pattern."""
# found = sorted(WORKSPACE.glob(file_glob))
# found = [f.relative_to(WORKSPACE) for f in found if f.is_file()]
# if not found:
# return f"No files matching '{file_glob}' in workspace."
# return "\n".join(str(f) for f in found)
#
#
#@register_tool
#def grep_search(pattern: str, file_glob: str = "*.py") -> str:
# """Search for a pattern in workspace files and return matching lines with line numbers."""
# matches = []
# for filepath in sorted(WORKSPACE.glob(file_glob)):
# if filepath.suffix not in (".py", ".txt"):
# continue
# try:
# lines = filepath.read_text().splitlines()
# except Exception:
# continue
# for i, line in enumerate(lines, 1):
# if pattern in line:
# rel = filepath.relative_to(WORKSPACE)
# matches.append(f"{rel}:{i}: {line}")
# if not matches:
# return f"No matches for '{pattern}' in {file_glob}."
# return "\n".join(matches)
#
#
#@register_tool
#def run_python(path: str) -> str:
# """Execute a Python file in the workspace and return stdout and stderr."""
# target = (WORKSPACE / path).resolve()
# if not str(target).startswith(str(WORKSPACE.resolve())):
# return "ERROR: path is outside the workspace."
# if not target.exists():
# return f"ERROR: file '{path}' not found."
# result = subprocess.run(
# [sys.executable, str(target)],
# capture_output=True, text=True, timeout=30,
# cwd=str(WORKSPACE),
# )
# output = ""
# if result.stdout:
# output += f"STDOUT:\n{result.stdout}"
# if result.stderr:
# output += f"STDERR:\n{result.stderr}"
# output += f"\nExit code: {result.returncode}"
# return output.strip()
#
#
#@register_tool
#def validate_python(path: str) -> str:
# """Check whether a Python file has valid syntax using ast.parse."""
# target = (WORKSPACE / path).resolve()
# if not str(target).startswith(str(WORKSPACE.resolve())):
# return "ERROR: path is outside the workspace."
# if not target.exists():
# return f"ERROR: file '{path}' not found."
# source = target.read_text()
# try:
# ast.parse(source)
# return "OK: syntax is valid."
# except SyntaxError as e:
# return f"SYNTAX ERROR: {e}"
#
#
#@register_tool
#def done(summary: str) -> str:
# """Signal that the agent has finished its task."""
# return f"DONE: {summary}"
#
#
## ═════════════════════════════════════════════════════════════════════════════
## PART B TOOL DISPATCHER
## ═════════════════════════════════════════════════════════════════════════════
#
#def build_tool_description() -> str:
# """Auto-generate tool descriptions from function signatures and docstrings."""
# lines = []
# for name, func in TOOL_REGISTRY.items():
# sig = inspect.signature(func)
# params = []
# for pname, param in sig.parameters.items():
# if param.default is inspect.Parameter.empty:
# params.append(f'"{pname}": "<value>"')
# else:
# params.append(f'"{pname}": "<optional, default={param.default!r}>"')
# param_str = ", ".join(params)
# doc = (func.__doc__ or "").strip().split("\n")[0]
# lines.append(f" - {name}({{{param_str}}}): {doc}")
# return "\n".join(lines)
#
#
#def dispatch_tool(tool_name: str, arguments: dict) -> str:
# """Call a tool by name with the given arguments."""
# if tool_name not in TOOL_REGISTRY:
# return f"ERROR: unknown tool '{tool_name}'. Available: {list(TOOL_REGISTRY.keys())}"
# func = TOOL_REGISTRY[tool_name]
# try:
# return func(**arguments)
# except TypeError as e:
# return f"ERROR calling {tool_name}: {e}"
# except Exception as e:
# return f"ERROR in {tool_name}: {type(e).__name__}: {e}"
#
TOOL_REGISTRY: dict[str, callable] = {}
async def get_tools_for_prompt(query: str) -> str:
"""Get relevant tools from the MCP servers based on the query."""
relevant_tools = adapter.get_relevant_tools(query, top_k=3)
def register_tool(func):
"""Decorator adds a function to the global tool registry."""
TOOL_REGISTRY[func.__name__] = func
return func
descriptions = []
for tool in relevant_tools:
params = tool.inputSchema.get("properties", {})
if params:
param_lines = []
for pname, pinfo in params.items():
ptype = pinfo.get("type", "any")
pdesc = pinfo.get("description", "")
param_lines.append(f" - {pname} ({ptype}): {pdesc}")
param_str = "\n".join(param_lines)
else:
param_str = " (none)"
descriptions.append(
f"- {tool.name}: {tool.description}\n"
f"Parameters:\n{param_str}"
)
return "\n".join(descriptions)
@register_tool
def read_file(path: str) -> str:
"""Read a .py or .txt file from the workspace and return its contents."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if not target.exists():
return f"ERROR: file '{path}' not found."
if target.suffix not in (".py", ".txt"):
return f"ERROR: can only read .py and .txt files, got '{target.suffix}'."
return target.read_text()
async def dispatch_tool(tool_name: str, arguments: dict) -> str:
"""Call a tool by name with the given arguments using the MCP adapter."""
@register_tool
def write_file(path: str, content: str) -> str:
"""Write content to a .py or .txt file in the workspace."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if target.suffix not in (".py", ".txt"):
return f"ERROR: can only write .py and .txt files, got '{target.suffix}'."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return f"OK: wrote {len(content)} chars to {path}."
@register_tool
def list_files(file_glob: str = "*") -> str:
"""List files in the workspace matching the glob pattern."""
found = sorted(WORKSPACE.glob(file_glob))
found = [f.relative_to(WORKSPACE) for f in found if f.is_file()]
if not found:
return f"No files matching '{file_glob}' in workspace."
return "\n".join(str(f) for f in found)
@register_tool
def grep_search(pattern: str, file_glob: str = "*.py") -> str:
"""Search for a pattern in workspace files and return matching lines with line numbers."""
matches = []
for filepath in sorted(WORKSPACE.glob(file_glob)):
if filepath.suffix not in (".py", ".txt"):
continue
try:
lines = filepath.read_text().splitlines()
except Exception:
continue
for i, line in enumerate(lines, 1):
if pattern in line:
rel = filepath.relative_to(WORKSPACE)
matches.append(f"{rel}:{i}: {line}")
if not matches:
return f"No matches for '{pattern}' in {file_glob}."
return "\n".join(matches)
@register_tool
def run_python(path: str) -> str:
"""Execute a Python file in the workspace and return stdout and stderr."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if not target.exists():
return f"ERROR: file '{path}' not found."
result = subprocess.run(
[sys.executable, str(target)],
capture_output=True, text=True, timeout=30,
cwd=str(WORKSPACE),
)
output = ""
if result.stdout:
output += f"STDOUT:\n{result.stdout}"
if result.stderr:
output += f"STDERR:\n{result.stderr}"
output += f"\nExit code: {result.returncode}"
return output.strip()
@register_tool
def validate_python(path: str) -> str:
"""Check whether a Python file has valid syntax using ast.parse."""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE.resolve())):
return "ERROR: path is outside the workspace."
if not target.exists():
return f"ERROR: file '{path}' not found."
source = target.read_text()
if tool_name == "done":
# Handle the "done" tool locally since it's not an MCP tool
summary = arguments.get("summary", "Task completed.")
return f"DONE: {summary}"
try:
ast.parse(source)
return "OK: syntax is valid."
except SyntaxError as e:
return f"SYNTAX ERROR: {e}"
result = await adapter.call_tool(tool_name, arguments)
@register_tool
def done(summary: str) -> str:
"""Signal that the agent has finished its task."""
return f"DONE: {summary}"
# ═════════════════════════════════════════════════════════════════════════════
# PART B TOOL DISPATCHER
# ═════════════════════════════════════════════════════════════════════════════
def build_tool_description() -> str:
"""Auto-generate tool descriptions from function signatures and docstrings."""
lines = []
for name, func in TOOL_REGISTRY.items():
sig = inspect.signature(func)
params = []
for pname, param in sig.parameters.items():
if param.default is inspect.Parameter.empty:
params.append(f'"{pname}": "<value>"')
else:
params.append(f'"{pname}": "<optional, default={param.default!r}>"')
param_str = ", ".join(params)
doc = (func.__doc__ or "").strip().split("\n")[0]
lines.append(f" - {name}({{{param_str}}}): {doc}")
return "\n".join(lines)
def dispatch_tool(tool_name: str, arguments: dict) -> str:
"""Call a tool by name with the given arguments."""
if tool_name not in TOOL_REGISTRY:
return f"ERROR: unknown tool '{tool_name}'. Available: {list(TOOL_REGISTRY.keys())}"
func = TOOL_REGISTRY[tool_name]
try:
return func(**arguments)
except TypeError as e:
return f"ERROR calling {tool_name}: {e}"
if result.isError:
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)
except Exception as e:
return f"ERROR in {tool_name}: {type(e).__name__}: {e}"
return f"Error calling tool '{tool_name}': {e}"
# ═════════════════════════════════════════════════════════════════════════════
# PART C SYSTEM PROMPT
# SYSTEM PROMPT
# ═════════════════════════════════════════════════════════════════════════════
SYSTEM_PROMPT = f"""\
You are a coding agent that helps users with Python programming tasks.
You work inside a workspace directory and have access to tools.
You solve tasks by interacting with a workspace through a
dynamic set of tools via the Model Context Protocol (MCP).
<capabilities>
You can:
- Read .py and .txt files from the workspace
- Write .py and .txt files to the workspace
- List files in the workspace
- Search for patterns in files using grep
- Execute Python files and see their output
- Validate Python syntax using ast.parse
- Signal completion when the task is done
<capabilities>
You have access to a workspace where you can manage files,
analyze code, and execute Python scripts.
Your available tools are provided dynamically based on your current needs.
</capabilities>
<tools>
{build_tool_description()}
tool will be provided dynamically based on the agent's current context and needs.
</tools>
<workflow>
For every user request, follow this workflow:
1. PLAN: Think about what steps are needed. List them in "thought".
@ -252,7 +300,7 @@ Example:
# ═════════════════════════════════════════════════════════════════════════════
# PART D CODING AGENT CLASS
# CODING AGENT CLASS
# ═════════════════════════════════════════════════════════════════════════════
def truncate_result(result: str) -> str:
@ -335,8 +383,9 @@ class CodingAgent:
self.api_key = os.getenv("API_KEY")
self.model = os.getenv("MODEL")
def _call_api(self, messages: list) -> str:
async def _call_api(self, messages: list, relevant_tools: list = None) -> str:
"""Make a raw API call and return the response content string."""
headers = {"Content-Type": "application/json"}
if self.api_key and self.api_key != "EMPTY":
headers["Authorization"] = f"Bearer {self.api_key}"
@ -344,12 +393,19 @@ class CodingAgent:
payload = {
"model": self.model,
"messages": messages,
"tools": relevant_tools,
"temperature": 0.2,
"max_tokens": 4096,
"stream": False,
}
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
async with httpx.AsyncClient(timeout=30) as client:
response = await client.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:
raise Exception(f"API Error {response.status_code}: {response.text}")
@ -371,7 +427,7 @@ class CodingAgent:
self.is_done = False
self.iteration = 0
def propose_next_action(self) -> dict:
async def propose_next_action(self) -> dict:
"""Ask the LLM what to do next.
Returns the parsed action dict without executing anything.
@ -387,12 +443,14 @@ class CodingAgent:
if self.iteration >= MAX_ITERATIONS:
return {"thought": "Max iterations reached.", "tool": "done",
"arguments": {"summary": "Stopped: max iterations reached."}}
current_context = self.messages[-1]["content"] if self.messages else ""
self.iteration += 1
self.messages = trim_messages(self.messages)
relevant_tools = await get_tools_for_prompt(current_context)
try:
raw = self._call_api(self.messages)
raw = await self._call_api(self.messages, relevant_tools)
raw = _strip_code_fences(raw)
action = json.loads(raw)
except json.JSONDecodeError:
@ -413,7 +471,7 @@ class CodingAgent:
self.pending_action = {"raw": raw, "action": action}
return action
def approve(self) -> dict:
async def approve(self) -> dict:
"""Execute the pending action and return the result.
Returns:
@ -442,7 +500,7 @@ class CodingAgent:
}
# Execute the tool
result = dispatch_tool(tool_name, arguments)
result = await dispatch_tool(tool_name, arguments)
result = truncate_result(result)
# Build feedback nudge agent to replan on errors
@ -506,3 +564,5 @@ class CodingAgent:
),
})
self.pending_action = None

View File

@ -13,6 +13,7 @@ class MCPToolRAGAdapter:
def __init__ (self, config_path: str = "mcp_server_config.json"):
self.config_path = config_path
self.tools = []
self.toolnames = []
self.embedder = SentenceTransformer('all-MiniLM-L6-v2') # for embedding tool descriptions
self.sessions = {}
self.exit_stack = {}
@ -64,6 +65,7 @@ class MCPToolRAGAdapter:
"definition": tool,
"search_text": f"{tool['name']}: {tool.get('description', '')}",
})
self.tool_names.append(tool["name"])
# embeddings for all tools in this session
if self.tool_registry:

View File

@ -2,6 +2,7 @@
"command": "python",
"args": ["servers/mcp_server_file_search.py"]
},
"WebSearchServer": {
"command": "python",
"args": ["servers/mcp_server_web_search.py"],
@ -9,6 +10,7 @@
"DDGS_API_KEY": "your_ddgs_api_key_here"
}
},
"CodeExecutionServer": {
"command": "python",
"args": ["servers/mcp_server_code_execution.py"]

View File

@ -2,8 +2,18 @@ import streamlit as st
from backend.managers.chat_manager import ChatManager
from backend.managers.system_prompter import SystemPrompter
import asyncio
# ── Agent Mode helpers ────────────────────────────────────────────────────────
def _run_async(coro):
"""Hilfsfunktion um async Code in sync Streamlit auszuführen"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def _start_agent(task: str):
"""Initialise a fresh CodingAgent, start the task,
@ -11,7 +21,7 @@ def _start_agent(task: str):
from backend.agent.coding_agent import CodingAgent
agent = CodingAgent()
agent.start_task(task)
action = agent.propose_next_action()
action = _run_async(agent.propose_next_action())
st.session_state.coding_agent = agent
st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval"
@ -24,7 +34,7 @@ def _approve_action():
agent = st.session_state.coding_agent
pending = st.session_state.agent_pending_action
result = agent.approve()
result = _run_async(agent.approve())
st.session_state.agent_log.append({
"thought": pending.get("thought", ""),
@ -37,7 +47,7 @@ def _approve_action():
st.session_state.agent_status = "done"
st.session_state.agent_pending_action = None
else:
next_action = agent.propose_next_action()
next_action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval"
@ -46,7 +56,7 @@ def _reject_action(feedback: str):
"""Reject the pending action with optional feedback, then replan."""
agent = st.session_state.coding_agent
agent.reject(feedback or "Please try a different approach.")
next_action = agent.propose_next_action()
next_action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval"
@ -55,7 +65,7 @@ def _followup_agent(question: str):
"""Inject a follow-up question into the finished agent and resume the loop."""
agent = st.session_state.coding_agent
agent.follow_up(question)
action = agent.propose_next_action()
action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval"
@ -103,6 +113,8 @@ def render_agent_mode():
placeholder="e.g. Write a function that sorts a list and saves it to sorted.py",
)
if st.button("Start Agent", type="primary", use_container_width=True):
#loop = asyncio.new_event_loop()
#asyncio.set_event_loop(loop)
if task.strip():
with st.spinner("Agent is thinking..."):
_start_agent(task.strip())