Object Oriented AI Agents with NVIDIA’s OO Agents Framework

Most agent frameworks in circulation today share the same basic shape. You write a system prompt as a string, you register a list of tools as JSON schemas, and you hand both to a runtime that decides when to call which tool. Everything about the agent is external metadata: the prompt is a text blob, the tools are dictionaries, the state lives in whatever data structures you happen to pass around, and the interface between the model and your Python code is a serialization boundary that you write by hand.

NVIDIA’s Object Oriented Agents framework, packaged as nooa, takes a different approach. An agent is a Python class. Its system prompt is the class docstring. Each of its capabilities is a method. Some of those methods are ordinary deterministic Python and behave as tools the language model may call. Other methods have ... as their body, and the framework fills in the implementation at runtime by prompting the language model with the method’s signature, docstring, and return type. State is just class fields with type annotations. There is no separate tool schema, no manual JSON glue, and no drift between what the prompt promises and what the code enforces.

This chapter builds a complete example on top of that idea: a travel planner agent that recommends a destination, prices a trip against a budget, drafts a day by day itinerary, offers packing advice, and produces a structured trip plan. It runs on NVIDIA’s free NIM inference endpoint using the same NVIDIA_client.py helper introduced in the previous chapter LLMs with Public APIs — a helper that is now itself a thin wrapper over litellm, the open source library that puts a single OpenAI-format interface in front of more than a hundred model providers.

Why represent an agent as a class

Consider the operations a travel planner needs to perform. Some are unambiguous and cheap:

  • Look up the flight cost to a city.
  • Multiply the hotel nightly rate by the number of nights.
  • Filter destinations to those under a budget.

Others require judgment, taste, or synthesis:

  • Pick the single best city for a traveler whose interests are “hiking, ancient history, and lively food markets”.
  • Draft a plausible seven day itinerary in Cusco.
  • Write a memorable one sentence packing tip.

The first group belongs in Python. Nothing is gained, and much is lost, by asking a language model to multiply 180 by 6. The second group belongs to the language model, because the space of plausible answers is huge and no closed form procedure captures what “best” means.

In a traditional agent framework you would express this split by writing Python functions for the deterministic operations, wrapping them in JSON tool schemas, and writing prompts for the generative operations as separate strings. In nooa the split shows up directly in the class body. Deterministic operations are regular methods. Generative operations are async methods with ... for a body. Both live on self, so the language model can call the deterministic tools while producing an answer for the generative ones. The type annotations on each method double as the interface contract the framework enforces on the model’s output.

What we will build

The finished program is a single file, travel_planner_agent.py, driven by a Makefile, with a small README.md for reference. The plan_trip method is the top level orchestrator. It calls one language model method to pick a destination, a second to draft an itinerary, a third for a packing tip, and finally one direct call to the low level NVIDIA_client.complete helper for a local greeting phrase. Along the way it uses the deterministic methods for cost math.

The eventual return value is a Pydantic TravelPlan with a numeric total, a boolean flag for whether the plan fits the budget, and a list of DayPlan records for each day of the trip.

The mock destination database

The example ships with a hand written dictionary of five destinations. In a real application this would be a call to a booking API, but keeping it inline makes the example easy to run and easy to hack. Here is a representative entry so you can see the shape before you read the code that consumes it:

1 "Cusco": {
2     "country": "Peru",
3     "flight": 900,
4     "hotel_night": 90,
5     "vibe": "Inca ruins, Andean markets, coca-tea culture",
6 }

Each destination has a country, a round trip flight cost in USD, a nightly hotel rate in USD, and a short “vibe” string that captures the city’s character in a form the language model can reason over. Five entries is deliberately small; the goal is that a reader can add a sixth destination in ten seconds and immediately see the agent consider it.

Structured output: the Pydantic models

Before the agent class itself, the file defines three Pydantic models. These are the return types the language model must produce. The framework parses model output against these classes, so any field that is missing, mistyped, or out of range triggers an automatic retry or a raised exception rather than silently corrupt data flowing forward.

 1 class DayPlan(BaseModel):
 2     day: int = Field(ge=1)
 3     theme: str
 4     morning: str
 5     afternoon: str
 6     evening: str
 7 
 8 
 9 class Itinerary(BaseModel):
