Add improved project structure with agents and utils directories

This commit is contained in:
Livio Meuli 2026-03-26 17:51:41 +01:00
parent c9106e6750
commit 2faf88d2d0
7 changed files with 605 additions and 0 deletions

View File

View File

View File

@ -0,0 +1,605 @@
"""
Exercise 5b -- Build a Basic AI Coding Agent (Guided Version)
==============================================================
AISE501 . Prompting in Coding . Spring Semester 2026
This is a GUIDED version of Exercise 5 with more scaffolding.
It teaches the same concepts but reduces boilerplate so you can
focus on the key insight: how an LLM uses tools.
The key insight
---------------
An LLM cannot run code or read files by itself. But we can give it
"superpowers" through a simple trick:
1. TELL the LLM (via the system prompt) what tools exist.
2. ASK the LLM to respond with JSON saying which tool to call.
3. PARSE the JSON, call the real Python function, and
4. FEED the result back into the conversation as a new message.
This is how ALL AI coding agents work (Claude Code, Cursor, Copilot).
The LLM never actually "runs" code it just asks us to run it!
What is already provided
------------------------
To let you focus on the interesting parts, the following are PRE-BUILT:
- All 7 tool functions (Part A) read_file, grep_search, etc.
- The tool dispatcher (Part B) maps tool names to functions.
- Helper functions: truncate_result, trim_messages, ask_human.
What you need to build (the interesting parts)
-----------------------------------------------
Part C The SYSTEM PROMPT that teaches the LLM about its tools (TODOs 1-2).
Part D The AGENT LOOP that connects the LLM to the tools (TODOs 3-6).
Part E The INTERACTIVE CHAT interface (TODOs 7-8).
Think of it like wiring a robot:
- Part A+B are the robot's HANDS (already built).
- Part C is the robot's INSTRUCTION MANUAL (you write it).
- Part D is the robot's BRAIN LOOP (you wire it).
- Part E is the ON SWITCH (you connect it).
The conversation flow
---------------------
Here is exactly what happens in one iteration of the agent loop:
messages = [
{"role": "system", "content": "<system prompt>"},
{"role": "user", "content": "Fix the bug in app.py"},
]
LLM generates
JSON response
{"thought": "I should...",
"tool": "read_file",
"arguments": {
"path": "app.py"
}}
You parse JSON,
call read_file()
Append to messages:
{"role":"assistant", "content":..}
{"role":"user", "content":
"<tool_result>file contents │
</tool_result>"} │
Next iteration:
LLM sees result,
picks next tool
"""
import ast
import json
import subprocess
import sys
from pathlib import Path
from server_utils import (
chat,
chat_json,
get_client,
print_messages,
print_separator,
strip_code_fences,
)
client = get_client()
# ── Agent Configuration ──────────────────────────────────────────────────────
WORKSPACE = Path(__file__).parent / "workspace"
WORKSPACE.mkdir(exist_ok=True)
MAX_ITERATIONS = 50
MAX_RESULT_LENGTH = 8000
MAX_HISTORY_CHARS = 60000
# ═══════════════════════════════════════════════════════════════════════════════
# PART A -- TOOL FUNCTIONS (pre-built)
# ═══════════════════════════════════════════════════════════════════════════════
#
# These are the tools the agent can use. Each is a normal Python function.
# The LLM will never call these directly — it will OUTPUT JSON saying
# "please call read_file with path='app.py'", and OUR CODE will call it.
def read_file(path: str) -> str:
"""Read a .txt or .py 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()
def grep_search(pattern: str, file_glob: str = "*.py") -> str:
"""Search for a pattern in workspace files matching the glob."""
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)
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)
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}."
def run_python(path: str) -> str:
"""Execute a Python file in the workspace and return stdout + 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()
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}"
def done(summary: str) -> str:
"""Signal that the agent has finished its task."""
return f"DONE: {summary}"
# ═══════════════════════════════════════════════════════════════════════════════
# PART B -- TOOL DISPATCHER (pre-built)
# ═══════════════════════════════════════════════════════════════════════════════
#
# This is the bridge between the LLM's JSON output and Python function calls.
#
# When the LLM says: {"tool": "read_file", "arguments": {"path": "app.py"}}
# The dispatcher does: TOOL_FUNCTIONS["read_file"](path="app.py")
#
# The **arguments syntax means "unpack the dict as keyword arguments":
# {"path": "app.py"} → read_file(path="app.py")
TOOL_FUNCTIONS = {
"read_file": read_file,
"grep_search": grep_search,
"list_files": list_files,
"write_file": write_file,
"run_python": run_python,
"validate_python": validate_python,
"done": done,
}
def dispatch_tool(tool_name: str, arguments: dict) -> str:
"""Look up a tool by name and call it with the given arguments.
Example:
dispatch_tool("read_file", {"path": "app.py"})
calls read_file(path="app.py")
returns the file contents as a string
"""
if tool_name not in TOOL_FUNCTIONS:
return f"ERROR: unknown tool '{tool_name}'. Available: {list(TOOL_FUNCTIONS.keys())}"
func = TOOL_FUNCTIONS[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 (TODOs 1-2)
# ═══════════════════════════════════════════════════════════════════════════════
#
# The system prompt is the MOST IMPORTANT part of the agent. It is the only
# way the LLM knows what tools it has and how to use them.
#
# Think about it: the LLM is just a text model. It has no built-in ability
# to read files or run code. The system prompt is where we TELL it:
# "You have these tools. When you want to use one, output this JSON format.
# I (the code) will parse your JSON, run the tool, and give you the result."
#
# The LLM then "plays along" — it outputs JSON that LOOKS LIKE a tool call,
# and our agent loop code makes it ACTUALLY happen.
# TODO 1: Complete the TOOL_DESCRIPTIONS string below.
# This text will be embedded in the system prompt inside a <tools> section.
# The LLM needs to know:
# - The name of each tool (must match the keys in TOOL_FUNCTIONS above!)
# - What arguments each tool takes
# - What each tool does
#
# Four tools are already described for you as examples.
# Add the missing three: write_file, run_python, validate_python.
#
# Follow the same format:
# - tool_name({"param": "<description>"}): What the tool does.
TOOL_DESCRIPTIONS = """\
- read_file({"path": "<relative path>"}): Read a .py or .txt file from the workspace.
- grep_search({"pattern": "<text>", "file_glob": "<glob, default='*.py'>"}): Search for a pattern in files.
- list_files({"file_glob": "<glob, default='*'>"}): List files matching the pattern.
- write_file({"path": "<relative path>", "content": "<file content>"}): Write a .py or .txt file to the workspace.
- run_python({"path": "<relative path>"}): Execute a Python file and return stdout + stderr.
- validate_python({"path": "<relative path>"}): Check Python file syntax and return result.
- done({"summary": "<what you accomplished>"}): Signal that you are finished.
"""
# TODO 2: Complete the system prompt.
# The structure is provided — fill in the <workflow> and <rules> sections.
#
# For <workflow>, describe these steps:
# 1. PLAN: Think about what steps are needed. List them in "thought".
# 2. ACT: Choose ONE tool to call.
# 3. OBSERVE: Analyse the tool's output carefully.
# 4. REPLAN: If the result was unexpected, revise your plan.
# 5. REPEAT: Go back to ACT if more work is needed.
# 6. DONE: Call the "done" tool when the task is complete.
#
# For <rules>, include at least:
# - Always plan before acting.
# - Call exactly ONE tool per response.
# - After writing code, always validate and run it.
# - If an error occurs, try to fix it (up to 3 retries).
# - Stay within the workspace directory.
# - When finished, call the "done" tool.
#
# IMPORTANT: The JSON example uses {{ and }} because this is an f-string.
# In an f-string, {{ produces a literal { in the output.
# So {{"thought": "..."}} becomes {"thought": "..."} when printed.
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.
<tools>
Available tools:
{TOOL_DESCRIPTIONS}
</tools>
<workflow>
To accomplish a task, follow this workflow:
1. PLAN: Think about what steps are needed. List them in "thought".
2. ACT: Choose ONE tool to call.
3. OBSERVE: Analyse the tool's output carefully.
4. REPLAN: If the result was unexpected, revise your plan.
5. REPEAT: Go back to ACT 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. The format is:
{{{{
"thought": "<your reasoning about what to do next>",
"tool": "<tool name from the list above>",
"arguments": {{{{ <arguments for the tool> }}}}
}}}}
Example to read a file:
{{{{
"thought": "I need to read app.py to understand the code.",
"tool": "read_file",
"arguments": {{{{"path": "app.py"}}}}
}}}}
Example to signal completion:
{{{{
"thought": "I have fixed all the bugs and verified the code runs.",
"tool": "done",
"arguments": {{{{"summary": "Fixed 3 bugs in app.py and verified all tests pass."}}}}
}}}}
</response_format>
<rules>
Rules for operating as a coding agent:
- Always plan before acting. Your "thought" should explain your reasoning and strategy.
- Call exactly ONE tool per response. Do not try to call multiple tools.
- Always validate Python code after writing it using validate_python.
- Always run Python code after validation to verify it works using run_python.
- If an error occurs, analyze it carefully and retry up to 3 times.
- Stay within the workspace directory. Never try to access files outside it.
- When the task is complete, immediately call the "done" tool with a summary of what was accomplished.
- If a human provides feedback in <human_message> tags, acknowledge it and adjust your plan accordingly.
</rules>
"""
# ═══════════════════════════════════════════════════════════════════════════════
# PART D -- AGENT LOOP (TODOs 3-6)
# ═══════════════════════════════════════════════════════════════════════════════
#
# This is where everything comes together. The agent loop:
#
# 1. Sends messages to the LLM (including the system prompt with tools).
# 2. The LLM responds with JSON like: {"tool": "read_file", "arguments": {"path": "app.py"}}
# 3. We parse that JSON and call the real Python function.
# 4. We put the result back into the conversation as a new message.
# 5. We send the updated conversation to the LLM again.
# 6. The LLM sees the result and decides what to do next.
# 7. Repeat until the LLM calls "done" or we hit the iteration limit.
def truncate_result(result: str) -> str:
"""Truncate a tool result if it 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, showing first and last {half}] ...\n\n"
+ result[-half:]
)
def trim_messages(messages: list) -> list:
"""Trim older messages if total character count exceeds MAX_HISTORY_CHARS."""
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. "
f"REMINDER — your original task was:\n{original_task}\n"
"Continue from where you left off.</system_note>"
),
}
return head + [reminder] + tail
def ask_human() -> str:
"""Ask the user to approve, redirect, or stop before each action."""
try:
reply = input(
"\n [Enter]=continue, or type a comment (stop to abort): "
).strip()
return reply
except (EOFError, KeyboardInterrupt):
return "stop"
def agent_loop(user_task: str) -> None:
"""Run the agent loop: plan -> user review -> act -> observe -> repeat.
Study this function carefully it IS the agent. Everything else is
just support. The loop implements this cycle:
LLM produces JSON we parse it we call the tool
we feed the result back LLM produces next JSON ...
"""
# TODO 3: Initialise the message list.
# Create a list with two messages:
# 1. {"role": "system", "content": SYSTEM_PROMPT}
# 2. {"role": "user", "content": user_task}
#
# The system message teaches the LLM about its tools.
# The user message is the task to accomplish.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_task},
]
for iteration in range(1, MAX_ITERATIONS + 1):
print_separator(f"Agent Iteration {iteration}")
messages = trim_messages(messages)
# TODO 4: Get the LLM's next action.
try:
raw = chat_json(client, messages, temperature=0.2, max_tokens=4096)
action = json.loads(raw)
thought = action.get("thought", "")
tool_name = action.get("tool", "")
arguments = action.get("arguments", {})
except json.JSONDecodeError as e:
print(f"Failed to parse JSON response: {e}")
messages.append({"role": "assistant", "content": raw})
messages.append(
{
"role": "user",
"content": "Please respond with valid JSON in the specified format.",
}
)
continue
# Print the agent's plan
print(f"\nThought: {thought}")
print(f"Tool: {tool_name}")
print(f"Arguments: {arguments}")
# TODO 5: Human-in-the-loop — let the user review before execution.
if tool_name == "done":
print(
f"\n✓ Agent proposed completion: {arguments.get('summary', 'Task completed')}"
)
user_input = ask_human()
if user_input.lower() in {"stop", "abort", "cancel"}:
print("Aborted by user.")
return
elif user_input:
messages.append({"role": "assistant", "content": raw})
messages.append(
{
"role": "user",
"content": f"<human_message>{user_input}</human_message>\nPlease revise your plan based on this feedback.",
}
)
continue
# If user approved (pressed Enter), fall through to execute
else:
user_input = ask_human()
if user_input.lower() in {"stop", "abort", "cancel"}:
print("Agent aborted by user.")
return
elif user_input:
# User provided feedback - don't execute, ask to revise
messages.append({"role": "assistant", "content": raw})
messages.append(
{
"role": "user",
"content": f"<human_message>{user_input}</human_message>\nPlease revise your plan based on this feedback.",
}
)
continue
# TODO 6: Execute the tool and feed the result back.
if tool_name == "done":
print(f"\n✓ Agent completed: {arguments.get('summary', 'Task completed')}")
return
# Call the tool
result = dispatch_tool(tool_name, arguments)
result = truncate_result(result)
# Append the assistant's response and tool result to the conversation
messages.append({"role": "assistant", "content": raw})
messages.append(
{
"role": "user",
"content": f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>',
}
)
# Print result for debugging
print(
f"\nResult: {result[:200]}..."
if len(result) > 200
else f"\nResult: {result}"
)
print_separator("Agent stopped (max iterations reached)")
# ═══════════════════════════════════════════════════════════════════════════════
# PART E -- INTERACTIVE CHAT (TODOs 7-8)
# ═══════════════════════════════════════════════════════════════════════════════
# TODO 7: Implement the input loop.
# - Read input with: user_input = input("You> ").strip()
# - Handle EOFError and KeyboardInterrupt (Ctrl+C)
# - Skip empty input
# - Exit on "quit" or "exit"
# - Otherwise call agent_loop(user_input)
def interactive_chat():
"""Run an interactive chat loop where the user gives tasks to the agent."""
print_separator("AI Coding Agent -- Interactive Mode")
print("Type your task and press Enter. Type 'quit' or 'exit' to stop.")
print(f"Workspace: {WORKSPACE.resolve()}\n")
# Show what files are in the workspace
files = [f for f in sorted(WORKSPACE.glob("*")) if f.is_file()]
if files:
print("Files in workspace:")
for f in files:
print(f" {f.name}")
else:
print("Workspace is empty.")
print()
# TODO 7: Implement the input loop.
while True:
try:
user_input = input("You> ").strip()
except (EOFError, KeyboardInterrupt):
print("\nExiting...")
return
# Skip empty input
if not user_input:
continue
# Check for exit commands
if user_input.lower() in {"quit", "exit"}:
print("Exiting interactive chat.")
return
# Run the agent with the user's task
agent_loop(user_input)
print()
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
source = Path(__file__).parent / "analyze_me.py"
dest = WORKSPACE / "analyze_me.py"
if not dest.exists():
dest.write_text(source.read_text())
interactive_chat()

0
backend/agent/tools.py Normal file
View File

View File

View File

0
workspace/.gitkeep Normal file
View File