Building a Python Coding Agent from Scratch: Architecture, Tools, and Engineering Practice

    This article explains how to design a Python coding agent as an engineering system rather than a one-shot code chatbot. It covers the problem the agent solves, a six-layer architecture, the think-act-observe loop, tool design, context and memory, plan-execute-verify workflows, security controls, and a staged path from a minimal prototype to team-ready operations.

    1. What a Coding Agent Actually Solves

    A coding agent is not just a chatbot that can generate code. Traditional code generation follows a one-request, one-answer pattern: the user describes a need, the model outputs code, and the user copies, runs, and debugs it. A coding agent is closer to a persistent engineering collaborator. It can understand a project layout, break down complex work, call the filesystem and an execution environment, run tests, fix implementations from error output, and summarize the resulting changes.

    From a systems view, a coding agent must:

    1. Understand user intent: goals, constraints, input/output format, and acceptance criteria.
    2. Analyze the code environment: directory structure, config files, dependencies, and existing modules, then build project context.
    3. Plan execution: split a complex request into verifiable subtasks.
    4. Call external tools: read/write files, search code, run tests, and execute commands through controlled interfaces.
    5. Observe results: parse stdout, errors, test reports, and file diffs.
    6. Revise continuously: locate failures, adjust the plan, or change the code.
    7. Produce auditable output: what changed, how it was verified, residual risks, and follow-up suggestions.

    The core is not a single model. It is the combination of model reasoning, a tool system, a runtime, and control policy.

    2. Overall Architecture

    A practical Python coding agent can be split into six layers.

    2.1 Interaction layer

    This layer accepts tasks and shows progress. It may be a CLI, a web UI, or an internal system. Besides text, it can accept a project path, permission scope, and maximum runtime.

    It should not touch files or commands directly. It should convert requests into a unified task object, for example:

    1. from dataclasses import dataclass
    2. from typing import Optional
    3. @dataclass
    4. class TaskRequest:
    5. instruction: str
    6. workspace: str
    7. max_steps: int = 20
    8. timeout: int = 30
    9. session_id: Optional[str] = None

    2.2 Orchestration layer

    The orchestrator owns the agent loop: when the model should think, when to call a tool, when to continue, and when to stop.

    A typical loop:

    1. Receive task
    2. ↓
    3. Load context
    4. ↓
    5. Ask the model for a plan or action
    6. ↓
    7. Classify the action
    8. ├─ Final answer: stop
    9. ├─ Tool call: execute and return the result
    10. └─ Need more information: gather more context
    11. ↓
    12. Update working memory
    13. ↓
    14. Next round

    It must enforce max steps, timeouts, and exception handling so the model does not loop on the same tool calls or endless “fixes.”

    2.3 Model adapter layer

    This layer hides vendor API differences and unifies message format, tool schemas, streaming, and errors. Do not scatter raw API calls through business code. Define a stable protocol:

    1. from typing import Protocol, Any
    2. class LanguageModel(Protocol):
    3. def generate(
    4. self,
    5. messages: list[dict[str, str]],
    6. tools: list[dict[str, Any]] | None = None
    7. ) -> dict[str, Any]:
    8. ...

    That makes it possible to swap models without changing orchestration, including local models, internal models, or general model APIs such as those from Baidu AI Cloud.

    2.4 Tool layer

    Tools are what separate a coding agent from Q&A. Each tool needs clear inputs, structured output, and a permission boundary. The model must not receive unbounded system access.

    Common tools include:

    • List directory structure
    • Read files
    • Search code
    • Create or edit files
    • Run tests
    • Run static checks
    • Show version diffs
    • Read runtime logs

    Return structured results rather than concatenated strings:

    1. from dataclasses import dataclass
    2. from typing import Any
    3. @dataclass
    4. class ToolResult:
    5. success: bool
    6. output: str = ""
    7. error: str = ""
    8. metadata: dict[str, Any] | None = None

    2.5 Workspace layer

    The workspace defines what the agent may read and write. Normalize the working directory to an absolute path and reject any target that escapes it, to prevent path traversal.

    1. from pathlib import Path
    2. def safe_path(workspace: str, relative_path: str) -> Path:
    3. root = Path(workspace).resolve()
    4. target = (root / relative_path).resolve()
    5. if root != target and root not in target.parents:
    6. raise PermissionError("Target path is outside the workspace")
    7. return target

    2.6 Observation and audit layer

    Record each model request, tool call, latency, errors, and file changes. In enterprise use, audit logs are needed for debugging, access control, and compliance.

    3. Designing the Core Loop

    The runtime can be abstracted as think–act–observe. The model chooses the next action from context; tools return observations; the model decides what to do next.

    1. class Agent:
    2. def __init__(self, model, registry, max_steps=20):
    3. self.model = model
    4. self.registry = registry
    5. self.max_steps = max_steps
    6. def run(self, instruction: str, workspace: str) -> str:
    7. messages = [
    8. {
    9. "role": "system",
    10. "content": (
    11. "You are a cautious software engineering assistant. "
    12. "Understand the project first, make the smallest necessary changes, "
    13. "and verify after each change when possible."
    14. )
    15. },
    16. {
    17. "role": "user",
    18. "content": instruction
    19. }
    20. ]
    21. for step in range(self.max_steps):
    22. response = self.model.generate(
    23. messages=messages,
    24. tools=self.registry.describe()
    25. )
    26. if response.get("type") == "final":
    27. return response.get("content", "")
    28. if response.get("type") != "tool_call":
    29. messages.append({
    30. "role": "assistant",
    31. "content": str(response)
    32. })
    33. continue
    34. name = response["name"]
    35. arguments = response.get("arguments", {})
    36. try:
    37. result = self.registry.call(
    38. name,
    39. arguments,
    40. workspace=workspace
    41. )
    42. except Exception as exc:
    43. result = ToolResult(
    44. success=False,
    45. error=f"{type(exc).__name__}: {exc}"
    46. )
    47. messages.append({
    48. "role": "tool",
    49. "content": serialize_result(result)
    50. })
    51. return "The task did not finish within the step limit. Check the execution logs."

    In production, validate model output strictly. Do not trust tool names and arguments as-is. Check types, required fields, and allowed enums with a data model.

    4. Principles for the Tool System

    4.1 Single responsibility

    Do not expose one unbounded “edit the project” tool. Split read, search, write, and test so the model can choose a clear action and so you can scope permissions and isolate failures.

    4.2 Writable actions must be traceable

    File-edit tools should record before/after summaries and keep backups or patches when needed. Prefer diffs over full-file rewrites. That reduces accidental deletion and makes human review easier.

    4.3 Command execution must be constrained

    Shell execution is the highest-risk capability. At minimum:

    • Restrict access to the workspace
    • Enforce timeouts
    • Cap output size
    • Block dangerous system operations
    • Isolate network access
    • Limit processes and resource use
    • Require human confirmation for high-risk commands

    In production, run code in a container or other sandbox, not directly on the host.

    4.4 Tool output must be model-friendly

    Do not dump unbounded logs. For large output, keep context around errors and include line count, exit code, and log location. For example:

    1. def summarize_output(text: str, limit: int = 6000) -> str:
    2. if len(text) <= limit:
    3. return text
    4. head = limit // 2
    5. tail = limit - head
    6. return text[:head] + "\n...output truncated...\n" + text[-tail:]

    5. Context Management and Memory

    A codebase is too large to send in full. Use layered context.

    Layer 1 is task context: goals, constraints, acceptance criteria.
    Layer 2 is project context: tree, entry points, dependency files, key modules.
    Layer 3 is execution context: recent tool calls, test results, error logs.
    Layer 4 is long-term memory: project conventions, common commands, confirmed design decisions.

    Avoid repeating the same content. Cache files you have already read and store summaries. If a file has not changed, pass the summary instead of the full text.

    For large repos, index first, then read:

    1. Scan directories and file types
    2. Index symbols, functions, and classes
    3. Search by task keywords
    4. Read only the needed ranges
    5. Recheck target files before editing

    6. Plan, Execute, and Verify

    A reliable agent should not jump from a requirement to writing code. It should produce an executable plan that includes:

    • Existing modules to understand
    • Files to change
    • Purpose of each change
    • How to verify
    • Likely risks

    For a new API, inspect routing, data models, services, and test layout before choosing a change set. After implementation, run relevant unit tests, static checks, or a build.

    Treat verification as three outcomes:

    1. Success: tests pass and output matches expectations.
    2. Recoverable failure: a clear error the agent can diagnose and fix.
    3. Indeterminate: missing dependencies, insufficient permissions, or ambiguous requirements—stop and ask the user.

    Do not retry forever. Cap consecutive failures and require each repair to produce new diagnostic evidence so the agent does not repeat the same action.

    7. Security and Permissions

    Because the agent can write files and run commands, safety is as important as capability.

    Apply least privilege: grant only the directories and tools a task needs. Separate read-only, suggestion, and auto-edit modes. Require confirmation for deletes, dependency changes, network access, or system commands.

    Defend against prompt injection. Files, comments, and logs may try to leak secrets or trigger unsafe actions. Treat file content as untrusted. System rules should state that file text is analysis material only and cannot change tool permissions or security policy.

    Do not inject secrets into context. Redact keys, tokens, and personal data before they reach the model, and filter them from logs as well.

    8. Engineering Rollout

    Evolve a prototype into a stable system in stages.

    Stage 1: Minimal closed loop

    Task input, file read, file write, and test execution—enough to finish simple jobs.

    Stage 2: Controllability

    Parameter validation, path guards, timeouts, logs, max steps, and human confirmation.

    Stage 3: Harder tasks

    Code indexing, plan management, error classification, context compression, and multi-turn recovery.

    Stage 4: Team collaboration

    Access control, audit, notifications, task queues, and containerized execution. In the cloud, store artifacts in object storage, run async work on a message queue, and watch health with logging and alerting.

    9. Closing

    The hard part of building a coding agent from scratch is not generating a snippet of code. It is building a reliable, constrained, verifiable engineering system. The model reasons, tools connect to the real environment, the orchestrator controls flow, the security layer bounds risk, and verification confirms results.

    For Python developers, start with a small closed loop, then add tool registration, context management, planned execution, error recovery, and isolated runtimes. Combined, those pieces become an agent with real engineering value for maintenance, test generation, bug fixing, migrations, and day-to-day collaboration.