├── .gitignore ├── app ├── __init__.py ├── agents │ ├── __init__.py │ ├── flights_advisor_agent.py │ ├── hotel_advisor_agent.py │ └── supervisor_agent.py ├── graph.py └── tools │ ├── __init__.py │ └── handoff_tool.py ├── langgraph.json ├── main.py ├── requirements.txt └── travel-agent.ipynb /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .env 3 | .venv 4 | __pycache__ 5 | *.pyc 6 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sarangk90/travel-agent/610eebe5176b7c44b29663311c485e8944966f72/app/__init__.py -------------------------------------------------------------------------------- /app/agents/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sarangk90/travel-agent/610eebe5176b7c44b29663311c485e8944966f72/app/agents/__init__.py -------------------------------------------------------------------------------- /app/agents/flights_advisor_agent.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from enum import IntEnum 4 | from typing import Optional, Literal, Dict, Any 5 | 6 | import serpapi 7 | from langchain_core.tools import tool 8 | from langchain_openai import ChatOpenAI 9 | from langgraph.graph import MessagesState 10 | from langgraph.prebuilt import create_react_agent 11 | from langgraph.types import Command 12 | from pydantic import BaseModel, Field, field_validator, model_validator 13 | 14 | from app.tools.handoff_tool import make_handoff_tool 15 | 16 | 17 | class FlightType(IntEnum): 18 | """Enum for flight types supported by Google Flights API""" 19 | 20 | ROUND_TRIP = 1 21 | ONE_WAY = 2 22 | 23 | 24 | class FlightsInput(BaseModel): 25 | departure_airport: str = Field(description="Departure airport code (IATA)") 26 | arrival_airport: str = Field(description="Arrival airport code (IATA)") 27 | outbound_date: str = Field( 28 | description="Parameter defines the outbound date. The format is YYYY-MM-DD. e.g. 2024-06-22" 29 | ) 30 | return_date: Optional[str] = Field( 31 | default=None, 32 | description="Parameter defines the return date. The format is YYYY-MM-DD. e.g. 2024-06-28", 33 | ) 34 | adults: int = Field( 35 | 1, ge=1, description="Parameter defines the number of adults. Default to 1." 36 | ) 37 | children: int = Field( 38 | 0, ge=0, description="Parameter defines the number of children. Default to 0." 39 | ) 40 | infants_in_seat: int = Field( 41 | 0, 42 | ge=0, 43 | description="Parameter defines the number of infants in seat. Default to 0.", 44 | ) 45 | infants_on_lap: int = Field( 46 | 0, 47 | ge=0, 48 | description="Parameter defines the number of infants on lap. Default to 0.", 49 | ) 50 | type: FlightType = Field( 51 | FlightType.ROUND_TRIP, 52 | description="Parameter defines the type of the flights: 1 - Round trip (default), 2 - One way", 53 | ) 54 | stops: str = Field( 55 | "0,1,2,3", 56 | description="Parameter defines the maximum number of stops. Format: comma-separated values (0,1,2,3)", 57 | ) 58 | currency: str = Field("INR", description="Currency for pricing.") 59 | 60 | @field_validator("departure_airport", "arrival_airport") 61 | @classmethod 62 | def validate_airport_code(cls, v): 63 | if not v or not isinstance(v, str) or len(v) != 3: 64 | raise ValueError("Airport code must be a 3-letter IATA code") 65 | return v.upper() 66 | 67 | @field_validator("outbound_date", "return_date") 68 | @classmethod 69 | def validate_date_format(cls, v): 70 | if not v: 71 | return v 72 | 73 | try: 74 | date = datetime.datetime.strptime(v, "%Y-%m-%d").date() 75 | today = datetime.date.today() 76 | 77 | if date < today: 78 | raise ValueError(f"Date {v} is in the past") 79 | 80 | return v 81 | except ValueError as e: 82 | if "does not match format" in str(e): 83 | raise ValueError(f"Date must be in YYYY-MM-DD format, got {v}") 84 | raise e 85 | 86 | @model_validator(mode="after") 87 | def validate_flight_consistency(self): 88 | flight_type = self.type 89 | return_date = self.return_date 90 | outbound_date = self.outbound_date 91 | 92 | if flight_type == FlightType.ONE_WAY and return_date: 93 | raise ValueError("Return date should not be provided for one-way flights") 94 | 95 | if flight_type == FlightType.ROUND_TRIP and not return_date: 96 | raise ValueError("Return date is required for round-trip flights") 97 | 98 | # Verify return date is after outbound date for round trips 99 | if flight_type == FlightType.ROUND_TRIP and outbound_date and return_date: 100 | outbound = datetime.datetime.strptime(outbound_date, "%Y-%m-%d").date() 101 | return_d = datetime.datetime.strptime(return_date, "%Y-%m-%d").date() 102 | 103 | if return_d < outbound: 104 | raise ValueError( 105 | f"Return date {return_date} must be after outbound date {outbound_date}" 106 | ) 107 | 108 | return self 109 | 110 | 111 | class FlightsInputSchema(BaseModel): 112 | params: FlightsInput 113 | 114 | 115 | @tool(args_schema=FlightsInputSchema) 116 | def find_flights(params: FlightsInput) -> Dict[str, Any]: 117 | """ 118 | Find flights using the Google Flights engine via SerpAPI. 119 | For round trips, fetches both initial search results and complete itinerary details. 120 | 121 | This tool handles: 122 | 1. Initial flight search to get options and prices 123 | 2. For round-trips, performs follow-up queries to get complete itineraries 124 | 3. Also makes a separate return flight search to ensure comprehensive data 125 | 126 | Returns a structured dictionary with all available flight information. 127 | """ 128 | api_key = os.environ.get("SERPAPI_API_KEY") 129 | if not api_key: 130 | raise ValueError("SERPAPI_API_KEY environment variable is not set") 131 | 132 | results = { 133 | "initial_search": None, # Initial search results with outbound options 134 | "complete_itineraries": [], # Complete itineraries from token-based follow-ups 135 | "return_flight_search": None, # Separate search for return flights (as backup) 136 | "error": None, 137 | } 138 | 139 | try: 140 | # 1. Initial search to get flight options and tokens 141 | search_params = { 142 | "api_key": api_key, 143 | "engine": "google_flights", 144 | "hl": "en", 145 | "gl": "us", 146 | "departure_id": params.departure_airport, 147 | "arrival_id": params.arrival_airport, 148 | "outbound_date": params.outbound_date, 149 | "currency": params.currency, 150 | "adults": params.adults, 151 | "infants_in_seat": params.infants_in_seat, 152 | "stops": params.stops, 153 | "infants_on_lap": params.infants_on_lap, 154 | "children": params.children, 155 | "type": str(int(params.type)), 156 | } 157 | 158 | if params.type == FlightType.ROUND_TRIP and params.return_date: 159 | search_params["return_date"] = params.return_date 160 | 161 | # Execute the initial search 162 | initial_results = serpapi.search(search_params).data 163 | results["initial_search"] = initial_results 164 | 165 | # 2. For round trips, fetch complete itineraries using tokens 166 | if params.type == FlightType.ROUND_TRIP: 167 | # Extract tokens from best flights and other flights (limit to top 5 total for efficiency) 168 | tokens = [] 169 | 170 | # First from best flights 171 | for flight in initial_results.get("best_flights", [])[:3]: 172 | if "departure_token" in flight: 173 | tokens.append(flight["departure_token"]) 174 | 175 | # Then from other flights if needed 176 | if len(tokens) < 5 and "other_flights" in initial_results: 177 | remaining_slots = 5 - len(tokens) 178 | for flight in initial_results.get("other_flights", [])[ 179 | :remaining_slots 180 | ]: 181 | if "departure_token" in flight: 182 | tokens.append(flight["departure_token"]) 183 | 184 | # Get complete itineraries using tokens 185 | for i, token in enumerate(tokens): 186 | token_params = { 187 | "api_key": api_key, 188 | "engine": "google_flights", 189 | "hl": "en", 190 | "gl": "us", 191 | "departure_token": token, 192 | } 193 | try: 194 | token_result = serpapi.search(token_params).data 195 | # Add reference to the original flight in initial results 196 | if "best_flights" in initial_results and i < len( 197 | initial_results.get("best_flights", []) 198 | ): 199 | token_result["original_flight_info"] = initial_results[ 200 | "best_flights" 201 | ][i] 202 | results["complete_itineraries"].append(token_result) 203 | except Exception as e: 204 | # Continue with other tokens if one fails 205 | continue 206 | 207 | # 3. As a backup, make a separate search for return flights 208 | return_params = { 209 | "api_key": api_key, 210 | "engine": "google_flights", 211 | "hl": "en", 212 | "gl": "us", 213 | "departure_id": params.arrival_airport, # Swap airports for return 214 | "arrival_id": params.departure_airport, 215 | "outbound_date": params.return_date, # Use return date as outbound 216 | "currency": params.currency, 217 | "adults": params.adults, 218 | "infants_in_seat": params.infants_in_seat, 219 | "stops": params.stops, 220 | "infants_on_lap": params.infants_on_lap, 221 | "children": params.children, 222 | "type": "2", # One-way for return leg 223 | } 224 | 225 | try: 226 | return_results = serpapi.search(return_params).data 227 | results["return_flight_search"] = return_results 228 | except Exception as e: 229 | # If return search fails, continue with what we have 230 | pass 231 | 232 | return results 233 | 234 | except serpapi.exceptions.SerpApiError as e: 235 | results["error"] = f"SerpAPI error: {str(e)}" 236 | return results 237 | except Exception as e: 238 | results["error"] = f"An unexpected error occurred: {str(e)}" 239 | return results 240 | 241 | 242 | model = ChatOpenAI(model="gpt-4o-2024-08-06") 243 | 244 | flights_advisor_tools = [ 245 | find_flights, 246 | make_handoff_tool(agent_name="supervisor"), 247 | ] 248 | 249 | flights_advisor = create_react_agent( 250 | model=model.bind_tools( 251 | flights_advisor_tools, parallel_tool_calls=False, strict=True 252 | ), 253 | tools=flights_advisor_tools, 254 | prompt=( 255 | "# Flight Expert Assistant\n\n" 256 | f"You are an expert flight advisor specialized in searching and recommending optimal flight options. Today is {datetime.datetime.now().strftime('%Y-%m-%d')}.\n\n" 257 | "## Your Capabilities\n" 258 | "- Search for flights between airports using IATA codes\n" 259 | "- Analyze comprehensive flight data including prices, schedules, airlines, and layovers\n" 260 | "- Provide personalized recommendations based on user preferences\n\n" 261 | "- If the question is not related to flights, transfer to a supervisor\n" 262 | "## Guidelines for Flight Recommendations\n" 263 | "1. **Always provide complete information**:\n" 264 | " - Flight prices with currency\n" 265 | " - Airlines and flight numbers\n" 266 | " - Departure and arrival times (with timezone information where available)\n" 267 | " - Duration of flights and layovers\n" 268 | " - Number of stops\n\n" 269 | "2. **Search Pattern Analysis**:\n" 270 | " - For round trips: Provide both outbound and return flight options\n" 271 | " - For one-way trips: Provide only the relevant flight leg\n" 272 | " - If a user asks about a 'return flight' only (e.g., from destination back to origin), interpret this as a one-way flight search\n\n" 273 | "3. **Data Processing**:\n" 274 | " - First review the initial search results for overall options\n" 275 | " - For round trips:\n" 276 | " * Examine the complete itineraries data for detailed outbound + return combinations\n" 277 | " * If available, use the return_flight_search data to supplement information\n" 278 | " * Present complete round-trip itineraries showing both outbound and return flights\n" 279 | " - For one-way trips, analyze available flight options directly\n" 280 | " - Compare options across different metrics (price, duration, convenience)\n" 281 | " - Highlight notable features (e.g., direct flights, significant savings, premium options)\n\n" 282 | "4. **Handling Round-Trip Data**:\n" 283 | " - Always present BOTH the outbound AND return flights for round trips\n" 284 | " - First check the complete_itineraries data which contains paired outbound and return options\n" 285 | " - If complete_itineraries is insufficient, combine data from initial_search and return_flight_search\n" 286 | " - When presenting combinations, clearly label outbound and return flights\n\n" 287 | "## Conversation Flow\n" 288 | "1. **Before making tool calls**:\n" 289 | " - Thoroughly understand the user's request\n" 290 | " - Ask for clarification if airport codes, dates, or other critical information is missing\n\n" 291 | "2. **After receiving search results**:\n" 292 | " - Provide a concise summary of the top options\n" 293 | " - Explain your recommendations with clear reasoning\n" 294 | " - Format information in an easily readable way\n\n" 295 | "3. **For non-flight inquiries**:\n" 296 | # " - Explain that you specialize in flight information\n" 297 | # " - Ask if the user would like to be transferred to a supervisor for other assistance\n" 298 | # " - Only transfer after receiving confirmation from the user\n" 299 | " - Do NOT answer any non-flight related enquiry and offer to transfer immediately\n\n" 300 | "## Common Scenarios\n" 301 | "- If no flights match the criteria: Suggest alternative dates or airports\n" 302 | "- If flights are expensive: Mention factors affecting price and possible alternatives\n" 303 | "- If search parameters are ambiguous: Ask clarifying questions before searching\n\n" 304 | "Always aim to be helpful, accurate, and focused on finding the best flight options for the user's specific needs." 305 | ), 306 | name="flights_advisor", 307 | ) 308 | 309 | 310 | def call_flights_advisor( 311 | state: MessagesState, 312 | ) -> Command[Literal["supervisor", "human"]]: 313 | response = flights_advisor.invoke(state) 314 | return Command(update=response, goto="human") 315 | -------------------------------------------------------------------------------- /app/agents/hotel_advisor_agent.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from enum import IntEnum 4 | from typing import Optional, Literal, Dict, Any 5 | 6 | import serpapi 7 | from langchain_core.tools import tool 8 | from langchain_openai import ChatOpenAI 9 | from langgraph.graph import MessagesState 10 | from langgraph.prebuilt import create_react_agent 11 | from langgraph.types import Command 12 | from pydantic import BaseModel, Field, field_validator, model_validator 13 | 14 | from app.tools.handoff_tool import make_handoff_tool 15 | 16 | 17 | class SortingOptions(IntEnum): 18 | """Enum for hotel sorting options supported by Google Hotels API""" 19 | 20 | RECOMMENDED = 0 21 | PRICE_LOW_TO_HIGH = 1 22 | PRICE_HIGH_TO_LOW = 2 23 | RATING_HIGH_TO_LOW = 8 24 | POPULARITY = 16 25 | 26 | 27 | class HotelsInput(BaseModel): 28 | q: str = Field( 29 | description="Location of the hotel (city, area, or specific hotel name)" 30 | ) 31 | check_in_date: str = Field( 32 | description="Check-in date. The format is YYYY-MM-DD. e.g. 2024-06-22" 33 | ) 34 | check_out_date: str = Field( 35 | description="Check-out date. The format is YYYY-MM-DD. e.g. 2024-06-28" 36 | ) 37 | sort_by: SortingOptions = Field( 38 | SortingOptions.RATING_HIGH_TO_LOW, 39 | description="Parameter is used for sorting the results. Default is sort by highest rating (8). " 40 | "Options: 0 (recommended), 1 (price low to high), 2 (price high to low), " 41 | "8 (rating high to low), 16 (popularity)", 42 | ) 43 | adults: int = Field(1, ge=1, description="Number of adults. Default to 1.") 44 | children: int = Field(0, ge=0, description="Number of children. Default to 0.") 45 | rooms: int = Field(1, ge=1, description="Number of rooms. Default to 1.") 46 | hotel_class: Optional[str] = Field( 47 | None, 48 | description='Parameter defines to include only certain hotel class in the results. Format: comma-separated values (e.g. "2,3,4" for 2-4 star hotels)', 49 | ) 50 | currency: str = Field("INR", description="Currency for pricing.") 51 | 52 | @field_validator("check_in_date", "check_out_date") 53 | @classmethod 54 | def validate_date_format(cls, v): 55 | if not v: 56 | raise ValueError("Date must be provided") 57 | 58 | try: 59 | date = datetime.datetime.strptime(v, "%Y-%m-%d").date() 60 | today = datetime.date.today() 61 | 62 | if date < today: 63 | raise ValueError(f"Date {v} is in the past") 64 | 65 | return v 66 | except ValueError as e: 67 | if "does not match format" in str(e): 68 | raise ValueError(f"Date must be in YYYY-MM-DD format, got {v}") 69 | raise e 70 | 71 | @field_validator("hotel_class") 72 | @classmethod 73 | def validate_hotel_class(cls, v): 74 | if not v: 75 | return v 76 | 77 | classes = v.split(",") 78 | try: 79 | for hotel_class in classes: 80 | class_int = int(hotel_class) 81 | if class_int < 1 or class_int > 5: 82 | raise ValueError( 83 | f"Hotel class must be between 1 and 5, got {hotel_class}" 84 | ) 85 | except ValueError: 86 | raise ValueError(f"Hotel class must be comma-separated numbers, got {v}") 87 | 88 | return v 89 | 90 | @model_validator(mode="after") 91 | def validate_dates_consistency(self): 92 | check_in = datetime.datetime.strptime(self.check_in_date, "%Y-%m-%d").date() 93 | check_out = datetime.datetime.strptime(self.check_out_date, "%Y-%m-%d").date() 94 | 95 | if check_out <= check_in: 96 | raise ValueError( 97 | f"Check-out date {self.check_out_date} must be after check-in date {self.check_in_date}" 98 | ) 99 | 100 | return self 101 | 102 | 103 | class HotelsInputSchema(BaseModel): 104 | params: HotelsInput 105 | 106 | 107 | @tool(args_schema=HotelsInputSchema) 108 | def get_hotel_recommendations(params: HotelsInput) -> Dict[str, Any]: 109 | """ 110 | Find hotels using the Google Hotels engine via SerpAPI. 111 | 112 | Parameters: 113 | params (HotelsInput): Hotel search parameters including location, check-in/out dates, and room requirements. 114 | 115 | Returns: 116 | dict: Complete hotel search results including property details, amenities, and pricing information. 117 | 118 | Note: 119 | This tool requires a valid SERPAPI_API_KEY environment variable. 120 | """ 121 | 122 | api_key = os.environ.get("SERPAPI_API_KEY") 123 | if not api_key: 124 | raise ValueError("SERPAPI_API_KEY environment variable is not set") 125 | 126 | search_params = { 127 | "api_key": api_key, 128 | "engine": "google_hotels", 129 | "hl": "en", 130 | "gl": "us", 131 | "q": params.q, 132 | "check_in_date": params.check_in_date, 133 | "check_out_date": params.check_out_date, 134 | "currency": params.currency, 135 | "adults": params.adults, 136 | "children": params.children, 137 | "rooms": params.rooms, 138 | "sort_by": str(int(params.sort_by)), 139 | } 140 | 141 | # Add hotel_class only if it's provided 142 | if params.hotel_class: 143 | search_params["hotel_class"] = params.hotel_class 144 | 145 | try: 146 | search = serpapi.search(search_params) 147 | return search.data.get("properties", {"error": "No properties found"}) 148 | except serpapi.exceptions.SerpApiError as e: 149 | return {"error": f"SerpAPI error: {str(e)}"} 150 | except Exception as e: 151 | return {"error": f"An unexpected error occurred: {str(e)}"} 152 | 153 | 154 | model = ChatOpenAI(model="gpt-4o-2024-08-06") 155 | 156 | # Define hotel advisor tools and ReAct agent 157 | hotel_advisor_tools = [ 158 | get_hotel_recommendations, 159 | make_handoff_tool(agent_name="supervisor"), 160 | ] 161 | hotel_advisor = create_react_agent( 162 | model=model.bind_tools(hotel_advisor_tools, parallel_tool_calls=False, strict=True), 163 | tools=hotel_advisor_tools, 164 | prompt=( 165 | "# Hotel Expert Assistant\n\n" 166 | f"You are an expert hotel advisor specialized in finding and recommending the best accommodations. Today is {datetime.datetime.now().strftime('%Y-%m-%d')}.\n\n" 167 | "## Your Capabilities\n" 168 | "- Search for hotels in any location worldwide\n" 169 | "- Analyze hotel options based on price, rating, amenities, and location\n" 170 | "- Provide personalized recommendations based on guest preferences\n\n" 171 | "## Guidelines for Hotel Recommendations\n" 172 | "1. **Always provide complete information**:\n" 173 | " - Hotel name and star rating\n" 174 | " - Price per night with currency\n" 175 | " - Location details and proximity to attractions\n" 176 | " - Guest ratings and notable reviews\n" 177 | " - Key amenities (pool, spa, restaurant, free breakfast, etc.)\n\n" 178 | "2. **Search Pattern Analysis**:\n" 179 | " - Consider the number of guests and required rooms\n" 180 | " - Note the length of stay and adjust recommendations accordingly\n" 181 | " - For family travel, highlight family-friendly amenities\n" 182 | " - For business travel, emphasize business centers and workspace availability\n\n" 183 | "3. **Data Processing**:\n" 184 | " - Analyze all available hotel data\n" 185 | " - Compare options across different metrics (price, rating, amenities, location)\n" 186 | " - Highlight exceptional value or unique features\n\n" 187 | "## Conversation Flow\n" 188 | "1. **Before making tool calls**:\n" 189 | " - Thoroughly understand the user's needs and preferences\n" 190 | " - Ask for clarification if location, dates, or other critical information is missing\n\n" 191 | "2. **After receiving search results**:\n" 192 | " - Provide a concise summary of the top 3-5 options\n" 193 | " - Explain your recommendations with clear reasoning\n" 194 | " - Format information in an easily readable way\n\n" 195 | "3. **For non-hotel inquiries**:\n" 196 | # " - Explain that you specialize in hotel information\n" 197 | # " - Ask if the user would like to be transferred to a supervisor for other assistance\n" 198 | # " - Only transfer after receiving confirmation from the user\n" 199 | " - Do NOT answer any non-hotel related enquiry and transfer immediately to supervisor\n\n" 200 | "## Common Scenarios\n" 201 | "- If no hotels match the criteria: Suggest alternatives with slightly different parameters\n" 202 | "- If hotels are expensive: Mention factors affecting price and suggest nearby alternatives\n" 203 | "- If search parameters are ambiguous: Ask clarifying questions before searching\n\n" 204 | "Always aim to be helpful, accurate, and focused on finding the best accommodation options for the user's specific needs." 205 | ), 206 | name="hotel_advisor", 207 | ) 208 | 209 | 210 | def call_hotel_advisor( 211 | state: MessagesState, 212 | ) -> Command[Literal["supervisor", "human"]]: 213 | response = hotel_advisor.invoke(state) 214 | return Command(update=response, goto="human") 215 | -------------------------------------------------------------------------------- /app/agents/supervisor_agent.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Literal 3 | 4 | from langchain_openai import ChatOpenAI 5 | from langgraph.graph import MessagesState 6 | from langgraph.prebuilt import create_react_agent 7 | from langgraph.types import Command 8 | 9 | from app.tools.handoff_tool import make_handoff_tool 10 | 11 | supervisor_tools = [ 12 | make_handoff_tool(agent_name="flights_advisor"), 13 | make_handoff_tool(agent_name="hotel_advisor"), 14 | ] 15 | model = ChatOpenAI(model="gpt-4o-2024-08-06") 16 | 17 | supervisor = create_react_agent( 18 | model=model.bind_tools(supervisor_tools, parallel_tool_calls=False, strict=True), 19 | tools=supervisor_tools, 20 | prompt=( 21 | "You are a team supervisor for a travel agency managing a hotel and flights advisor." 22 | f"Today is {datetime.datetime.now().strftime('%Y-%m-%d')}. " 23 | "Whenever you receive request from human for first time, Greet them and provide them with options you can help with like hotel, flight booking and suggest iteinery." 24 | "For finding hotels, use hotel_advisor. " 25 | "For finding flights, use flights_advisor." 26 | "Transfer to only one agent (or tool) at a time, nothing more than one. Sending requests to multiple agents at a time is NOT supported" 27 | "Be very friendly and helpful to the user. Make sure to provide human-readable response before transferring to another agent. Do NOT transfer to another agent without asking human" 28 | ), 29 | name="supervisor", 30 | ) 31 | 32 | 33 | def call_supervisor( 34 | state: MessagesState, 35 | ) -> Command[Literal["hotel_advisor", "human", "flights_advisor"]]: 36 | response = supervisor.invoke(state) 37 | return Command(update=response, goto="human") 38 | -------------------------------------------------------------------------------- /app/graph.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from dotenv import load_dotenv 4 | from langchain_core.messages import HumanMessage 5 | from langgraph.checkpoint.memory import MemorySaver 6 | from langgraph.constants import START 7 | from langgraph.graph import StateGraph, MessagesState 8 | from langgraph.types import Command, interrupt 9 | 10 | from app.agents.supervisor_agent import call_supervisor 11 | from app.agents.flights_advisor_agent import call_flights_advisor 12 | from app.agents.hotel_advisor_agent import call_hotel_advisor 13 | 14 | _ = load_dotenv() 15 | 16 | 17 | def human_node( 18 | state: MessagesState, config 19 | ) -> Command[Literal["hotel_advisor", "flights_advisor", "human"]]: 20 | """A node for collecting user input.""" 21 | 22 | message = state["messages"][-1].content 23 | user_input = interrupt(value=message) 24 | 25 | # identify the last active agent 26 | # (the last active node before returning to human) 27 | langgraph_triggers = config["metadata"]["langgraph_triggers"] 28 | if len(langgraph_triggers) != 1: 29 | raise AssertionError("Expected exactly 1 trigger in human node") 30 | 31 | active_agent = langgraph_triggers[0].split(":")[1] 32 | 33 | return Command( 34 | update={ 35 | "messages": [ 36 | HumanMessage(content=user_input), 37 | ] 38 | }, 39 | goto=active_agent, 40 | ) 41 | 42 | 43 | builder = StateGraph(MessagesState) 44 | builder.add_node("supervisor", call_supervisor) 45 | builder.add_node("hotel_advisor", call_hotel_advisor) 46 | builder.add_node("flights_advisor", call_flights_advisor) 47 | 48 | builder.add_node("human", human_node) 49 | 50 | builder.add_edge(START, "supervisor") 51 | 52 | checkpointer = MemorySaver() 53 | graph = builder.compile(checkpointer=checkpointer) 54 | -------------------------------------------------------------------------------- /app/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sarangk90/travel-agent/610eebe5176b7c44b29663311c485e8944966f72/app/tools/__init__.py -------------------------------------------------------------------------------- /app/tools/handoff_tool.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from langchain_core.tools import tool, InjectedToolCallId 4 | from langgraph.prebuilt import InjectedState 5 | from langgraph.types import Command 6 | 7 | 8 | def make_handoff_tool(*, agent_name: str): 9 | """Create a tool that can return handoff via a Command""" 10 | tool_name = f"transfer_to_{agent_name}" 11 | 12 | @tool(tool_name) 13 | def handoff_to_agent( 14 | state: Annotated[dict, InjectedState], 15 | tool_call_id: Annotated[str, InjectedToolCallId], 16 | ): 17 | """Ask another agent for help.""" 18 | print(f"Handing off to {agent_name}...") 19 | tool_message = { 20 | "role": "tool", 21 | "content": f"Successfully transferred to {agent_name}", 22 | "name": tool_name, 23 | "tool_call_id": tool_call_id, 24 | } 25 | return Command( 26 | # navigate to another agent node in the PARENT graph 27 | goto=agent_name, 28 | graph=Command.PARENT, 29 | # This is the state update that the agent `agent_name` will see when it is invoked. 30 | # We're passing agent's FULL internal message history AND adding a tool message to make sure 31 | # the resulting chat history is valid. 32 | update={"messages": state["messages"] + [tool_message]}, 33 | ) 34 | 35 | return handoff_to_agent 36 | -------------------------------------------------------------------------------- /langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphs": { 3 | "travel_agent": "./app/graph.py:graph" 4 | }, 5 | "env": "./.env", 6 | "dependencies": [ 7 | "." 8 | ], 9 | "python_version": "3.11" 10 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from dotenv import load_dotenv, find_dotenv 4 | 5 | load_dotenv(find_dotenv()) 6 | from langgraph.types import Command 7 | 8 | from app.graph import graph 9 | 10 | 11 | def invoke_graph(user_input: str, thread_id: str): 12 | thread_config = {"configurable": {"thread_id": thread_id}} 13 | state = graph.get_state({"configurable": {"thread_id": thread_id}}) 14 | input_data = ( 15 | Command(resume=user_input) 16 | if len(state.next) > 0 and state.next[0] == "human" 17 | else {"messages": [{"role": "user", "content": user_input}]} 18 | ) 19 | 20 | for update in graph.stream(input_data, config=thread_config, stream_mode="updates"): 21 | if "__interrupt__" in update: 22 | break 23 | else: 24 | for node_id, value in update.items(): 25 | if "messages" in value and value["messages"]: 26 | last_message = value["messages"][-1] 27 | if last_message.type == "ai": 28 | print(f"{node_id}: {last_message.content}") 29 | 30 | 31 | if __name__ == "__main__": 32 | thread_id = uuid.uuid4() 33 | while True: 34 | user_input = input("Enter your message: ") 35 | result = invoke_graph(user_input, thread_id) 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv==1.0.1 2 | langchain==0.3.19 3 | langchain-openai==0.3.7 4 | langgraph==0.3.2 5 | grandalf==0.8 6 | serpapi==0.1.5 -------------------------------------------------------------------------------- /travel-agent.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "f769921f", 6 | "metadata": {}, 7 | "source": [ 8 | "# LangGraph Travel Agent Demo\n", 9 | "\n", 10 | "This notebook demonstrates a multi-agent system built with LangGraph that simulates a travel agency with specialized agents.\n", 11 | "\n", 12 | "## Key Concepts:\n", 13 | "- **Multi-Agent System**: Multiple specialized agents working together\n", 14 | "- **LangGraph**: Framework for building stateful, multi-agent applications\n", 15 | "- **Agent Specialization**: Each agent has specific expertise and responsibilities\n", 16 | "- **Handoff Mechanism**: Agents can transfer control to other agents\n", 17 | "\n", 18 | "## System Architecture\n", 19 | "\n", 20 | "1. **Supervisor Agent**: Coordinates between specialized agents\n", 21 | "2. **Hotel Advisor Agent**: Specializes in hotel recommendations\n", 22 | "3. **Flights Advisor Agent**: Specializes in flight recommendations\n", 23 | "4. **Human Node**: Handles user interaction and routes messages\n" 24 | ] 25 | }, 26 | { 27 | "cell_type": "markdown", 28 | "id": "253a4ac7", 29 | "metadata": {}, 30 | "source": [] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": 2, 35 | "id": "initial_id", 36 | "metadata": { 37 | "ExecuteTime": { 38 | "end_time": "2025-03-02T08:35:17.007848Z", 39 | "start_time": "2025-03-02T08:35:17.005496Z" 40 | }, 41 | "collapsed": true 42 | }, 43 | "outputs": [], 44 | "source": [ 45 | "from dotenv import load_dotenv\n", 46 | "\n", 47 | "_ = load_dotenv()" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": 3, 53 | "id": "983327824d48c5fe", 54 | "metadata": { 55 | "ExecuteTime": { 56 | "end_time": "2025-03-02T08:35:17.029503Z", 57 | "start_time": "2025-03-02T08:35:17.017037Z" 58 | } 59 | }, 60 | "outputs": [], 61 | "source": [ 62 | "from langchain_openai import ChatOpenAI\n", 63 | "\n", 64 | "model = ChatOpenAI(model=\"gpt-4o-2024-08-06\")" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "id": "fd36c685e397c7bc", 70 | "metadata": {}, 71 | "source": [ 72 | "## Agent Definitions\n", 73 | "\n", 74 | "### Supervisor Agent\n", 75 | "The supervisor agent acts as the coordinator, directing user queries to specialized agents." 76 | ] 77 | }, 78 | { 79 | "cell_type": "code", 80 | "execution_count": 71, 81 | "id": "c13482631890e3d0", 82 | "metadata": { 83 | "ExecuteTime": { 84 | "end_time": "2025-03-02T08:35:17.044281Z", 85 | "start_time": "2025-03-02T08:35:17.034658Z" 86 | } 87 | }, 88 | "outputs": [], 89 | "source": [ 90 | "import datetime\n", 91 | "from typing import Literal\n", 92 | "\n", 93 | "from langgraph.graph import MessagesState\n", 94 | "from langgraph.prebuilt import create_react_agent\n", 95 | "from langgraph.types import Command\n", 96 | "\n", 97 | "from app.tools.handoff_tool import make_handoff_tool\n", 98 | "\n", 99 | "supervisor_tools = [\n", 100 | " make_handoff_tool(agent_name=\"flights_advisor\"),\n", 101 | " make_handoff_tool(agent_name=\"hotel_advisor\"),\n", 102 | "]\n", 103 | "\n", 104 | "supervisor = create_react_agent(\n", 105 | " model=model.bind_tools(supervisor_tools, parallel_tool_calls=False, strict=True),\n", 106 | " tools=supervisor_tools,\n", 107 | " prompt=(\n", 108 | " \"You are a team supervisor for a travel agency managing a hotel and flights advisor.\"\n", 109 | " f\"Today is {datetime.datetime.now().strftime('%Y-%m-%d')}. \"\n", 110 | " \"Whenever you receive request from human for first time, Greet them and provide them with options you can help with like hotel, flight booking and suggest iteinery.\"\n", 111 | " \"For finding hotels, use hotel_advisor. \"\n", 112 | " \"For finding flights, use flights_advisor.\"\n", 113 | " \"Transfer to only one agent (or tool) at a time, nothing more than one. Sending requests to multiple agents at a time is NOT supported\"\n", 114 | " \"Be very friendly and helpful to the user. Make sure to provide human-readable response before transferring to another agent. Do NOT transfer to another agent without asking human\"\n", 115 | " ),\n", 116 | " name=\"supervisor\",\n", 117 | ")\n", 118 | "\n", 119 | "\n", 120 | "def call_supervisor(\n", 121 | " state: MessagesState,\n", 122 | ") -> Command[Literal[\"hotel_advisor\", \"human\", \"flights_advisor\"]]:\n", 123 | " response = supervisor.invoke(state)\n", 124 | " return Command(update=response, goto=\"human\")\n" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "id": "4df8814f25795cd4", 130 | "metadata": {}, 131 | "source": [ 132 | "### Hotel Advisor Agent:\n", 133 | "Specialized agent for hotel recommendations with detailed knowledge of accommodations." 134 | ] 135 | }, 136 | { 137 | "cell_type": "code", 138 | "execution_count": 72, 139 | "id": "9d54b2bc2c8a5e59", 140 | "metadata": { 141 | "ExecuteTime": { 142 | "end_time": "2025-03-02T08:35:17.058675Z", 143 | "start_time": "2025-03-02T08:35:17.049010Z" 144 | } 145 | }, 146 | "outputs": [], 147 | "source": [ 148 | "\n", 149 | "from app.agents.hotel_advisor_agent import get_hotel_recommendations\n", 150 | "\n", 151 | "# Define hotel advisor tools and ReAct agent\n", 152 | "hotel_advisor_tools = [\n", 153 | " get_hotel_recommendations,\n", 154 | " make_handoff_tool(agent_name=\"supervisor\"),\n", 155 | "]\n", 156 | "hotel_advisor = create_react_agent(\n", 157 | " model=model.bind_tools(hotel_advisor_tools, parallel_tool_calls=False, strict=True),\n", 158 | " tools=hotel_advisor_tools,\n", 159 | " prompt=(\n", 160 | " \"# Hotel Expert Assistant\\n\\n\"\n", 161 | " f\"You are an expert hotel advisor specialized in finding and recommending the best accommodations. Today is {datetime.datetime.now().strftime('%Y-%m-%d')}.\\n\\n\"\n", 162 | " \"## Your Capabilities\\n\"\n", 163 | " \"- Search for hotels in any location worldwide\\n\"\n", 164 | " \"- Analyze hotel options based on price, rating, amenities, and location\\n\"\n", 165 | " \"- Provide personalized recommendations based on guest preferences\\n\\n\"\n", 166 | " \"## Guidelines for Hotel Recommendations\\n\"\n", 167 | " \"1. **Always provide complete information**:\\n\"\n", 168 | " \" - Hotel name and star rating\\n\"\n", 169 | " \" - Price per night with currency\\n\"\n", 170 | " \" - Location details and proximity to attractions\\n\"\n", 171 | " \" - Guest ratings and notable reviews\\n\"\n", 172 | " \" - Key amenities (pool, spa, restaurant, free breakfast, etc.)\\n\\n\"\n", 173 | " \"2. **Search Pattern Analysis**:\\n\"\n", 174 | " \" - Consider the number of guests and required rooms\\n\"\n", 175 | " \" - Note the length of stay and adjust recommendations accordingly\\n\"\n", 176 | " \" - For family travel, highlight family-friendly amenities\\n\"\n", 177 | " \" - For business travel, emphasize business centers and workspace availability\\n\\n\"\n", 178 | " \"3. **Data Processing**:\\n\"\n", 179 | " \" - Analyze all available hotel data\\n\"\n", 180 | " \" - Compare options across different metrics (price, rating, amenities, location)\\n\"\n", 181 | " \" - Highlight exceptional value or unique features\\n\\n\"\n", 182 | " \"## Conversation Flow\\n\"\n", 183 | " \"1. **Before making tool calls**:\\n\"\n", 184 | " \" - Thoroughly understand the user's needs and preferences\\n\"\n", 185 | " \" - Ask for clarification if location, dates, or other critical information is missing\\n\\n\"\n", 186 | " \"2. **After receiving search results**:\\n\"\n", 187 | " \" - Provide a concise summary of the top 3-5 options\\n\"\n", 188 | " \" - Explain your recommendations with clear reasoning\\n\"\n", 189 | " \" - Format information in an easily readable way\\n\\n\"\n", 190 | " \"3. **For non-hotel inquiries**:\\n\"\n", 191 | " # \" - Explain that you specialize in hotel information\\n\"\n", 192 | " # \" - Ask if the user would like to be transferred to a supervisor for other assistance\\n\"\n", 193 | " # \" - Only transfer after receiving confirmation from the user\\n\"\n", 194 | " \" - Do NOT answer any non-hotel related enquiry and transfer immediately to supervisor\\n\\n\"\n", 195 | " \"## Common Scenarios\\n\"\n", 196 | " \"- If no hotels match the criteria: Suggest alternatives with slightly different parameters\\n\"\n", 197 | " \"- If hotels are expensive: Mention factors affecting price and suggest nearby alternatives\\n\"\n", 198 | " \"- If search parameters are ambiguous: Ask clarifying questions before searching\\n\\n\"\n", 199 | " \"Always aim to be helpful, accurate, and focused on finding the best accommodation options for the user's specific needs.\"\n", 200 | " ),\n", 201 | " name=\"hotel_advisor\",\n", 202 | ")\n", 203 | "\n", 204 | "\n", 205 | "def call_hotel_advisor(\n", 206 | " state: MessagesState,\n", 207 | ") -> Command[Literal[\"supervisor\", \"human\"]]:\n", 208 | " response = hotel_advisor.invoke(state)\n", 209 | " return Command(update=response, goto=\"human\")" 210 | ] 211 | }, 212 | { 213 | "cell_type": "markdown", 214 | "id": "40b7a4ceed7c0b70", 215 | "metadata": {}, 216 | "source": [ 217 | "### Flights Advisor Agent:\n", 218 | "Specialized agent for flight recommendations with detailed knowledge of airlines and routes." 219 | ] 220 | }, 221 | { 222 | "cell_type": "code", 223 | "execution_count": 73, 224 | "id": "a46a69d97794f7b", 225 | "metadata": { 226 | "ExecuteTime": { 227 | "end_time": "2025-03-02T08:35:17.073556Z", 228 | "start_time": "2025-03-02T08:35:17.063729Z" 229 | } 230 | }, 231 | "outputs": [], 232 | "source": [ 233 | "\n", 234 | "from app.agents.flights_advisor_agent import find_flights\n", 235 | "\n", 236 | "flights_advisor_tools = [\n", 237 | " find_flights,\n", 238 | " make_handoff_tool(agent_name=\"supervisor\"),\n", 239 | "]\n", 240 | "\n", 241 | "flights_advisor = create_react_agent(\n", 242 | " model=model.bind_tools(\n", 243 | " flights_advisor_tools, parallel_tool_calls=False, strict=True\n", 244 | " ),\n", 245 | " tools=flights_advisor_tools,\n", 246 | " prompt=(\n", 247 | " \"# Flight Expert Assistant\\n\\n\"\n", 248 | " f\"You are an expert flight advisor specialized in searching and recommending optimal flight options. Today is {datetime.datetime.now().strftime('%Y-%m-%d')}.\\n\\n\"\n", 249 | " \"## Your Capabilities\\n\"\n", 250 | " \"- Search for flights between airports using IATA codes\\n\"\n", 251 | " \"- Analyze comprehensive flight data including prices, schedules, airlines, and layovers\\n\"\n", 252 | " \"- Provide personalized recommendations based on user preferences\\n\\n\"\n", 253 | " \"- If the question is not related to flights, transfer to a supervisor\\n\"\n", 254 | " \"## Guidelines for Flight Recommendations\\n\"\n", 255 | " \"1. **Always provide complete information**:\\n\"\n", 256 | " \" - Flight prices with currency\\n\"\n", 257 | " \" - Airlines and flight numbers\\n\"\n", 258 | " \" - Departure and arrival times (with timezone information where available)\\n\"\n", 259 | " \" - Duration of flights and layovers\\n\"\n", 260 | " \" - Number of stops\\n\\n\"\n", 261 | " \"2. **Search Pattern Analysis**:\\n\"\n", 262 | " \" - For round trips: Provide both outbound and return flight options\\n\"\n", 263 | " \" - For one-way trips: Provide only the relevant flight leg\\n\"\n", 264 | " \" - If a user asks about a 'return flight' only (e.g., from destination back to origin), interpret this as a one-way flight search\\n\\n\"\n", 265 | " \"3. **Data Processing**:\\n\"\n", 266 | " \" - First review the initial search results for overall options\\n\"\n", 267 | " \" - For round trips:\\n\"\n", 268 | " \" * Examine the complete itineraries data for detailed outbound + return combinations\\n\"\n", 269 | " \" * If available, use the return_flight_search data to supplement information\\n\"\n", 270 | " \" * Present complete round-trip itineraries showing both outbound and return flights\\n\"\n", 271 | " \" - For one-way trips, analyze available flight options directly\\n\"\n", 272 | " \" - Compare options across different metrics (price, duration, convenience)\\n\"\n", 273 | " \" - Highlight notable features (e.g., direct flights, significant savings, premium options)\\n\\n\"\n", 274 | " \"4. **Handling Round-Trip Data**:\\n\"\n", 275 | " \" - Always present BOTH the outbound AND return flights for round trips\\n\"\n", 276 | " \" - First check the complete_itineraries data which contains paired outbound and return options\\n\"\n", 277 | " \" - If complete_itineraries is insufficient, combine data from initial_search and return_flight_search\\n\"\n", 278 | " \" - When presenting combinations, clearly label outbound and return flights\\n\\n\"\n", 279 | " \"## Conversation Flow\\n\"\n", 280 | " \"1. **Before making tool calls**:\\n\"\n", 281 | " \" - Thoroughly understand the user's request\\n\"\n", 282 | " \" - Ask for clarification if airport codes, dates, or other critical information is missing\\n\\n\"\n", 283 | " \"2. **After receiving search results**:\\n\"\n", 284 | " \" - Provide a concise summary of the top options\\n\"\n", 285 | " \" - Explain your recommendations with clear reasoning\\n\"\n", 286 | " \" - Format information in an easily readable way\\n\\n\"\n", 287 | " \"3. **For non-flight inquiries**:\\n\"\n", 288 | " # \" - Explain that you specialize in flight information\\n\"\n", 289 | " # \" - Ask if the user would like to be transferred to a supervisor for other assistance\\n\"\n", 290 | " # \" - Only transfer after receiving confirmation from the user\\n\"\n", 291 | " \" - Do NOT answer any non-flight related enquiry and offer to transfer immediately\\n\\n\"\n", 292 | " \"## Common Scenarios\\n\"\n", 293 | " \"- If no flights match the criteria: Suggest alternative dates or airports\\n\"\n", 294 | " \"- If flights are expensive: Mention factors affecting price and possible alternatives\\n\"\n", 295 | " \"- If search parameters are ambiguous: Ask clarifying questions before searching\\n\\n\"\n", 296 | " \"Always aim to be helpful, accurate, and focused on finding the best flight options for the user's specific needs.\"\n", 297 | " ),\n", 298 | " name=\"flights_advisor\",\n", 299 | ")\n", 300 | "\n", 301 | "\n", 302 | "def call_flights_advisor(\n", 303 | " state: MessagesState,\n", 304 | ") -> Command[Literal[\"supervisor\", \"human\"]]:\n", 305 | " response = flights_advisor.invoke(state)\n", 306 | " return Command(update=response, goto=\"human\")" 307 | ] 308 | }, 309 | { 310 | "cell_type": "markdown", 311 | "id": "4a088316004d7e75", 312 | "metadata": {}, 313 | "source": [ 314 | "## Defining Human Node:\n", 315 | "The human node handles user interaction and routes messages to the appropriate agent." 316 | ] 317 | }, 318 | { 319 | "cell_type": "code", 320 | "execution_count": 4, 321 | "id": "18938c67bbe5490a", 322 | "metadata": { 323 | "ExecuteTime": { 324 | "end_time": "2025-03-02T08:35:17.085570Z", 325 | "start_time": "2025-03-02T08:35:17.082461Z" 326 | } 327 | }, 328 | "outputs": [], 329 | "source": [ 330 | "from langchain_core.messages import HumanMessage\n", 331 | "from typing import Literal\n", 332 | "\n", 333 | "from dotenv import load_dotenv\n", 334 | "from langgraph.checkpoint.memory import MemorySaver\n", 335 | "from langgraph.constants import START\n", 336 | "from langgraph.graph import StateGraph, MessagesState\n", 337 | "from langgraph.types import Command, interrupt\n", 338 | "\n", 339 | "_ = load_dotenv()\n", 340 | "\n", 341 | "\n", 342 | "def human_node(\n", 343 | " state: MessagesState, config\n", 344 | ") -> Command[Literal[\"hotel_advisor\",\"supervisor\", \"flights_advisor\", \"human\"]]:\n", 345 | " \"\"\"A node for collecting user input.\"\"\"\n", 346 | "\n", 347 | " message = state[\"messages\"][-1].content\n", 348 | " user_input = interrupt(value=message)\n", 349 | "\n", 350 | " # identify the last active agent\n", 351 | " # (the last active node before returning to human)\n", 352 | " langgraph_triggers = config[\"metadata\"][\"langgraph_triggers\"]\n", 353 | " if len(langgraph_triggers) != 1:\n", 354 | " raise AssertionError(\"Expected exactly 1 trigger in human node\")\n", 355 | "\n", 356 | " active_agent = langgraph_triggers[0].split(\":\")[1]\n", 357 | "\n", 358 | " return Command(\n", 359 | " update={\n", 360 | " \"messages\": [\n", 361 | " HumanMessage(content=user_input),\n", 362 | " ]\n", 363 | " },\n", 364 | " goto=active_agent,\n", 365 | " )\n", 366 | "\n", 367 | "\n" 368 | ] 369 | }, 370 | { 371 | "cell_type": "markdown", 372 | "id": "9001f6dfa72f2237", 373 | "metadata": {}, 374 | "source": [ 375 | "## Building the Graph:\n", 376 | "Here we define the structure of our multi-agent system using LangGraph's StateGraph.\n" 377 | ] 378 | }, 379 | { 380 | "cell_type": "code", 381 | "execution_count": 5, 382 | "id": "d7327f9c03b65ca", 383 | "metadata": { 384 | "ExecuteTime": { 385 | "end_time": "2025-03-02T08:35:17.090164Z", 386 | "start_time": "2025-03-02T08:35:17.087394Z" 387 | } 388 | }, 389 | "outputs": [], 390 | "source": [ 391 | "\n", 392 | "# Create a state graph with MessagesState as the state type\n", 393 | "builder = StateGraph(MessagesState)\n", 394 | "\n", 395 | "# Add nodes for each agent and the human node\n", 396 | "builder.add_node(\"supervisor\", call_supervisor)\n", 397 | "builder.add_node(\"hotel_advisor\", call_hotel_advisor)\n", 398 | "builder.add_node(\"flights_advisor\", call_flights_advisor)\n", 399 | "\n", 400 | "builder.add_node(\"human\", human_node)\n", 401 | "\n", 402 | "# Define the starting point of the graph\n", 403 | "builder.add_edge(START, \"supervisor\")\n", 404 | "\n", 405 | "# Set up checkpointing to maintain conversation state\n", 406 | "checkpointer = MemorySaver()\n", 407 | "\n", 408 | "graph = builder.compile(checkpointer=checkpointer)\n" 409 | ] 410 | }, 411 | { 412 | "cell_type": "markdown", 413 | "id": "1b948db20b2c251a", 414 | "metadata": {}, 415 | "source": [ 416 | "## Displaying the Graph:\n", 417 | "Visualize the structure of our multi-agent system." 418 | ] 419 | }, 420 | { 421 | "cell_type": "code", 422 | "execution_count": 6, 423 | "id": "e6ad18e5b5ed06e7", 424 | "metadata": { 425 | "ExecuteTime": { 426 | "end_time": "2025-03-02T08:35:17.581592Z", 427 | "start_time": "2025-03-02T08:35:17.095181Z" 428 | } 429 | }, 430 | "outputs": [ 431 | { 432 | "data": { 433 | "image/png": "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", 434 | "text/plain": [ 435 | "" 436 | ] 437 | }, 438 | "metadata": {}, 439 | "output_type": "display_data" 440 | } 441 | ], 442 | "source": [ 443 | "from IPython.display import Image, display\n", 444 | "\n", 445 | "graph.get_graph().draw_mermaid()\n", 446 | "display(Image(graph.get_graph().draw_mermaid_png()))" 447 | ] 448 | }, 449 | { 450 | "cell_type": "markdown", 451 | "id": "dfa7e2d441c36d7d", 452 | "metadata": {}, 453 | "source": [ 454 | "## Invoking the Graph:\n", 455 | "Function to run the multi-agent system with user input.\n" 456 | ] 457 | }, 458 | { 459 | "cell_type": "code", 460 | "execution_count": 77, 461 | "id": "9f206178bc1f80d7", 462 | "metadata": { 463 | "ExecuteTime": { 464 | "end_time": "2025-03-02T08:35:17.591377Z", 465 | "start_time": "2025-03-02T08:35:17.587452Z" 466 | } 467 | }, 468 | "outputs": [], 469 | "source": [ 470 | "def invoke_graph(user_input: str, thread_id: str):\n", 471 | " thread_config = {\"configurable\": {\"thread_id\": thread_id}}\n", 472 | " state = graph.get_state({\"configurable\": {\"thread_id\": thread_id}})\n", 473 | " input_data = (\n", 474 | " Command(resume=user_input)\n", 475 | " if len(state.next) > 0 and state.next[0] == \"human\"\n", 476 | " else {\"messages\": [{\"role\": \"user\", \"content\": user_input}]}\n", 477 | " )\n", 478 | "\n", 479 | " for update in graph.invoke(input_data, config=thread_config, stream_mode=\"updates\"):\n", 480 | " if \"__interrupt__\" in update:\n", 481 | " break\n", 482 | " else:\n", 483 | " for node_id, value in update.items():\n", 484 | " if \"messages\" in value and value[\"messages\"]:\n", 485 | " last_message = value[\"messages\"][-1]\n", 486 | " if last_message.type == \"ai\":\n", 487 | " print(f\"{node_id}: {last_message.content}\")\n" 488 | ] 489 | }, 490 | { 491 | "cell_type": "code", 492 | "execution_count": 78, 493 | "id": "75fef1ae53dab234", 494 | "metadata": { 495 | "ExecuteTime": { 496 | "end_time": "2025-03-02T08:35:17.600979Z", 497 | "start_time": "2025-03-02T08:35:17.598334Z" 498 | } 499 | }, 500 | "outputs": [ 501 | { 502 | "data": { 503 | "text/plain": [ 504 | "UUID('509d68c0-2e37-4279-8ea7-7960383ba407')" 505 | ] 506 | }, 507 | "execution_count": 78, 508 | "metadata": {}, 509 | "output_type": "execute_result" 510 | } 511 | ], 512 | "source": [ 513 | "import uuid\n", 514 | "\n", 515 | "thread_id = uuid.uuid4()\n", 516 | "thread_id" 517 | ] 518 | }, 519 | { 520 | "cell_type": "code", 521 | "execution_count": null, 522 | "id": "9854ba88dac49da6", 523 | "metadata": { 524 | "ExecuteTime": { 525 | "end_time": "2025-03-02T08:35:19.780240Z", 526 | "start_time": "2025-03-02T08:35:17.607320Z" 527 | } 528 | }, 529 | "outputs": [ 530 | { 531 | "name": "stdout", 532 | "output_type": "stream", 533 | "text": [ 534 | "supervisor: Hello! I'd be delighted to help you plan your trip from New York to Delhi. I can assist you with both hotel bookings and flights, and also suggest some itineraries for your stay in Delhi. Would you like me to start with booking a flight or finding a hotel for your stay?\n" 535 | ] 536 | } 537 | ], 538 | "source": [ 539 | "# Initial query to plan a trip\n", 540 | "user_input = \"Plan a trip to Delhi from New York\"\n", 541 | "invoke_graph(user_input, thread_id)" 542 | ] 543 | }, 544 | { 545 | "cell_type": "code", 546 | "execution_count": 80, 547 | "id": "a72ace2e5497a9b6", 548 | "metadata": { 549 | "ExecuteTime": { 550 | "end_time": "2025-03-02T08:35:58.629528Z", 551 | "start_time": "2025-03-02T08:35:55.270441Z" 552 | } 553 | }, 554 | "outputs": [ 555 | { 556 | "name": "stdout", 557 | "output_type": "stream", 558 | "text": [ 559 | "Handing off to flights_advisor...\n", 560 | "flights_advisor: Alright, let's look into flights from New York (JFK) to Delhi (DEL). Could you please provide me with your preferred travel dates, and let me know if you are looking for a round-trip or one-way ticket? Additionally, how many passengers will be traveling?\n" 561 | ] 562 | } 563 | ], 564 | "source": [ 565 | "invoke_graph(\"I would like to book a flight\", thread_id)" 566 | ] 567 | }, 568 | { 569 | "cell_type": "code", 570 | "execution_count": 81, 571 | "id": "88eeb30c97666ce1", 572 | "metadata": { 573 | "ExecuteTime": { 574 | "end_time": "2025-03-02T08:36:50.960035Z", 575 | "start_time": "2025-03-02T08:36:35.791260Z" 576 | } 577 | }, 578 | "outputs": [ 579 | { 580 | "name": "stdout", 581 | "output_type": "stream", 582 | "text": [ 583 | "flights_advisor: Here are some flight options for your round-trip from New York (JFK) to Delhi (DEL) departing on April 30, 2025, and returning on May 7, 2025:\n", 584 | "\n", 585 | "### Option 1: \n", 586 | "- **Total Price:** $758 USD\n", 587 | "- **Airlines:** Delta, Virgin Atlantic\n", 588 | "- **Outbound Flight:**\n", 589 | " - Departure: 20:20 from JFK on April 30, 2025\n", 590 | " - Arrival: 00:05 at DEL on May 2, 2025 (Layover at LHR for 1h 40m)\n", 591 | " - Flight Duration: 18h 15m\n", 592 | "- **Return Flight:**\n", 593 | " - Departure: 03:25 from DEL on May 7, 2025\n", 594 | " - Arrival: 15:10 at JFK (Layover at DOH for 3h 5m)\n", 595 | " - Flight Duration: 21h 15m\n", 596 | "\n", 597 | "### Option 2:\n", 598 | "- **Total Price:** $768 USD\n", 599 | "- **Airlines:** Air India\n", 600 | "- **Outbound Flight:**\n", 601 | " - Departure: 11:55 from JFK on April 30, 2025\n", 602 | " - Arrival: 11:05 at DEL on May 1, 2025\n", 603 | " - Flight Duration: 13h 40m (Direct)\n", 604 | "- **Return Flight:**\n", 605 | " - Departure: 10:25 from DEL on May 7, 2025 \n", 606 | " - Arrival: 22:30 at JFK (Layover at DOH for 3h 25m)\n", 607 | " - Flight Duration: 21h 35m\n", 608 | "\n", 609 | "### Option 3:\n", 610 | "- **Total Price:** $920 USD\n", 611 | "- **Airlines:** American\n", 612 | "- **Outbound Flight:**\n", 613 | " - Departure: 21:10 from JFK on April 30, 2025\n", 614 | " - Arrival: 21:00 at DEL on May 1, 2025\n", 615 | " - Flight Duration: 14h 20m (Direct)\n", 616 | "- **Return Flight:**\n", 617 | " - Departure: 06:35 from DEL on May 7, 2025 \n", 618 | " - Arrival: 17:55 at JFK (Layover at IST for 3h)\n", 619 | " - Flight Duration: 20h 20m\n", 620 | "\n", 621 | "These are some of the available options. Please let me know if you want to book any of these flights or if you need further assistance!\n" 622 | ] 623 | } 624 | ], 625 | "source": [ 626 | "invoke_graph(\"I am travelling alone, book for April 30 for 7 days\", thread_id)" 627 | ] 628 | }, 629 | { 630 | "cell_type": "code", 631 | "execution_count": 82, 632 | "id": "3ad2e73108c62ac9", 633 | "metadata": { 634 | "ExecuteTime": { 635 | "end_time": "2025-03-02T09:50:55.408318Z", 636 | "start_time": "2025-03-02T09:50:50.094782Z" 637 | } 638 | }, 639 | "outputs": [ 640 | { 641 | "name": "stdout", 642 | "output_type": "stream", 643 | "text": [ 644 | "flights_advisor: You've chosen Option 1 with Delta and Virgin Atlantic. Here's a recap of your selected itinerary:\n", 645 | "\n", 646 | "### Outbound Flight:\n", 647 | "- **Departure:** 20:20 from New York (JFK) on April 30, 2025\n", 648 | "- **Arrival:** 00:05 at Delhi (DEL) on May 2, 2025\n", 649 | "- **Layover:** Heathrow Airport (LHR) for 1h 40m\n", 650 | "- **Flight Duration:** 18h 15m\n", 651 | "\n", 652 | "### Return Flight:\n", 653 | "- **Departure:** 03:25 from Delhi (DEL) on May 7, 2025\n", 654 | "- **Arrival:** 15:10 at New York (JFK)\n", 655 | "- **Layover:** Hamad International (DOH) for 3h 5m\n", 656 | "- **Flight Duration:** 21h 15m\n", 657 | "\n", 658 | "### Total Price: $758 USD\n", 659 | "\n", 660 | "Please follow [this link](https://www.google.com/travel/flights?hl=en&gl=us&curr=USD&tfs=CBwQAhoeEgoyMDI1LTA0LTMwagcIARIDSkZLcgcIARIDREVMGh4SCjIwMjUtMDUtMDdqBwgBEgNERUxyBwgBEgNKRktCAQFIAXABmAEB&tfu=EgIIAQ) to complete your booking directly through Google Flights.\n", 661 | "\n", 662 | "Safe travels, and if you need anything else, feel free to ask!\n" 663 | ] 664 | } 665 | ], 666 | "source": [ 667 | "invoke_graph(\"Great! I will choose option 1\", thread_id)" 668 | ] 669 | }, 670 | { 671 | "cell_type": "code", 672 | "execution_count": 83, 673 | "id": "3b83e9271ec2ef7e", 674 | "metadata": { 675 | "ExecuteTime": { 676 | "end_time": "2025-03-02T09:51:51.042239Z", 677 | "start_time": "2025-03-02T09:51:42.102046Z" 678 | } 679 | }, 680 | "outputs": [ 681 | { 682 | "name": "stdout", 683 | "output_type": "stream", 684 | "text": [ 685 | "Handing off to supervisor...\n", 686 | "Handing off to hotel_advisor...\n", 687 | "hotel_advisor: To help you book a hotel in Delhi, could you please provide me with:\n", 688 | "- Your check-in and check-out dates\n", 689 | "- Number of rooms required\n", 690 | "- Any specific preferences or budget you have in mind\n", 691 | "\n", 692 | "Once I have these details, I'll be able to provide you with the best options available.\n" 693 | ] 694 | } 695 | ], 696 | "source": [ 697 | "invoke_graph(\"Great! I would like to book hotel now\", thread_id)" 698 | ] 699 | }, 700 | { 701 | "cell_type": "code", 702 | "execution_count": 84, 703 | "id": "4f8eb8a86380afec", 704 | "metadata": { 705 | "ExecuteTime": { 706 | "end_time": "2025-03-02T09:53:04.680692Z", 707 | "start_time": "2025-03-02T09:52:49.704518Z" 708 | } 709 | }, 710 | "outputs": [ 711 | { 712 | "name": "stdout", 713 | "output_type": "stream", 714 | "text": [ 715 | "hotel_advisor: Here are some excellent hotel options in the Karol Bagh area of Delhi, perfect for a stay from May 1 to May 7, 2025:\n", 716 | "\n", 717 | "### 1. Bloom Hotel - Karol Bagh\n", 718 | "- **Price:** ₹5,197 per night (Total: ₹31,181)\n", 719 | "- **Star Rating:** 4-star\n", 720 | "- **Rating:** 4.8/5 (3,179 reviews)\n", 721 | "- **Location:** Close to Karol Bagh Metro Station (4 min walk)\n", 722 | "- **Amenities:** Free Wi-Fi, Pool, Air conditioning, Restaurant, Room service, Breakfast available\n", 723 | "- **Proximity:** 9 min drive to Shri Laxmi Narayan Temple\n", 724 | "- **Booking Link:** [Book Now](https://staybloom.com/hotels/delhi/bloom-hotel-karol-bagh?couponCode=BLOOM15&utm_source=google&utm_medium=gmb&utm_campaign=gmb-website-cta)\n", 725 | "\n", 726 | "\n", 727 | "### 2. Hotel Livasa Inn\n", 728 | "- **Price:** ₹3,472 per night (Total: ₹20,832)\n", 729 | "- **Rating:** 4.6/5 (1,854 reviews)\n", 730 | "- **Location:** 10 min walk to Karol Bagh\n", 731 | "- **Amenities:** Free breakfast, Free Wi-Fi, Free parking, Room service, Airport shuttle\n", 732 | "- **Proximity:** 27 min taxi to Indira Gandhi International Airport\n", 733 | "- **Booking Link:** [Visit Website](http://www.hotellivasa.com/)\n", 734 | "\n", 735 | "\n", 736 | "### 3. Hotel Silver Stone (3-Star Hotel)\n", 737 | "- **Price:** ₹2,188 per night (Total: ₹13,128)\n", 738 | "- **Star Rating:** 3-star\n", 739 | "- **Rating:** 4.5/5 (1,685 reviews)\n", 740 | "- **Location:** 6 min walk to Karol Bagh\n", 741 | "- **Amenities:** Free breakfast, Free Wi-Fi, Free parking, Room service, Restaurant\n", 742 | "- **Proximity:** 28 min taxi to Indira Gandhi International Airport\n", 743 | "- **Booking Link:** [Visit Website](http://www.hotelsilverstonedelhi.com/)\n", 744 | "\n", 745 | "\n", 746 | "### 4. Hotel Sunstar Heritage\n", 747 | "- **Price:** ₹3,696 per night (Total: ₹22,177)\n", 748 | "- **Rating:** 4.5/5 (507 reviews)\n", 749 | "- **Location:** 10 min walk to Karol Bagh\n", 750 | "- **Amenities:** Free breakfast, Wi-Fi, Free parking, Restaurant, Room service\n", 751 | "- **Proximity:** 26 min taxi to Indira Gandhi International Airport\n", 752 | "- **Booking Link:** [Visit Website](http://www.hotelsunstarheritage.com/)\n", 753 | "\n", 754 | "\n", 755 | "If you have any preferences or need further assistance, feel free to let me know!\n" 756 | ] 757 | } 758 | ], 759 | "source": [ 760 | "invoke_graph(\"Great! I am travelling alone, pls refer to shortlisted flights for checkin and checkout dates. I prefer hotels near Karol Bagh\", thread_id)" 761 | ] 762 | }, 763 | { 764 | "cell_type": "code", 765 | "execution_count": 85, 766 | "id": "ab8166468c68fc5b", 767 | "metadata": { 768 | "ExecuteTime": { 769 | "end_time": "2025-03-02T09:53:23.509828Z", 770 | "start_time": "2025-03-02T09:53:14.310441Z" 771 | } 772 | }, 773 | "outputs": [ 774 | { 775 | "name": "stdout", 776 | "output_type": "stream", 777 | "text": [ 778 | "hotel_advisor: You've chosen to stay at **Bloom Hotel - Karol Bagh**. Here's a quick summary of your choice:\n", 779 | "\n", 780 | "### Bloom Hotel - Karol Bagh\n", 781 | "- **Price**: ₹5,197 per night (Total: ₹31,181 for 6 nights)\n", 782 | "- **Star Rating**: 4-star\n", 783 | "- **Overall Rating**: 4.8/5 (3,179 reviews)\n", 784 | "- **Check-in**: May 1, 2025, 2:00 PM\n", 785 | "- **Check-out**: May 7, 2025, 11:00 AM\n", 786 | "- **Amenities**: \n", 787 | " - Free Wi-Fi \n", 788 | " - Pool \n", 789 | " - Air conditioning \n", 790 | " - Restaurant \n", 791 | " - Room service \n", 792 | " - Breakfast available\n", 793 | "- **Proximity**: \n", 794 | " - 4-minute walk to Karol Bagh Metro Station\n", 795 | " - 9-minute taxi to Shri Laxmi Narayan Temple\n", 796 | "\n", 797 | "You can proceed to [Book Now](https://staybloom.com/hotels/delhi/bloom-hotel-karol-bagh?couponCode=BLOOM15&utm_source=google&utm_medium=gmb&utm_campaign=gmb-website-cta).\n", 798 | "\n", 799 | "If you need anything else, feel free to ask! Enjoy your stay!\n" 800 | ] 801 | } 802 | ], 803 | "source": [ 804 | "invoke_graph(\"Great! I will choose option 1\", thread_id)" 805 | ] 806 | }, 807 | { 808 | "cell_type": "code", 809 | "execution_count": null, 810 | "id": "8a81d81579f272f7", 811 | "metadata": {}, 812 | "outputs": [], 813 | "source": [] 814 | } 815 | ], 816 | "metadata": { 817 | "kernelspec": { 818 | "display_name": ".venv", 819 | "language": "python", 820 | "name": "python3" 821 | }, 822 | "language_info": { 823 | "codemirror_mode": { 824 | "name": "ipython", 825 | "version": 3 826 | }, 827 | "file_extension": ".py", 828 | "mimetype": "text/x-python", 829 | "name": "python", 830 | "nbconvert_exporter": "python", 831 | "pygments_lexer": "ipython3", 832 | "version": "3.11.11" 833 | } 834 | }, 835 | "nbformat": 4, 836 | "nbformat_minor": 5 837 | } 838 | --------------------------------------------------------------------------------