Where we left off
Every lesson since Lesson 20 has hand-rolled a list of documents and a hand-rolled tool registry, rebuilt from scratch inside main(). This lesson draws the same boundary naive_rag Lesson 23 drew for a plain retrieval pipeline, ingest() (expensive, once) and ask() (cheap, per question), applied to this course's agent loop, and graduates the hand-rolled list-based store to a real vector database, chromadb, the same graduation naive_rag Lesson 20 made.
What lives inside `State`
State = tuple[chromadb.Collection, ToolRegistry]This course's State, the thing ingest() returns and ask() consumes, is a two-tuple: a ready-to-query chromadb.Collection (everything search_notes() needs to retrieve), and a ToolRegistry (dict[str, Tool], Lesson 21's registry, already built against that same collection). Anything downstream holding a State, a script's main(), Lesson 24's FastAPI service, or a later course's router, can call ask(query, state, k) without knowing chromadb, function calling, or this course's specific toolset are involved at all. This is stated explicitly here, rather than left implicit, specifically so adaptive_rag Lesson 21 (a later course in this series) can wire this strategy in by reading this paragraph, not this file's full implementation.
The code, piece by piece
def tools(collection: chromadb.Collection) -> ToolRegistry: return {"search_notes": Tool(..., fn=lambda query: search_notes(query, collection)), ...}Lesson 21's build_registry(), renamed tools() to match this course's spec, and repointed at a chromadb.Collection instead of a plain Python list, everything else about it (declaration and function bundled together, one dict entry per tool) is unchanged.
def run_agent(query: str, registry: ToolRegistry, max_steps: int = MAX_STEPS) -> str: ...Lesson 21's loop, unchanged.
def ingest(notes_dir: Path, chroma_client) -> State: ... registry = tools(collection) return collection, registry
def ask(query: str, state: State, k: int = 1) -> str: _, registry = state return run_agent(query, registry)ingest() does the expensive, once-per-run work: embed every note, load it into a fresh chromadb collection, build the tool registry against that collection, and return both as State. ask() does the cheap, per-question work: unpack the registry from State, hand it to run_agent(). This is tools() + run_agent() at the inner boundary, ingest() + ask() at the outer one, both boundaries this course's spec asked for, drawn in the same two functions.
Why `k` is accepted but only loosely used
The series' ask(query, state, k) signature includes k, the number of chunks to retrieve, because every other course's retrieval genuinely varies with it. This course's search_notes() tool always retrieves one document per call, by design, since Lesson 10: a question needing facts from multiple documents gets that by calling the tool again, not by asking for a bigger k in one call. ask() still accepts k for signature compatibility with the rest of the series, worth stating explicitly rather than leaving as an unexplained unused parameter.
Checkpoint
- `State = tuple[chromadb.Collection, ToolRegistry]`: this course's answer to the series' shared Strategy protocol, a ready collection plus a registry already built against it.
- `ingest(notes_dir, chroma_client) -> State`: expensive, runs once.
- `ask(query, state, k) -> str`: cheap, runs per question, wraps
run_agent()internally. tools()andrun_agent()are this course's inner boundary;ingest()andask()are the series' shared outer boundary, wrapping them.
If anything here still feels unclear, ask before moving to Lesson 24.