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.

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]. This is a small defensive choice. Structured output from language models works most reliably when the top level type is a single object, so draft_itinerary returns Itinerary and the orchestrator unwraps .days before packing them into the final TravelPlan. 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 uses the litellm library under the hood, so it can point at any OpenAI compatible endpoint by combining the openai/<model> prefix with an api_base and api_key. Reusing the constants from the previous chapter’s NVIDIA_client.py file, the model client is:

 1 from NVIDIA_client import DEFAULT_MODEL, _BASE_URL, complete
 2 
 3 from nooa import Agent
 4 from nooa.unifiedllm.registry import get_llm_client
 5 
 6 llm = get_llm_client(
 7     f"openai/{DEFAULT_MODEL}",
 8     api_base=_BASE_URL,
 9     api_key=os.getenv("NVIDIA_API_KEY"),
10 )

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.

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) -> Itinerary:
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 = await self.draft_itinerary(city, nights)
14     tip = await self.packing_tip(city)
15 
16     phrase = complete(
17         f"Give ONE short local greeting phrase a traveller could use in "
18         f"{city}, {self.get_country(city)}, with a phonetic pronunciation "
19         f"in parentheses. Reply with the phrase only - no preamble."
20     ).strip()
21 
22     return TravelPlan(
23         destination=city,
24         country=self.get_country(city),
25         total_cost_usd=cost,
26         duration_days=nights + 1,
27         within_budget=cost <= budget,
28         days=itinerary.days,
29         packing_tip=tip,
30         local_phrase=phrase,
31     )

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 plain HTTP style calls to the same endpoint; you do not have to route everything through the agent to benefit from it.

Complete file listing

The full travel_planner_agent.py:

  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",
  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 _SIBLING = Path(__file__).resolve().parent.parent / "llm_public_apis"
 20 sys.path.insert(0, str(_SIBLING))
 21 from NVIDIA_client import DEFAULT_MODEL, _BASE_URL, complete  # noqa: E402
 22 
 23 from nooa import Agent  # noqa: E402
 24 from nooa.unifiedllm.registry import get_llm_client  # noqa: E402
 25 
 26 
 27 if not os.getenv("NVIDIA_API_KEY"):
 28     raise SystemExit("Set NVIDIA_API_KEY first (free key at https://build.nvidia.com)")
 29 
 30 llm = get_llm_client(
 31     f"openai/{DEFAULT_MODEL}",
 32     api_base=_BASE_URL,
 33     api_key=os.getenv("NVIDIA_API_KEY"),
 34 )
 35 
 36 
 37 DESTINATIONS: dict[str, dict[str, object]] = {
 38     "Kyoto":     {"country": "Japan",    "flight": 1400, "hotel_night": 180, "vibe": "zen temples, bamboo forests, matcha rituals"},
 39     "Reykjavik": {"country": "Iceland",  "flight":  650, "hotel_night": 220, "vibe": "geothermal lagoons, aurora borealis, glacier hikes"},
 40     "Cusco":     {"country": "Peru",     "flight":  900, "hotel_night":  90, "vibe": "Inca ruins, Andean markets, coca-tea culture"},
 41     "Lisbon":    {"country": "Portugal", "flight":  550, "hotel_night": 140, "vibe": "tile-clad hillsides, fado music, pastel de nata"},
 42     "Marrakech": {"country": "Morocco",  "flight":  700, "hotel_night": 110, "vibe": "labyrinth souks, Sahara excursions, mint-tea evenings"},
 43 }
 44 
 45 
 46 class DayPlan(BaseModel):
 47     day: int = Field(ge=1)
 48     theme: str
 49     morning: str
 50     afternoon: str
 51     evening: str
 52 
 53 
 54 class Itinerary(BaseModel):
 55     days: list[DayPlan]
 56 
 57 
 58 class TravelPlan(BaseModel):
 59     destination: str
 60     country: str
 61     total_cost_usd: float = Field(ge=0)
 62     duration_days: int = Field(ge=1)
 63     within_budget: bool
 64     days: list[DayPlan]
 65     packing_tip: str
 66     local_phrase: str
 67 
 68 
 69 class TravelPlannerAgent(Agent, llm=llm):
 70     """You are a seasoned travel planner. You blend hard cost math from the
 71     deterministic helper methods with creative, culturally-aware itinerary
 72     design. Never invent prices - always call the helper methods for numeric
 73     facts. Prefer destinations whose vibe genuinely matches the traveller's
 74     stated interests."""
 75 
 76     def list_destinations(self) -> list[str]:
 77         """Every city you can plan a trip to."""
 78         return list(DESTINATIONS.keys())
 79 
 80     def get_vibe(self, city: str) -> str:
 81         """One-line character description of the city."""
 82         return str(DESTINATIONS[city]["vibe"])
 83 
 84     def get_country(self, city: str) -> str:
 85         """Country the city sits in."""
 86         return str(DESTINATIONS[city]["country"])
 87 
 88     def estimate_cost(self, city: str, nights: int) -> float:
 89         """Round-trip flight + nights * hotel, in USD."""
 90         d = DESTINATIONS[city]
 91         return float(d["flight"]) + float(d["hotel_night"]) * nights
 92 
 93     def cheapest_within(self, budget: float, nights: int) -> list[str]:
 94         """Cities whose total (flight + nights * hotel) is <= budget."""
 95         return [c for c in DESTINATIONS if self.estimate_cost(c, nights) <= budget]
 96 
 97     async def recommend_destination(
 98         self, interests: str, budget: float, nights: int
 99     ) -> str:
