Building a Python Code Agent from Scratch: Architecture and Engineering Practices
This article explains how to build a maintainable Python code agent from scratch, covering layered architecture, unified data structures, model adapters, controlled tools, the agent loop, context management, verification, planning, error recovery, security, evaluation, and a practical implementation roadmap.
1. What Problem Does a Code Agent Actually Solve?
A code agent is not simply a chat-based code generator. Traditional code completion usually predicts subsequent code based on the current file and cursor position. A code agent, by contrast, must handle more complex software engineering tasks: understanding an unfamiliar project, locating cross-file issues, modifying multiple modules, running tests, analyzing errors, and continuing to fix them.
The core goal of a code agent is therefore not to generate a piece of code in one shot, but to form a sustainable loop around a task:
- Understand the user’s requirements.
- Analyze the current project state.
- Create an execution plan.
- Select and invoke appropriate tools.
- Modify or create files.
- Run tests, builds, or inspection commands.
- Revise the approach based on feedback.
- Report results and the scope of changes to the user.
From a system perspective, it is closer to a large language model application that can operate a development environment than to an ordinary question-answering program.
2. Overall Architecture: Model, Tools, and Runtime
A maintainable code agent can usually be divided into five layers.
2.1 Interaction Layer
The interaction layer receives user input and outputs the agent’s reasoning summary, action process, and final result. The simplest implementation is a command-line interface, but it can also be extended to a web page, editor plugin, or internal enterprise development platform.
The interaction layer should not directly carry business logic. It is only responsible for:
- Receiving tasks;
- Displaying progress;
- Handling user confirmations;
- Interrupting or resuming tasks;
- Showing file changes and test results.
2.2 Intelligent Decision Layer
The intelligent decision layer calls the large language model and decides the next action based on context. It needs to maintain message history, tool definitions, execution results, and task state.
The decision layer should not assume that the model can complete the task directly every time. A more reliable approach is to let the model choose actions from a limited tool set, while the runtime validates and executes them.
2.3 Tool Layer
The tool layer is the bridge between the code agent and the real development environment. Common tools include:
- Listing directories;
- Reading files;
- Searching text;
- Creating files;
- Modifying files;
- Running tests;
- Running static checks;
- Viewing version changes;
- Retrieving project configuration.
Tools must have clear input structures, output structures, and error messages. They should not expose all capabilities as a vague “execute arbitrary command” interface.
2.4 Workspace Layer
The workspace layer manages project directories, file reads and writes, change records, temporary files, and the execution environment. It is both the object the agent operates on and an important boundary for security control.
2.5 Security and Observability Layer
The security layer handles permission validation, path restrictions, command interception, timeout control, and human confirmation. The observability layer records model requests, tool calls, latency, errors, retry counts, and final results for debugging and evaluation.
3. Define Unified Data Structures First
In a Python implementation, it is recommended to use data classes to describe messages, tool calls, and execution results, avoiding the transfer of opaque dictionaries between modules.
from dataclasses import dataclass, fieldfrom typing import Any, Optional@dataclassclass Message:role: strcontent: strtool_name: Optional[str] = Nonetool_args: Optional[dict[str, Any]] = None@dataclassclass ToolResult:ok: booloutput: strerror: Optional[str] = Nonemetadata: dict[str, Any] = field(default_factory=dict)
Unified data structures provide three benefits.
First, they make it easier to record a complete trace. Second, they make it easier to swap adapters between different model services. Third, they make it easier to write automated tests that verify whether a tool returns results that conform to the contract.
4. Model Adapter: Isolate External Interface Differences
Do not scatter the request formats of specific model services throughout business code. Define an abstract interface and let adapters perform the actual calls.
from typing import Protocolclass ModelClient(Protocol):def generate(self,messages: list[Message],tools: list[dict[str, Any]]) -> Message:...
The business layer depends only on ModelClient and does not care which protocol is used for the underlying request. This provides better replaceability and makes it easier to use a simulated model in a test environment.
A fake model can return a preset action:
class FakeModel:def generate(self, messages, tools):return Message(role="assistant",content="Read the project entry file",tool_name="read_file",tool_args={"path": "src/main.py"})
In production, the adapter needs to handle request failures, network timeouts, malformed responses, excessive context length, and service rate limiting.
5. Tool Design: Move from “Can Execute” to “Controlled Execution”
5.1 File Reading Tool
A file reading tool must restrict the accessible scope and set an upper limit on file size to avoid loading extremely large files at once.
from pathlib import Pathclass Workspace:def __init__(self, root: str):self.root = Path(root).resolve()def safe_path(self, relative_path: str) -> Path:target = (self.root / relative_path).resolve()if target != self.root and self.root not in target.parents:raise PermissionError("Path is outside the workspace")return targetdef read_file(self, relative_path: str, limit: int = 200_000) -> str:path = self.safe_path(relative_path)if not path.is_file():raise FileNotFoundError(relative_path)data = path.read_text(encoding="utf-8")return data[:limit]
Path validation here is critical. Directly concatenating paths provided by users or models can lead to unauthorized reads of files outside the workspace.
5.2 File Modification Tool
File modification can use full-file overwrite, range replacement, or patch application. For large projects, patches make it easier to control the scope of changes.
When a tool returns a result, it should include:
- The modified file path;
- A summary before and after modification;
- The number of changed lines;
- Whether the operation succeeded;
- The failure reason.
Do not return only “modified successfully”; otherwise, the model cannot determine the actual state in subsequent steps.
5.3 Command Execution Tool
Command execution is the highest-risk capability in a code agent. Design should follow the principle of least privilege:
- Allow execution only within the workspace directory;
- Set a timeout;
- Limit output length;
- Intercept dangerous commands;
- Distinguish read-only checks from write operations;
- Require user confirmation for actions that may have destructive effects.
Example:
import subprocessdef run_command(command: list[str], cwd: str, timeout: int = 30) -> ToolResult:try:process = subprocess.run(command,cwd=cwd,text=True,capture_output=True,timeout=timeout)output = (process.stdout + "\n" + process.stderr)[:10000]return ToolResult(ok=process.returncode == 0,output=output,metadata={"returncode": process.returncode})except subprocess.TimeoutExpired:return ToolResult(ok=False,output="",error="Command timed out")
A real system also needs command allowlists, environment variable filtering, resource limits, and process tree cleanup. Untrusted code should be run in an isolated environment rather than directly with host permissions.
6. Agent Main Loop: Observe, Decide, Act
The core of a code agent can be abstracted as a loop:
Receive task↓Read current context↓Call the model for decision-making↓If the model answers directly, finish↓If the model requests a tool, validate parameters↓Execute the tool and record the result↓Add the result to context↓Check whether the maximum number of steps has been reached↓Continue to the next round
Python implementation example:
class Agent:def __init__(self, model, tools, max_steps=20):self.model = modelself.tools = toolsself.max_steps = max_stepsdef run(self, task: str) -> str:messages = [Message(role="system",content=("You are a cautious code agent. ""First understand the task, then choose the minimum necessary tools. ""After modifying code, you must run verification whenever possible.")),Message(role="user", content=task)]for _ in range(self.max_steps):tool_specs = [tool.schema() for tool in self.tools.values()]response = self.model.generate(messages, tool_specs)if not response.tool_name:return response.contenttool = self.tools.get(response.tool_name)if tool is None:messages.append(Message(role="tool",content=f"Unknown tool: {response.tool_name}"))continuetry:result = tool.execute(**(response.tool_args or {}))except Exception as exc:result = ToolResult(ok=False,output="",error=str(exc))messages.append(response)messages.append(Message(role="tool",content=result.output if result.ok else f"Execution failed: {result.error}",tool_name=response.tool_name))return "Task was not completed within the step limit. Please review the execution trace."
This loop can be further improved with a state machine, parallel tool calls, failure retries, and human confirmation checkpoints.
7. Context Management Is Critical to Success
A code project often contains many files, and it is impossible to put an entire repository into context at once. Effective context management should use a layered strategy.
Layer 1: Task Context
This includes user goals, constraints, acceptance criteria, and the scope that must not be modified.
Layer 2: Project Context
This includes directory structure, dependency configuration, entry files, test locations, and build methods.
Layer 3: Local Context
Only when needed, read the relevant files, functions, and call chains.
Layer 4: Execution Context
This includes command output, error stacks, test results, and the most recent changes.
Historical information should also be compressed. Earlier tool calls can be converted into summaries, for example: “Viewed the configuration files and confirmed that the project uses a certain test directory; modified module A to add parameter validation.” This preserves key information while preventing context from growing indefinitely.
8. From “Modifying Code” to “Verifying Code”
A code agent without a verification stage can easily produce results that look correct but do not actually run. At minimum, the following verification levels should be established:
- Syntax checking: ensure files can be parsed;
- Type or static checking: find obvious interface and type errors;
- Unit testing: verify local behavior;
- Integration testing: verify collaboration between modules;
- Build checking: confirm the project can be packaged or started normally;
- Regression checking: ensure existing functionality has not been broken.
The agent should not judge success solely based on the model’s self-assessment. It should prioritize executable verification results. If tests fail, provide concise, structured error feedback to the model instead of endlessly appending full logs.
9. Task Planning and Complex Problem Decomposition
For tasks such as “add a feature,” the agent should ideally form a plan before making modifications. A reasonable plan usually includes:
- Clarifying requirements and boundaries;
- Finding relevant modules;
- Analyzing the existing implementation;
- Determining modification points;
- Adding or adjusting tests;
- Running verification;
- Summarizing changes.
Planning is not about adding formality; it is about reducing the probability of blind modifications. For simple tasks, the agent can act directly. For cross-module tasks, it should limit the goal of each step and re-evaluate the project state at key stages.
10. Exception Handling and Recovery Mechanisms
In real environments, tool calls may fail, and model output may not meet expectations. The system needs to distinguish different types of exceptions:
- Parameter format errors: ask the model to regenerate parameters;
- File not found: let the model relocate the path;
- Permission errors: prompt the user to confirm or adjust the scope;
- Command failures: analyze standard error and enter the repair flow;
- Execution timeouts: terminate the task and preserve the scene;
- Context limit exceeded: compress history and retry;
- Model service exceptions: use backoff retries or exit safely.
Each retry should be limited in count. Unlimited retries waste resources and may cause files to be modified repeatedly or commands to execute in a loop.
11. Security Design Cannot Be Treated as an Add-On
A code agent has read and execution capabilities, so it must be treated as a high-risk environment by default. At minimum, implement the following measures:
- Workspace sandboxing;
- Path normalization;
- Command allowlists;
- File size and output size limits;
- Network access control;
- Resource quotas;
- Sensitive file blocking;
- Audit before and after changes;
- Human confirmation for high-risk operations;
- Complete operation logs.
In particular, avoid injecting keys, personal data, and production configuration directly into the model context. Sensitive information should not be stored as-is in logs either.
12. Evaluation System: Do Not Just Look at Whether Code Was Generated
Code agent evaluation should cover multiple dimensions:
- Task completion rate;
- Test pass rate;
- Whether the scope of changes is reasonable;
- Whether regressions are introduced;
- Number of tool calls;
- Average execution time;
- Error recovery capability;
- Dangerous operation interception rate;
- Whether users need to intervene frequently.
A fixed task set can be established to cover code understanding, defect fixing, feature development, test completion, refactoring, and documentation generation. After each adjustment to prompts, tool definitions, or model configuration, automatically run the task set and compare result changes.
13. Engineering Implementation Recommendations
If starting from scratch, proceed in the following order:
Phase 1: Complete workspace management, file reading, and simple model dialogue to establish a minimal loop.
Phase 2: Add search, file modification, and test execution so the agent can complete small repair tasks.
Phase 3: Introduce context compression, task planning, error recovery, and change auditing.
Phase 4: Add permission control, isolated execution, evaluation datasets, and observability.
Phase 5: Then consider parallel execution, multi-agent collaboration, long-term memory, and enterprise-grade permission systems.
Do not pursue a complex architecture from the beginning. The most important qualities of a code agent are that every action is explainable, verifiable, and rollback-capable. Only after the basic loop is stable does it make sense to add more tools and more complex planning mechanisms.
Conclusion
Building a code agent from scratch essentially combines the understanding capabilities of large language models with a software engineering runtime. The model handles analysis and decision-making, tools act on the real environment, and the runtime handles security, state, and verification. All three are indispensable.
A truly usable system should not focus only on generating more code. It should focus on whether the task is completed, whether changes are reliable, whether the process is auditable, and whether it can recover after failure. When implementing such a system in Python, start with unified data structures, model adapters, controlled tools, a task loop, and verification mechanisms, and gradually build an intelligent programming assistant with real engineering value.
