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.
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.Add a deterministic tool method. Write a
season_score(self, city: str, month: str) -> floatmethod 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 onrecommend_destinationto mention the new tool and rerun. Inspect the output to confirm the picks shift with the month.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 intoplan_tripand add the returned list toTravelPlan.Persist the plan to disk. Extend the orchestrator to write the returned
TravelPlanas JSON to a file whose name includes the destination and the current date. Confirm the file round trips throughTravelPlan.model_validate_json.Swap the model. Change the
DEFAULT_MODELconstant in../llm_public_apis/NVIDIA_client.pyto a different model available on NVIDIA NIM. Rerun and compare the tone and structure of the itineraries.Multi city trip. Extend the agent with a
plan_multi_city_trip(interests, budget, nights, cities: int)generative method that returns a list ofTravelPlanobjects, 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.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.
Add a small evaluation harness. Write a script that calls
plan_tripwith 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.