10     days: list[DayPlan]
11 
12 
13 class TravelPlan(BaseModel):
14     destination: str
15     country: str
16     total_cost_usd: float = Field(ge=0)
17     duration_days: int = Field(ge=1)
18     within_budget: bool
19     days: list[DayPlan]
20     packing_tip: str
21     local_phrase: str

Notice Itinerary wraps a list[DayPlan]. The generative method itself returns list[DayPlan], and the orchestrator wraps that list in an Itinerary before reading .days back out to pack into the final TravelPlan. This is a small defensive choice: structured output from language models works most reliably when the top level type is a single object. The Field(ge=1) and Field(ge=0) constraints let Pydantic reject nonsensical outputs like a day zero or a negative cost.

Configuring the language model

nooa drives its own litellm client, so it can point at any OpenAI compatible endpoint by combining the openai/<model> prefix with an api_base and an api_key. The sibling NVIDIA_client.py from the previous chapter wraps litellm as well: it hardcodes _BASE_URL = "https://integrate.api.nvidia.com/v1", passes api_key=os.getenv("NVIDIA_API_KEY") on every call — litellm’s NIM provider would otherwise look for the key under NVIDIA_NIM_API_KEY — and builds its request model string as nvidia_nim/ plus the model id, so a call through that helper goes out as nvidia_nim/meta/llama-3.1-8b-instruct while its DEFAULT_MODEL constant stays the bare meta/llama-3.1-8b-instruct. Both constants are exported, and this chapter reuses them:

 1 import os
 2 import sys
 3 from pathlib import Path
 4 
 5 # Reuse endpoint + model from the sibling NVIDIA_client demo
 6 _SIBLING = Path(__file__).resolve().parent.parent / "llm_public_apis"
 7 sys.path.insert(0, str(_SIBLING))
 8 from NVIDIA_client import DEFAULT_MODEL, _BASE_URL, complete  # noqa: E402
 9 
10 from nooa import Agent  # noqa: E402
11 from nooa.unifiedllm.registry import get_llm_client  # noqa: E402
12 
13 
14 if not os.getenv("NVIDIA_API_KEY"):
15     raise SystemExit("Set NVIDIA_API_KEY first (free key at https://build.nvidia.com)")
16 
17 # nooa builds its own litellm client; litellm's "openai/" prefix means "any
18 # OpenAI-compatible endpoint", so combining it with NVIDIA_client's constants
19 # routes every call to NVIDIA NIM.
20 llm = get_llm_client(
21     f"openai/{DEFAULT_MODEL}",
22     api_base=_BASE_URL,
23     api_key=os.getenv("NVIDIA_API_KEY"),
24 )

NVIDIA_client.py is a plain file in a sibling directory rather than an installed package, so the script puts ../llm_public_apis on sys.path before importing from it. litellm itself is an ordinary PyPI dependency that uv installs like any other, so there is no in-repo library to import by path.

This llm object is then passed to the class definition itself with class TravelPlannerAgent(Agent, llm=llm):. Every generative method on the class will use this client. Because we are importing _BASE_URL and DEFAULT_MODEL from the earlier chapter’s file, swapping models is a one line change in one place — and the endpoint is declared once, in NVIDIA_client.py.

The agent class, method by method

The class is short enough that we can walk through it in three passes: the docstring, the deterministic tools, and the generative methods.

The docstring as a system prompt

1 class TravelPlannerAgent(Agent, llm=llm):
2     """You are a seasoned travel planner. You blend hard cost math from the
3     deterministic helper methods with creative, culturally-aware itinerary
4     design. Never invent prices - always call the helper methods for numeric
5     facts. Prefer destinations whose vibe genuinely matches the traveller's
6     stated interests."""

Two things worth noticing. First, the docstring is the system prompt. There is no separate system_prompt= argument. Second, the docstring names the helper methods by name and instructs the model to prefer them for numeric facts. This is how you steer the model toward using your deterministic tools rather than guessing.

Deterministic tools

Five ordinary methods act as tools the language model can call from any generative step:

 1 def list_destinations(self) -> list[str]:
 2     """Every city you can plan a trip to."""
 3     return list(DESTINATIONS.keys())
 4 
 5 def get_vibe(self, city: str) -> str:
 6     """One-line character description of the city."""
 7     return str(DESTINATIONS[city]["vibe"])
 8 
 9 def get_country(self, city: str) -> str:
