> For the complete documentation index, see [llms.txt](https://agenticai.kasinathanramesh.vip/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://agenticai.kasinathanramesh.vip/section-5-multi-agent-setup-with-langgraph/lecture-3.md).

# Lecture 3

## Multi-Agent Systems with LangGraph: A Travel Planning Assistant <a href="#multi-agent-systems-with-langgraph-a-travel-planning-assistant" id="multi-agent-systems-with-langgraph-a-travel-planning-assistant"></a>

This notebook is a simple, beginner friendly walkthrough of building **multi-agent systems** with LangGraph. It covers:

1. **Multi-agent interaction via shared graph state**
2. **Dynamic task allocation and conditional logic**
3. **Memory, buffers, and context handling**
4. **Retry and fallback patterns**

### The real world use case

To keep things grounded, every section uses the same simple example:

> **A Travel Planning Assistant** made of several small agents that work together: one plans a trip, one writes the itinerary, a router sends requests to the right specialist (flights, hotels, or general questions), and a memory buffer keeps track of the conversation. We also show how to recover gracefully when an external service fails.

We use **OpenRouter** as our model provider, and a **free model called `openai/gpt-oss-20b:free`**. OpenRouter gives an OpenAI compatible API, so we use it directly with `ChatOpenAI` from `langchain_openai` by changing the `base_url` and `api_key`.

The notebook is organized into **4 tasks**, each written as its own Python function so you can run them independently:

* `task_1_shared_graph_state()`
* `task_2_dynamic_routing()`
* `task_3_memory_and_buffers()`
* `task_4_retry_and_fallback()`

At the end, all 4 tasks are run one after another. Wherever it makes sense, agents are built with `create_react_agent` so the model can decide which tools to call on its own.

```
## Step 0: Environment Setup

We install the same packages as before:

- `langchain` - core framework (chains, tools, prompts)
- `langchain-openai` - lets us talk to OpenAI compatible APIs (including OpenRouter)
- `langchain-community` - extra community integrations
- `langgraph` - the graph based framework used to build our multi-agent system

Run the cell below once.
```

```
# Install the required packages
# The -q flag just keeps the output quiet
!pip install -q langchain langchain-openai langchain-community langgraph
```

#### Setting up the Open Router API key and the model[¶](http://localhost:8888/notebooks/Lecture3.ipynb#Setting-up-the-OpenRouter-API-key-and-the-model) <a href="#setting-up-the-openrouter-api-key-and-the-model" id="setting-up-the-openrouter-api-key-and-the-model"></a>

Just like before, replace the placeholder text below with your real Open Router API key. Every agent in this notebook reuses this same `llm` object.

```
import os

# -----------------------------------------------------------------------
# Put your OpenRouter API key here.
# You can get a free key from https://openrouter.ai/keys
# -----------------------------------------------------------------------
OPENROUTER_API_KEY = "sk-or-your-openrouter-api-key-here"

os.environ["OPENROUTER_API_KEY"] = OPENROUTER_API_KEY

from langchain_openai import ChatOpenAI

# Create the LLM object that every agent in this notebook will reuse.
# "openai/gpt-oss-20b:free" is a free, open weight model available on OpenRouter.
llm = ChatOpenAI(
    model="openai/gpt-oss-20b:free",
    api_key=OPENROUTER_API_KEY,
    base_url="https://openrouter.ai/api/v1",
    temperature=0,
)

# Quick sanity check
test_response = llm.invoke("In one short sentence, say hello and introduce yourself.")
print(test_response.content)
```

### Task 1: Multi-Agent Interaction via Shared Graph State

In LangGraph, multiple agents can collaborate by reading from and writing to the **same state object** as it flows through the graph. This is the simplest way to build a multi-agent system: each agent is just a node, and the shared state is how they "talk" to each other.

In this task we build two agents:

1. **planner\_agent** - looks at a trip topic and writes a short plan. It uses a tool called `suggest_destinations`.
2. **writer\_agent** - reads the plan that the planner just wrote (from the shared state) and turns it into a friendly itinerary paragraph. It uses a tool called `count_words`.

Both agents are built using `create_react_agent`. The flow is: **planner -> writer -> end**, and the `plan` field written by the planner is visible to the writer through the shared `TripState`.

```
def task_1_shared_graph_state():
    """
    Task 1: Multi-agent interaction via shared graph state.

    Two agents work on the same task by reading and writing to one shared
    state dictionary:
      1. planner_agent - creates a short trip plan
      2. writer_agent  - reads that plan and writes a short itinerary

    Both agents are built with create_react_agent and given their own tools.
    """
    from typing import TypedDict
    from langchain_core.tools import tool
    from langgraph.graph import StateGraph, END
    from langgraph.prebuilt import create_react_agent

    # Shared state: every agent in the graph reads from and writes to this
    # same dictionary as it moves through the graph.
    class TripState(TypedDict):
        topic: str   # what the user wants to plan a trip about
        plan: str    # written by the planner agent
        draft: str   # written by the writer agent

    # ------------------------------------------------------------------
    # Tool used by the planner agent: suggests destinations for a topic
    # ------------------------------------------------------------------
    @tool
    def suggest_destinations(topic: str) -> str:
        """Suggest 2-3 example destinations that match the given trip topic."""
        ideas = {
            "beach": "Bali, Maldives, Goa",
            "mountain": "Swiss Alps, Rocky Mountains, Himalayas",
            "city": "Tokyo, Paris, New York",
        }
        for key, value in ideas.items():
            if key in topic.lower():
                return value
        return "Rome, Bangkok, Cape Town"

    # ------------------------------------------------------------------
    # Tool used by the writer agent: counts words in a piece of text
    # ------------------------------------------------------------------
    @tool
    def count_words(text: str) -> str:
        """Count the number of words in the given text."""
        return f"{len(text.split())} words"

    # Build the two agents. Each one is a small ready-made graph from
    # create_react_agent, combining our shared LLM with its own tools.
    planner_agent = create_react_agent(llm, [suggest_destinations])
    writer_agent = create_react_agent(llm, [count_words])

    # ------------------------------------------------------------------
    # Node 1: planner agent writes a short plan into the shared state
    # ------------------------------------------------------------------
    def planner_node(state: TripState) -> dict:
        instruction = (
            f"The user wants a trip plan about: '{state['topic']}'. "
            "Use the suggest_destinations tool to pick destinations, then "
            "write a short 2-3 step plan as plain text."
        )
        result = planner_agent.invoke({"messages": [("user", instruction)]})
        plan_text = result["messages"][-1].content
        print("[planner_node] plan written:")
        print(plan_text)
        # This update is written into the SHARED state, so the writer can read it
        return {"plan": plan_text}

    # ------------------------------------------------------------------
    # Node 2: writer agent reads the shared plan and writes a draft
    # ------------------------------------------------------------------
    def writer_node(state: TripState) -> dict:
        instruction = (
            f"Here is a trip plan:\n{state['plan']}\n\n"
            "Turn this into a short, friendly itinerary paragraph (3-4 sentences). "
            "Then use the count_words tool to report how many words your paragraph has."
        )
        result = writer_agent.invoke({"messages": [("user", instruction)]})
        draft_text = result["messages"][-1].content
        print("\n[writer_node] draft written:")
        print(draft_text)
        return {"draft": draft_text}

    # ------------------------------------------------------------------
    # Build the graph: planner -> writer -> END
    # Both nodes share the same TripState object as it flows through.
    # ------------------------------------------------------------------
    graph = StateGraph(TripState)
    graph.add_node("planner", planner_node)
    graph.add_node("writer", writer_node)
    graph.set_entry_point("planner")
    graph.add_edge("planner", "writer")
    graph.add_edge("writer", END)

    app = graph.compile()

    final_state = app.invoke({"topic": "a relaxing beach vacation", "plan": "", "draft": ""})

    print("\nFinal shared state contains keys:", list(final_state.keys()))

```

### Task 2: Dynamic Task Allocation and Conditional Logic

A common multi-agent pattern is to have one **router** decide which **specialist agent** should handle each request. The routing decision can change every time, based on the content of the request - this is what makes it "dynamic".

In this task we build:

* **router\_node** - a simple chain (prompt -> model) that classifies a request as `"flight"`, `"hotel"`, or `"faq"`.
* **flight\_agent** - a `create_react_agent` with a `search_flights` tool.
* **hotel\_agent** - a `create_react_agent` with a `search_hotels` tool.
* **faq\_node** - answers general questions directly with the LLM, no tools needed.

`add_conditional_edges` is used to send each request down a different path based on what the router decided.

```
def task_2_dynamic_routing():
    """
    Task 2: Dynamic task allocation and conditional logic.

    A "router" looks at the user's request and decides which specialist
    agent should handle it:
      - flight_agent : handles flight related questions
      - hotel_agent  : handles hotel related questions
      - faq_node     : handles general questions

    The routing decision is made separately for each request using
    add_conditional_edges, so different requests can take different paths
    through the graph.
    """
    from typing import TypedDict
    from langchain_core.tools import tool
    from langchain_core.prompts import ChatPromptTemplate
    from langgraph.graph import StateGraph, END
    from langgraph.prebuilt import create_react_agent

    class RouterState(TypedDict):
        request: str
        route: str
        response: str

    # ------------------------------------------------------------------
    # Tools for the specialist agents (mock data, no real API calls)
    # ------------------------------------------------------------------
    @tool
    def search_flights(origin: str, destination: str) -> str:
        """Search for flights between two cities and return example prices."""
        return f"Example flights from {origin} to {destination}: $250 (economy), $700 (business)."

    @tool
    def search_hotels(city: str) -> str:
        """Search for hotels in a city and return example options."""
        return f"Example hotels in {city}: Budget Inn ($60/night), City Center Hotel ($120/night)."

    flight_agent = create_react_agent(llm, [search_flights])
    hotel_agent = create_react_agent(llm, [search_hotels])

    # ------------------------------------------------------------------
    # Node: router classifies the request into one of 3 categories.
    # This is a plain chain (prompt -> llm), not an agent, because it
    # only needs to output a single category word.
    # ------------------------------------------------------------------
    classify_prompt = ChatPromptTemplate.from_messages([
        ("system",
         "Classify the user's request into exactly one word: "
         "'flight', 'hotel', or 'faq'. Reply with only that one word, "
         "nothing else."),
        ("human", "{request}")
    ])
    classify_chain = classify_prompt | llm

    def router_node(state: RouterState) -> dict:
        raw_category = classify_chain.invoke({"request": state["request"]}).content
        category = raw_category.strip().lower()

        # Make sure we always end up with one of our 3 valid categories,
        # even if the model adds extra words or punctuation.
        if "flight" in category:
            category = "flight"
        elif "hotel" in category:
            category = "hotel"
        else:
            category = "faq"

        print(f"[router_node] request classified as: {category}")
        return {"route": category}

    # ------------------------------------------------------------------
    # Specialist nodes
    # ------------------------------------------------------------------
    def flight_node(state: RouterState) -> dict:
        result = flight_agent.invoke({"messages": [("user", state["request"])]})
        return {"response": result["messages"][-1].content}

    def hotel_node(state: RouterState) -> dict:
        result = hotel_agent.invoke({"messages": [("user", state["request"])]})
        return {"response": result["messages"][-1].content}

    def faq_node(state: RouterState) -> dict:
        # A simple direct LLM call for general questions, no tools needed
        answer = llm.invoke(state["request"]).content
        return {"response": answer}

    # ------------------------------------------------------------------
    # Routing function: tells LangGraph which node to go to next, based
    # on the "route" value that router_node just wrote into the state.
    # ------------------------------------------------------------------
    def route_decision(state: RouterState) -> str:
        return state["route"]

    # ------------------------------------------------------------------
    # Build the graph
    # ------------------------------------------------------------------
    graph = StateGraph(RouterState)
    graph.add_node("router", router_node)
    graph.add_node("flight", flight_node)
    graph.add_node("hotel", hotel_node)
    graph.add_node("faq", faq_node)

    graph.set_entry_point("router")

    # This is what makes task allocation "dynamic": the same router can
    # send different requests down completely different paths.
    graph.add_conditional_edges(
        "router",
        route_decision,
        {"flight": "flight", "hotel": "hotel", "faq": "faq"},
    )

    graph.add_edge("flight", END)
    graph.add_edge("hotel", END)
    graph.add_edge("faq", END)

    app = graph.compile()

    # Try a few different requests and watch them take different paths
    sample_requests = [
        "Find me flights from London to Tokyo.",
        "What hotels are available in Paris?",
        "What currency is used in Japan?",
    ]

    for req in sample_requests:
        print("\nUser request:", req)
        result = app.invoke({"request": req, "route": "", "response": ""})
        print("Response:", result["response"])
```

### Task 3: Memory, Buffers, and Context Handling

When agents have long conversations, we cannot keep sending the entire history to the model forever - it gets slow, expensive, and can exceed the model's context limit. A common pattern is to keep a **buffer** of recent messages, and periodically **summarize** older messages into a short running summary.

In this task we build a small graph with two nodes:

1. **add\_to\_buffer\_node** - appends the newest message to a buffer (a list stored in the shared state). This is our "memory".
2. **maybe\_summarize\_node** - if the buffer grows past a limit, it asks the LLM to summarize the oldest messages into 1-2 sentences, folds that into a running `summary`, and keeps only the most recent messages in the buffer.

We then simulate a multi-turn conversation by calling the graph repeatedly, carrying the state (buffer and summary) forward between calls.

def task\_3\_memory\_and\_buffers(): """ Task 3: Memory, buffers, and context handling.

```
We keep a running list of conversation messages (a "buffer") inside
the shared state. When the buffer grows past a limit, an extra node
summarizes the older messages so the context does not grow without
bound, while still remembering the important facts.
"""
from typing import TypedDict, List
from langgraph.graph import StateGraph, END

class ChatState(TypedDict):
    new_message: str    # the latest incoming message
    buffer: List[str]   # recent raw messages (our short term memory)
    summary: str        # rolling summary of older messages

# Keep at most this many raw messages before summarizing the rest
MAX_BUFFER_SIZE = 4

# ------------------------------------------------------------------
# Node: add the new message to the buffer
# ------------------------------------------------------------------
def add_to_buffer_node(state: ChatState) -> dict:
    updated_buffer = state["buffer"] + [state["new_message"]]
    print(f"[add_to_buffer_node] buffer size is now {len(updated_buffer)}")
    return {"buffer": updated_buffer}

# ------------------------------------------------------------------
# Node: if the buffer is too big, summarize the oldest messages and
# fold them into "summary", keeping only the most recent messages
# in the buffer.
# ------------------------------------------------------------------
def maybe_summarize_node(state: ChatState) -> dict:
    buffer = state["buffer"]

    if len(buffer) <= MAX_BUFFER_SIZE:
        # Buffer is small enough, nothing to summarize yet
        return {}

    # Split into "old" messages to fold into the summary, and
    # "recent" messages to keep as-is in the buffer.
    split_point = len(buffer) - MAX_BUFFER_SIZE
    old_messages = buffer[:split_point]
    recent_messages = buffer[split_point:]

    summary_prompt = (
        "Summarize the following conversation history in 1-2 short "
        "sentences. Keep only the important facts.\n\n"
        f"Existing summary: {state['summary']}\n"
        f"Older messages: {old_messages}"
    )
    new_summary = llm.invoke(summary_prompt).content

    print(f"[maybe_summarize_node] summarized {len(old_messages)} old message(s)")
    print(f"[maybe_summarize_node] new summary: {new_summary}")

    return {"summary": new_summary, "buffer": recent_messages}

# ------------------------------------------------------------------
# Build the graph: add_to_buffer -> maybe_summarize -> END
# ------------------------------------------------------------------
graph = StateGraph(ChatState)
graph.add_node("add_to_buffer", add_to_buffer_node)
graph.add_node("maybe_summarize", maybe_summarize_node)
graph.set_entry_point("add_to_buffer")
graph.add_edge("add_to_buffer", "maybe_summarize")
graph.add_edge("maybe_summarize", END)

app = graph.compile()

# ------------------------------------------------------------------
# Simulate a multi-turn conversation by calling the graph repeatedly.
# The state (buffer and summary) is carried forward between calls,
# which is how "memory" works across turns.
# ------------------------------------------------------------------
state = {"new_message": "", "buffer": [], "summary": ""}

messages = [
    "User: I am planning a trip to Italy.",
    "User: I want to visit Rome and Florence.",
    "User: My budget is around 1500 dollars.",
    "User: I prefer trains over flights for getting around.",
    "User: I would also like some vegetarian restaurant recommendations.",
]

for msg in messages:
    state["new_message"] = msg
    state = app.invoke(state)

print("\nFinal buffer (recent messages):", state["buffer"])
print("Final summary (older messages):", state["summary"])
```

### Task 4: Retry and Fallback Patterns

Real agents often depend on external services (APIs, databases, tools) that can fail temporarily. A good multi-agent system should:

* **Retry** a failed step a few times, in case the failure is temporary.
* **Fall back** to a safe default response if every retry fails, instead of crashing or leaving the user with nothing.

In this task we simulate an unreliable "exchange rate service" with a function that fails on its first few calls and then succeeds. We build a small graph with:

* **agent\_node** - tries to call the unreliable service and, if it succeeds, uses the LLM to turn the result into a friendly sentence.
* **fallback\_node** - returns a safe default message if every retry fails.
* A **conditional edge with a self loop** - `agent_node` can route back to itself (retry), to `fallback_node` (give up gracefully), or to `END` (success).

Note: for this task we call the unreliable service directly (instead of through `create_react_agent`). This keeps the retry and error handling logic clear and visible, since wrapping it inside an agent's tool-calling loop would hide the failures from our graph logic.

```
def task_4_retry_and_fallback():
    """
    Task 4: Retry and fallback patterns.

    We call a function that simulates an unreliable external service: it
    "fails" on its first few calls and then succeeds. The graph retries
    this step automatically up to a maximum number of attempts. If every
    attempt fails, the graph falls back to a safe default response instead
    of crashing.
    """
    from typing import TypedDict
    from langgraph.graph import StateGraph, END

    MAX_RETRIES = 3

    class RetryState(TypedDict):
        currency: str
        attempts: int
        result: str
        failure_threshold: int  # how many times the service fails before succeeding

    # ------------------------------------------------------------------
    # A "flaky" external service. call_counter keeps track of how many
    # times it has been called. It raises an error for the first
    # "failure_threshold" calls, then succeeds on every call after that.
    # ------------------------------------------------------------------
    call_counter = {"count": 0}

    def call_exchange_rate_service(currency: str, failure_threshold: int) -> str:
        call_counter["count"] += 1
        if call_counter["count"] <= failure_threshold:
            raise RuntimeError("Exchange rate service is temporarily unavailable.")
        return f"1 {currency} = 0.9 USD (example rate)"

    # ------------------------------------------------------------------
    # Node: try to call the service. If it raises an error, catch it and
    # leave "result" empty so the graph knows this attempt failed.
    # If it succeeds, ask the LLM to phrase the result as a friendly
    # sentence for the user.
    # ------------------------------------------------------------------
    def agent_node(state: RetryState) -> dict:
        attempts = state["attempts"] + 1
        print(f"[agent_node] attempt {attempts}")

        try:
            raw_result = call_exchange_rate_service(state["currency"], state["failure_threshold"])
            answer = llm.invoke(
                f"In one short sentence, tell the user this exchange rate info: {raw_result}"
            ).content
            print(f"[agent_node] attempt {attempts} succeeded")
            return {"attempts": attempts, "result": answer}
        except Exception as error:
            print(f"[agent_node] attempt {attempts} failed: {error}")
            return {"attempts": attempts, "result": ""}

    # ------------------------------------------------------------------
    # Node: fallback response used if every retry fails
    # ------------------------------------------------------------------
    def fallback_node(state: RetryState) -> dict:
        print("[fallback_node] all retries failed, returning a safe default answer")
        return {"result": "Sorry, the exchange rate service is currently unavailable. Please try again later."}

    # ------------------------------------------------------------------
    # Routing function, called after agent_node:
    #   - if we already have a result, we are done
    #   - if not, and we still have retries left, try agent_node again
    #   - if not, and we are out of retries, go to fallback_node
    # ------------------------------------------------------------------
    def decide_next(state: RetryState) -> str:
        if state["result"]:
            return "done"
        if state["attempts"] < MAX_RETRIES:
            return "retry"
        return "fallback"

    # ------------------------------------------------------------------
    # Build the graph.
    # Note the self loop: "agent" can route back to itself, which is how
    # retries work in LangGraph.
    # ------------------------------------------------------------------
    graph = StateGraph(RetryState)
    graph.add_node("agent", agent_node)
    graph.add_node("fallback", fallback_node)

    graph.set_entry_point("agent")

    graph.add_conditional_edges(
        "agent",
        decide_next,
        {"retry": "agent", "fallback": "fallback", "done": END},
    )
    graph.add_edge("fallback", END)

    app = graph.compile()

    # ------------------------------------------------------------------
    # Scenario 1: the service fails twice, then succeeds on the 3rd try.
    # With MAX_RETRIES = 3, this should succeed without needing fallback.
    # ------------------------------------------------------------------
    print("--- Scenario 1: service recovers after 2 failures ---")
    call_counter["count"] = 0
    result_1 = app.invoke({
        "currency": "EUR",
        "attempts": 0,
        "result": "",
        "failure_threshold": 2,
    })
    print("Final result:", result_1["result"])
    print("Total attempts:", result_1["attempts"])

    # ------------------------------------------------------------------
    # Scenario 2: the service never recovers (failure_threshold is very
    # high). This should exhaust all retries and trigger the fallback node.
    # ------------------------------------------------------------------
    print("\n--- Scenario 2: service never recovers ---")
    call_counter["count"] = 0
    result_2 = app.invoke({
        "currency": "GBP",
        "attempts": 0,
        "result": "",
        "failure_threshold": 100,
    })
    print("Final result:", result_2["result"])
    print("Total attempts:", result_2["attempts"])
```

### Putting It All Together

Now we run all four tasks one after another. Each task is fully self-contained in its own function, so you can also run them individually in any order, as many times as you like.

```
print("=" * 70)
print("TASK 1: Multi-agent interaction via shared graph state")
print("=" * 70)
task_1_shared_graph_state()

print("\n" + "=" * 70)
print("TASK 2: Dynamic task allocation and conditional logic")
print("=" * 70)
task_2_dynamic_routing()

print("\n" + "=" * 70)
print("TASK 3: Memory, buffers, and context handling")
print("=" * 70)
task_3_memory_and_buffers()

print("\n" + "=" * 70)
print("TASK 4: Retry and fallback patterns")
print("=" * 70)
task_4_retry_and_fallback()

```

### Summary

In this notebook we covered:

1. **Multi-agent interaction via shared graph state** - two agents (`planner_agent` and `writer_agent`), each built with `create_react_agent`, collaborated by reading and writing to one shared `TripState` dictionary.
2. **Dynamic task allocation and conditional logic** - a lightweight router chain classified each request, and `add_conditional_edges` sent the request to the matching specialist agent (`flight_agent`, `hotel_agent`, or a plain FAQ node).
3. **Memory, buffers, and context handling** - a running message buffer acted as short term memory, and once it grew past a limit, older messages were folded into a rolling summary so context stays bounded.
4. **Retry and fallback patterns** - a self-looping conditional edge let a node retry a failing step up to a maximum number of times, and a dedicated fallback node provided a safe default response if every retry failed.

All of this was powered by a single free model from OpenRouter (`openai/gpt-oss-20b:free`), accessed through LangChain's `ChatOpenAI` class with a custom `base_url`, and orchestrated with LangGraph's `StateGraph` and `create_react_agent`.

{% file src="/files/SdKg4vKDZqOmA8j8dXdL" %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://agenticai.kasinathanramesh.vip/section-5-multi-agent-setup-with-langgraph/lecture-3.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
