chapter 08
Capstone Project
Assembling the full modular, enterprise-shaped agent project.
1 / 14
Everything from Chapters 1–7 now becomes one real, runnable folder. This is the project structure you'll reuse for every future agent you build.
Think of this chapter less as "new material" and more as a guided tour of a house built from rooms you've already framed. Every file below corresponds to a concept you already understand: a manifest (Chapter 2), a data structure (Chapters 1 and 3), an HTTP client (Chapter 5), a loop (Chapter 6), and an abstraction layer (Chapter 7). The only genuinely new skill here is organizing those pieces into separate files that import from each other cleanly - which is itself a skill worth practicing deliberately, because "one giant script" stops scaling the moment a project has more than one contributor, or even just more than a few hundred lines.
8.1 The file tree
my-agent/
├── .venv/ ← Chapter 2: isolated environment (git-ignored)
├── .env ← Chapter 7: PROVIDER, API keys (git-ignored)
├── .gitignore
├── pyproject.toml ← Chapter 2: the manifest
├── main.py ← entry point, wires everything together
└── src/
└── agent/
├── __init__.py
├── memory.py ← Chapter 6.2
├── tools.py ← Chapter 6.3
├── providers.py ← Chapter 7
└── loop.py ← Chapter 6.4 / 7.7 WHO DEPENDS ON WHOM
main.py
│ imports
▼
loop.py ──uses──▶ providers.py ──uses──▶ openai / anthropic SDKs
│ uses
▼
memory.py, tools.pyThis dependency diagram is worth reading carefully, because it tells you something important:
memory.py and tools.py know nothing about providers.py, and providers.py knows
nothing about loop.py. Data and behavior only ever flow in one direction - down the arrows.
This is what "low coupling" means in practice: you could delete providers.py entirely, write a
brand new one with a completely different set of provider classes, and as long as it still
exposes a build_provider() function returning something with a .chat(...) method,
loop.py would never need to change a single line. That's the entire payoff of Chapter 7's
abstraction work, made concrete as an actual file layout.
Common mistake: letting a "lower" file import from a "higher" one - for example, having
memory.pyimport something fromloop.py. This creates a circular dependency (loop needs memory, memory needs loop) which Python will refuse to resolve cleanly, and which is a strong sign the code's responsibilities aren't cleanly separated. If you ever find yourself needing to do this, it usually means a piece of shared logic should be pulled out into its own third file that both can import from instead.
Why src/agent/ instead of just agent/ directly at the root? This is called the "src layout"
and it's a common Python convention: keeping importable code inside a src/ folder, separate
from configuration files like pyproject.toml and .env, makes it much harder to accidentally
import an old, uninstalled copy of your package instead of the one properly installed via
pip install -e .. It's a small detail, but it's the kind of convention that saves real
debugging time on larger projects - you're seeing it now so it looks familiar later.
8.2 pyproject.toml
[project]
name = "my-agent"
version = "0.1.0"
description = "A local-first, multi-provider LLM agent"
requires-python = ">=3.11"
dependencies = [
"openai>=1.40.0",
"anthropic>=0.34.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0.0"]Every one of these lines maps directly back to Chapter 2. dependencies is the same idea as
package.json's dependencies field - a declared, versioned list of what your project needs to
run at all. [project.optional-dependencies] with a dev group is the same idea as
devDependencies in the JavaScript world: things needed to develop and test the project
(like pytest) but not needed by someone simply running your agent in production. The
version constraints (>=1.40.0) exist because provider SDKs occasionally change their function
signatures between major versions - pinning a minimum version protects you from accidentally
running against an SDK version too old to have the features this book's code relies on (like
tools= support).
8.3 .gitignore
.venv/ .env __pycache__/ *.pyc
Each line here prevents a different category of problem. .venv/ is excluded because virtual
environments are large, machine-specific, and trivially reproducible from pyproject.toml - committing
it would bloat your repository for no benefit. .env is excluded because it holds secrets (API
keys) that must never end up in a shared git history, as Chapter 7.6 warned. __pycache__/ and
*.pyc are Python's compiled bytecode caches - regenerated automatically, and meaningless to
anyone but the exact machine and Python version that created them.
8.4 src/agent/memory.py
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This is copied verbatim from Chapter 6.2 - nothing changes when you move code from a notebook-style snippet into a real project file, other than where it lives. That's exactly the point of having built it as a clean, self-contained class from the start: it required zero modification to become production file structure.
8.5 src/agent/tools.py
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
If you followed the "check yourself" exercise in Chapter 6 and added a second tool, this is
exactly the file where it would live. Adding word_count (or get_weather, or anything else)
means adding one function, one schema entry to TOOLS, and one key to AVAILABLE_FUNCTIONS -
and every other file in this project remains completely untouched, precisely because
loop.py only ever imports TOOLS and AVAILABLE_FUNCTIONS by name, not by counting how many
tools exist.
8.6 src/agent/providers.py
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This is Chapter 7's entire abstraction, unchanged, sitting in its own file. Notice this file
never imports Memory, TOOLS, or anything from loop.py - it doesn't need to, because a
"provider" is a pure translation layer between your uniform messages list and a specific
vendor's API. That independence is exactly why it's safe to unit test in isolation (Section 8.10
shows the pattern) and why you could, in principle, reuse this exact file in an entirely
different agent project that has different tools and different memory logic.
8.7 src/agent/loop.py
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
The from .tools import ... and from .providers import BaseProvider lines are relative
imports - the leading dot means "look inside this same package (agent/) for a sibling
module," rather than searching the globally installed packages. This is what makes loop.py
portable: as long as memory.py, tools.py, and providers.py sit next to it inside
src/agent/, these imports resolve correctly regardless of where the whole project is copied to
on disk.
Common mistake: trying to run
loop.pydirectly withpython src/agent/loop.pyand getting anImportError: attempted relative import with no known parent package. Relative imports only work when the file is imported as part of a package (which is exactly whatmain.pydoes viafrom src.agent.loop import run_agent), not when the file itself is executed as a standalone script. This trips up almost everyone the first time they split a project into multiple files - the fix is always to run the entry point (main.py), never an internal module, directly.
8.8 main.py
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
main.py is intentionally the thinnest file in the whole project - it does exactly one job:
wire the pieces together and start the interaction loop with the user. This is a deliberate
design habit worth adopting broadly: keep your entry point almost boring, with all the real
logic living in well-named, independently testable modules. If someone new joins the project and
wants a five-second overview of "what does this program actually do," main.py should answer
that question without them needing to read anything else.
THE FULL CONVERSATION LOOP, END TO END:
user types "What is 12*8?" ──▶ memory.add_user(...) ──▶ run_agent(provider, memory)
│ │
│ ▼
│ provider.chat(memory.messages, tools=TOOLS)
│ │
│ model replies with tool_call or final text
│ │
│ loop internally handles ACT + OBSERVE (Ch 6.1)
│ │
◀───────────────────── prints "Agent: 96" ◀────────────────8.9 Running it, start to finish
mkdir my-agent && cd my-agent python -m venv .venv source .venv/bin/activate # .venv\Scripts\activate on Windows # create the files above in the shown structure, then: pip install -e . echo "PROVIDER=lmstudio" > .env echo "LMSTUDIO_MODEL=your-model-id-here" >> .env # make sure LM Studio's server is toggled on (Chapter 5.2) python main.py
You: What is 47 * 3, plus 10? Agent: 47 * 3 + 10 = 151
pip install -e . deserves a second look - the -e stands for "editable." It installs your
project in a mode where Python's import system finds your src/agent/ code directly, without
copying it anywhere else. That means any edit you make to memory.py or loop.py is
immediately reflected the next time you run main.py, with no re-install step. This is the same
pyproject.toml-driven installation idea from Chapter 2, now applied to your own package
rather than a third-party one - your project is, from pip's perspective, just another
installable Python package.
If something goes wrong at this point, the most common culprits are, roughly in order of
likelihood: LM Studio's server toggle isn't actually on (Chapter 5.2), LMSTUDIO_MODEL in
.env doesn't exactly match the model ID shown in LM Studio, or the virtual environment wasn't
activated before running pip install -e . (so the dependencies installed into the wrong
Python entirely). Working through these in order will resolve the overwhelming majority of
first-run issues.
8.10 A minimal test (why testing matters even for one script)
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
pip install pytest pytest
Testing the deterministic parts (tools, memory) is cheap and catches real bugs. Testing the model's actual replies is inherently fuzzy - that's a separate discipline called LLM evaluation, mentioned below.
Why draw that line so sharply? Because calculator("2 + 2") will return "4" every single
time, on every machine, forever - it's pure, deterministic Python, exactly the kind of thing
pytest is built for. But asking "did the agent give a good answer to 'summarize this
document'?" has no single correct string to assert equality against; the same good answer could
be phrased a dozen different ways. Conflating these two kinds of testing is a common early
mistake - trying to write assert response == "exact expected string" against a live model call
produces tests that are flaky not because your code is broken, but because you're testing the
wrong kind of thing with the wrong tool.
You can grow this test file exactly like a normal codebase's test suite as the project grows:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: skipping tests entirely on "just a script" because it feels too small to bother. The
calculatorandMemorytests above take under a minute to write and will instantly catch a regression (e.g. someone "cleans up"calculatorand accidentally breaks its error handling) before it ever reaches a live conversation with a model - where the bug would be far more confusing to track down, because you'd be second-guessing the model's behavior instead of your own code.
8.11 Where to go next
| Direction | What it adds | Starting point |
|---|---|---|
| RAG (Retrieval-Augmented Generation) | Ground answers in your own documents using the embeddings idea from Chapter 4.6 | Add a vector store (e.g. chromadb), embed your docs, retrieve relevant chunks before calling the model |
| More tools | Web search, file read/write, code execution | Add entries to TOOLS + AVAILABLE_FUNCTIONS, following the calculator's shape exactly |
| Better memory | Summarize old messages instead of dropping them when the context window (Chapter 4.3) fills up | Add a summarization step that runs when token count crosses a threshold |
| Evaluation | Systematically score whether your agent's answers are actually good | Look into frameworks like promptfoo or writing your own scored test set |
| Orchestration frameworks | Pre-built agent/tool/memory scaffolding (you now understand what's underneath them) | LangChain, LlamaIndex - read their source with fresh eyes, you'll recognize every pattern here |
| Faster dependency tooling | Manifest + lock + venv in one modern tool | uv (Chapter 2.4) |
Each row in that table is, not coincidentally, an extension of a pattern you've already built by
hand in this book. RAG is "give the model more context before it answers" (Chapter 4.3's
context window idea, applied deliberately). Better memory is "manage the messages list more
carefully" (Chapter 6.2, made smarter). Evaluation is "testing," but for the fuzzy, model-driven
parts instead of the deterministic ones (Section 8.10's dividing line, extended). None of these
are new concepts - they are all refinements of the same handful of ideas this book spent eight
chapters building from scratch.
THE SHAPE OF WHAT COMES NEXT: RAG = context window (Ch 4.3) + a retrieval step before the model call Better memory = Memory class (Ch 6.2), made smarter about what it keeps More tools = TOOLS + AVAILABLE_FUNCTIONS (Ch 6.3), just more entries Evaluation = testing (8.10), applied to fuzzy model output instead of pure functions Orchestration frameworks = everything above, pre-packaged by someone else
8.12 What you actually learned
Not "how to copy an agent tutorial" - you learned:
- Python's core shapes (lists, dicts, functions, classes) well enough to read any AI SDK's source
- Why
pyproject.toml/venvexist, not just the commands - What HTTP + JSON actually are, because every API call is just that
- The real mental model behind tokens, context windows, and chat roles
- How to run a model 100% locally and for free with LM Studio
- How to turn a single model call into a looping, tool-using agent
- How to abstract providers behind one contract, so your code outlives any single vendor
That last point is the whole game in this field: providers, models, and libraries change constantly - the mental models in this book do not.
Check yourself
- Walk through the dependency diagram in 8.1 and explain why
memory.pynever needs to import anything fromproviders.py, even though both are used together insideloop.py. - What error do you get if you try to run
python src/agent/loop.pydirectly, and why does runningpython main.pyinstead avoid that error? - Explain the difference between
dependenciesand[project.optional-dependencies] dev = [...]inpyproject.toml. Which group would a new tool you add for scraping web pages belong in, if your agent needs it to actually function? - Why is
assert calculator("2 + 2") == "4"a reasonable test, butassert run_agent(provider, memory) == "The answer is four."generally is not? What's the underlying distinction being drawn in Section 8.10? - Pick one row from the "Where to go next" table and describe, in your own words, which chapter's existing concept it builds directly on top of.
- If you wanted to add a fourth provider (say, a Mistral API), list every file in the project tree you would need to touch, and every file you would be able to leave completely unchanged.