← Back to curriculum

Module 8 — Agentic AI

Project: multi-agent travel planner

Planner + executor agents, weather/maps APIs, MongoDB or file memory for preferences, retries, and a Next.js UI with reasoning trace.

~300 min read + exercises

Project: multi-agent travel planner

Before we begin

Build an interview-ready demo: user asks for a trip plan; a planner agent drafts steps; an executor agent calls weather and maps/geocoding APIs; memory stores preferences; the UI shows reasoning steps.

Figure

Project architecture

Usertrip askPlannerplanExecutorAPIsMemoryprefsUIsteps
User → planner → executor + APIs → memory → UI with trace.

How this connects to Module 8

LessonWhere you use it
Agents vs chainsPlanner proposes; executor grounds with tools
Tool callingget_weather, geocode_city JSON schemas
MemoryUser prefs injected into planner system prompt
ReAct / loopsObserve tool result → replan on failure
GuardrailsMax retries, iteration cap, read-only tools

Folder layout:

text
travel-planner/
  agents/
    planner.py
    executor.py
    graph.py           # LangGraph or manual loop
  tools/
    weather.py
    geocode.py
    memory.py
  server/
    app.py             # FastAPI or Flask
  app/
    api/travel-plan/route.ts
    travel-lab/page.tsx

What you will build

  1. Planner agent — itinerary outline from user request + stored prefs.
  2. Executor agentget_weather, geocode_city, optional distance_estimate tools.
  3. Memory — MongoDB or JSON file (preferredClimate, homeAirport, past destinations).
  4. Retry logic — bad city name, API timeout → replan (max 3).
  5. Next.js UI — chat + expandable reasoning trace (thoughts, tool calls, observations).

Estimated time: 5–8 hours.


Before you start

  • Finish Module 8 quiz.
  • LLM API with tool calling.
  • OpenWeatherMap API key (or similar).
  • Optional: OpenRouteService / Google Maps for distances.
  • pip install langgraph langchain-openai pymongo python-dotenv requests

Step 1 — Define tools (executor)

Goal: Small, testable functions the LLM can call — each returns structured JSON.

python
# tools/weather.py
import os
import requests
 
def get_weather(lat: float, lon: float) -> dict:
    key = os.environ["OPENWEATHER_API_KEY"]
    url = "https://api.openweathermap.org/data/2.5/weather"
    r = requests.get(url, params={"lat": lat, "lon": lon, "appid": key, "units": "metric"}, timeout=10)
    r.raise_for_status()
    data = r.json()
    return {
        "temp_c": data["main"]["temp"],
        "description": data["weather"][0]["description"],
        "humidity": data["main"]["humidity"],
    }
python
# tools/geocode.py
def geocode_city(city: str) -> dict:
    key = os.environ["OPENWEATHER_API_KEY"]
    url = "https://api.openweathermap.org/geo/1.0/direct"
    r = requests.get(url, params={"q": city, "limit": 1, "appid": key}, timeout=10)
    r.raise_for_status()
    hits = r.json()
    if not hits:
        return {"error": f"city not found: {city}"}
    h = hits[0]
    return {"city": h["name"], "country": h.get("country"), "lat": h["lat"], "lon": h["lon"]}

Tool schemas for the LLM:

python
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "geocode_city",
            "description": "Resolve a city name to latitude and longitude",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Current weather for coordinates",
            "parameters": {
                "type": "object",
                "properties": {
                    "lat": {"type": "number"},
                    "lon": {"type": "number"},
                },
                "required": ["lat", "lon"],
            },
        },
    },
]

Dispatch table:

python
def run_tool(name: str, args: dict) -> dict:
    if name == "geocode_city":
        return geocode_city(args["city"])
    if name == "get_weather":
        return get_weather(args["lat"], args["lon"])
    return {"error": f"unknown tool {name}"}

Step 2 — Memory layer

python
# tools/memory.py
import json
from pathlib import Path
 
STORE = Path("data/user_prefs.json")
 
def load_user_prefs(user_id: str) -> dict:
    if not STORE.exists():
        return {}
    all_users = json.loads(STORE.read_text())
    return all_users.get(user_id, {})
 
def save_preference(user_id: str, key: str, value: str) -> str:
    all_users = json.loads(STORE.read_text()) if STORE.exists() else {}
    prefs = all_users.setdefault(user_id, {})
    prefs[key] = value
    STORE.parent.mkdir(exist_ok=True)
    STORE.write_text(json.dumps(all_users, indent=2))
    return f"saved {key}={value}"
 
def append_trip_history(user_id: str, summary: dict):
    prefs = load_user_prefs(user_id)
    history = prefs.setdefault("past_trips", [])
    history.append(summary)
    save_preference(user_id, "past_trips", json.dumps(history))  # or dedicated fn