10     """Country the city sits in."""
11     return str(DESTINATIONS[city]["country"])
12 
13 def estimate_cost(self, city: str, nights: int) -> float:
14     """Round-trip flight + nights * hotel, in USD."""
15     d = DESTINATIONS[city]
16     return float(d["flight"]) + float(d["hotel_night"]) * nights
17 
18 def cheapest_within(self, budget: float, nights: int) -> list[str]:
19     """Cities whose total (flight + nights * hotel) is <= budget."""
20     return [c for c in DESTINATIONS if self.estimate_cost(c, nights) <= budget]

These are plain Python. There is no @tool decorator, no schema declaration, no registration step. nooa’s runtime gives the language model a REPL-like environment with self bound, and the model calls these methods by writing Python such as self.cheapest_within(2500, 6). Because the signatures and docstrings are already in the class, the model has everything it needs to pick the right call without additional glue.

Generative methods

The three generative methods are declared the same way as normal Python methods, but their bodies are just ...:

 1 async def recommend_destination(
 2     self, interests: str, budget: float, nights: int
 3 ) -> str:
 4     """Pick the SINGLE best city for `interests` within `budget` for
 5     `nights` nights. First call `cheapest_within` to filter, then
 6     compare `get_vibe` for each candidate. Return only the city name -
 7     no punctuation, no explanation."""
 8     ...
 9 
10 async def draft_itinerary(self, city: str, nights: int) -> list[DayPlan]:
11     """Produce a `nights + 1` day itinerary for `city`. Use `get_vibe`
12     for local flavor. Number days starting at 1. Each day should have
13     a theme plus morning/afternoon/evening activities."""
14     ...
15 
16 async def packing_tip(self, city: str) -> str:
17     """One vivid sentence of packing advice tailored to `city`."""
18     ...

At runtime, nooa intercepts every call to one of these methods, packages the class docstring, the method docstring, the method signature, and the return type into a prompt, sends that prompt to the configured llm, and validates the reply against the annotated return type. If the return type is a Pydantic model the reply is parsed into an instance. If validation fails the framework can retry.

The docstrings do more than describe intent. In recommend_destination the docstring names the two deterministic helper methods the model should call, so the model plans, filters, and compares before answering. In draft_itinerary the docstring establishes the shape of a day and the numbering convention.

The orchestrator

The last method is regular async Python. It calls the generative methods, calls the deterministic methods, and calls the sibling NVIDIA_client.complete helper directly for a bonus local greeting phrase:

 1 async def plan_trip(
 2     self, interests: str, budget: float, nights: int
 3 ) -> TravelPlan:
 4     """End-to-end: choose city -> cost it -> itinerary -> packing tip
 5     -> a local greeting phrase (via a direct NVIDIA_client call)."""
 6     raw = (await self.recommend_destination(interests, budget, nights)).strip()
 7     city = raw.strip('"').splitlines()[0].strip()
 8     if city not in DESTINATIONS:
 9         affordable = self.cheapest_within(budget, nights)
10         city = affordable[0] if affordable else next(iter(DESTINATIONS))
11 
12     cost = self.estimate_cost(city, nights)
13     itinerary = Itinerary(days=await self.draft_itinerary(city, nights))
14     tip = await self.packing_tip(city)
15 
16     # Bonus flourish: one direct sync call through the sibling
17     # NVIDIA_client, which is backed by litellm. Demonstrates that the OO
18     # agent framework composes cleanly with plain litellm calls to the same
19     # model.
20     phrase = complete(
21         f"Give ONE short local greeting phrase a traveller could use in "
22         f"{city}, {self.get_country(city)}, with a phonetic pronunciation "
23         f"in parentheses. Reply with the phrase only - no preamble."
24     ).strip()
25 
26     return TravelPlan(
27         destination=city,
28         country=self.get_country(city),
29         total_cost_usd=cost,
30         duration_days=nights + 1,
31         within_budget=cost <= budget,
32         days=itinerary.days,
33         packing_tip=tip,
34         local_phrase=phrase,
35     )

