491 lines
19 KiB
Python
491 lines
19 KiB
Python
"""
|
||
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 ast
|
||
import inspect
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import requests
|
||
from dotenv import load_dotenv
|
||
|
||
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
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# PART A – TOOL FUNCTIONS
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
#
|
||
# 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}"
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# PART C – 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.
|
||
|
||
<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>
|
||
|
||
<tools>
|
||
{build_tool_description()}
|
||
</tools>
|
||
|
||
<workflow>
|
||
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.
|
||
</workflow>
|
||
|
||
<response_format>
|
||
You MUST respond with a JSON object every time:
|
||
{{
|
||
"thought": "<your reasoning about what to do next>",
|
||
"tool": "<tool name>",
|
||
"arguments": {{ <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): ..."}}
|
||
}}
|
||
</response_format>
|
||
|
||
<rules>
|
||
- 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 <human_message>, acknowledge it and adjust your plan.
|
||
- If you receive a <replan> tag, revise your plan before choosing the next tool.
|
||
</rules>
|
||
"""
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# PART D – CODING AGENT CLASS
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
def truncate_result(result: str) -> str:
|
||
"""Truncate a tool result that exceeds MAX_RESULT_LENGTH."""
|
||
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:
|
||
"""Drop old messages when history exceeds MAX_HISTORY_CHARS.
|
||
Always keeps the system prompt (index 0) and original task (index 1).
|
||
"""
|
||
total = sum(len(m["content"]) for m in messages)
|
||
if total <= MAX_HISTORY_CHARS:
|
||
return messages
|
||
|
||
head = messages[:2]
|
||
tail = messages[2:]
|
||
original_task = messages[1]["content"] if len(messages) > 1 else ""
|
||
|
||
while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
|
||
tail.pop(0)
|
||
|
||
reminder = {
|
||
"role": "user",
|
||
"content": (
|
||
"<system_note>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.</system_note>"
|
||
),
|
||
}
|
||
return head + [reminder] + tail
|
||
|
||
|
||
def _strip_code_fences(text: str) -> str:
|
||
"""Remove markdown code fences (```json ... ```) from a string."""
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
lines = text.split("\n")
|
||
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,
|
||
"temperature": 0.2,
|
||
"max_tokens": 4096,
|
||
"stream": False,
|
||
}
|
||
|
||
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
|
||
|
||
if response.status_code != 200:
|
||
raise Exception(f"API Error {response.status_code}: {response.text}")
|
||
|
||
data = response.json()
|
||
if "choices" in data and len(data["choices"]) > 0:
|
||
return data["choices"][0]["message"]["content"]
|
||
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
|
||
|
||
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)
|
||
action = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
action = {
|
||
"thought": "Could not parse LLM response as JSON.",
|
||
"tool": "done",
|
||
"arguments": {"summary": "Stopped: JSON parse error."},
|
||
}
|
||
raw = json.dumps(action)
|
||
except Exception as e:
|
||
action = {
|
||
"thought": f"API call failed: {e}",
|
||
"tool": "done",
|
||
"arguments": {"summary": f"Stopped: {e}"},
|
||
}
|
||
raw = json.dumps(action)
|
||
|
||
self.pending_action = {"raw": raw, "action": action}
|
||
return action
|
||
|
||
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
|
||
|
||
# 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 = dispatch_tool(tool_name, arguments)
|
||
result = truncate_result(result)
|
||
|
||
# Build feedback – nudge agent to replan on errors
|
||
feedback = f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>'
|
||
if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"):
|
||
feedback += (
|
||
"\n\n<replan>The 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.</replan>"
|
||
)
|
||
|
||
self.messages.append({"role": "user", "content": feedback})
|
||
|
||
return {
|
||
"tool": tool_name,
|
||
"arguments": arguments,
|
||
"result": result,
|
||
"is_done": False,
|
||
}
|
||
|
||
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"<human_message>{feedback}</human_message>\n"
|
||
"<replan>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.</replan>"
|
||
),
|
||
})
|
||
self.pending_action = None
|