Inject into planner system message:

text
User prefers warm destinations, avoids red-eye flights.
Home airport: BLR.
Past trips: Rome (liked), Helsinki (too cold).

Step 3 — Planner agent

Goal: Produce structured itinerary without inventing weather — executor will fetch facts.

python
# agents/planner.py
PLANNER_SYSTEM = """You are a travel planner.
Output valid JSON only:
{"days": [{"date": "YYYY-MM-DD", "city": "...", "activities": ["..."]}], "notes": "..."}
Use user preferences when provided.
Do NOT invent weather or coordinates — the executor will fetch them."""
 
def plan_trip(client, user_message: str, prefs: dict) -> dict:
    messages = [
        {"role": "system", "content": PLANNER_SYSTEM + "\nPrefs: " + str(prefs)},
        {"role": "user", "content": user_message},
    ]
    resp = client.chat.completions.create(model="gpt-4o-mini", messages=messages, temperature=0.4)
    import json
    return json.loads(resp.choices[0].message.content)

Step 4 — Executor loop with LangGraph

python
# agents/graph.py
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END
 
class TripState(TypedDict):
    plan: dict
    step_index: int
    observations: list
    trace: list
    retries: int
    status: Literal["running", "done", "failed"]
 
def execute_node(state: TripState):
    day = state["plan"]["days"][state["step_index"]]
    city = day["city"]
    trace = state["trace"]
 
    geo = run_tool("geocode_city", {"city": city})
    trace.append({"type": "tool", "name": "geocode_city", "args": {"city": city}})
    trace.append({"type": "observation", "text": str(geo)})
 
    if "error" in geo:
        return {**state, "retries": state["retries"] + 1, "trace": trace}
 
    weather = run_tool("get_weather", {"lat": geo["lat"], "lon": geo["lon"]})
    trace.append({"type": "tool", "name": "get_weather", "args": geo})
    trace.append({"type": "observation", "text": str(weather)})
 
    observations = state["observations"] + [{"city": city, "geo": geo, "weather": weather}]
    return {
        **state,
        "step_index": state["step_index"] + 1,
        "observations": observations,
        "trace": trace,
        "status": "done" if state["step_index"] + 1 >= len(state["plan"]["days"]) else "running",
    }
 
def should_continue(state: TripState):
    if state["status"] == "done":
        return END
    if state["retries"] > 3:
        return "fail"
    return "execute"
 
graph = StateGraph(TripState)
graph.add_node("execute", execute_node)
graph.set_entry_point("execute")
graph.add_conditional_edges("execute", should_continue, {"execute": "execute", "fail": END, END: END})
app = graph.compile()

On geocode failure, increment retries and optionally call planner again with error context to fix city spelling.


Step 5 — Next.js API + UI

typescript
// app/api/travel-plan/route.ts
export async function POST(req: Request) {
  const { userId, message } = await req.json();
  const res = await fetch(process.env.PY_TRAVEL_URL ?? "http://127.0.0.1:5010/plan", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, message }),
  });
  return Response.json(await res.json());
}

Response shape:

json
{
  "itinerary": "Day 1 Rome — 18°C cloudy…",
  "trace": [
    {"type": "thought", "text": "Fetching Rome weather"},
    {"type": "tool", "name": "get_weather", "args": {"city": "Rome"}},
    {"type": "observation", "text": "18°C cloudy"}
  ]
}

UI (travel-lab/page.tsx):

  • Chat input for trip request.
  • Final itinerary markdown block.
  • Collapsible trace timeline — icons for thought / tool / observation.
  • Highlight retry steps when user deliberately misspells a city.

Step 6 — Real APIs checklist

  • Weather from live API, shown in final plan
  • Geocoding resolves city → coordinates
  • At least one retry shown in trace after deliberate typo ("Rom")
  • Preferences persist across two sessions (same userId)

Step 7 — Safety

  • Read-only tools for v1 — no real bookings or payments.
  • Rate-limit /api/travel-plan (30/min).
  • Cap iterations — e.g. 10 tool calls max per request.
  • Never log full API keys; use .env.local.

Troubleshooting

SymptomFix
Planner returns prose not JSONAdd response_format={"type":"json_object"} or stricter prompt
city not found loopCap retries; ask user to clarify spelling
Empty trace in UIReturn trace array from Python; map types in React
401 from weather APICheck OPENWEATHER_API_KEY in env

Deliverables

  • Planner + executor separation documented in README
  • Trace visible in UI
  • Memory read/write working
  • README with architecture diagram + demo screencap

What's next

Module 8 complete. Continue to Module 9 — Multimodal & image models, then Module 10 — Production & scaling for the course capstone.

Return to the AI course curriculum anytime to track progress.