Two design choices in this method deserve attention. First, even though recommend_destination is instructed to return only a city name, the orchestrator defensively strips quotes, takes the first line, and falls back to the cheapest affordable option if the reply is not a known city. Real language model output is noisy, and a wrapper of a few lines is far cheaper than a corrupted downstream stage. Second, the complete call at the end is deliberately synchronous. It shows that the object oriented framework composes cleanly with the kind of plain litellm call the NVIDIA_client helper makes to the same model; you do not have to route everything through the agent to benefit from it.

Complete listing of travel_planner_agent.py:

Let’s wrap this example by listing the code in its entirety:

  1 # travel_planner_agent.py - Trip Planner built with NVIDIA OO Agents (nooa)
  2 
  3 # /// script
  4 # requires-python = ">=3.10"
  5 # dependencies = [
  6 #   "nooa @ git+https://github.com/NVIDIA-NeMo/labs-OO-Agents",
  7 #   "openai>=1.0",
  8 #   "pydantic>=2",
  9 # ]
 10 # ///
 11 
 12 import asyncio
 13 import os
 14 import sys
 15 from pathlib import Path
 16 
 17 from pydantic import BaseModel, Field
 18 
 19 # Reuse endpoint + model from the sibling NVIDIA_client demo
 20 _SIBLING = Path(__file__).resolve().parent.parent / "llm_public_apis"
 21 sys.path.insert(0, str(_SIBLING))
 22 from NVIDIA_client import DEFAULT_MODEL, _BASE_URL, complete  # noqa: E402
 23 
 24 from nooa import Agent  # noqa: E402
 25 from nooa.unifiedllm.registry import get_llm_client  # noqa: E402
 26 
 27 
 28 if not os.getenv("NVIDIA_API_KEY"):
 29     raise SystemExit("Set NVIDIA_API_KEY first (free key at https://build.nvidia.com)")
 30 
 31 # nooa builds its own litellm client; litellm's "openai/" prefix means "any
 32 # OpenAI-compatible endpoint", so combining it with NVIDIA_client's constants
 33 # routes every call to NVIDIA NIM.
 34 llm = get_llm_client(
 35     f"openai/{DEFAULT_MODEL}",
 36     api_base=_BASE_URL,
 37     api_key=os.getenv("NVIDIA_API_KEY"),
 38 )
 39 
 40 
 41 # ── Mock "destination database" - edit freely to add your own cities ──────
 42 DESTINATIONS: dict[str, dict[str, object]] = {
 43     "Kyoto":     {"country": "Japan",    "flight": 1400, "hotel_night": 180, "vibe": "zen temples, bamboo forests, matcha rituals"},
 44     "Reykjavik": {"country": "Iceland",  "flight":  650, "hotel_night": 220, "vibe": "geothermal lagoons, aurora borealis, glacier hikes"},
 45     "Cusco":     {"country": "Peru",     "flight":  900, "hotel_night":  90, "vibe": "Inca ruins, Andean markets, coca-tea culture"},
 46     "Lisbon":    {"country": "Portugal", "flight":  550, "hotel_night": 140, "vibe": "tile-clad hillsides, fado music, pastel de nata"},
 47     "Marrakech": {"country": "Morocco",  "flight":  700, "hotel_night": 110, "vibe": "labyrinth souks, Sahara excursions, mint-tea evenings"},
 48 }
 49 
 50 
 51 class DayPlan(BaseModel):
 52     day: int = Field(ge=1)
 53     theme: str
 54     morning: str
 55     afternoon: str
 56     evening: str
 57 
 58 
 59 class Itinerary(BaseModel):
 60     days: list[DayPlan]
 61 
 62 
 63 class TravelPlan(BaseModel):
 64     destination: str
 65     country: str
 66     total_cost_usd: float = Field(ge=0)
 67     duration_days: int = Field(ge=1)
 68     within_budget: bool
 69     days: list[DayPlan]
 70     packing_tip: str
 71     local_phrase: str
 72 
 73 
 74 class TravelPlannerAgent(Agent, llm=llm):
 75     """You are a seasoned travel planner. You blend hard cost math from the
 76     deterministic helper methods with creative, culturally-aware itinerary
 77     design. Never invent prices - always call the helper methods for numeric
 78     facts. Prefer destinations whose vibe genuinely matches the traveller's
 79     stated interests."""
 80 
 81     # ── Deterministic "tool" methods (regular Python, no `...`) ──────────
 82     #    The LLM can call any of these on `self` while it works.
 83 
 84     def list_destinations(self) -> list[str]:
 85         """Every city you can plan a trip to."""
 86         return list(DESTINATIONS.keys())
 87 
 88     def get_vibe(self, city: str) -> str:
 89         """One-line character description of the city."""
 90         return str(DESTINATIONS[city]["vibe"])
 91 
 92     def get_country(self, city: str) -> str:
 93         """Country the city sits in."""
 94         return str(DESTINATIONS[city]["country"])
 95 
 96     def estimate_cost(self, city: str, nights: int) -> float:
 97         """Round-trip flight + nights * hotel, in USD."""
 98         d = DESTINATIONS[city]
 99         return float(d["flight"]) + float(d["hotel_night"]) * nights  # pyright: ignore[reportArgumentType]
