Back to labLearn AIShovon Saha

chapter 08

Capstone Project

Assembling the full modular, enterprise-shaped agent project.

Contents

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.py

This 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.py import something from loop.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

python · sandbox

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

python · sandbox

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

python · sandbox

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

python · sandbox

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.py directly with python src/agent/loop.py and getting an ImportError: 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 what main.py does via from 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

python · sandbox

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)

python · sandbox

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:

python · sandbox

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 calculator and Memory tests above take under a minute to write and will instantly catch a regression (e.g. someone "cleans up" calculator and 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

DirectionWhat it addsStarting point
RAG (Retrieval-Augmented Generation)Ground answers in your own documents using the embeddings idea from Chapter 4.6Add a vector store (e.g. chromadb), embed your docs, retrieve relevant chunks before calling the model
More toolsWeb search, file read/write, code executionAdd entries to TOOLS + AVAILABLE_FUNCTIONS, following the calculator's shape exactly
Better memorySummarize old messages instead of dropping them when the context window (Chapter 4.3) fills upAdd a summarization step that runs when token count crosses a threshold
EvaluationSystematically score whether your agent's answers are actually goodLook into frameworks like promptfoo or writing your own scored test set
Orchestration frameworksPre-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 toolingManifest + lock + venv in one modern tooluv (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/venv exist, 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.py never needs to import anything from providers.py, even though both are used together inside loop.py.
  • What error do you get if you try to run python src/agent/loop.py directly, and why does running python main.py instead avoid that error?
  • Explain the difference between dependencies and [project.optional-dependencies] dev = [...] in pyproject.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, but assert 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.