""" Coding Agent ============ Autonomous AI coding agent based on the Plan→Act→Observe→Fix→Done loop. Structure follows ex05_coding_agent_solution.py from the course. Instead of a blocking CLI loop (input()), the CodingAgent class exposes step-by-step methods so Streamlit can drive the loop via session_state: agent.start_task(task) # initialise action = agent.propose_next_action() # ask LLM → returns action, does NOT execute result = agent.approve() # execute pending action agent.reject(feedback) # skip action, inject user feedback """ import json import os import re from pathlib import Path import asyncio import requests from dotenv import load_dotenv from backend.agent.mcp_server_adapter import MCPToolAdapter from backend.managers.debug_logger import get_logger logger = get_logger(__name__) # ── mcp server initialization ──────────────────────────────────────────────────────────────── adapter = MCPToolAdapter() logger.info("MCPToolAdapter created. Listing all tools from servers...") asyncio.run(adapter.initialize_all_servers()) logger.info("Listed tools from all servers") load_dotenv() # ── Workspace ──────────────────────────────────────────────────────────────── # Two levels up from backend/agent/ → project root → workspace/ WORKSPACE = Path(__file__).resolve().parents[2] / "workspace" WORKSPACE.mkdir(exist_ok=True) # ── Agent limits ───────────────────────────────────────────────────────────── MAX_ITERATIONS = 100 MAX_RESULT_LENGTH = 10_000 MAX_HISTORY_CHARS = 80_000 # ═════════════════════════════════════════════════════════════════════════════ # Tool dispatching and result handling # ═════════════════════════════════════════════════════════════════════════════ def build_all_tool_description() -> str: """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 ``"- : "``. """ all_tools = adapter.get_all_tools() logger.info("Building tool description for %s tools.", str(len(all_tools))) descriptions = [] for tool in all_tools: descriptions.append(f"- {tool['tool_name']}: {tool['tool_description']}") return "\n".join(descriptions) async def dispatch_tool(tool_name: str, arguments: dict) -> str: """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": # 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: logger.info("Calling tool '%s' in dispatch_tool through MCPToolAdapter...", tool_name) result = await adapter.call_tool(tool_name, arguments) logger.info("Result from tool '%s' received", tool_name) 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"] logger.warning("Result from '%s' is Error", tool_name) 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: logger.exception("Error calling tool '%s' with argument: %s", tool_name, arguments) return f"Error calling tool '{tool_name}': {e}" # ═════════════════════════════════════════════════════════════════════════════ # 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). You have access to a workspace where you can manage files, analyze code, and execute Python scripts. You can call tools to interact with the workspace and get feedback. You can write and read files, list directory contents, search for patterns, validate Python syntax, and run Python code. You can access web search and page fetching tools to gather information from the internet. You can use these capabilities to iteratively work towards completing the user's task. {build_all_tool_description()} For every user request, follow this workflow: 1. PLAN: Think about what steps are needed. List them in "thought". 2. ACT: Choose ONE tool to call for the current step. 3. OBSERVE: You will receive the tool output. Analyse it carefully. 4. REPLAN: If the result was unexpected, revise your plan in "thought". 5. REPEAT: Go back to step 2 if more work is needed. 6. DONE: Call the "done" tool when the task is complete. You MUST respond with a JSON object every time: {{ "thought": "", "tool": "", "arguments": {{ }} }} Example: {{ "thought": "I need to write fibonacci.py first, then validate and run it.", "tool": "write_file", "arguments": {{"path": "fibonacci.py", "content": "def fib(n): ..."}} }} - Always plan before acting. - Call exactly ONE tool per response. - After writing code, ALWAYS validate it with validate_python. - After validation passes, run it with run_python to verify correctness. - If an error occurs, analyse it and try to fix it (up to 3 retries). - Stay within the workspace directory. - When the task is fully complete, call the "done" tool. - If you receive a , acknowledge it and adjust your plan. - If you receive a tag, revise your plan before choosing the next tool. """ # ═════════════════════════════════════════════════════════════════════════════ # CODING AGENT CLASS # ═════════════════════════════════════════════════════════════════════════════ def truncate_result(result: str) -> str: """Truncate a tool result that exceeds MAX_RESULT_LENGTH.""" logger.info("Result has been truncated") if len(result) <= MAX_RESULT_LENGTH: return result half = MAX_RESULT_LENGTH // 2 return ( result[:half] + f"\n\n... [TRUNCATED – {len(result)} chars total] ...\n\n" + result[-half:] ) def trim_messages(messages: list) -> list: """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. """ logger.info("Message is being trimmed") 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 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) # 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": ( "Earlier conversation history was trimmed to fit the context window. " f"REMINDER – your original task was:\n{original_task}\n" "Continue working towards completing this task. " "Do NOT start over or redo work already completed." ), } return head + [reminder] + tail def _repair_json_strings(text: str) -> str: """Replace unescaped control characters inside JSON string values. 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 escape = False _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 if ch == '\\' and in_string: result.append(ch) 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) return ''.join(result) def extract_json(text: str) -> str: """ Extract and repair a JSON object or array from an LLM response that may contain extra prose, markdown code fences, or unescaped control characters. Strategy: 1. Strip markdown ```json ... ``` or ``` ... ``` fences. 2. Find the first '{' or '[' and extract to the matching closing bracket. 3. Repair unescaped newlines/tabs inside string values. Returns the cleaned JSON string, or the original text as a fallback (so json.loads can raise a meaningful error with context). """ if text is None: return "" # 1. Strip markdown fences fenced = re.sub(r"```(?:json)?\s*([\s\S]*?)\s*```", r"\1", text.strip()) if fenced != text.strip(): return _repair_json_strings(fenced.strip()) # 2. Find first JSON container and extract to matching close extracted = text for start_char, end_char in [('{', '}'), ('[', ']')]: idx = text.find(start_char) if idx == -1: continue depth = 0 in_string = False escape = False for i, ch in enumerate(text[idx:], start=idx): if escape: escape = False continue if ch == '\\' and in_string: escape = True continue if ch == '"': in_string = not in_string continue if in_string: continue if ch == start_char: depth += 1 elif ch == end_char: depth -= 1 if depth == 0: extracted = text[idx: i + 1] break break # 3. Repair unescaped control characters inside string values return _repair_json_strings(extracted) def _strip_code_fences(text: str) -> str: """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() class CodingAgent: """Step-by-step coding agent for use in Streamlit. Usage: agent = CodingAgent() agent.start_task("Write a fibonacci function") action = agent.propose_next_action() # → {"thought": "...", "tool": "write_file", "arguments": {...}} # Show action in UI, wait for user input result = agent.approve() # → {"tool": "write_file", "result": "OK: ...", "is_done": False} agent.reject("Please use recursion instead") # → agent replans on next propose_next_action() """ def __init__(self): self.messages: list = [] self.pending_action: dict | None = None self.is_done: bool = False self.iteration: int = 0 self._setup_api() def _setup_api(self): """Load API configuration from environment variables.""" self.api_url = f"http://{os.getenv('HOST')}:{os.getenv('PORT')}/v1/chat/completions" self.api_key = os.getenv("API_KEY") self.model = os.getenv("MODEL") def _call_api(self, messages: list) -> 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}" payload = { "model": self.model, "messages": messages, # 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, } try: response = requests.post( self.api_url, headers=headers, json=payload, timeout=60) response.raise_for_status() logger.info("LLM API response requested") except requests.RequestException as exc: logger.exception("API Error; HTTP-Fehler: %s", exc) raise Exception(f"HTTP-Fehler: {exc}") from exc data = response.json() if "choices" in data and len(data["choices"]) > 0: logger.info("valid API output, data returned") return data["choices"][0]["message"]["content"] logger.error("Invalid API response format") raise Exception("Invalid API response format") # ── Public interface ────────────────────────────────────────────────────── def start_task(self, task: str) -> None: """Initialise the agent with a new task. Resets all state.""" self.messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, ] self.pending_action = None self.is_done = False self.iteration = 0 logger.info("New ask initialized") async def propose_next_action(self) -> dict: """Ask the LLM what to do next. Returns the parsed action dict without executing anything. The action is stored internally as pending_action until approve() or reject() is called. Returns: {"thought": str, "tool": str, "arguments": dict} """ if self.is_done: return {"thought": "Task already completed.", "tool": "done", "arguments": {}} if self.iteration >= MAX_ITERATIONS: return {"thought": "Max iterations reached.", "tool": "done", "arguments": {"summary": "Stopped: max iterations reached."}} self.iteration += 1 self.messages = trim_messages(self.messages) try: raw = self._call_api(self.messages) raw = _strip_code_fences(raw) cleaned = extract_json(raw) action = json.loads(cleaned) logger.info("Propose next action successfull") except json.JSONDecodeError: action = { "thought": "Could not parse LLM response as JSON.", "tool": "done", "arguments": {"summary": "Stopped: JSON parse error."}, } raw = json.dumps(action) logger.critical("Parsing API response into valid JASON failed in Step 'propose_next_action'") except Exception as e: action = { "thought": f"API call failed: {e}", "tool": "done", "arguments": {"summary": f"Stopped: {e}"}, } raw = json.dumps(action) logger.critical("API call faliled in Step %s: %s", self.iteration, e) self.pending_action = {"raw": raw, "action": action} return action async def approve(self) -> dict: """Execute the pending action and return the result. Returns: {"tool": str, "arguments": dict, "result": str, "is_done": bool} """ if not self.pending_action: raise Exception("No pending action. Call propose_next_action() first.") raw = self.pending_action["raw"] action = self.pending_action["action"] tool_name = action.get("tool", "") arguments = action.get("arguments", {}) # Append the assistant message to history self.messages.append({"role": "assistant", "content": raw}) self.pending_action = None logger.info("Messages prepared after approval") # Handle completion if tool_name == "done": self.is_done = True return { "tool": "done", "arguments": arguments, "result": arguments.get("summary", "Task completed."), "is_done": True, } # Execute the tool result = await dispatch_tool(tool_name, arguments) result = truncate_result(result) logger.info("Tool called and result truncated") # Wrap the tool output in an XML tag so the LLM can easily find it. # Append a tag on errors to force the agent to reconsider # its plan rather than blindly retrying the same failing action. feedback = f'\n{result}\n' if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"): feedback += ( "\n\nThe tool returned an error. " "Re-examine your plan: what went wrong and what should you do differently? " "State your revised plan in your next thought." ) logger.warning("Error Message in the tool result, replan-feedback will appended") self.messages.append({"role": "user", "content": feedback}) return { "tool": tool_name, "arguments": arguments, "result": result, "is_done": False, } def follow_up(self, question: str) -> None: """Continue a completed task with a follow-up question. Resets the done-flag and appends the user's question to the existing conversation, so the agent retains full context of what was already done. Call propose_next_action() afterwards to continue the loop. """ self.is_done = False self.messages.append({ "role": "user", "content": ( f"{question}\n" "The user has a follow-up question or correction regarding " "the task you just completed. Review what you already did and " "address their question accordingly." ), }) logger.info("Follow-up message appended.") def reject(self, feedback: str) -> None: """Reject the pending action and inject user feedback. The pending action is NOT executed. On the next call to propose_next_action() the agent will replan based on the feedback. """ if not self.pending_action: return raw = self.pending_action["raw"] action = self.pending_action["action"] tool_name = action.get("tool", "unknown") self.messages.append({"role": "assistant", "content": raw}) self.messages.append({ "role": "user", "content": ( f"{feedback}\n" "The user has given you guidance BEFORE you executed " f"your proposed action ({tool_name}). Do NOT proceed with that action. " "Revise your plan to incorporate their feedback and state your " "updated plan in your next thought." ), }) self.pending_action = None logger.info("Rejection message appended.")