100 
101     def cheapest_within(self, budget: float, nights: int) -> list[str]:
102         """Cities whose total (flight + nights * hotel) is <= budget."""
103         return [c for c in DESTINATIONS if self.estimate_cost(c, nights) <= budget]
104 
105     # ── LLM-driven generation methods (body is `...`) ────────────────────
106     #    Signature + docstring become the contract; the framework generates
107     #    the implementation at call time using the configured `llm`.
108 
109     async def recommend_destination(
110         self, interests: str, budget: float, nights: int
111     ) -> str:
112         """Pick the SINGLE best city for `interests` within `budget` for
113         `nights` nights. First call `cheapest_within` to filter, then
114         compare `get_vibe` for each candidate. Return only the city name -
115         no punctuation, no explanation."""
116         ...
117 
118     async def draft_itinerary(self, city: str, nights: int) -> list[DayPlan]:
119         """Produce a `nights + 1` day itinerary for `city`. Use `get_vibe`
120         for local flavor. Number days starting at 1. Each day should have
121         a theme plus morning/afternoon/evening activities."""
122         ...
123 
124     async def packing_tip(self, city: str) -> str:
125         """One vivid sentence of packing advice tailored to `city`."""
126         ...
127 
128     # ── Orchestrator: plain async Python that composes the above ─────────
129 
130     async def plan_trip(
131         self, interests: str, budget: float, nights: int
132     ) -> TravelPlan:
133         """End-to-end: choose city -> cost it -> itinerary -> packing tip
134         -> a local greeting phrase (via a direct NVIDIA_client call)."""
135         raw = (await self.recommend_destination(interests, budget, nights)).strip()
136         city = raw.strip('"').splitlines()[0].strip()
137         if city not in DESTINATIONS:
138             affordable = self.cheapest_within(budget, nights)
139             city = affordable[0] if affordable else next(iter(DESTINATIONS))
140 
141         cost = self.estimate_cost(city, nights)
142         itinerary = Itinerary(days=await self.draft_itinerary(city, nights))
143         tip = await self.packing_tip(city)
144 
145         # Bonus flourish: one direct sync call through the sibling
146         # NVIDIA_client, which is backed by litellm. Demonstrates that the OO
147         # agent framework composes cleanly with plain litellm calls to the same
148         # model.
149         phrase = complete(
150             f"Give ONE short local greeting phrase a traveller could use in "
151             f"{city}, {self.get_country(city)}, with a phonetic pronunciation "
152             f"in parentheses. Reply with the phrase only - no preamble."
153         ).strip()
154 
155         return TravelPlan(
156             destination=city,
157             country=self.get_country(city),
158             total_cost_usd=cost,
159             duration_days=nights + 1,
160             within_budget=cost <= budget,
161             days=itinerary.days,
162             packing_tip=tip,
163             local_phrase=phrase,
164         )
165 
166 
167 async def main() -> None:
168     agent = TravelPlannerAgent()
169     plan = await agent.plan_trip(
170         interests="hiking, ancient history, and lively food markets",
171         budget=2500.0,
172         nights=6,
173     )
174     print(plan.model_dump_json(indent=2))
175 
176 
177 if __name__ == "__main__":
178     asyncio.run(main())

We will run this example after building a Makefile to run it.

The Makefile and PEP 723 script metadata

