In this section, the MCP, tools, state, and sub-Agents learned earlier are arranged together to build a coordinator agent that can plan a destination wedding.
This notebook is a comprehensive practice of Module 2. The main Agent plays the role of wedding coordinator. It first collects necessary fields and writes them into the state, and then entrusts three expert Agents to handle flights, venues and playlists respectively.
| Role | Tool source | Responsible for content |
|---|---|---|
| Travel agent | Kiwi MCP tools | Search for flights to and from your destination |
| Venue agent | web_search | Search for wedding venues that match location and number of people |
| Playlist agent | query_playlist_db | Query the Chinook SQLite database and plan a playlist |
| Coordinator | Wrapped expert tools + update_state | Collecting requirements, dispatching tasks and combining results |
Real remote tools fail. The notebook first defines RetryMCPInterceptor to retry transient MCP errors and convert unrecoverable errors into readable messages.
RETRYABLE_MCP_CODES = {-32603}
class RetryMCPInterceptor:
"""Intercept MCP tool calls: retry transient failures, surface all errors gracefully."""
client = MultiServerMCPClient(
{
"travel_server": {
"transport": "streamable_http",
"url": "https://mcp.kiwi.com",
}
},
tool_interceptors=[RetryMCPInterceptor()],
)
tools = await client.get_tools()The earlier lessons focused on "can it call the tool?" This lesson starts handling "what if the call fails?" Remote MCP, search and databases all need explicit error-handling boundaries.
The venue specialist uses Tavily search, and the playlist specialist queries the local Chinook database. Both tools are exposed to their corresponding expert Agents through @tool.
tavily_client = TavilyClient()
search_count = 0
@tool
def web_search(query: str) -> Dict[str, Any]:
"""Search the web for information."""
# call Tavily and return search results
db = SQLDatabase.from_uri("sqlite:///resources/Chinook.db")
@tool
def query_playlist_db(query: str) -> str:
"""Query the database for playlist information"""
try:
return db.run(query)
except Exception as e:
return f"Error: {e}"In multi-agent scenarios, the tool docstring is the model's operating instruction. Clearer descriptions make expert Agents less likely to call tools with wrong parameters.
The coordinator first extracts wedding requirements from the user input and writes them into state. The notebook's WeddingState includes origin, destination, guest count and music genre.
class WeddingState(AgentState):
origin: str
destination: str
guest_count: str
genre: strWhere the couple is departing from.
The wedding destination.
The venue capacity requirement.
The playlist style.
Each expert Agent receives only its own tools and system prompt. This is easier to control than making one large Agent understand flights, venues and SQL at the same time.
today = date.today().isoformat()
travel_agent = create_agent(
model=deepseek_model(),
tools=tools,
system_prompt=f"""
You are a travel agent. Search for flights to the desired destination wedding location.
Today is {today}. Any departureDate you pass to tools must be strictly after {today}.
You are not allowed to ask any more follow up questions.
""",
)
venue_agent = create_agent(
model=deepseek_model(),
tools=[web_search],
system_prompt="""You are a venue specialist. Search for venues in the desired location.""",
)
playlist_agent = create_agent(
model=deepseek_model(),
tools=[query_playlist_db],
system_prompt="""You are a playlist specialist. Query the SQL database and curate a wedding playlist.""",
)Expert Agent prompts can be shorter and more specific; the main Agent only collects information, delegates and summarizes.
The coordinator's tools do not search directly. They read structured fields from runtime.state, then call the corresponding expert Agent.
@tool
async def search_flights(runtime: ToolRuntime) -> str:
"""Travel agent searches for flights to the desired destination wedding location."""
origin = runtime.state["origin"]
destination = runtime.state["destination"]
response = await travel_agent.ainvoke(
{"messages": [HumanMessage(content=f"Find flights from {origin} to {destination}")]}
)
return response["messages"][-1].content
@tool
def search_venues(runtime: ToolRuntime) -> str:
guest_count = runtime.state["guest_count"]
destination = runtime.state["destination"]
response = venue_agent.invoke(
{"messages": [HumanMessage(content=f"Find venues in {destination} for {guest_count} guests")]}
)
return response["messages"][-1].contentIf requirements are not saved in structured state first, every expert has to extract fields again from the raw user sentence, which raises the error rate.
Finally, the notebook creates the coordinator and uses a higher recursion_limit to leave room for multiple tool-call rounds. It demonstrates both ordinary ainvoke and streaming output with astream.
coordinator = create_agent(
model=deepseek_model(),
tools=[search_flights, search_venues, suggest_playlist, update_state],
state_schema=WeddingState,
system_prompt="""
You are a wedding coordinator.
First find all the information you need to update the state.
Then delegate tasks to your specialists for flights, venues, and playlists.
""",
)
response = await coordinator.ainvoke(
{
"messages": [
HumanMessage(content="I'm from London and I'd like a wedding in Paris for 100 guests, jazz-genre")
]
},
config={"recursion_limit": 40},
)This lesson ties together Module 2's main themes: external tools come from MCP/search/SQL, temporary requirements go into state, expert Agents solve subtasks, and the coordinator organizes the final answer.