Prologue: The Autocomplete That Grew Up
There is a moment that many software engineers remember vividly. You are typing a function name in your IDE, and suddenly a ghost-grey suggestion appears — completing not just the line, but the entire function body. You press Tab, and the code is just there: correct, idiomatic, ready to go. That moment felt magical in 2021 when GitHub Copilot first arrived. It felt like someone had finally knocked on the door of the future.
But that was just the knock. The door is now wide open, and what walked in is something far more interesting, far more capable, and far more disruptive than a smarter autocomplete. What walked in is an agent — a software entity that does not merely suggest the next token in your stream of thought, but one that reads your goal, forms a plan, writes code, runs tests, reads the error messages, fixes the bugs, and iterates until the job is done. It behaves, in short, like a junior software engineer who never sleeps, never complains about the coffee machine, and is genuinely enthusiastic about writing unit tests.
This article is about that transformation. It is about the shift from code completion to goal execution — from passive suggestion to active agency. It is about how software engineering changes when the thing sitting next to you in the metaphorical open-plan office can plan, test, and iterate entirely on its own. It is about the new paradigm called Agentic Programming, and it represents one of the most profound shifts in the history of software development since the invention of the compiler.
We will explore the constituent parts of this paradigm deeply and honestly. We will write real code. We will think hard about what it means for the craft of engineering. And we will try to have some fun along the way — because if you cannot find the wonder in a machine that teaches itself to fix its own bugs, you may need to check your own firmware.
Chapter One: The Lineage of Intelligence in the IDE
To understand where we are, we need to understand where we came from. The history of AI assistance in software development is a story of steadily expanding ambition — each generation of tools doing more than the last, each one quietly redefining what it means to be a programmer.
The first generation of intelligent coding tools were essentially very sophisticated dictionaries. Syntax highlighting told you that def was a keyword. Autocomplete suggested method names from the class you had already imported. These tools operated entirely on the syntactic surface of code. They knew the grammar of the language but nothing of its meaning. They were like a very fast typist who had memorized the dictionary but had never actually read a book.
The second generation introduced semantic understanding. Tools like IntelliSense in Visual Studio and the language servers that power modern editors understood not just syntax but types, scopes, and interfaces. They could tell you that the method you were calling expected a string, not an integer. They could find all usages of a symbol across a codebase. They understood the structure of programs, not just their surface appearance. This was a genuine leap forward, and it made developers significantly more productive.
The third generation arrived with statistical language models. Tools trained on billions of lines of code learned to predict, with remarkable accuracy, what code a programmer was likely to write next. GitHub Copilot, powered by OpenAI Codex, was the flagship of this generation. It could complete functions, suggest entire blocks, and even generate boilerplate from a comment describing the intent. It was impressive — but it was still fundamentally reactive. It waited for you to start typing. It responded to your cursor position. It had no memory of what you had done five minutes ago and no concept of the goal you were trying to achieve. It was a brilliant parrot, not a collaborator.
The fourth generation — the one we are living through right now — is categorically different. It is the generation of agents. An agent is not a tool that responds to your keystrokes. It is a system that receives a goal expressed in natural language, forms a multi-step plan to achieve that goal, executes each step using a set of tools (which may include writing code, running tests, searching documentation, or calling APIs), observes the results of each step, and adjusts its plan accordingly. It operates in a loop, not a line. It has memory, not just a context window. It can fail, learn from the failure, and try again. It is, in the most meaningful sense of the word, a software engineering agent.
Chapter Two: What Exactly Is an Agent?
Before we go further, we need to be precise about what we mean by "agent" — because the word gets used loosely in the industry, and the looseness causes real confusion. An agent, in the context of agentic AI, is a system that combines four core capabilities: perception, reasoning, action, and memory.
Perception is the ability to receive and interpret information from the environment. For a coding agent, this means reading code files, parsing error messages, consuming test output, reading documentation, and understanding natural language instructions from a human engineer.
Reasoning is the ability to think about the perceived information and decide what to do next. Modern agents use large language models as their reasoning engine. The LLM reads the current state of the world — the goal, the history of what has been tried, and the results of previous actions — and produces a decision about what to do next. This reasoning is not hardcoded. It is emergent from the model's training and from the context it is given.
Action is the ability to actually do things in the world. A coding agent can write files, execute shell commands, run test suites, call external APIs, search the web, and even spawn other agents to handle subtasks. The set of actions available to an agent is called its tool set, and the richness of that tool set largely determines what the agent can accomplish. In 2026, the Model Context Protocol (MCP) has become the standard wire protocol for exposing tools to agents: any MCP-compliant server can offer its capabilities to any MCP-compliant agent host, creating a rich and interoperable ecosystem of agent tools.
Memory is the ability to retain information across steps. This is more subtle than it sounds. An LLM by itself has no persistent memory — every time you call it, you start fresh. Agents solve this by maintaining a structured record of what has happened, what has been tried, and what has been learned, and by injecting the relevant parts of that record into the LLM's context window at each step. More advanced agents use external memory stores, such as vector databases, to retain information that would not fit in a single context window.
When you combine these four capabilities in a loop, you get the fundamental architecture of an agentic system. The agent perceives the current state, reasons about what to do, takes an action, observes the result, updates its memory, and loops back to perception. This loop continues until the goal is achieved — or the agent decides it cannot achieve the goal and asks for human help.
This loop has a name in the research literature. It is called the ReAct loop, which stands for Reasoning and Acting. It was described in a landmark 2022 paper by Yao et al. and has since become the foundational architecture for virtually every serious agentic system. Understanding the ReAct loop is essential to understanding agentic programming, so let us look at it carefully.
The ReAct loop works like this. At each step, the agent produces a Thought — a natural language description of its current reasoning. Then it produces an Action — a specific tool call with specific parameters. Then it receives an Observation — the result of that tool call. The thought, action, and observation are all recorded in the agent's working memory, and the whole history is fed back into the LLM at the next step. This gives the LLM the context it needs to reason about what to do next.
The elegance of this design is that the reasoning is transparent. You can read the agent's thoughts and understand why it made each decision. This is not a black box. It is a glass box, and that transparency is enormously valuable for debugging, auditing, and building trust.
Let us make this concrete with a small illustration. Imagine you ask an agent to "add input validation to the user registration endpoint." Here is what the ReAct loop might look like, rendered as a trace of the agent's internal monologue:
Thought: I need to find the user registration endpoint in the codebase.
I will search for files related to user registration.
Action: search_files(query="user registration endpoint")
Observation: Found auth/views.py, line 47: def register_user(request)
Thought: I found the endpoint. Let me read the current implementation
to understand what validation, if any, is already in place.
Action: read_file(path="auth/views.py", start_line=47, end_line=80)
Observation: [code content showing no input validation]
Thought: There is no input validation. I need to add validation for
email format, password strength, and required fields. I will
write the updated function.
Action: write_file(path="auth/views.py", content="[updated code]")
Observation: File written successfully.
Thought: I should run the existing tests to make sure I have not
broken anything.
Action: run_tests(path="tests/test_auth.py")
Observation: 3 tests passed, 1 test failed: test_register_missing_email
Thought: One test is failing. Let me read the test to understand
what it expects.
Action: read_file(path="tests/test_auth.py", start_line=23, end_line=35)
Observation: [test code showing expected 400 response for missing email]
Thought: My validation returns a 422, but the test expects a 400.
I need to adjust the status code in my validation logic.
Action: edit_file(path="auth/views.py", ...)
Observation: File updated.
Thought: Let me run the tests again.
Action: run_tests(path="tests/test_auth.py")
Observation: 4 tests passed, 0 failed.
Thought: All tests pass. The task is complete.
Action: finish(message="Added input validation to register_user. ...")
This trace illustrates something profound. The agent did not just write code. It searched, read, wrote, tested, diagnosed a failure, fixed the failure, and verified the fix. It behaved like a junior engineer who had been given a task and worked through it methodically. The human engineer who assigned the task did not need to supervise each step — they could review the final result and the trace of reasoning to verify that the work was done correctly.
This is the essence of the paradigm shift. The unit of work is no longer a line of code or a function. It is a goal.
Chapter Three: The Anatomy of an Agentic System
Now that we understand the conceptual architecture, let us look at the technical components that make a real agentic system work. There are six major components, and each one deserves careful attention.
The LLM as Reasoning Engine
The large language model is the brain of the agent. It is responsible for all the reasoning: understanding the goal, planning the steps, deciding which tool to call, interpreting the results of tool calls, and determining when the goal has been achieved. The quality of the LLM determines the quality of the agent's reasoning, and this is why the choice of model matters enormously.
For coding tasks, the best-performing models as of August 2026 are GPT-5.6 Sol from OpenAI — scoring 96.2% on SWE-bench Verified and leading on agentic terminal benchmarks — and Claude Opus 5 from Anthropic, which scores 96% on the same benchmark and is the recommended choice for agentic coding and enterprise work. Claude Sonnet 5, released in June 2026, offers near-Opus coding capability at a lower cost and is the default model for most Anthropic users. On the Google side, Gemini 3.1 Pro is the current flagship for complex reasoning and agentic tasks, while Gemini 3.5 Flash is optimized specifically for sustained agentic and coding workloads. On the open-source and local side, Llama 4 Scout from Meta brings a ten-million-token context window and multimodal capabilities, Qwen3-Coder 30B from Alibaba is widely regarded as the best local coding model for 24 GB GPU systems, and DeepSeek V4 Flash offers frontier-class reasoning with a one-million-token context window and efficient MoE architecture.
The LLM receives its instructions through a carefully crafted system prompt that defines the agent's role, its available tools, its constraints, and its output format. The quality of this system prompt is one of the most important factors in agent performance. Writing good system prompts for agents is a skill in itself, and it has given rise to the discipline of context engineering — which we will discuss later.
The Tool Layer
Tools are the hands of the agent. They are the mechanisms through which the agent interacts with the world. A tool is simply a function that the agent can call, with a well-defined interface: a name, a description, and a set of typed parameters. The LLM decides which tool to call and with what parameters, and the agent framework executes the tool and returns the result to the LLM.
Common tools in a coding agent include file system operations (read, write, edit, delete files), shell execution (run arbitrary commands, including test runners and build tools), code search (find files, search for symbols, grep for patterns), web search (look up documentation, search for solutions to errors), API calls (interact with external services like GitHub, Jira, or package registries), and sub-agent spawning (delegate a subtask to another agent).
The design of the tool layer is critical. Tools must be reliable, because an agent that gets incorrect results from a tool will reason incorrectly. Tools must be safe, because an agent with access to a shell can do a lot of damage if it makes a mistake. And tools must be well-described, because the LLM chooses which tool to use based on the description — and a poorly described tool will be used incorrectly or not at all.
In 2026, the Model Context Protocol (MCP) has emerged as the standard way to expose tools to agents. An MCP server packages tools, resources, and prompt templates into a well-defined interface that any MCP-compatible agent host can consume. This means you can write a tool once as an MCP server and use it from Claude, from the OpenAI Agents SDK, from LangGraph, or from your own custom agent — all without changing the server code. The MCP Python SDK (mcp>=1.5.0) makes building these servers straightforward, and the streamable-http transport introduced in early 2026 makes them easy to deploy as standard web services.
The Memory System
Memory is what separates an agent from a chatbot. A chatbot has only the current conversation in its context window. An agent needs to maintain state across many steps — and potentially across many sessions.
Agentic memory systems typically have multiple layers. The working memory is the current context window, which contains the system prompt, the current task, the history of thoughts, actions, and observations for the current session, and any relevant retrieved information. This is the agent's short-term memory, and it is limited by the context window size of the LLM. Modern flagship models have pushed this boundary to extraordinary lengths: GPT-5.6 Sol, Gemini 3.1 Pro, and Claude Opus 5 all support context windows of one million tokens or more, making it possible to hold entire large codebases in a single context. Local models like Llama 4 Scout push even further, with a ten-million-token context window.
The episodic memory is a persistent store of past experiences. When an agent completes a task, it can store a summary of what it did and what it learned in an external database. When it starts a new task, it can retrieve relevant past experiences to inform its approach. This is analogous to a junior engineer who remembers that they solved a similar problem last month and goes back to look at their notes.
The semantic memory is a knowledge base of facts about the codebase, the domain, and the tools. This might be stored in a vector database and retrieved using semantic search. When the agent needs to know something about the codebase, it can query the semantic memory and retrieve the relevant information without having to search the entire codebase from scratch.
The Orchestration Layer
The orchestration layer is the control loop that drives the agent. It is responsible for calling the LLM, parsing its output to extract tool calls, executing the tools, feeding the results back to the LLM, and deciding when to stop. It also handles error cases — such as when the LLM produces malformed output or when a tool call fails.
Modern orchestration frameworks like LangGraph, CrewAI, and the OpenAI Agents SDK provide sophisticated orchestration capabilities, including support for multi-agent systems where one orchestrator agent delegates tasks to multiple specialist agents.
The Planning Module
For complex tasks, a simple ReAct loop is not enough. The agent needs to be able to decompose a high-level goal into a sequence of subtasks, assign those subtasks to appropriate agents or tools, and track the progress of the overall plan. This is the job of the planning module.
Planning in agentic systems can be done in several ways. The simplest approach is to have the LLM generate a plan as part of its initial reasoning, and then execute the plan step by step. More sophisticated approaches use dedicated planning models or algorithms to generate and manage plans, and can handle dependencies between tasks, parallel execution of independent tasks, and dynamic replanning when something goes wrong.
The Human-in-the-Loop Interface
No matter how capable an agent is, there are situations where it needs human input. It might encounter an ambiguous requirement, a decision with significant consequences, or a situation it has never seen before. The human-in-the-loop interface is the mechanism through which the agent can pause, ask a question, and wait for a human response before continuing.
This is not a weakness of the agentic paradigm — it is a feature. The goal is not to eliminate human judgment but to focusit on the decisions that genuinely require it, while automating the rest. A well-designed agent knows its own limits and asks for help at the right moments.
Chapter Four: Setup and Deployment
Before we write any agent code, we need a working environment. This chapter covers everything you need to install, configure, and run the examples in this article. All code targets Python 3.11 or later.
Installing Python Dependencies
Create a virtual environment and install the required packages:
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install openai>=1.82.0 pytest>=8.3.0 mcp>=1.5.0
The requirements.txt for this project is:
openai>=1.82.0
pytest>=8.3.0
mcp>=1.5.0
Setting Up the OpenAI Backend
Export your API key before running any example that uses the remote OpenAI backend:
export OPENAI_API_KEY="sk-..." # On Windows: set OPENAI_API_KEY=sk-...
Setting Up the Local Ollama Backend
Ollama lets you run open-source LLMs locally with an OpenAI-compatible API. Install it from https://ollama.ai, then pull the models used in this article:
# Install Ollama (macOS / Linux)
curl -fsSL https://ollama.ai/install.sh | sh
# Start the Ollama server — runs on http://localhost:11434 by default
ollama serve
# Pull the models used in this article (run in a separate terminal)
ollama pull llama4:scout
ollama pull qwen3-coder:30b
ollama pull deepseek-v4-flash
Project Layout
Place all files from this article in a single directory:
agentic/
├── requirements.txt
├── llm_client.py
├── tools.py
├── agent.py
├── multi_agent.py
├── self_healing.py
├── context_manager.py
├── planning_agent.py
├── guardrails.py
├── test_generator.py
└── main.py
Running the Main Example
# Remote OpenAI backend (requires OPENAI_API_KEY)
python main.py
# Local Ollama backend (requires ollama serve + ollama pull llama4:scout)
python main.py --local
Running the Other Modules
# Self-healing code generator
python self_healing.py
# Multi-agent code reviewer
python multi_agent.py
# Planning agent
python planning_agent.py
# Test generator (pass a source file as argument)
python test_generator.py path/to/source.py
Chapter Five: Building an Agent from Scratch
Theory is valuable, but code is where understanding becomes real. Let us build a working coding agent step by step — starting with the simplest possible version and gradually adding sophistication. We will build it in Python, using the OpenAI API for the remote LLM and Ollama for the local LLM, so you can run it either way depending on your setup and preferences.
First, let us establish the foundation. We need a way to talk to both a local LLM running in Ollama and a remote LLM via the OpenAI API. The beautiful thing about Ollama is that it exposes an OpenAI-compatible API, which means we can use the same client library for both. The only thing that changes is the base URL and the model name.
The following module handles all LLM communication and provides a clean abstraction that the rest of our agent code can use without caring whether the model is running locally or in the cloud:
# llm_client.py
# Requires Python 3.11+
# Unified LLM client supporting both local Ollama models and remote
# OpenAI-compatible models. The caller does not need to know which
# backend is in use.
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from openai import OpenAI
@dataclass
class LLMConfig:
"""
Configuration for an LLM backend.
Attributes:
model: The model identifier (e.g. "gpt-5.6-sol" or "llama4:scout").
base_url: The API base URL. None means use the OpenAI default.
api_key: The API key. For Ollama, any non-empty string works.
timeout: Request timeout in seconds.
"""
model: str
base_url: str | None = None
api_key: str | None = None
timeout: int = 120
# ---------------------------------------------------------------------------
# Pre-built configurations for common backends.
# ---------------------------------------------------------------------------
# OpenAI remote backends — require OPENAI_API_KEY environment variable.
# GPT-5.6 Sol: flagship model, 96.2% SWE-bench Verified, best for complex
# agentic coding tasks. Released July 2026.
OPENAI_GPT56_SOL = LLMConfig(
model="gpt-5.6-sol",
api_key=os.getenv("OPENAI_API_KEY"),
)
# GPT-5.5: strong all-round model, excellent cost-to-performance ratio.
# Released April 2026.
OPENAI_GPT55 = LLMConfig(
model="gpt-5.5",
api_key=os.getenv("OPENAI_API_KEY"),
)
# GPT-5.6 Terra: balanced variant — good intelligence at lower cost than Sol.
OPENAI_GPT56_TERRA = LLMConfig(
model="gpt-5.6-terra",
api_key=os.getenv("OPENAI_API_KEY"),
)
# Ollama local backends — require `ollama serve` and the model pulled.
# Pull commands:
# ollama pull llama4:scout
# ollama pull qwen3-coder:30b
# ollama pull deepseek-v4-flash
# Llama 4 Scout: Meta's MoE model, 10M-token context, multimodal.
# Best for: general-purpose local tasks with large context needs.
OLLAMA_LLAMA4_SCOUT = LLMConfig(
model="llama4:scout",
base_url="http://localhost:11434/v1",
api_key="ollama", # Ollama ignores this value but the client requires it.
)
# Qwen3-Coder 30B: best local coding model for 24 GB GPU systems.
# 256K context window, 220 tokens/sec, top SWE-bench among local models.
OLLAMA_QWEN3_CODER = LLMConfig(
model="qwen3-coder:30b",
base_url="http://localhost:11434/v1",
api_key="ollama",
)
# DeepSeek V4 Flash: MoE, 284B total / 13B active params, 1M-token context.
# Best for: reasoning-heavy tasks on high-VRAM local hardware.
OLLAMA_DEEPSEEK_V4_FLASH = LLMConfig(
model="deepseek-v4-flash",
base_url="http://localhost:11434/v1",
api_key="ollama",
)
class LLMClient:
"""
A thin wrapper around the OpenAI client that supports both local
and remote LLM backends transparently.
"""
def __init__(self, config: LLMConfig) -> None:
self._config = config
self._client = OpenAI(
api_key=config.api_key or "not-needed",
base_url=config.base_url,
timeout=config.timeout,
)
def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
temperature: float = 0.2,
) -> Any:
"""
Send a chat request to the LLM and return the response message.
Args:
messages: Conversation history as a list of role/content dicts.
tools: Optional list of tool definitions in OpenAI format.
temperature: Sampling temperature. Lower values are more deterministic.
Returns:
The assistant message object from the API response.
"""
kwargs: dict[str, Any] = {
"model": self._config.model,
"messages": messages,
"temperature": temperature,
}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
response = self._client.chat.completions.create(**kwargs)
return response.choices[0].message
@property
def model_name(self) -> str:
"""Return the model identifier for logging and display."""
return self._config.model
This module is the foundation of everything else. Notice that it uses a dataclass for configuration, which makes it easy to create new backend configurations without changing any code. The LLMClient class itself is intentionally simple: it wraps the OpenAI client and exposes a single chat method. The caller passes in a list of messages and optionally a list of tool definitions, and gets back the model's response. Whether that response comes from a GPU in your laptop running Ollama or from a data center running GPT-5.6 Sol is completely transparent to the caller.
The temperature parameter controls the randomness of the model's output. A temperature of 0 makes the model completely deterministic, always choosing the most likely next token. A temperature of 1 makes it quite creative and unpredictable. For coding agents, we want low temperatures — typically between 0.0 and 0.3 — because we want reliable, consistent behavior, not creative surprises. A bug fix that works 70% of the time is not a bug fix.
Now let us build the tool system. Tools are the mechanism through which our agent interacts with the file system and the shell. We will implement a small but practical set of tools that a coding agent actually needs:
# tools.py
# Requires Python 3.11+
# Tool implementations for the coding agent.
# Each tool is a plain Python function registered with metadata
# that describes it to the LLM in OpenAI's tool-calling format.
#
# IMPORTANT: These tools have real side effects. In production,
# wrap them in a sandbox (e.g. Docker) to limit blast radius.
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Tool registry: maps tool names to their implementations and schemas.
# ---------------------------------------------------------------------------
TOOL_REGISTRY: dict[str, Any] = {}
def register_tool(name: str, description: str, parameters: dict) -> Any:
"""
Decorator factory that registers a function as an agent tool.
Args:
name: The tool name the LLM will use to call it.
description: A clear description of what the tool does.
This is what the LLM reads to decide when to use it.
parameters: JSON Schema describing the tool's parameters.
"""
def decorator(func: Any) -> Any:
TOOL_REGISTRY[name] = {
"function": func,
"schema": {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
},
}
return func
return decorator
def get_tool_schemas() -> list[dict]:
"""Return all registered tool schemas in OpenAI format."""
return [entry["schema"] for entry in TOOL_REGISTRY.values()]
def execute_tool(name: str, arguments: dict) -> str:
"""
Execute a registered tool by name with the given arguments.
Returns the tool's output as a string. Errors are caught and
returned as descriptive strings so the agent can reason about them.
"""
if name not in TOOL_REGISTRY:
return f"Error: Unknown tool '{name}'. Available: {list(TOOL_REGISTRY)}"
try:
result = TOOL_REGISTRY[name]["function"](**arguments)
return str(result)
except Exception as exc:
return f"Error executing {name}: {type(exc).__name__}: {exc}"
# ---------------------------------------------------------------------------
# File system tools
# ---------------------------------------------------------------------------
@register_tool(
name="read_file",
description=(
"Read the contents of a file. Returns the file content as a string. "
"Use this to examine existing code before modifying it."
),
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path relative to the working directory.",
},
},
"required": ["path"],
},
)
def read_file(path: str) -> str:
"""Read and return the contents of the specified file."""
file_path = Path(path)
if not file_path.exists():
return f"Error: File '{path}' does not exist."
return file_path.read_text(encoding="utf-8")
@register_tool(
name="write_file",
description=(
"Write content to a file, creating it if it does not exist "
"or overwriting it if it does. Use this to create or update code files."
),
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to write to.",
},
"content": {
"type": "string",
"description": "The full content to write to the file.",
},
},
"required": ["path", "content"],
},
)
def write_file(path: str, content: str) -> str:
"""Write content to a file, creating parent directories as needed."""
file_path = Path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"Successfully wrote {len(content)} characters to '{path}'."
@register_tool(
name="list_files",
description=(
"List all files in a directory recursively. "
"Use this to explore the structure of a codebase."
),
parameters={
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "The directory to list. Defaults to '.' (current dir).",
},
},
"required": [],
},
)
def list_files(directory: str = ".") -> str:
"""List all files in the given directory recursively."""
base = Path(directory)
if not base.exists():
return f"Error: Directory '{directory}' does not exist."
files = [str(p) for p in base.rglob("*") if p.is_file()]
return "\n".join(sorted(files)) if files else "No files found."
# ---------------------------------------------------------------------------
# Shell execution tool
# ---------------------------------------------------------------------------
@register_tool(
name="run_command",
description=(
"Run a shell command and return its stdout and stderr output. "
"Use this to run tests (e.g. 'python -m pytest tests/'), "
"install packages, or execute scripts. "
"Commands are run in the current working directory."
),
parameters={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
},
"timeout": {
"type": "integer",
"description": "Maximum seconds to wait. Defaults to 60.",
},
},
"required": ["command"],
},
)
def run_command(command: str, timeout: int = 60) -> str:
"""
Execute a shell command and return combined stdout/stderr.
The command runs with shell=True for convenience, but in production
you should use a whitelist of allowed commands or a sandbox.
"""
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
output_parts = []
if result.stdout:
output_parts.append(f"STDOUT:\n{result.stdout}")
if result.stderr:
output_parts.append(f"STDERR:\n{result.stderr}")
output_parts.append(f"Return code: {result.returncode}")
return "\n".join(output_parts) if output_parts else "(no output)"
if __name__ == "__main__":
print("Testing write_file...")
print(write_file("_test_tmp.txt", "hello from tools.py"))
print("Testing read_file...")
print(read_file("_test_tmp.txt"))
print("Testing list_files...")
print(list_files("."))
print("Testing run_command...")
print(run_command("echo tools smoke test passed"))
os.remove("_test_tmp.txt")
print("All tool smoke tests passed.")
The tool registry pattern used here is worth examining carefully. Each tool is a plain Python function decorated with metadata that describes it to the LLM. The description is the most important part: it is what the LLM reads when deciding which tool to call. A good description answers three questions: what does this tool do, when should I use it, and what does it return? Notice that the descriptions in the code above answer all three questions explicitly.
The execute_tool function is the bridge between the LLM's decision and the actual execution. It looks up the tool by name, calls it with the arguments the LLM provided, and returns the result as a string. Crucially, it catches all exceptions and returns them as descriptive strings rather than letting them propagate. This is important because the agent needs to be able to read error messages and reason about them. If an exception propagates up and crashes the agent loop, the agent has no opportunity to recover.
Now let us build the agent loop itself — the heart of the system, the orchestration layer that drives the ReAct cycle:
# agent.py
# Requires Python 3.11+
# The core agent loop implementing the ReAct (Reasoning + Acting) pattern.
# The agent receives a goal, reasons about it, calls tools, observes results,
# and iterates until the goal is achieved or the maximum steps are reached.
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from llm_client import LLMClient
from tools import get_tool_schemas, execute_tool
SYSTEM_PROMPT = """You are an expert software engineering agent. Your job is to
help users accomplish coding tasks by reasoning carefully and using the tools
available to you.
When given a task:
1. Think step by step about what needs to be done.
2. Use tools to read existing code before modifying it.
3. Always run tests after making changes to verify correctness.
4. If a test fails, read the error carefully and fix the issue.
5. Keep iterating until all tests pass or you have a good reason to stop.
Be methodical, careful, and thorough. Prefer small, focused changes over
large rewrites. Always explain your reasoning before taking an action.
When you have completed the task, summarize what you did and why."""
@dataclass
class AgentStep:
"""
A record of one step in the agent's execution.
Captures the thought, action, and observation at each iteration
for logging, debugging, and auditing purposes.
"""
step_number: int
thought: str
tool_name: str | None = None
tool_args: dict | None = None
observation: str | None = None
is_final: bool = False
@dataclass
class AgentResult:
"""
The final result of an agent run.
Contains the final answer, the full execution trace,
and metadata about the run.
"""
goal: str
final_answer: str
steps: list[AgentStep] = field(default_factory=list)
success: bool = True
model_used: str = ""
class CodingAgent:
"""
A ReAct-style coding agent that plans, acts, and iterates
to accomplish software engineering goals.
The agent maintains a conversation history that grows with each
step, giving the LLM full context of everything that has happened.
"""
def __init__(self, llm_client: LLMClient, max_steps: int = 20) -> None:
"""
Initialize the agent.
Args:
llm_client: The LLM backend to use for reasoning.
max_steps: Safety limit on the number of ReAct iterations.
Prevents infinite loops on difficult tasks.
"""
self._llm = llm_client
self._max_steps = max_steps
self._tools = get_tool_schemas()
def run(self, goal: str, verbose: bool = True) -> AgentResult:
"""
Run the agent on the given goal and return the result.
Args:
goal: The task to accomplish, in natural language.
verbose: If True, print each step to stdout as it happens.
Returns:
An AgentResult containing the final answer and execution trace.
"""
if verbose:
print(f"\nAgent starting. Model: {self._llm.model_name}")
print(f"Goal: {goal}\n")
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": goal},
]
steps: list[AgentStep] = []
for step_num in range(1, self._max_steps + 1):
if verbose:
print(f"--- Step {step_num} ---")
response = self._llm.chat(messages=messages, tools=self._tools)
if response.tool_calls:
step = self._handle_tool_calls(
response=response,
step_num=step_num,
messages=messages,
verbose=verbose,
)
steps.append(step)
else:
final_answer = response.content or "(no response)"
steps.append(AgentStep(
step_number=step_num,
thought=final_answer,
is_final=True,
))
if verbose:
print(f"\nFinal Answer:\n{final_answer}\n")
return AgentResult(
goal=goal,
final_answer=final_answer,
steps=steps,
success=True,
model_used=self._llm.model_name,
)
timeout_message = (
f"Reached maximum steps ({self._max_steps}) without completing the task."
)
return AgentResult(
goal=goal,
final_answer=timeout_message,
steps=steps,
success=False,
model_used=self._llm.model_name,
)
def _handle_tool_calls(
self,
response: Any,
step_num: int,
messages: list[dict],
verbose: bool,
) -> AgentStep:
"""
Process tool calls from the LLM response.
Executes each tool, adds the results to the conversation history,
and returns a step record for logging.
"""
messages.append(response)
first_call = response.tool_calls[0]
tool_name = first_call.function.name
try:
tool_args = json.loads(first_call.function.arguments)
except json.JSONDecodeError:
tool_args = {}
if verbose:
print(f"Tool: {tool_name}")
print(f"Args: {json.dumps(tool_args, indent=2)}")
observations: list[str] = []
for tool_call in response.tool_calls:
name = tool_call.function.name
try:
args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
args = {}
observation = execute_tool(name, args)
observations.append(observation)
if verbose:
display = (
observation[:500] + "..."
if len(observation) > 500
else observation
)
print(f"Observation: {display}\n")
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": observation,
})
return AgentStep(
step_number=step_num,
thought=response.content or "",
tool_name=tool_name,
tool_args=tool_args,
observation="\n---\n".join(observations),
)
This is the complete agent loop. Let us walk through the key design decisions.
The system prompt is the agent's constitution. It tells the agent who it is, what it can do, and how it should behave. Notice that it explicitly instructs the agent to read existing code before modifying it, to run tests after making changes, and to iterate until tests pass. These are not optional suggestions — they are the behavioral norms that make the agent reliable. Without these instructions, the agent might write code without reading the context, or make changes without verifying them.
The messages list is the agent's working memory. It starts with the system prompt and the user's goal, and it grows with each step as the agent adds its responses and the tool results. This growing conversation is what gives the LLM the context it needs to reason about the current state of the task. By the time we are on step ten, the LLM can see everything that happened in steps one through nine — and it can use that history to make better decisions.
The step limit is a critical safety mechanism. Without it, a confused or stuck agent could loop forever, consuming API credits and doing nothing useful. Twenty steps is a reasonable default for most coding tasks. Complex tasks might need more, but if an agent is taking more than thirty steps on a simple task, something has gone wrong and human intervention is warranted.
The json.loads calls are wrapped in try/except blocks. If the LLM returns malformed JSON for tool arguments — which can happen with weaker models — the agent gracefully falls back to an empty argument dict and returns a descriptive error observation rather than crashing.
Now let us write the entry point that ties everything together:
# main.py
# Requires Python 3.11+
# Entry point for the coding agent. Demonstrates how to use the agent
# with both a local Ollama model and a remote OpenAI model.
# Run with: python main.py (uses OpenAI gpt-5.5)
# python main.py --local (uses local Ollama llama4:scout)
from __future__ import annotations
import sys
from llm_client import LLMClient, OPENAI_GPT55, OLLAMA_LLAMA4_SCOUT
from agent import CodingAgent
def select_backend(use_local: bool) -> LLMClient:
"""
Select the LLM backend based on the user's preference.
Args:
use_local: If True, use a local Ollama model.
If False, use the remote OpenAI API.
Returns:
A configured LLMClient instance.
"""
if use_local:
print("Using local Ollama model (llama4:scout).")
print("Make sure Ollama is running: ollama serve")
print("And the model is pulled: ollama pull llama4:scout\n")
return LLMClient(OLLAMA_LLAMA4_SCOUT)
print("Using remote OpenAI model (gpt-5.5).")
print("Make sure OPENAI_API_KEY is set in your environment.\n")
return LLMClient(OPENAI_GPT55)
def main() -> None:
use_local = "--local" in sys.argv
llm_client = select_backend(use_local)
agent = CodingAgent(llm_client=llm_client, max_steps=15)
task = """
Create a Python module called 'calculator.py' that implements a simple
calculator with add, subtract, multiply, and divide functions.
The divide function should raise a ValueError if the divisor is zero.
Then create a test file called 'test_calculator.py' using pytest that
tests all four functions, including the zero-division error case.
Finally, run the tests and make sure they all pass.
"""
result = agent.run(goal=task.strip(), verbose=True)
print("\nAGENT RUN COMPLETE")
print(f"Success: {result.success}")
print(f"Steps taken: {len(result.steps)}")
print(f"Model used: {result.model_used}")
print(f"\nFinal answer:\n{result.final_answer}")
if __name__ == "__main__":
main()
When you run this program with a local Ollama model (python main.py --local) or with the OpenAI API (python main.py), you will see the agent work through the task step by step. It will create the calculator module, write the tests, run them, and if anything fails, it will read the error output and fix the problem. The entire process is visible in the terminal, giving you a window into the agent's reasoning that no traditional IDE tool has ever provided.
Chapter Six: The Multi-Agent Paradigm
A single agent is powerful. A team of agents is transformative. Just as human software teams divide work among specialists, multi-agent systems divide work among specialized AI agents — each with its own role, its own tools, and its own area of expertise.
The multi-agent paradigm introduces two new concepts: the orchestrator and the subagent. The orchestrator is a high-level agent that receives the overall goal, decomposes it into subtasks, and delegates those subtasks to appropriate subagents. The subagents are specialists that focus on specific aspects of the work — writing code, reviewing code, generating tests, or searching documentation.
This division of labor has several advantages. Specialized agents can be given tighter, more focused system prompts that make them better at their specific task. Independent subtasks can be executed in parallel, dramatically reducing the time to completion. The orchestrator can review the outputs of subagents before combining them, providing a built-in quality check. And the system can be scaled by adding more specialized agents without changing the overall architecture.
Let us look at a concrete example: a multi-agent system for code review. When a developer submits a pull request, the orchestrator receives the diff and spawns three specialist subagents — a security reviewer who looks for vulnerabilities, a performance reviewer who looks for inefficiencies, and a style reviewer who checks for adherence to coding standards. Each subagent analyses the diff independently, and the orchestrator combines their findings into a comprehensive review report.
# multi_agent.py
# Requires Python 3.11+
# A multi-agent system for automated code review.
# An orchestrator coordinates three specialist subagents:
# security, performance, and style reviewers.
# Each specialist runs independently and returns a structured report.
# The orchestrator combines the reports into a final review.
from __future__ import annotations
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed
from llm_client import LLMClient, LLMConfig
SECURITY_SYSTEM_PROMPT = """You are a security-focused code reviewer with deep
expertise in OWASP Top 10 vulnerabilities, injection attacks, authentication
flaws, and insecure data handling. Review the provided code diff and identify
any security issues. For each issue, provide:
- The severity (Critical, High, Medium, Low)
- The line or function where the issue occurs
- A clear explanation of the risk
- A concrete recommendation for fixing it
Be thorough but precise. Only report genuine security concerns."""
PERFORMANCE_SYSTEM_PROMPT = """You are a performance-focused code reviewer
specializing in algorithmic complexity, database query optimization, memory
management, and concurrency. Review the provided code diff and identify
performance issues. For each issue, provide:
- The estimated impact (High, Medium, Low)
- The specific code location
- An explanation of the performance problem
- A recommended optimization
Focus on issues that will matter at scale."""
STYLE_SYSTEM_PROMPT = """You are a code quality reviewer focused on clean code
principles, readability, maintainability, and adherence to Python best practices
(PEP 8, type hints, docstrings). Review the provided code diff and identify
style and quality issues. For each issue, provide:
- The category (naming, documentation, structure, etc.)
- The specific location
- The current problem
- The recommended improvement
Be constructive and educational in your feedback."""
@dataclass
class ReviewReport:
"""The output of a single specialist reviewer."""
reviewer_role: str
findings: str
issue_count: int
def run_specialist_review(
llm_config: LLMConfig,
system_prompt: str,
role_name: str,
code_diff: str,
) -> ReviewReport:
"""
Run a specialist code review agent on the given diff.
Designed to be called from a thread pool for parallel execution.
Args:
llm_config: The LLM backend configuration to use.
system_prompt: The specialist's system prompt defining their focus.
role_name: A human-readable name for this reviewer role.
code_diff: The code diff to review.
Returns:
A ReviewReport with the specialist's findings.
"""
client = LLMClient(llm_config)
messages = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": (
"Please review the following code diff and provide your findings:\n\n"
f"```diff\n{code_diff}\n```"
),
},
]
response = client.chat(messages=messages, temperature=0.1)
findings = response.content or "No findings."
issue_count = findings.count("Severity:") + findings.count("Impact:")
return ReviewReport(
reviewer_role=role_name,
findings=findings,
issue_count=issue_count,
)
class CodeReviewOrchestrator:
"""
Orchestrates a multi-agent code review process.
Spawns three specialist reviewers in parallel, collects their reports,
and synthesizes them into a final comprehensive review.
"""
def __init__(self, llm_config: LLMConfig) -> None:
"""
Initialize the orchestrator.
Args:
llm_config: The LLM configuration to use for all agents.
In a real system, different specialists might use
different models optimized for their task.
"""
self._llm_config = llm_config
self._synthesis_client = LLMClient(llm_config)
def review(self, code_diff: str) -> str:
"""
Perform a comprehensive multi-agent code review.
Runs security, performance, and style reviews in parallel,
then synthesizes the results into a single report.
Args:
code_diff: The code diff to review (e.g. from `git diff`).
Returns:
A formatted string containing the complete review report.
"""
specialists = [
(SECURITY_SYSTEM_PROMPT, "Security Reviewer"),
(PERFORMANCE_SYSTEM_PROMPT, "Performance Reviewer"),
(STYLE_SYSTEM_PROMPT, "Style Reviewer"),
]
print("Launching specialist reviewers in parallel...")
reports: list[ReviewReport] = []
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(
run_specialist_review,
self._llm_config,
prompt,
role,
code_diff,
): role
for prompt, role in specialists
}
for future in as_completed(futures):
role = futures[future]
report = future.result()
reports.append(report)
print(f" {role} completed. Found {report.issue_count} issues.")
return self._synthesize_reports(reports)
def _synthesize_reports(self, reports: list[ReviewReport]) -> str:
"""
Use the LLM to synthesize specialist reports into a final review.
Combines findings, removes duplicates, prioritizes issues,
and produces a developer-friendly summary.
"""
combined_findings = "\n\n".join(
f"[{r.reviewer_role}]\n{r.findings}"
for r in reports
)
synthesis_prompt = (
"You are a senior engineering lead synthesizing a code review "
"from three specialist reviewers. Below are their findings.\n"
"Your job is to:\n"
"1. Combine and deduplicate the findings.\n"
"2. Prioritize issues from most to least critical.\n"
"3. Write a clear, actionable summary for the developer.\n"
"4. Start with a one-paragraph overall assessment.\n\n"
f"Specialist findings:\n{combined_findings}\n\n"
"Write the final review report now."
)
messages = [{"role": "user", "content": synthesis_prompt}]
response = self._synthesis_client.chat(messages=messages, temperature=0.2)
return response.content or "Synthesis failed."
if __name__ == "__main__":
from llm_client import OPENAI_GPT55
sample_diff = """
--- a/auth/views.py
+++ b/auth/views.py
@@ -10,6 +10,10 @@ def login(request):
username = request.POST.get('username')
password = request.POST.get('password')
+ query = f"SELECT * FROM users WHERE username='{username}'"
+ user = db.execute(query)
if user and user.check_password(password):
return redirect('dashboard')
"""
orchestrator = CodeReviewOrchestrator(llm_config=OPENAI_GPT55)
report = orchestrator.review(sample_diff)
print(report)
The parallel execution using ThreadPoolExecutor is a key architectural decision here. Code review is a task where the three specialist analyses are completely independent of each other — the security reviewer does not need to wait for the performance reviewer to finish. By running them in parallel, we cut the total review time by roughly two-thirds compared to running them sequentially. This is a pattern that applies broadly in multi-agent systems: whenever subtasks are independent, run them in parallel.
The synthesis step is equally important. Three separate reports from three specialists would be overwhelming and potentially contradictory. The synthesis agent reads all three reports and produces a single, coherent, prioritized review — mirroring what a senior engineer does when they integrate the perspectives of multiple junior reviewers and present a unified recommendation.
Chapter Seven: Self-Healing Code and the Iterative Loop
One of the most striking capabilities of agentic systems is their ability to self-heal. A self-healing agent is one that can detect when its own code is broken, diagnose the problem, fix it, and verify the fix — all without human intervention. This is not science fiction. It is a practical pattern that works today, and it is one of the most compelling demonstrations of the agentic paradigm.
The self-healing pattern works as follows. The agent writes some code. It runs the code or the tests. If the code fails, the agent reads the error output, reasons about what went wrong, makes a targeted fix, and runs the code again. This loop continues until the code passes — or the agent determines that it cannot fix the problem and needs human help.
The key insight is that error messages are data. A stack trace is not just a notification that something went wrong. It is a detailed description of exactly what went wrong, where it went wrong, and often why it went wrong. An LLM that has been trained on millions of stack traces and their corresponding fixes is extraordinarily good at reading an error message and knowing what to do about it.
# self_healing.py
# Requires Python 3.11+
# A self-healing code generator that writes code, tests it,
# and automatically fixes errors until all tests pass.
# Demonstrates the "Try -> Observe -> Heal -> Retry" pattern
# that is central to agentic software development.
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from llm_client import LLMClient
GENERATION_PROMPT = """You are an expert Python developer. Generate a Python
implementation for the following task:
{task}
Respond with a JSON object containing exactly two keys:
- "implementation": The complete Python code for the implementation.
- "tests": Complete pytest test code that thoroughly tests the implementation.
The tests should import from a module called 'solution'.
Make the tests comprehensive: test normal cases, edge cases, and error cases.
Respond ONLY with the JSON object, no other text."""
FIX_PROMPT = """You are an expert Python debugger. The following code has
failing tests. Analyze the error and fix the implementation.
Current implementation:
```python
{implementation}
```
Test code:
```python
{tests}
```
Test output (showing the failures):
{test_output}
Respond with a JSON object containing exactly three keys:
- "implementation": The FIXED Python implementation.
- "tests": The test code (you may also fix tests if they are wrong).
- "explanation": A brief explanation of what was wrong and what you fixed.
Respond ONLY with the JSON object, no other text."""
class SelfHealingGenerator:
"""
Generates Python code from a natural language description and
automatically fixes it until all tests pass.
Uses a "generate -> test -> heal -> repeat" loop that mirrors
the workflow of a careful junior developer.
"""
def __init__(self, llm_client: LLMClient, max_iterations: int = 5) -> None:
"""
Initialize the generator.
Args:
llm_client: The LLM to use for generation and healing.
max_iterations: Maximum number of fix attempts before giving up.
"""
self._llm = llm_client
self._max_iterations = max_iterations
def generate(self, task: str) -> dict:
"""
Generate a working implementation for the given task.
Args:
task: A natural language description of the function to implement.
Returns:
A dict with keys: 'implementation', 'tests', 'iterations', 'success'.
"""
print(f"Generating code for: {task[:80]}...")
initial_code = self._generate_initial(task)
if not initial_code:
return {"success": False, "error": "Failed to generate initial code."}
implementation = initial_code["implementation"]
tests = initial_code["tests"]
for iteration in range(1, self._max_iterations + 1):
print(f"\nIteration {iteration}: Running tests...")
test_result = self._run_tests(implementation, tests)
if test_result["passed"]:
print(f"All tests passed on iteration {iteration}!")
return {
"implementation": implementation,
"tests": tests,
"iterations": iteration,
"success": True,
}
print("Tests failed. Asking LLM to fix the code...")
print(f"Error summary: {test_result['output'][:300]}...")
fixed = self._heal(implementation, tests, test_result["output"])
if not fixed:
print("LLM could not produce a fix. Stopping.")
break
implementation = fixed["implementation"]
tests = fixed["tests"]
if "explanation" in fixed:
print(f"Fix explanation: {fixed['explanation']}")
return {
"implementation": implementation,
"tests": tests,
"iterations": self._max_iterations,
"success": False,
"error": "Max iterations reached without passing tests.",
}
def _generate_initial(self, task: str) -> dict | None:
"""Generate the initial implementation and tests from the task description."""
prompt = GENERATION_PROMPT.format(task=task)
messages = [{"role": "user", "content": prompt}]
response = self._llm.chat(messages=messages, temperature=0.2)
return self._parse_json_response(response.content)
def _heal(
self,
implementation: str,
tests: str,
test_output: str,
) -> dict | None:
"""Ask the LLM to fix a failing implementation based on test output."""
prompt = FIX_PROMPT.format(
implementation=implementation,
tests=tests,
test_output=test_output,
)
messages = [{"role": "user", "content": prompt}]
response = self._llm.chat(messages=messages, temperature=0.1)
return self._parse_json_response(response.content)
def _run_tests(self, implementation: str, tests: str) -> dict:
"""
Write the implementation and tests to a temp directory and run pytest.
Uses a temporary directory so that each test run is isolated and
does not pollute the working directory.
Returns:
A dict with 'passed' (bool) and 'output' (str) keys.
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
(tmp_path / "solution.py").write_text(implementation, encoding="utf-8")
(tmp_path / "test_solution.py").write_text(tests, encoding="utf-8")
result = subprocess.run(
["python", "-m", "pytest", "test_solution.py", "-v", "--tb=short"],
capture_output=True,
text=True,
cwd=tmpdir,
timeout=30,
)
return {
"passed": result.returncode == 0,
"output": result.stdout + result.stderr,
}
def _parse_json_response(self, content: str | None) -> dict | None:
"""
Parse a JSON response from the LLM.
LLMs sometimes wrap JSON in markdown code fences, so we strip those
before parsing. Returns None if parsing fails.
"""
if not content:
return None
cleaned = content.strip()
if cleaned.startswith("```"):
lines = cleaned.split("\n")
cleaned = "\n".join(lines[1:-1])
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
print(f"Warning: Could not parse LLM response as JSON: {e}")
return None
if __name__ == "__main__":
from llm_client import OPENAI_GPT55
generator = SelfHealingGenerator(LLMClient(OPENAI_GPT55), max_iterations=5)
result = generator.generate(
"A function called 'fibonacci' that returns the nth Fibonacci number "
"using memoization. It should raise a ValueError for negative inputs."
)
if result["success"]:
print("\nFinal implementation:")
print(result["implementation"])
else:
print(f"\nFailed after {result['iterations']} iterations: {result.get('error')}")
The self-healing generator encapsulates a pattern that is deceptively simple but enormously powerful. The key is that the fix prompt gives the LLM not just the broken code, but also the test code and the complete test output — the "crime scene evidence" approach. You give the detective everything they need to solve the case. The LLM can see exactly which test failed, what the expected output was, what the actual output was, and what the stack trace looked like. With this information, a capable model can diagnose and fix most common programming errors in a single iteration.
The use of a temporary directory for test execution is a subtle but important safety measure. By running tests in an isolated temporary directory, we ensure that each test run starts from a clean state, that the tests cannot accidentally modify files in the working directory, and that the temporary files are automatically cleaned up when the test run is complete. This is good practice for any system that executes generated code.
Chapter Eight: Context Engineering — The New Prompt Engineering
If you have been paying attention to the AI field, you have heard about prompt engineering: the practice of crafting the right inputs to get the right outputs from a language model. Prompt engineering is real and valuable, but it is only part of the story for agentic systems. The broader discipline is called context engineering, and it is arguably the most important skill for building effective agents.
Context engineering is the practice of curating and managing everything that goes into the LLM's context window at each step of the agent's execution. The context window is a finite resource. Modern flagship models have pushed this boundary to extraordinary lengths — GPT-5.6 Sol, Gemini 3.1 Pro, and Claude Opus 5 all support context windows of one million tokens or more, and local models like Llama 4 Scout extend to ten million tokens — but even these vast windows fill up surprisingly quickly when you are accumulating a history of tool calls, file contents, and observations. And here is the uncomfortable truth: LLMs do not perform equally well across the entire context window. Research has shown that models tend to pay more attention to information at the beginning and end of the context, and less attention to information in the middle. This is sometimes called the "lost in the middle" problem.
Context engineering addresses this by being deliberate about what goes into the context window and how it is structured. Some practical techniques include summarizing long tool outputs before adding them to the history, using retrieval augmented generation to pull only the most relevant parts of a large codebase into the context, structuring the system prompt to put the most important instructions at the beginning, and periodically compressing the conversation history by summarizing earlier steps.
The following module implements a context manager that handles these concerns automatically:
# context_manager.py
# Requires Python 3.11+
# Manages the agent's context window to keep it focused and efficient.
# Implements summarization and retrieval strategies to prevent context
# overflow and maintain the quality of the LLM's reasoning throughout
# long tasks.
from __future__ import annotations
from llm_client import LLMClient
# Maximum number of characters to include from a single tool observation
# before trimming it. Long file contents and test outputs are the
# most common sources of context bloat.
MAX_OBSERVATION_LENGTH = 2000
# When the conversation history grows beyond this many messages,
# we compress the middle section to prevent context overflow.
MAX_HISTORY_MESSAGES = 30
SUMMARIZATION_PROMPT = """Summarize the following content concisely, preserving
all information that is important for a software engineering task. Focus on:
- Key findings and results
- Error messages and their causes
- Important code structures
- Decisions made and their rationale
Content to summarize:
{content}
Provide a concise summary (maximum 200 words):"""
class ContextManager:
"""
Manages the agent's context window to keep it focused and efficient.
Provides utilities for truncating long observations, summarizing
verbose content, and compressing conversation history when it grows
too large for effective reasoning.
"""
def __init__(self, llm_client: LLMClient) -> None:
"""
Initialize the context manager.
Args:
llm_client: Used for summarization when content is too long.
"""
self._llm = llm_client
def trim_observation(self, observation: str) -> str:
"""
Trim a tool observation to a manageable length.
If the observation is short enough, return it unchanged.
If it is too long, return the beginning and end with a note
about the truncation. This preserves the most informative parts
(the start, which usually has the most important content, and
the end, which often has error messages and summaries).
Args:
observation: The raw tool output to trim.
Returns:
The trimmed observation string.
"""
if len(observation) <= MAX_OBSERVATION_LENGTH:
return observation
half = MAX_OBSERVATION_LENGTH // 2
start = observation[:half]
end = observation[-half:]
omitted = len(observation) - MAX_OBSERVATION_LENGTH
return (
f"{start}\n"
f"... [{omitted} characters omitted for brevity] ...\n"
f"{end}"
)
def summarize(self, content: str) -> str:
"""
Use the LLM to produce a concise summary of long content.
This is more intelligent than simple truncation: the LLM can
identify and preserve the most relevant information.
Args:
content: The content to summarize.
Returns:
A concise summary string.
"""
prompt = SUMMARIZATION_PROMPT.format(content=content[:8000])
messages = [{"role": "user", "content": prompt}]
response = self._llm.chat(messages=messages, temperature=0.1)
return response.content or content[:MAX_OBSERVATION_LENGTH]
def compress_history(self, messages: list[dict]) -> list[dict]:
"""
Compress the conversation history when it grows too long.
Keeps the system prompt (first message) and the most recent
messages intact, but summarizes the middle section to reduce
token usage while preserving the essential narrative.
Args:
messages: The full conversation history.
Returns:
A compressed version of the history.
"""
if len(messages) <= MAX_HISTORY_MESSAGES:
return messages
system_message = messages[0]
recent_messages = messages[-10:]
middle_messages = messages[1:-10]
middle_text = "\n\n".join(
f"[{msg['role'].upper()}]: {str(msg.get('content', ''))[:500]}"
for msg in middle_messages
if isinstance(msg.get("content"), str)
)
summary = self.summarize(
f"Conversation history to summarize:\n{middle_text}"
)
compressed_middle = {
"role": "system",
"content": (
f"[COMPRESSED HISTORY SUMMARY]\n{summary}\n"
"[END OF COMPRESSED HISTORY - continuing with recent messages]"
),
}
return [system_message, compressed_middle] + recent_messages
Context management is an area where the engineering craft of agentic systems becomes most apparent. It is not enough to simply concatenate everything into a growing string and hope the LLM can handle it. You need to actively manage what the LLM sees, ensuring that the most relevant information is always present and that noise and verbosity do not crowd out signal. This is a new kind of engineering challenge, and it requires a new kind of engineering skill.
Chapter Nine: The Planning Agent — Decomposing Complex Goals
So far, our agents have been reactive: they receive a goal and figure out how to achieve it step by step. But for truly complex tasks, this approach has limits. A task like "refactor the authentication system to use JWT tokens" involves dozens of interdependent changes across multiple files, and a simple ReAct loop may struggle to maintain coherence across all of them.
This is where explicit planning becomes essential. A planning agent first produces a structured plan before taking any action. The plan breaks the goal into a sequence of well-defined subtasks, identifies the dependencies between them, and estimates the effort required for each. Only after the plan is approved does the agent begin execution.
# planning_agent.py
# Requires Python 3.11+
# A planning agent that decomposes complex goals into structured plans
# before executing them. Appropriate for tasks that involve many
# interdependent changes or require careful sequencing.
from __future__ import annotations
import json
from dataclasses import dataclass, field
from enum import Enum
from llm_client import LLMClient
from agent import CodingAgent
class TaskStatus(Enum):
"""The execution status of a planned task."""
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class PlannedTask:
"""
A single task within a larger plan.
Each task has a clear description, a list of tasks it depends on,
and a status that is updated as the plan executes.
"""
task_id: str
description: str
depends_on: list[str] = field(default_factory=list)
status: TaskStatus = TaskStatus.PENDING
result: str = ""
notes: str = ""
PLANNING_PROMPT = """You are a senior software architect. Break down the following
complex software engineering goal into a structured plan of concrete, executable tasks.
Goal: {goal}
Respond with a JSON object containing a "tasks" array. Each task must have:
- "task_id": A short unique identifier (e.g. "T1", "T2").
- "description": A clear, specific description of what needs to be done.
Each task should be achievable in a single focused agent run.
- "depends_on": A list of task_ids that must complete before this task starts.
Use an empty list if there are no dependencies.
Order tasks logically. Keep each task focused and achievable.
Respond ONLY with the JSON object."""
class PlanningAgent:
"""
A two-phase agent that plans before acting.
Phase 1 (Planning): Decomposes the goal into a structured task list.
Phase 2 (Execution): Runs each task in dependency order using a CodingAgent.
This approach is more reliable than a single ReAct loop for complex,
multi-file refactoring tasks.
"""
def __init__(self, llm_client: LLMClient, max_steps_per_task: int = 15) -> None:
"""
Initialize the planning agent.
Args:
llm_client: The LLM to use for both planning and execution.
max_steps_per_task: Step limit for each individual task's agent run.
"""
self._llm = llm_client
self._executor = CodingAgent(llm_client, max_steps=max_steps_per_task)
def run(self, goal: str) -> dict:
"""
Plan and execute a complex software engineering goal.
Args:
goal: The high-level goal to accomplish.
Returns:
A dict summarizing the plan execution results.
"""
print("\nPlanning phase: Decomposing goal...")
plan = self._create_plan(goal)
if not plan:
return {"success": False, "error": "Failed to create a plan."}
print(f"\nPlan created with {len(plan)} tasks:")
for task in plan:
deps = (
f" (depends on: {', '.join(task.depends_on)})"
if task.depends_on else ""
)
print(f" [{task.task_id}] {task.description}{deps}")
print("\nExecution phase: Running tasks...")
return self._execute_plan(plan, goal)
def _create_plan(self, goal: str) -> list[PlannedTask] | None:
"""Use the LLM to decompose the goal into a list of planned tasks."""
prompt = PLANNING_PROMPT.format(goal=goal)
messages = [{"role": "user", "content": prompt}]
response = self._llm.chat(messages=messages, temperature=0.3)
try:
content = response.content or ""
if "```" in content:
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
data = json.loads(content.strip())
return [
PlannedTask(
task_id=t["task_id"],
description=t["description"],
depends_on=t.get("depends_on", []),
)
for t in data["tasks"]
]
except (json.JSONDecodeError, KeyError) as e:
print(f"Failed to parse plan: {e}")
return None
def _execute_plan(self, plan: list[PlannedTask], goal: str) -> dict:
"""
Execute the plan by running tasks in dependency order.
Tasks whose dependencies have all completed are eligible to run.
Tasks whose dependencies have failed are skipped.
Args:
plan: The list of planned tasks.
goal: The original goal (for context in task prompts).
Returns:
A summary dict of the execution results.
"""
task_map = {t.task_id: t for t in plan}
completed_count = 0
failed_count = 0
max_rounds = len(plan) * 2
for _ in range(max_rounds):
ready_tasks = [
t for t in plan
if t.status == TaskStatus.PENDING
and all(
task_map[dep].status == TaskStatus.COMPLETED
for dep in t.depends_on
if dep in task_map
)
]
if not ready_tasks:
pending = [t for t in plan if t.status == TaskStatus.PENDING]
if not pending:
break
for task in pending:
task.status = TaskStatus.SKIPPED
task.notes = "Skipped because a dependency failed."
break
task = ready_tasks[0]
task.status = TaskStatus.IN_PROGRESS
print(f"\nExecuting [{task.task_id}]: {task.description}")
task_prompt = (
f"Overall goal: {goal}\n\n"
f"Your specific task: {task.description}\n\n"
"Focus only on this task. Do not attempt to do other tasks."
)
result = self._executor.run(goal=task_prompt, verbose=False)
if result.success:
task.status = TaskStatus.COMPLETED
task.result = result.final_answer
completed_count += 1
print(f" [{task.task_id}] COMPLETED")
else:
task.status = TaskStatus.FAILED
task.result = result.final_answer
failed_count += 1
print(f" [{task.task_id}] FAILED: {result.final_answer[:100]}")
return {
"success": failed_count == 0,
"total_tasks": len(plan),
"completed": completed_count,
"failed": failed_count,
"skipped": sum(1 for t in plan if t.status == TaskStatus.SKIPPED),
"task_results": [
{
"id": t.task_id,
"description": t.description,
"status": t.status.value,
"result_summary": t.result[:200] if t.result else "",
}
for t in plan
],
}
if __name__ == "__main__":
from llm_client import OPENAI_GPT55
agent = PlanningAgent(LLMClient(OPENAI_GPT55), max_steps_per_task=10)
outcome = agent.run(
"Create a small REST API in Python using only the standard library "
"that exposes a /health endpoint returning JSON and a /echo endpoint "
"that echoes back any JSON body sent to it. Include a test file."
)
print(f"\nOutcome: {outcome['success']}")
print(f"Completed {outcome['completed']} of {outcome['total_tasks']} tasks.")
The planning agent introduces a concept that is fundamental to managing complexity in agentic systems: the separation of planning from execution. By generating the entire plan before taking any action, we give ourselves several important advantages. We can review the plan before execution begins, catching misunderstandings or bad approaches early. We can identify which tasks can be parallelized. We can track progress at the task level, not just the step level. And we can handle failures gracefully by skipping tasks whose dependencies have failed rather than continuing blindly.
The dependency tracking in _execute_plan implements a simple form of topological sort. At each round, we find all tasks whose dependencies have been completed and execute the first one. This ensures that tasks are always executed in a valid order, even if the plan was not perfectly ordered by the LLM. In a production system, you would extend this to execute independent tasks in parallel, which can dramatically reduce the total execution time for large plans.
Chapter Ten: Guardrails, Safety, and the Limits of Autonomy
No discussion of agentic systems would be complete without an honest conversation about their risks and limitations. Agents that can write files, run commands, and call APIs can also delete files, run destructive commands, and make unauthorized API calls. The same autonomy that makes them powerful makes them potentially dangerous if not properly constrained.
The industry has developed several categories of guardrails for agentic systems. Input guardrails validate and sanitize the goals and instructions given to agents, preventing prompt injection attacks where malicious content in the environment tries to hijack the agent's behavior. Output guardrails review the agent's proposed actions before they are executed, blocking actions that violate safety policies. Sandboxing restricts the environment in which the agent operates, limiting what files it can access, what commands it can run, and what network resources it can reach. Audit loggingrecords every action the agent takes, creating an immutable trail that can be reviewed after the fact.
# guardrails.py
# Requires Python 3.11+
# Safety guardrails for the agent's tool execution layer.
# Implements a policy-based system that validates tool calls
# before they are executed, blocking dangerous operations.
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Callable
@dataclass
class GuardrailViolation:
"""Describes a policy violation detected by a guardrail."""
tool_name: str
arguments: dict
policy_name: str
reason: str
class GuardrailPolicy:
"""
A single safety policy that can approve or block a tool call.
Policies are composable: you can combine multiple policies
to build a comprehensive safety layer.
"""
def __init__(
self,
name: str,
check_fn: Callable[[str, dict], str | None],
) -> None:
"""
Initialize a policy.
Args:
name: A human-readable name for this policy.
check_fn: A function that takes (tool_name, arguments) and returns
None if the call is safe, or a reason string if it is not.
"""
self.name = name
self._check = check_fn
def check(self, tool_name: str, arguments: dict) -> str | None:
"""
Check whether a tool call violates this policy.
Returns None if safe, or a reason string if the call is blocked.
"""
return self._check(tool_name, arguments)
class GuardrailLayer:
"""
A composable layer of safety policies applied to all tool calls.
The guardrail layer sits between the agent's decision to call a tool
and the actual execution of that tool. It checks each proposed call
against all registered policies and blocks any that violate them.
"""
def __init__(self, policies: list[GuardrailPolicy] | None = None) -> None:
"""Initialize with an optional list of policies."""
self._policies: list[GuardrailPolicy] = policies or []
def add_policy(self, policy: GuardrailPolicy) -> None:
"""Add a new policy to the guardrail layer."""
self._policies.append(policy)
def check(self, tool_name: str, arguments: dict) -> list[GuardrailViolation]:
"""
Check a proposed tool call against all policies.
Args:
tool_name: The name of the tool to be called.
arguments: The arguments for the tool call.
Returns:
A list of violations. An empty list means the call is safe.
"""
violations = []
for policy in self._policies:
reason = policy.check(tool_name, arguments)
if reason:
violations.append(GuardrailViolation(
tool_name=tool_name,
arguments=arguments,
policy_name=policy.name,
reason=reason,
))
return violations
def make_dangerous_command_policy() -> GuardrailPolicy:
"""
Block shell commands that could cause irreversible damage.
This is a conservative blocklist. In production, consider a
whitelist approach instead: only allow explicitly approved commands.
"""
dangerous_patterns = [
r"\brm\s+-rf\b", # Recursive force delete
r"\bdd\b", # Disk destroyer
r"\bmkfs\b", # Format filesystem
r"\bsudo\b", # Privilege escalation
r"\bchmod\s+777\b", # World-writable permissions
r">\s*/dev/sd", # Write to raw disk device
r"\bcurl\b.*\|\s*sh\b", # Download and execute
r"\bwget\b.*\|\s*sh\b", # Download and execute
]
def check(tool_name: str, arguments: dict) -> str | None:
if tool_name != "run_command":
return None
command = arguments.get("command", "")
for pattern in dangerous_patterns:
if re.search(pattern, command, re.IGNORECASE):
return (
f"Command matches dangerous pattern '{pattern}'. "
"This command requires human approval."
)
return None
return GuardrailPolicy("DangerousCommandPolicy", check)
def make_path_restriction_policy(allowed_dirs: list[str]) -> GuardrailPolicy:
"""
Restrict file operations to a set of allowed directories.
Prevents the agent from reading or writing files outside the
project directory, protecting sensitive system files.
Args:
allowed_dirs: List of directory prefixes that are allowed.
"""
file_tools = {"read_file", "write_file", "list_files"}
def check(tool_name: str, arguments: dict) -> str | None:
if tool_name not in file_tools:
return None
path = arguments.get("path", arguments.get("directory", ""))
normalized = os.path.normpath(path)
if normalized.startswith("..") or normalized.startswith("/"):
return (
f"Path '{path}' is outside the allowed directories. "
f"Allowed: {allowed_dirs}"
)
return None
return GuardrailPolicy("PathRestrictionPolicy", check)
def make_default_guardrails(project_dir: str = ".") -> GuardrailLayer:
"""
Create a guardrail layer with sensible default policies.
Args:
project_dir: The root directory of the project the agent is working on.
Returns:
A configured GuardrailLayer ready to use.
"""
layer = GuardrailLayer()
layer.add_policy(make_dangerous_command_policy())
layer.add_policy(make_path_restriction_policy([project_dir, "."]))
return layer
if __name__ == "__main__":
layer = make_default_guardrails()
safe_call = ("run_command", {"command": "python -m pytest tests/"})
unsafe_call = ("run_command", {"command": "rm -rf /tmp/myproject"})
path_call = ("read_file", {"path": "../../etc/passwd"})
for tool, args in [safe_call, unsafe_call, path_call]:
violations = layer.check(tool, args)
if violations:
for v in violations:
print(f"BLOCKED [{v.policy_name}]: {v.reason}")
else:
print(f"ALLOWED: {tool}({args})")
The guardrail system shown here is a starting point, not a complete solution. In production, you would want to add many more policies: rate limiting to prevent the agent from making too many API calls, content filtering to prevent the agent from generating inappropriate content, human approval workflows for high-stakes actions like deploying to production, and integration with your organization's security policies and compliance requirements.
The most important design principle for guardrails is defense in depth. No single guardrail is sufficient. You need multiple layers, each catching different categories of problems. The dangerous command policy catches the most obvious risks. The path restriction policy catches directory traversal. An output review agent could catch subtle logic errors. Human approval workflows catch anything that slips through the automated layers. Together, these layers create a system that is robust without being so restrictive that it cannot do useful work.
Chapter Eleven: Agentic Testing — When the Agent Writes and Runs Its Own Tests
One of the most practically valuable applications of agentic programming is in the domain of software testing. Testing is one of those tasks that every engineer knows is important and that many engineers find tedious. Writing good tests requires creativity and discipline in equal measure, and it is easy to let test coverage slip when deadlines are pressing. An agent that can automatically generate comprehensive tests, run them, and fix the code until they pass is not just a convenience — it is a genuine improvement in software quality.
Modern agentic testing systems go far beyond simply generating a few unit tests. They analyse the code to understand its intended behavior, generate tests that cover normal cases, edge cases, and error cases, run the tests and observe the failures, diagnose whether the failure is in the test or in the code, fix whichever is wrong, and iterate until the entire test suite passes. They can also generate integration tests that test multiple components together, property-based tests that generate random inputs to find edge cases, and regression tests based on bug reports.
# test_generator.py
# Requires Python 3.11+
# An agentic test generator that analyzes existing code and produces
# a comprehensive pytest test suite. It understands the code's structure,
# identifies testable behaviors, and generates tests that cover them.
from __future__ import annotations
import sys
from llm_client import LLMClient
from tools import read_file
ANALYSIS_PROMPT = """You are an expert software tester analyzing Python code
to identify all behaviors that should be tested.
Code to analyze:
```python
{code}
```
Identify and list:
1. All public functions and methods with their expected behaviors.
2. Edge cases and boundary conditions for each function.
3. Error conditions that should be tested (invalid inputs, exceptions).
4. Any dependencies or side effects that need to be mocked.
Be thorough and specific. This analysis will be used to generate tests."""
TEST_GENERATION_PROMPT = """You are an expert Python test engineer.
Based on the following code and analysis, write a comprehensive pytest test suite.
Source code (in module '{module_name}'):
```python
{code}
```
Analysis of behaviors to test:
{analysis}
Requirements for the test suite:
- Use pytest and pytest conventions.
- Each test function should test exactly one behavior.
- Use descriptive test names that explain what is being tested.
- Use fixtures for shared setup where appropriate.
- Mock external dependencies using unittest.mock.
- Include docstrings explaining what each test verifies.
- Aim for at least 90% coverage of the public interface.
Write the complete test file now:"""
class TestGeneratorAgent:
"""
Analyzes existing Python code and generates a comprehensive test suite.
Uses a two-phase approach: first analyzing the code to understand its
behaviors, then generating tests that cover those behaviors.
"""
def __init__(self, llm_client: LLMClient) -> None:
"""
Initialize the test generator.
Args:
llm_client: The LLM to use for analysis and generation.
"""
self._llm = llm_client
def generate_tests_for_file(self, source_path: str) -> str:
"""
Generate a comprehensive test suite for a Python source file.
Args:
source_path: Path to the Python file to generate tests for.
Returns:
The generated test code as a string.
"""
source_code = read_file(source_path)
if source_code.startswith("Error:"):
raise FileNotFoundError(f"Could not read {source_path}: {source_code}")
module_name = source_path.split("/")[-1].replace(".py", "")
print(f"Analyzing {source_path}...")
analysis = self._analyze_code(source_code)
print("Analysis complete. Generating tests...")
return self._generate_tests(source_code, analysis, module_name)
def _analyze_code(self, source_code: str) -> str:
"""Use the LLM to analyze the code and identify testable behaviors."""
prompt = ANALYSIS_PROMPT.format(code=source_code)
messages = [{"role": "user", "content": prompt}]
response = self._llm.chat(messages=messages, temperature=0.2)
return response.content or "No analysis available."
def _generate_tests(
self,
source_code: str,
analysis: str,
module_name: str,
) -> str:
"""Generate the test suite based on the code and analysis."""
prompt = TEST_GENERATION_PROMPT.format(
code=source_code,
analysis=analysis,
module_name=module_name,
)
messages = [{"role": "user", "content": prompt}]
response = self._llm.chat(messages=messages, temperature=0.3)
content = response.content or ""
if "```python" in content:
start = content.find("```python") + 9
end = content.rfind("```")
if end > start:
content = content[start:end].strip()
elif "```" in content:
start = content.find("```") + 3
end = content.rfind("```")
if end > start:
content = content[start:end].strip()
return content
if __name__ == "__main__":
from llm_client import OPENAI_GPT55
if len(sys.argv) < 2:
print("Usage: python test_generator.py <path/to/source.py>")
sys.exit(1)
generator = TestGeneratorAgent(LLMClient(OPENAI_GPT55))
tests = generator.generate_tests_for_file(sys.argv[1])
output_path = sys.argv[1].replace(".py", "_test.py")
with open(output_path, "w", encoding="utf-8") as f:
f.write(tests)
print(f"Tests written to {output_path}")
The two-phase approach in the test generator — analysis followed by generation — is a deliberate architectural choice. By separating the analysis from the generation, we give the LLM a chance to think carefully about what needs to be tested before it starts writing test code. This produces significantly better tests than asking the LLM to generate tests directly from the source code, because the analysis step forces the model to enumerate the behaviors explicitly before trying to test them.
This is analogous to the test-driven development practice of writing test cases on paper before writing any code. The act of articulating what you are testing before you write the test code leads to better tests — whether the tester is human or artificial.
Chapter Twelve: The Engineer's New Role
We have spent a lot of time looking at what agents can do. Now let us think carefully about what this means for the human engineers who work alongside them. Because the agentic paradigm does not eliminate the need for human engineers. It transforms what those engineers do — and in many ways it makes the job more interesting and more demanding.
The most common analogy used in the industry is that of the senior engineer and the junior teammate. When you work with a capable junior engineer, you do not disappear and let them work in isolation. You set the direction. You define the architecture. You review their work. You catch the subtle errors that come from inexperience. You make the judgment calls that require deep domain knowledge. You are responsible for the outcome, even though you did not write every line of code.
Working with AI agents is similar, but with some important differences. An AI agent is faster than any human junior engineer. It can work around the clock without fatigue. It has read more code than any human could read in a lifetime. But it also lacks genuine understanding of your specific business context, your users, your organizational constraints, and the subtle requirements that are never written down anywhere. It can make confident-sounding mistakes. It can optimize for the wrong metric. It can miss the forest for the trees.
This means that the engineer's most important new skill is not prompt writing or agent configuration. It is critical evaluation — the ability to look at an agent's output and quickly, accurately assess whether it is correct, whether it is appropriate, whether it is safe, and whether it actually solves the right problem. This requires deep technical knowledge, not less of it. The engineer who cannot read code cannot evaluate an agent's code. The engineer who does not understand security cannot evaluate an agent's security decisions.
The second most important new skill is goal formulation. The quality of an agent's output is heavily determined by the quality of the goal it is given. A vague goal produces vague results. A goal that does not specify the constraints produces code that violates the constraints. A goal that does not mention the existing architecture produces code that conflicts with it. Writing good goals for agents is a skill that combines technical precision with natural language clarity, and it is harder than it sounds.
The third new skill is system design for agentic workflows — deciding which tasks to delegate to agents, how to structure multi-agent systems, how to set up the right guardrails and human-in-the-loop checkpoints, and how to integrate agents into existing development workflows. These are architectural decisions that require both technical depth and organizational understanding.
What does not change is the fundamental responsibility of the engineer. The code that ships is your code, whether you wrote it or an agent wrote it. The bugs are your bugs. The security vulnerabilities are your vulnerabilities. The users who are affected by a bad deployment are affected by your decisions. Agents are powerful tools, but they do not transfer responsibility. They amplify it.
Epilogue: The Craft Endures
Let us end where we began — with the moment of the ghost-grey suggestion appearing in the IDE. That moment was magical because it felt like the machine understood what you were trying to do. It felt like collaboration.
Agentic programming is that feeling amplified to a degree that would have seemed like science fiction a decade ago. The machine does not just complete your sentence. It takes your goal, forms a plan, writes the code, runs the tests, fixes the bugs, and hands you back a working solution. It is a junior teammate who is always available, always enthusiastic, and always willing to write the boilerplate.
But here is the thing about junior teammates: they make you a better engineer. When you have to explain your goal clearly enough for an agent to execute it, you understand your own goal better. When you review an agent's code, you see your codebase through fresh eyes. When you design guardrails and evaluation criteria for an agent, you articulate the standards that you previously held only implicitly.
The craft of software engineering does not disappear in the age of agents. It deepens. The engineer who understands systems, who can evaluate code critically, who can formulate precise goals and design reliable processes, becomes more valuable — not less. The engineer who cannot do these things, who relied on the mechanical act of typing code to feel productive, will find the transition harder.
The paradigm is shifting. Code completion was the knock on the door. Goal execution is the guest who walked in. And the party, it turns out, is just getting started.
References and Further Reading
Yao, S. et al. (2022). "ReAct: Synergizing Reasoning and Acting in Language Models." arXiv:2210.03629. The foundational paper describing the ReAct pattern.
Anthropic, Model Context Protocol Specification (2025–2026). The open standard for connecting AI agents to tools and data sources. https://modelcontextprotocol.io
OpenAI Agents SDK documentation (2026). Reference implementation for tool calling, agent orchestration, and multi-agent handoffs. https://platform.openai.com/docs/agents
Ollama project (https://ollama.ai). Tool for running open-source LLMs locally with an OpenAI-compatible API. Latest version: v0.32.0 (July 2026).
LangGraph documentation (2026). LangChain's framework for building stateful, multi-actor applications with LLMs. https://langchain-ai.github.io/langgraph
CrewAI documentation (2026). Framework for orchestrating role-playing AI agents in collaborative workflows. https://docs.crewai.com
Liu et al. (2023). "Lost in the Middle: How Language Models Use Long Contexts." arXiv:2307.03172. The research behind context engineering best practices.
Meta AI (2026). Llama 4 model family. https://ai.meta.com/llama. Scout variant: 10M-token context, MoE architecture, multimodal.
Alibaba Cloud (2026). Qwen3-Coder model family. https://qwenlm.github.io. Qwen3-Coder 30B: leading local coding model for 24 GB GPU systems.
DeepSeek (2026). DeepSeek V4 model family. https://deepseek.com. V4 Flash: 284B total / 13B active parameters, 1M-token context window.
No comments:
Post a Comment