Build a Code Agent from Scratch with Python: A Practical Guide
A hands-on guide to building a code agent in Python, covering the four-layer architecture, the LLM adapter layer, the tool system, the ReAct-style agent loop, context trimming, execution isolation and safety guardrails, and how to evaluate and iterate on the agent. All examples are complete, runnable skeletons built on an OpenAI-compatible API.
Build a Code Agent from Scratch with Python: A Practical Guide
1. What “Intelligence” Actually Means in a Code Agent
A code agent is a system that uses a large language model as its reasoning core and can autonomously understand a task, plan the steps, call tools, and do software engineering work. The “perceive → decide → act” cycle in this article refers to one basic loop:
- Perceive the current state (read files, command output, error messages)
- Decide the next step (whether to call a tool, which one, with what arguments)
- Act by executing the tool and feeding the result back into the conversation
The loop repeats until the task converges.
What separates this from traditional code completion is the closed loop. The model does not just generate snippets: it can read the project, execute commands, run tests, and self-correct based on the feedback.
Understanding this architecture is the point of the article. It gets you out of the black box of calling someone else’s finished product, and lets you build, customize, and optimize your own coding assistant on demand.
2. Setting Up the Environment (First Make the Code Run)
All examples in this article assume the following prerequisites. Missing any one of them can break the reproduction:
- Python version: 3.9 or later (the code uses
typingand modern type annotations). - Dependencies:
requestsfor the model HTTP calls. Install withpip install requests. - Model service: any OpenAI-compatible endpoint (
base_urlin the formhttps://xxx/v1). - API key configuration: use an environment variable,
export LLM_API_KEY="sk-xxx", and never hard-code it in source.
Everything from this section onward is a complete, runnable skeleton, with TODO markers at the key spots where you plug in your own implementation.
3. The Overall Architecture: Four Layers and Their Replaceable Points
A mature code agent can be abstracted into four layers, each with a single responsibility and clear boundaries. Layering is what makes the system replaceable and testable. The table below lists each layer’s responsibility and what you touch when you swap it out:
| Layer | Responsibility | What changes when you swap it |
|---|---|---|
| Reasoning and decision layer | Task understanding, path planning, tool selection | Swapping the model only touches this layer |
| Tool execution layer | Atomic capabilities: file read/write, command execution, code search | Adding capabilities only touches this layer |
| Context management layer | Trimming and injecting conversation history and code snippets | Tuning the memory policy only touches this layer |
| Interaction and safety layer | User confirmation, permission checks, execution isolation, auditing | Swapping the sandbox only touches this layer |
4. The Model Interaction Layer: Giving the “Brain” a Runnable Interface
To avoid being locked into one protocol, add an adapter layer that decouples business logic from the concrete call. The following is a directly runnable OpenAI-compatible implementation:
import osimport requestsclass LLMAdapter:"""Model adapter layer: hides low-level protocol differences and exposesa unified interface for chat and tool calls."""def __init__(self, model: str, base_url: str, api_key: str | None = None):self.model = modelself.base_url = base_url.rstrip("/")self.api_key = api_key or os.environ["LLM_API_KEY"]def chat(self, messages, tools=None) -> dict:payload = {"model": self.model, "messages": messages}if tools:payload["tools"] = toolspayload["tool_choice"] = "auto"resp = requests.post(f"{self.base_url}/chat/completions",headers={"Authorization": f"Bearer {self.api_key}"},json=payload, timeout=60,)resp.raise_for_status()msg = resp.json()["choices"][0]["message"]# Unified contract: the layers above do not care about protocol detailsreturn {"content": msg.get("content") or "","tool_calls": msg.get("tool_calls") or []}SYSTEM_PROMPT = ("You are a code agent. You can call tools to read files, run commands, ""and execute tests. Advance step by step based on the real results the ""tools return. When you hit a problem, read the relevant information ""first, then decide. Do not guess when you lack information.")
It takes messages (the conversation history) and tools (tool descriptions) as input, and returns a uniform content and tool_calls. Switching to a different model service later is very cheap.
5. The Tool System: How to Make the Model Pick the Right Tool
Tools are the agent’s hands and feet. Each tool needs three pieces of information: a name, a natural-language description (so the model knows when to use it), and a parameter schema (JSON Schema). A registry is the recommended way to manage them:
class ToolSpec:def __init__(self, name, description, parameters, func):self.name, self.description = name, descriptionself.parameters, self.func = parameters, funcdef to_schema(self) -> dict:return {"type": "function", "function": {"name": self.name, "description": self.description,"parameters": self.parameters}}read_file_tool = ToolSpec(name="read_file", description="Read the text content of a file at the given path",parameters={"type": "object","properties": {"path": {"type": "string", "description": "File path"}},"required": ["path"]},func=lambda path: open(path, encoding="utf-8").read(),)
A usable code agent usually needs read_file / write_file (file read and write), list_dir (directory browsing), search_code (keyword search), run_command (command execution), and run_tests (test runs).
The wording of tool descriptions has a measurable impact on how often the model selects the right tool, and this is the most overlooked part in practice. Measure tool selection accuracy with an A/B test: fix a set of tasks, compare the share of “correct tool chosen” across different description versions, and quantify the improvement that way.
6. The Agent Loop: Let the Model Adjust as It Goes
The loop is the heart of the agent. It is essentially a ReAct-style flow (Reasoning + Acting): think → decide whether to call a tool → execute → feed the result back → keep thinking, until the task converges or the step limit is hit.
import jsondef run_agent(task, llm, tools, max_steps=20) -> str:schemas = [t.to_schema() for t in tools]tool_map = {t.name: t for t in tools}messages = [{"role": "system", "content": SYSTEM_PROMPT},{"role": "user", "content": task}]for _ in range(max_steps):resp = llm.chat(messages, tools=schemas)assistant_msg = {"role": "assistant","content": resp.get("content", ""),"tool_calls": resp.get("tool_calls", [])}messages.append(assistant_msg)if not assistant_msg["tool_calls"]:return assistant_msg["content"] # No tool call means it has convergedfor call in assistant_msg["tool_calls"]:name = call["function"]["name"]args = json.loads(call["function"]["arguments"] or "{}")try:result = tool_map[name].func(**args)except Exception as e:result = f"Tool execution error: {e}" # Return errors too, so the model can self-correctmessages.append({"role": "tool", "tool_call_id": call["id"],"content": str(result)})return "Maximum steps reached; the task did not converge."
Two engineering details matter here. Errors are feedback: when a tool fails, do not abort. Return the error to the model so it can adjust its arguments or pick a different path. That is the core difference between an agent and a script. A step limit: the loop must have a circuit breaker to prevent a “call → error → call again” death loop from burning compute.
7. Context Management: Remembering What Matters, Dropping What Doesn’t
The model’s context window is finite, and real projects easily run to thousands of files. The trimming logic has to treat the token budget and the retained message count as two separate parameters, and it must explicitly pin the system message and the first user message, or it will drop critical instructions:
class ContextManager:def __init__(self, max_tokens: int, max_history_msgs: int = 20):self.max_tokens = max_tokens # Token budgetself.max_history_msgs = max_history_msgs # How many recent messages to keep (count control)self.messages = []@staticmethoddef _estimate(text: str) -> int:return max(1, len(str(text)) // 3) # Rough estimate; use a tokenizer for accuracydef trim(self) -> None:# 1) Split first: all system messages plus the first user message are pinnedsystem_msgs = [m for m in self.messages if m["role"] == "system"]first_user = next((m for m in self.messages if m["role"] == "user"), None)pinned = system_msgs + ([first_user] if first_user else [])pinned_ids = {id(m) for m in pinned}# 2) Of the rest, keep the most recent N by message count (count, not tokens)remaining = [m for m in self.messages if id(m) not in pinned_ids]recent = remaining[-self.max_history_msgs:]# 3) Then trim by token budget from the back; pinned items are never droppedkept, total = [], 0for m in reversed(pinned + recent):cost = self._estimate(m.get("content", ""))if total + cost > self.max_tokens and id(m) not in pinned_ids:continuekept.insert(0, m)total += costself.messages = kept
Common strategies include a sliding window that keeps the last few turns, summarizing and compressing past tool output, and injecting large files in chunks on demand. The core principle: give the limited budget priority to the information most relevant to the current task.
8. Safety and Execution Isolation: The Prerequisite for Production
A code agent has real file and command permissions, and without isolation the risk is very high. Safety is both the prerequisite and the hard line for putting a code agent into production. The guardrail checklist:
- Sandboxed execution (run commands in a container or restricted environment, limiting network and filesystem access)
- Permission tiers (deleting, bulk rewrites, and installing dependencies require human confirmation or a whitelist)
- Path validation (restrict access to inside the project root)
- Audit logs (record every call and its parameters in full)
The following is a minimal safe implementation of write_file / run_command:
import os, shlex, subprocessPROJECT_ROOT = os.path.realpath("./workspace")DANGER_PATTERNS = ["rm -rf", "sudo", "mkfs", ":(){", "> /dev/sd"]def _safe_path(path: str) -> str:real = os.path.realpath(os.path.join(PROJECT_ROOT, path))if not real.startswith(PROJECT_ROOT + os.sep): # Prefix check to block escapesraise ValueError("Unauthorized access: path is outside the project root")return realdef write_file(path, content):real = _safe_path(path)with open(real, "w", encoding="utf-8") as f:f.write(content)return f"Wrote {real}"def run_command(cmd, confirm=None):if any(p in cmd for p in DANGER_PATTERNS): # Destructive operations need human confirmationif confirm is None or not confirm(cmd):return "Dangerous command blocked; human confirmation required"r = subprocess.run(shlex.split(cmd), cwd=PROJECT_ROOT, shell=False,capture_output=True, text=True, timeout=60)return (r.stdout or "") + (r.stderr or "")
9. Evaluation and Iteration: Making the Agent Better in a Measurable Way
Building it is only the starting point; evaluation is where the long-term value comes from. Concretely:
- A standard task set: define 10–20 representative tasks (for example, “add unit tests to a function”, “fix a given error”), and state the pass criterion for each (all tests green / output matches expectations).
- Success rate: number of tasks that pass end to end divided by the total number of tasks.
- Average steps: the average number of tool calls needed to finish a task; lower means more efficient.
- Cost: average token consumption or spend, converted using the model’s unit price.
- Regression case template: turn production failures into entries of the form
{"task":..., "expected":..., "actual":..., "root_cause":...}and add them to the task set to prevent recurrence.
10. Common Errors and How to Choose (FAQ)
Q: What if I hit a context overflow error? A: It usually means history was never trimmed. Use the ContextManager.trim() from section 7, and summarize large tool outputs first.
Q: The model keeps picking the wrong tool. A: Improve the tool descriptions, and run an A/B comparison on tool selection accuracy before finalizing them.
Q: It gets stuck in a loop, calling tools endlessly. A: Lower max_steps, and return tool errors in a structured form so the model can correct itself quickly.
Q: There are unauthorized reads and writes. A: You must enable the path prefix check and dangerous-command confirmation (section 8).
How do you choose a model? Use a model with strong reasoning for complex planning tasks, and a low-latency, low-cost model for simple tool calls. Compare candidates using the cost and success rate metrics from section 9.
How fine-grained should the tools be? Too coarse and the model tries to do too much in one step, which is hard to correct; too fine and the step count explodes. Start with “one atomic action per tool” and then tune based on measured step counts.
11. Next Steps
Once the basic loop is stable, you can gradually add: multi-agent collaboration (a planner that splits the work and executors that divide it), retrieval augmentation (turning project docs and historical code into a searchable knowledge base), and long-range cross-file dependency management. All of these build on top of the four-layer architecture and require no rewrite. That is the compound return of a good architecture. With these methods in hand, you can trim the tool set per scenario, tune the context strategy, and harden the security boundary, until you end up with your own coding assistant.