The block near the top of the file with # /// script is a PEP 723 header. It tells uv which Python version and which packages the script needs. Because of this header, uv run travel_planner_agent.py will create an ephemeral virtual environment on first run, install nooa, openai, and pydantic into it, and cache the environment for subsequent runs. No pyproject.toml is needed and no uv sync step is required. The sibling helper is not in that list because it is not a package: the script only needs ../llm_public_apis/NVIDIA_client.py on sys.path, which the sys.path line above the import takes care of, and litellm itself arrives as a dependency of nooa.

The Makefile uses this feature directly:

1 .PHONY: run clean
2 
3 run:
4     uv run travel_planner_agent.py
5 
6 clean:
7     find . -type d -name __pycache__ -not -path './.venv/*' -exec rm -rf {} +
8     find . -type d -name .ruff_cache -exec rm -rf {} +

Running the example

After exporting your NVIDIA API key and running make run, the output is a single JSON document. Every language model call is nondeterministic, so your exact wording will differ, but the structure and the numeric fields are stable. Here is a representative run:

 1 {
 2   "destination": "Cusco",
 3   "country": "Peru",
 4   "total_cost_usd": 1440.0,
 5   "duration_days": 7,
 6   "within_budget": true,
 7   "days": [
 8     {
 9       "day": 1,
10       "theme": "Arrival and gentle acclimatization",
11       "morning": "Land in Cusco and check in slowly; sip coca tea to settle the altitude.",
12       "afternoon": "Amble through San Blas, ducking into artisan studios and Inca stonework alleys.",
13       "evening": "Early quinoa-and-trout dinner near the Plaza de Armas."
14     },
15     {
16       "day": 2,
17       "theme": "Sacred Valley markets",
18       "morning": "Ride to Pisac; wander the Sunday market stacked with alpaca weavings and roasted corn.",
19       "afternoon": "Climb the Pisac ruins for a first taste of Inca terracing.",
20       "evening": "Return to Cusco for pisco sours and lomo saltado."
21     },
22     {
23       "day": 3,
24       "theme": "Andean day hike",
25       "morning": "Drive to the Rainbow Mountain trailhead and start the climb at first light.",
26       "afternoon": "Descend slowly; picnic near a glacial stream.",
27       "evening": "Recover with a hearty aji de gallina back in Cusco."
28     },
29     {
30       "day": 4,
31       "theme": "Ollantaytambo and the train",
32       "morning": "Explore Ollantaytambo's fortress and its living Inca street grid.",
33       "afternoon": "Board the afternoon train to Aguas Calientes at the foot of Machu Picchu.",
34       "evening": "Riverside dinner and an early night before sunrise."
35     },
36     {
37       "day": 5,
38       "theme": "Machu Picchu",
39       "morning": "First bus up the switchbacks; walk the classic circuit as mist lifts off the ruins.",
40       "afternoon": "Hike to the Sun Gate for a wider view.",
41       "evening": "Return train to Ollantaytambo, taxi back to Cusco."
42     },
43     {
44       "day": 6,
45       "theme": "Food markets and museums",
46       "morning": "Graze the San Pedro market; try chicha morada and salteñas.",
47       "afternoon": "Visit the Inka Museum for context on the ruins you have walked.",
48       "evening": "Farewell dinner at a Novoandino tasting menu."
49     },
50     {
51       "day": 7,
52       "theme": "Departure",
53       "morning": "Slow breakfast, last coca tea, final walk around the Plaza de Armas.",
54       "afternoon": "Airport transfer.",
55       "evening": "Fly home."
56     }
57   ],
58   "packing_tip": "Layer merino for icy dawn ascents that surrender to strong Andean sun by noon.",
59   "local_phrase": "Allillanchu (ah-lee-YAHN-choo)"
60 }

Interpreting the output

Several things in this output are worth pausing on.

The total_cost_usd is 1440.0, which is exactly 900 + 90 * 6, matching the Cusco entry in DESTINATIONS. The number was computed by estimate_cost, not invented by the language model. This is the payoff of writing deterministic tool methods: numeric fields are always right, because they never leave Python.

The within_budget flag is true, because 1440.0 <= 2500.0. Again, this is a plain Python comparison in the orchestrator, not a judgment call by the model. If the recommended city had come back over budget, this flag would be false and the caller could take corrective action.