100         """Pick the SINGLE best city for `interests` within `budget` for
101         `nights` nights. First call `cheapest_within` to filter, then
102         compare `get_vibe` for each candidate. Return only the city name."""
103         ...
104 
105     async def draft_itinerary(self, city: str, nights: int) -> Itinerary:
106         """Produce a `nights + 1` day itinerary for `city`. Use `get_vibe`
107         for local flavor. Number days starting at 1."""
108         ...
109 
110     async def packing_tip(self, city: str) -> str:
111         """One vivid sentence of packing advice tailored to `city`."""
112         ...
113 
114     async def plan_trip(
115         self, interests: str, budget: float, nights: int
116     ) -> TravelPlan:
117         """End-to-end: choose city, cost it, itinerary, packing tip, phrase."""
118         raw = (await self.recommend_destination(interests, budget, nights)).strip()
119         city = raw.strip('"').splitlines()[0].strip()
120         if city not in DESTINATIONS:
121             affordable = self.cheapest_within(budget, nights)
122             city = affordable[0] if affordable else next(iter(DESTINATIONS))
123 
124         cost = self.estimate_cost(city, nights)
125         itinerary = await self.draft_itinerary(city, nights)
126         tip = await self.packing_tip(city)
127 
128         phrase = complete(
129             f"Give ONE short local greeting phrase a traveller could use in "
130             f"{city}, {self.get_country(city)}, with a phonetic pronunciation "
131             f"in parentheses. Reply with the phrase only - no preamble."
132         ).strip()
133 
134         return TravelPlan(
135             destination=city,
136             country=self.get_country(city),
137             total_cost_usd=cost,
138             duration_days=nights + 1,
139             within_budget=cost <= budget,
140             days=itinerary.days,
141             packing_tip=tip,
142             local_phrase=phrase,
143         )
144 
145 
146 async def main() -> None:
147     agent = TravelPlannerAgent()
148     plan = await agent.plan_trip(
149         interests="hiking, ancient history, and lively food markets",
150         budget=2500.0,
151         nights=6,
152     )
153     print(plan.model_dump_json(indent=2))
154 
155 
156 if __name__ == "__main__":
157     asyncio.run(main())

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 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. 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 HTTP calls to the same endpoint. 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.