The destination is Cusco. The traveler’s stated interests were “hiking, ancient history, and lively food markets”. Compare against the vibe strings for each destination: Kyoto is temples and matcha, Reykjavik is glaciers and lagoons, Lisbon is tiles and pastries, Marrakech is souks and Sahara excursions. Cusco is the only destination whose vibe hits all three of hiking, ancient history, and food markets at once. The model made a defensible pick using only the short vibe descriptions in the database, filtered against the budget by way of cheapest_within.

The itinerary is seven DayPlan objects, matching nights + 1 = 7. Every day has theme, morning, afternoon, and evening populated as strings. This is the Pydantic contract at work. If the model had returned a day with a missing field or an integer where a string was expected, nooa would have raised or retried, and the caller would never see a half formed record.

The local_phrase came from the direct complete call rather than from the agent. This is important because it shows that you can freely mix framework calls and low level calls. The agent’s own methods go through nooa’s litellm client; complete goes through the NVIDIA_client wrapper, which is litellm underneath; both hit the same NVIDIA NIM endpoint using the same NVIDIA_API_KEY.

Wrap Up

The design principle behind nooa is that the boundary between “code the developer wrote” and “code the model wrote” should be a method boundary, not a serialization boundary. Once you accept that framing, most of the machinery of traditional agent frameworks becomes unnecessary. There is no tool registry because methods are already registered by being on the class. There is no prompt file because docstrings are prompts. There is no output parser because return types are already annotated and Pydantic already knows how to validate against them.

The travel planner in this chapter has fewer than two hundred lines of code and demonstrates six framework capabilities: class based agent definition, docstring driven prompting, deterministic tool methods, language model generation methods, structured Pydantic output, and clean composition with plain litellm calls to the same model through the NVIDIA_client helper. Every one of those capabilities is expressed as ordinary Python. That is the point.

The example is deliberately small enough to hack. Add a destination and the recommender considers it on the next run. Add a helper method such as weather_score(city, month) -> float and any generative method that mentions it in its docstring can call it. Add a whole new generative method with a ... body and a signature, and it works from the first call. The framework fades into the background and leaves you writing Python.

Optional Practice Problems

The following exercises range from small edits to more ambitious extensions. All of them build on the code in this chapter.

  1. Add a destination. Append a new entry to DESTINATIONS, for example Reykjavik replaced with Queenstown or Ushuaia, and change the traveler’s interests to something that clearly favors your new city. Confirm from the JSON output that the model picks it.

  2. Add a deterministic tool method. Write a season_score(self, city: str, month: str) -> float method that returns a number between zero and one based on any rules you like (for example, prefer Kyoto in April, Cusco in June, Marrakech in October). Update the docstring on recommend_destination to mention the new tool and rerun. Inspect the output to confirm the picks shift with the month.

  3. Add a new generation method. Declare async def budget_swap_suggestions(self, city: str, target_savings: float) -> list[str]: with a ... body and a docstring instructing the model to propose two or three concrete substitutions (for example, “swap the Andean train for a shared van, save around one hundred fifty USD”). Wire it into plan_trip and add the returned list to TravelPlan.

  4. Persist the plan to disk. Extend the orchestrator to write the returned TravelPlan as JSON to a file whose name includes the destination and the current date. Confirm the file round trips through TravelPlan.model_validate_json.

  5. Swap the model. Change the DEFAULT_MODEL constant in ../llm_public_apis/NVIDIA_client.py to a different model available on NVIDIA NIM. Rerun and compare the tone and structure of the itineraries.

  6. Multi city trip. Extend the agent with a plan_multi_city_trip(interests, budget, nights, cities: int) generative method that returns a list of TravelPlan objects, one per leg. You will need to think carefully about how to split the budget between legs; a deterministic helper method that allocates the budget proportionally to leg length is a good starting point.

  7. Better recovery from a bad recommendation. The orchestrator’s current fallback picks the first affordable city if the model returns something unrecognized. Improve this by writing a second attempt: prompt the model with the specific list of affordable cities and ask it to pick from that list. Only fall back to the arbitrary first pick if that second attempt also fails.

  8. Add a small evaluation harness. Write a script that calls plan_trip with three different (interests, budget, nights) inputs, checks that each returned plan is within budget and has the expected number of days, and prints a pass/fail summary. This is a good foundation for regression testing your agent as you extend it.