> ## Documentation Index
> Fetch the complete documentation index at: https://genai.codewithsiva.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Agentic AI

> Build autonomous agents capable of reasoning, using tools, and collaborating to solve complex tasks

<a id="table-of-contents" />

## 📋 Table of Contents

* [Chapter 1: Introduction to Autonomous Agents](#chapter-1-introduction-to-autonomous-agents)
* [Chapter 2: Tool Use & Function Calling](#chapter-2-tool-use--function-calling)
* [Chapter 3: Building a ReAct Agent](#chapter-3-building-a-react-agent)
* [Chapter 4: Multi-Agent Orchestration](#chapter-4-multi-agent-orchestration)
* [Chapter 5: Stateful Travel Assistant Project](#chapter-5-stateful-travel-assistant-project)
* [Chapter 6: Resume Analyzer Capstone Workflow](#chapter-6-resume-analyzer-capstone-workflow)

## 💻 Workshop Practice Notebook

Master all the concepts from this guide with hands-on practice (excluding MCP):

* **Practice in VS Code**: Open the notebook in your local editor. Requires a local `.env` file containing your API keys.
* **Practice in Google Colab**: Open the notebook directly in Colab. Setup cells are included to install packages and request API keys.

[💻 VS Code](vscode://file/Users/sivaprasad/Downloads/GenAI%20With%20Python/public/notebooks/agentic-ai/agentic-practice-vscode.ipynb) | [🚀 Colab](https://colab.research.google.com/github/prasad230776/genai-course/blob/master/public/notebooks/agentic-ai/agentic-practice-colab.ipynb) | <a href="/public/notebooks/agentic-ai/agentic-practice-vscode.ipynb" download>📥 Download Notebook</a>

## <a id="chapter-1-introduction-to-autonomous-agents" />Chapter 1: Introduction to Autonomous Agents

Most LLM applications operate in static chains, where a prompt triggers a model, and the model returns a response. **Autonomous Agents** shift this paradigm: instead of a fixed sequence, the LLM acts as a reasoning engine that dynamically determines which actions to take, which tools to call, and how to self-correct based on feedback.

## 1. Chains vs. Agents

The difference between chains and agents lies in **control flow**:

* **Static Chain**: The developer hardcodes the workflow.
  * *Example*: User Input $\rightarrow$ Prompt Template $\rightarrow$ LLM $\rightarrow$ StrOutputParser. The model has no choice but to follow this exact path.
* **Autonomous Agent**: The model dynamically routes execution based on the user's goal.
  * *Example*: If a user asks *"Check the price of Apple stock and email it to my manager,"* the agent decides to:
    1. Call a stock price search API tool.
    2. Read the results.
    3. Call an email API tool.
    4. Formulate a final response.

## 2. Core Components of an Agent

According to agent architectures, an autonomous agent consists of three central pillars:

```text theme={null}
              ┌──────────────────────────┐
              │           LLM            │
              │    (Reasoning Engine)    │
              └──────┬────────────┬──────┘
                     │            │
         ┌───────────▼──┐      ┌──▼───────────┐
         │   Memory     │      │   Planning   │
         │ (Short/Long) │      │ (Reflection) │
         └──────────────┘      └──────────────┘
                     │
         ┌───────────▼──┐
         │    Tools     │
         │ (APIs/Search)│
         └──────────────┘
```

### 2.1 Planning

* **Subgoal Decomposition**: Breaking a large, complex task into smaller, manageable milestones.
* **Reflection & Self-Correction**: Analyzing tool execution outputs. If a tool returns an error (e.g. API authentication failure), the agent alters its plan and attempts an alternative route instead of failing.

### 2.2 Memory

* **Short-Term Memory**: In-context conversation history. Allows the model to remember preceding turns in a chat session.
* **Long-Term Memory**: Storing past outputs and vector embeddings in a database, allowing the agent to recall information across days or weeks.

### 2.3 Tools

* Interfaces that allow the LLM to interact with the physical world.
* *Examples*: Web search engines (Tavily), calculation modules, databases, shell execution tools, or API endpoints.

## 3. The ReAct Design Pattern

The most popular agent execution framework is the **ReAct (Reason + Act)** pattern. ReAct combines reasoning (thoughts) and acting (actions) in a recursive loop:

```text theme={null}
User Input ──> Thought ──> Action ──> Observation ──> Thought ──> Final Answer
```

* **Thought**: The model reasons about the current state of the problem (e.g., *"I need to find the population of Paris. I should use the search tool."*).
* **Action**: The model triggers a specific tool with generated arguments (e.g., Calling `search_web("Paris population")`).
* **Observation**: The application executes the tool, gets the raw output, and feeds it back to the model (e.g., Output: `"2.1 million"`).
* **Thought / Final Answer**: The model evaluates the observation. If the goal is met, it outputs the final answer to the user.

## 4. Practice Exercises

### Practice 1: Identifying Agentic Capabilities

Suppose you want to build an application that analyzes a company budget CSV, generates a chart, and uploads it to Slack. Explain why this requires an **Agent** rather than a **Static Chain**.

<Accordion title="Solution">
  A static chain has a linear flow. However, this task involves dynamic steps:

  1. **Decision making**: The code must read the CSV and decide *which* columns are relevant for the chart based on the user's description.
  2. **Error recovery**: If the chart generation library throws a syntax error, a static chain crashes. An agent can read the traceback error (Observation), write corrected python code (Thought/Action), and execute it again until it succeeds.
  3. **Execution Routing**: Choosing the Slack upload API tool only *after* the chart file is successfully verified on disk.
</Accordion>

[Back to Top](#table-of-contents)

## <a id="chapter-2-tool-use--function-calling" />Chapter 2: Tool Use & Function Calling

Large Language Models cannot directly execute python code or fetch URL contents. Instead, they use **Function Calling** (Tool Binding), where the model outputs a structured JSON request indicating *which* tool to run and *what* arguments to pass. The client application executes the code locally and returns the result to the model.

## 1. The Function Calling Lifecycle

The function calling loop operates as a communication contract between the application client and the LLM API:

```text theme={null}
1. Client sends Query + Tool Schemas (JSON) ──> [ LLM ]
2. LLM decides to call tool ──> Returns Tool Call JSON Request {"name": "get_stock", "args": {"ticker": "AAPL"}}
3. Client runs get_stock("AAPL") locally ──> Returns result: "$240.50"
4. Client sends result to LLM ──> [ LLM ] ──> Generates conversational response: "Apple stock is currently $240.50."
```

> \[!IMPORTANT]
> **LLMs do not run your tools.** The model only generates the JSON arguments specifying *how* you should run them. Your Python script is responsible for executing the function and feeding the text result back to the model.

## 2. Defining & Binding Tools in LangChain

In LangChain, you convert any standard Python function into a tool using the **`@tool`** decorator. The decorator automatically generates the JSON schema description based on your function name, docstring, and type hints.

### 2.1 Python Implementation

Below is a working script demonstrating tool binding. The model evaluates a user question and outputs a structured tool call request.

```python theme={null}
import os
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model

load_dotenv()

# 1. Define a tool using the @tool decorator
# The docstring is critical: it tells the LLM when to call this tool
@tool
def calculate_salary_bonus(years_of_service: int, performance_score: float) -> float:
    """Calculates the end-of-year salary bonus for an employee based on tenure and score."""
    base_bonus = 1000.0
    return base_bonus + (years_of_service * 200.0) * performance_score

# 2. Initialize the Chat Model
llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

# 3. Bind the tool to the LLM
# This registers the tool schema with the model during call configuration
llm_with_tools = llm.bind_tools([calculate_salary_bonus])

# 4. Invoke the model
query = "How much bonus does a worker with 5 years of service and a 1.5 performance score get?"
print(f"Query: '{query}'")

response = llm_with_tools.invoke(query)

# 5. Inspect the response
# The model will not return conversational text; it returns a tool call request
print("\n--- Model Response Metadata ---")
print(f"AIMessage content: {response.content}")
print(f"Tool Calls requested: {response.tool_calls}")
```

**Output:**

```text theme={null}
Query: 'How much bonus does a worker with 5 years of service and a 1.5 performance score get?'

--- Model Response Metadata ---
AIMessage content: 
Tool Calls requested: [{'name': 'calculate_salary_bonus', 'args': {'years_of_service': 5, 'performance_score': 1.5}, 'id': '...', 'type': 'tool_call'}]
```

## 3. Practice Exercises

### Practice 1: Binding Multiple Tools

Create a second tool called `get_current_weather(city_name: str)` that returns a mock weather string (e.g. `"22°C and sunny"`). Bind **both** `calculate_salary_bonus` and `get_current_weather` to your model. Query `"What is the weather in London?"` and verify the correct tool call is returned.

**Instructions:**

1. Write the `get_current_weather` tool with a docstring.
2. Call `llm.bind_tools([calculate_salary_bonus, get_current_weather])`.
3. Invoke the query and print `response.tool_calls`.

<Accordion title="Solution">
  ```python theme={null}
  from langchain_core.tools import tool
  from langchain.chat_models import init_chat_model

  @tool
  def get_current_weather(city_name: str) -> str:
      """Fetches the current weather description for a given city."""
      return f"The weather in {city_name} is currently 22°C and sunny."

  llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

  # Bind both tools
  llm_with_tools = llm.bind_tools([calculate_salary_bonus, get_current_weather])

  # Query weather
  response = llm_with_tools.invoke("What is the weather in London?")
  print(response.tool_calls)
  ```
</Accordion>

[Back to Top](#table-of-contents)

## <a id="chapter-3-building-a-react-agent" />Chapter 3: Building a ReAct Agent

By combining reasoning and tool execution, we can construct a **ReAct Agent** that runs in a loop to solve problems. This page covers building a complete working agent project.

## 1. The Modern LangChain Agent Stack

To build agents in modern LangChain, we use **LangGraph**, which models agents as state graphs where the LLM decides node transitions (e.g. calling tools vs. returning the final response).

```text theme={null}
User Input ──> [ Agent Loop (LLM) ] ──(decides)──> [ Call Tool ] ──(execute)──> [ Return to Loop ]
                      │
                   (finish)
                      ▼
                 Final Answer
```

## 2. In-Memory Working Agent Project

Let's build a working agent equipped with a math evaluation tool and a dictionary database look-up tool.

### 2.1 Install Dependencies

Run in your terminal:

```bash theme={null}
uv add langgraph langchain-google-genai
```

### 2.2 Complete Code Implementation

Save and run this script:

```python theme={null}
import os
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent

# 1. Load environment variables
load_dotenv()

# 2. Define custom tools
@tool
def calculate_math(expression: str) -> str:
    """Evaluates basic mathematical expressions (addition, subtraction, multiplication, division)."""
    try:
        # Using eval safely by restricting scope
        result = eval(expression, {"__builtins__": None}, {})
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {str(e)}"

@tool
def get_user_email(employee_name: str) -> str:
    """Queries the internal HR directory to find an employee's email address."""
    directory = {
        "alice": "alice@company.com",
        "bob": "bob@company.com",
        "siva": "siva@company.com"
    }
    name_lower = employee_name.lower().strip()
    return directory.get(name_lower, f"User {employee_name} not found in HR directory.")

# 3. Initialize the Chat Model
llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

# 4. Bind tools and create the ReAct Agent
tools = [calculate_math, get_user_email]
agent = create_react_agent(llm, tools)

# 5. Run the agent with a multi-step query
# The agent must: 
# 1. Look up Siva's email.
# 2. Calculate the math expression.
# 3. Answer both in a single response.
query = "Find siva's email and calculate 345 * 12."
print(f"Goal: '{query}'\n")

print("Executing Agent Loop...")
inputs = {"messages": [("user", query)]}
response = agent.invoke(inputs)

# 6. Print final conversation trace
print("\n--- Final Agent Response ---")
print(response["messages"][-1].content)
```

**Expected Trace Behavior:**

1. The agent notices `"Find siva's email"` and runs `get_user_email(employee_name="siva")`.
2. The agent notices `"calculate 345 * 12"` and runs `calculate_math(expression="345 * 12")`.
3. The agent merges both observations and outputs: *"Siva's email is [siva@company.com](mailto:siva@company.com) and 345 \* 12 is 4140."*

## 3. Practice Exercises

### Practice 1: Add a Currency Converter Tool

Extend the working agent by adding a new tool `convert_usd_to_eur(amount: float) -> float` that multiplies the USD amount by `0.92`. Run the query: `"Find siva's email and convert 150 USD to EUR."` and print the output.

**Instructions:**

1. Write the `convert_usd_to_eur` tool.
2. Add it to the `tools` list.
3. Call `create_react_agent(llm, tools)`.
4. Invoke the agent and print the final message content.

<Accordion title="Solution">
  ```python theme={null}
  from langchain_core.tools import tool
  from langchain.chat_models import init_chat_model
  from langgraph.prebuilt import create_react_agent

  @tool
  def convert_usd_to_eur(amount: float) -> float:
      """Converts USD currency amount to EUR using a fixed exchange rate."""
      return amount * 0.92

  # Existing tools
  tools = [calculate_math, get_user_email, convert_usd_to_eur]
  llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

  agent = create_react_agent(llm, tools)

  query = "Find siva's email and convert 150 USD to EUR."
  inputs = {"messages": [("user", query)]}
  response = agent.invoke(inputs)

  print(response["messages"][-1].content)
  ```
</Accordion>

[Back to Top](#table-of-contents)

## <a id="chapter-4-multi-agent-orchestration" />Chapter 4: Multi-Agent Orchestration

Single agents can struggle when tasked with highly complex processes containing multiple distinct steps. **Multi-Agent Orchestration** solves this by breaking a system down into several specialist agents (e.g. researcher, writer, code auditor) that collaborate to achieve a goal.

## 1. Why Use Multi-Agent Collaboration?

Instead of relying on one agent with 20 tools, separating duties yields major benefits:

* **Separation of Concerns**: Each agent has a focused system prompt instruction and role-specific tools, reducing the reasoning burden on the model.
* **Context Preservation**: A single agent loop gains massive context length as it runs multiple tools. Multi-agent systems pass only relevant summaries between nodes, keeping the context window small and cheap.
* **Specialist Personas**: You can use different LLMs for different roles (e.g. a small, fast model for searching, and a large, reasoning model for coding).

## 2. Multi-Agent Design Patterns

Collaborative structures fall into three primary communication patterns:

```text theme={null}
1. Sequential (Pipeline)
[ Researcher Agent ] ──> [ Writer Agent ] ──> [ Editor Agent ] ──> Final Output

2. Hierarchical (Manager-Worker)
                     ┌── [ Specialist Worker A ]
[ Manager Agent ] ───┼── [ Specialist Worker B ]
                     └── [ Specialist Worker C ]

3. Network (Dynamic Conversation)
[ Agent A ] <───(Group Chat Exchange)───> [ Agent B ]
     ▲                                         ▲
     └─────────────────────────────────────────┘
```

### 2.1 Sequential Chains (Pipelines)

The task passes forward through a series of agents. Each agent acts as a filter or refinement step.

* *Example*: A *Researcher* agent extracts web data, passes it to a *Writer* agent to draft a blog post, which passes it to an *Editor* agent for grammar checking.

### 2.2 Hierarchical Orchestration

A central **Supervisor / Manager** agent evaluates the input query and delegates work to specialist child agents, collects their observations, and determines when the overall task is finished.

### 2.3 Network / Dynamic Collaboration

Agents join a shared conversational thread (Group Chat). The next speaker is determined dynamically based on the current context or a pre-defined conversation coordinator.

## 3. Major Multi-Agent Frameworks

To implement these patterns in production, developers use specialized orchestration libraries:

* **CrewAI**: A framework built around structured roles, goals, and tasks. Ideal for setting up role-playing agent "crews" that execute sequential workflows.
* **LangGraph**: An open-source graph orchestrator by LangChain. It offers maximum flexibility to define complex, stateful loops and cyclic agent interactions.
* **AutoGen**: A framework by Microsoft focusing on building conversational multi-agent communication channels.

## 4. Practice Exercises

### Practice 1: Multi-Agent Role Definition

Design a multi-agent team to handle customer refund complaints. Define:

1. The roles needed.
2. The specific tools assigned to each role.
3. The communication sequence.

<Accordion title="Solution">
  #### Role Definition:

  1. **Auditor Agent**:
     * *Role*: Verifies the user's order history and refund eligibility.
     * *Tools*: `query_database`, `check_refund_policy`.
  2. **Support Writer Agent**:
     * *Role*: Writes a professional email explaining the decision.
     * *Tools*: None (requires reasoning only).
  3. **Execution Agent**:
     * *Role*: Processes the financial refund transaction and emails the user.
     * *Tools*: `execute_refund_payment`, `send_email`.

  #### Communication Sequence:

  * **Auditor** analyzes customer ticket $\rightarrow$ passes verification outcome to **Support Writer** $\rightarrow$ **Support Writer** drafts confirmation email $\rightarrow$ **Execution Agent** processes payment and sends email.
</Accordion>

[Back to Top](#table-of-contents)

## <a id="chapter-5-stateful-travel-assistant-project" />Chapter 5: Stateful Travel Assistant Project

In this page, we build a stateful, collaborative multi-agent **Travel Assistant** application using LangGraph. This assistant extracts a travel destination, runs a weather forecast search, and calculates a budget estimation, all while remembering user preferences across chat turns.

## 1. Core Concepts Explained

This project illustrates the following agentic concepts:

* **State Management**: Using the shared graph state to pass specialized variables (like `destination`, `weather_info`, and `budget_estimate`) between agents.
* **Separation of Duties**: Dividing tasks so the **Weather Node** focuses solely on weather details, and the **Budget Node** handles numerical calculations.
* **Persistent Session Thread**: Using a checkpointer to remember what destination the user is planning to visit, allowing them to ask follow-up questions without repeating details.

***

## 2. Key APIs & Classes Used

* **`StateGraph`**: The core graph builder class from LangGraph.
* **`MemorySaver`**: An in-memory database checkpointer used to store chat sessions.
* **`add_messages`**: Reducer function telling the graph to append new chat messages instead of overwriting the history.
* **`thread_id`**: Session key passed to the checkpointer to scope the memory to a specific conversation thread.

***

## 3. Step-by-Step Practical Implementation

### Step 1: Define the Shared State Schema

The state holds the conversation history (`messages`), the `destination` city, the retrieved `weather_info`, and the generated `budget_estimate`:

```python theme={null}
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages

class TravelState(TypedDict):
    # Appends new messages to the list
    messages: Annotated[list[BaseMessage], add_messages]
    # Destination city extracted from user prompt
    destination: str
    # Weather description fetched by tool
    weather_info: str
    # Financial estimation calculated by agent
    budget_estimate: str
```

### Step 2: Create a Weather Search Tool

We write a mock tool using the `@tool` decorator that returns the current temperature and forecast for a destination:

```python theme={null}
from langchain_core.tools import tool

@tool
def get_city_weather(city: str) -> str:
    """Retrieves the current weather forecast and temperature for a given city."""
    forecasts = {
        "tokyo": "18°C, Cloudy with light rain.",
        "paris": "22°C, Sunny and clear skies.",
        "new york": "28°C, Hot and humid."
    }
    key = city.lower().strip()
    return forecasts.get(key, f"No weather records found for: '{city}'. Defaulting to 20°C, Mild.")
```

### Step 3: Define Node Functions

Nodes read from the state, call the LLM/tools, and return state key updates:

* **Weather Node**: Analyzes the query, calls the weather tool, and updates `destination` and `weather_info`.
* **Budget Node**: Calculates hotel/flight costs based on the destination and updates `budget_estimate`.

```python theme={null}
from langchain_core.messages import AIMessage, HumanMessage
from langchain.chat_models import init_chat_model

llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

def weather_node(state: TravelState):
    """Weather Agent: Extracts destination, calls weather tool, and updates state."""
    print("\n[Node: Weather Agent] Checking forecast...")
    messages = state["messages"]
    
    # Bind tool to LLM
    llm_with_tools = llm.bind_tools([get_city_weather])
    response = llm_with_tools.invoke(messages)
    
    city = "paris"  # Default fallback
    weather = "22°C, Sunny"
    
    if response.tool_calls:
        tool_call = response.tool_calls[0]
        city = tool_call["args"].get("city", "paris")
        weather = get_city_weather.invoke(tool_call["args"])
        
    return {
        "messages": [AIMessage(content="I have fetched the weather updates.")],
        "destination": city,
        "weather_info": weather
    }

def budget_node(state: TravelState):
    """Budget Agent: Uses destination to calculate estimated costs."""
    print("\n[Node: Budget Agent] Calculating travel estimates...")
    city = state.get("destination", "paris")
    weather = state.get("weather_info", "")
    
    prompt = [
        HumanMessage(content=(
            f"You are a travel advisor. Calculate a basic 3-day travel budget estimate "
            f"(lodging + flight) for visiting {city}. State if the weather ({weather}) is good for travel."
        ))
    ]
    
    response = llm.invoke(prompt)
    return {
        "messages": [response],
        "budget_estimate": response.content
    }
```

### Step 4: Assemble Nodes and Edges

We construct the graph, adding nodes and setting the routing path:

```python theme={null}
from langgraph.graph import StateGraph, START, END

builder = StateGraph(TravelState)

# Register nodes
builder.add_node("weather_agent", weather_node)
builder.add_node("budget_agent", budget_node)

# Map edges
builder.add_edge(START, "weather_agent")
builder.add_edge("weather_agent", "budget_agent")
builder.add_edge("budget_agent", END)
```

### Step 5: Compile with Memory Saver

We compile the graph with an in-memory checkpointer:

```python theme={null}
from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
travel_app = builder.compile(checkpointer=memory)
```

***

## 4. Combined Running Code Project

Here is the complete, self-contained Python script combining all steps:

```python theme={null}
import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver

# 1. Load keys
load_dotenv()

# 2. Define State Schema
class TravelState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    destination: str
    weather_info: str
    budget_estimate: str

# 3. Define Tool
@tool
def get_city_weather(city: str) -> str:
    """Retrieves the current weather forecast and temperature for a given city."""
    forecasts = {
        "tokyo": "18°C, Cloudy with light rain.",
        "paris": "22°C, Sunny and clear skies.",
        "new york": "28°C, Hot and humid."
    }
    key = city.lower().strip()
    return forecasts.get(key, f"No weather records found for: '{city}'. Defaulting to 20°C, Mild.")

# 4. Initialize LLM
llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

# 5. Define Nodes
def weather_node(state: TravelState):
    print("\n[Node: Weather Agent] Checking forecast...")
    messages = state["messages"]
    llm_with_tools = llm.bind_tools([get_city_weather])
    response = llm_with_tools.invoke(messages)
    
    city = "paris"
    weather = "22°C, Sunny"
    
    if response.tool_calls:
        tool_call = response.tool_calls[0]
        city = tool_call["args"].get("city", "paris")
        weather = get_city_weather.invoke(tool_call["args"])
        print(f" -> Found city: '{city}'. Weather: '{weather}'")
        
    return {
        "messages": [AIMessage(content=f"I checked the weather forecast for {city}. It is: {weather}")],
        "destination": city,
        "weather_info": weather
    }

def budget_node(state: TravelState):
    print("\n[Node: Budget Agent] Calculating travel estimates...")
    city = state.get("destination", "paris")
    weather = state.get("weather_info", "")
    
    prompt = [
        HumanMessage(content=(
            f"You are a travel advisor. Calculate a basic 3-day travel budget estimate "
            f"(lodging + flight) for visiting {city}. State if the weather ({weather}) is good for travel."
        ))
    ]
    response = llm.invoke(prompt)
    print(" -> Calculations complete.")
    return {
        "messages": [response],
        "budget_estimate": response.content
    }

# 6. Assemble Graph
builder = StateGraph(TravelState)
builder.add_node("weather_agent", weather_node)
builder.add_node("budget_agent", budget_node)
builder.add_edge(START, "weather_agent")
builder.add_edge("weather_agent", "budget_agent")
builder.add_edge("budget_agent", END)

# 7. Compile with Memory
memory = MemorySaver()
travel_app = builder.compile(checkpointer=memory)

# 8. Run
if __name__ == "__main__":
    # Create thread session
    config = {"configurable": {"thread_id": "tokyo-trip-2026"}}
    
    print("=== Start Travel Session ===")
    
    # Turn 1: Ingest destination and run multi-agent checks
    query1 = "Plan a 3-day trip to Tokyo."
    print(f"\nUser: {query1}")
    
    final_state = travel_app.invoke(
        {"messages": [HumanMessage(content=query1)]},
        config
    )
    print("\n=== Tailored Travel Advice ===")
    print(final_state["budget_estimate"])
    
    # Turn 2: Follow-up conversational query utilizing memory checkpointer
    query2 = "What weather did you find there? Remind me."
    print(f"\nUser: {query2}")
    
    follow_up_state = travel_app.invoke(
        {"messages": [HumanMessage(content=query2)]},
        config
    )
    print("\n=== Advisor Follow-Up Response ===")
    print(follow_up_state["messages"][-1].content)
```

***

## 5. Practice Exercises

### Practice 1: Add a Packing Planner Node

Extend the stateful graph by adding a third node called `packing_agent` that runs *after* the `budget_agent` node. It should read the `weather_info` from the state and suggest 3 items the user should pack for their trip.

**Instructions:**

1. Update `TravelState` to include a `packing_list` key.
2. Define a function `packing_node(state: TravelState)` that takes the `weather_info` and generates a list of 3 items to pack.
3. Add the node to the graph and update the edges: `budget_agent` $\rightarrow$ `packing_agent` $\rightarrow$ `END`.

<Accordion title="Solution">
  ```python theme={null}
  # 1. Update state schema
  class TravelState(TypedDict):
      messages: Annotated[list[BaseMessage], add_messages]
      destination: str
      weather_info: str
      budget_estimate: str
      packing_list: str # New Key

  # 2. Define Packing Node
  def packing_node(state: TravelState):
      print("\n[Node: Packing Agent] Generating packing list...")
      weather = state.get("weather_info", "")
      prompt = [
          HumanMessage(content=f"Suggest exactly 3 essential items to pack for a trip with this weather: {weather}")
      ]
      response = llm.invoke(prompt)
      return {
          "messages": [response],
          "packing_list": response.content
      }

  # 3. Add to Graph Builder
  builder = StateGraph(TravelState)
  builder.add_node("weather_agent", weather_node)
  builder.add_node("budget_agent", budget_node)
  builder.add_node("packing_agent", packing_node) # New Node

  builder.add_edge(START, "weather_agent")
  builder.add_edge("weather_agent", "budget_agent")
  builder.add_edge("budget_agent", "packing_agent") # Updated Edge
  builder.add_edge("packing_agent", END) # New Edge

  # Compile
  travel_app = builder.compile(checkpointer=MemorySaver())
  ```
</Accordion>

[Back to Top](#table-of-contents)

## <a id="chapter-6-resume-analyzer-capstone-workflow" />Chapter 6: Resume Analyzer Capstone Workflow

Single agents can struggle to handle multiple tasks like auditing documentation and planning schedules. In this page, we walk through building a stateful, collaborative multi-agent application utilizing a custom LangGraph configuration and memory checkpointers.

## 1. Core LangGraph Concepts

LangGraph models agents as state machines. There are four fundamental concepts:

* **State**: The central database or memory structure shared across the graph. Any node can read variables from this state and output key-value updates to write back to it.
* **Nodes**: Python functions representing independent operations or agents. A node receives the current state, processes it (e.g., calls an LLM or runs a tool), and returns a dictionary with state updates.
* **Edges**: Control flow rules. **Normal Edges** define a fixed sequential route (e.g. from node A to node B). **Conditional Edges** use a router function to dynamically decide which node to visit next based on state data.
* **Checkpointers (Memory)**: Databases that automatically save a snapshot of the graph's state after every node execution. This allows the graph to resume or remember conversations across separate API requests.

***

## 2. Available APIs & Key Classes

To build stateful agents, LangGraph provides the following key classes and decorators:

* **`StateGraph`**: The primary builder class used to construct graphs. It takes a typed dictionary (schema) defining the structure of the state.
  ```python theme={null}
  from langgraph.graph import StateGraph
  builder = StateGraph(StateSchemaClass)
  ```
* **`add_messages`**: An accumulator reducer function. In LangGraph, returning a key updates the state. For lists of messages, we don't want to overwrite previous history. Using `Annotated[list, add_messages]` tells the graph to append new messages instead of replacing the list.
* **`MemorySaver`**: A built-in, in-memory checkpointer class that preserves thread states.
  ```python theme={null}
  from langgraph.checkpoint.memory import MemorySaver
  memory = MemorySaver()
  graph = builder.compile(checkpointer=memory)
  ```
* **`thread_id`**: A config key passed during graph execution. Any queries sharing the same `thread_id` share the same conversation history in the checkpointer.

***

## 3. Practical Step-by-Step Implementation

### Step 1: Define the Graph State Schema

We define a Python class inheriting from `TypedDict` containing the keys that will be shared between our agents:

```python theme={null}
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages

class AgentState(TypedDict):
    # Holds message history for conversational query turns
    messages: Annotated[list[BaseMessage], add_messages]
    # Raw resume content passed into the pipeline
    resume_text: str
    # Evaluation findings from the Resume Analyzer Agent
    analysis_notes: str
    # Plan generated by the Interview Planner Agent
    interview_plan: str
```

### Step 2: Create a Verification Tool

We write a tool that the analyzer agent can invoke to cross-check candidate skills:

```python theme={null}
from langchain_core.tools import tool

@tool
def check_technical_keywords(skills: list[str]) -> str:
    """Verifies candidate skills against standard technical definitions and requirements."""
    verified = []
    for skill in skills:
        cleaned = skill.lower().strip()
        if cleaned in ["python", "langchain", "fastapi", "react", "sql", "docker"]:
            verified.append(f"{skill} (Industry Standard)")
        else:
            verified.append(f"{skill} (Alternative/General)")
    return "Verified Skills: " + ", ".join(verified)
```

### Step 3: Define Agent Node Functions

Nodes receive the shared state, call the LLM, and return updates:

```python theme={null}
from langchain_core.messages import AIMessage, HumanMessage
from langchain.chat_models import init_chat_model

llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

def resume_analyzer_node(state: AgentState):
    """Resume Analyzer Agent: Audits candidate skills and projects, verifying technical terms."""
    resume = state.get("resume_text", "")
    
    prompt = [
        HumanMessage(content=f"Analyze this candidate's resume text. Extract key skills, total experience, and notable projects. Here is the resume:\n\n{resume}")
    ]
    
    # Bind the keyword verification tool
    llm_with_tools = llm.bind_tools([check_technical_keywords])
    response = llm_with_tools.invoke(prompt)
    
    tool_notes = ""
    if response.tool_calls:
        tool_call = response.tool_calls[0]
        tool_result = check_technical_keywords.invoke(tool_call["args"])
        tool_notes = f"\nTool Output: {tool_result}"
    
    analysis = f"--- Profile Analysis ---\n{response.content}\n{tool_notes}"
    return {
        "messages": [AIMessage(content="I have completed analyzing the candidate's profile.")],
        "analysis_notes": analysis
    }

def interview_planner_node(state: AgentState):
    """Interview Planner Agent: Takes candidate analysis notes and creates a custom plan."""
    analysis = state.get("analysis_notes", "")
    
    prompt = [
        HumanMessage(content=f"You are a technical interviewer. Design a customized interview plan including 2 technical questions and 1 behavioral question tailored specifically to the candidate's profile below:\n\n{analysis}")
    ]
    
    response = llm.invoke(prompt)
    return {
        "messages": [response],
        "interview_plan": response.content
    }
```

### Step 4: Assemble Graph Nodes & Edges

We add nodes and map the route sequentially:

```python theme={null}
from langgraph.graph import StateGraph, START, END

builder = StateGraph(AgentState)

# Register nodes
builder.add_node("analyzer", resume_analyzer_node)
builder.add_node("planner", interview_planner_node)

# Set up edges
builder.add_edge(START, "analyzer")
builder.add_edge("analyzer", "planner")
builder.add_edge("planner", END)
```

### Step 5: Compile with Checkpointer Memory

We attach `MemorySaver` to compile our final application graph:

```python theme={null}
from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
hr_agent_app = builder.compile(checkpointer=memory)
```

***

## 4. Combined Running Code Project

Here is the complete, self-contained Python script ready to copy and run:

```python theme={null}
import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver

# 1. Load keys
load_dotenv()

# 2. Define State Schema
class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    resume_text: str
    analysis_notes: str
    interview_plan: str

# 3. Define Verification Tool
@tool
def check_technical_keywords(skills: list[str]) -> str:
    """Verifies candidate skills against standard technical definitions and requirements."""
    verified = []
    for skill in skills:
        cleaned = skill.lower().strip()
        if cleaned in ["python", "langchain", "fastapi", "react", "sql", "docker"]:
            verified.append(f"{skill} (Industry Standard)")
        else:
            verified.append(f"{skill} (Alternative/General)")
    return "Verified Skills: " + ", ".join(verified)

# 4. Initialize LLM
llm = init_chat_model("gemini-2.5-flash", model_provider="google_genai")

# 5. Define Nodes
def resume_analyzer_node(state: AgentState):
    print("\n[Node: Resume Analyzer] Evaluating resume profile...")
    resume = state.get("resume_text", "")
    prompt = [
        HumanMessage(content=f"Analyze this candidate's resume text. Extract key skills, total experience, and notable projects. Here is the resume:\n\n{resume}")
    ]
    llm_with_tools = llm.bind_tools([check_technical_keywords])
    response = llm_with_tools.invoke(prompt)
    
    tool_notes = ""
    if response.tool_calls:
        tool_call = response.tool_calls[0]
        tool_result = check_technical_keywords.invoke(tool_call["args"])
        tool_notes = f"\nTool Output: {tool_result}"
    
    analysis = f"--- Profile Analysis ---\n{response.content}\n{tool_notes}"
    print(" -> Analysis Complete.")
    return {
        "messages": [AIMessage(content="I have completed analyzing the candidate's profile.")],
        "analysis_notes": analysis
    }

def interview_planner_node(state: AgentState):
    print("\n[Node: Interview Planner] Generating custom technical interview questions...")
    analysis = state.get("analysis_notes", "")
    prompt = [
        HumanMessage(content=f"You are a technical interviewer. Design a customized interview plan including 2 technical questions and 1 behavioral question tailored specifically to the candidate's profile below:\n\n{analysis}")
    ]
    response = llm.invoke(prompt)
    print(" -> Interview Plan Generated.")
    return {
        "messages": [response],
        "interview_plan": response.content
    }

# 6. Assemble Graph
builder = StateGraph(AgentState)
builder.add_node("analyzer", resume_analyzer_node)
builder.add_node("planner", interview_planner_node)
builder.add_edge(START, "analyzer")
builder.add_edge("analyzer", "planner")
builder.add_edge("planner", END)

# 7. Compile with Memory
memory = MemorySaver()
hr_agent_app = builder.compile(checkpointer=memory)

# 8. Run
if __name__ == "__main__":
    config = {"configurable": {"thread_id": "candidate-assessment-john-doe"}}
    
    sample_resume = """
    John Doe
    Python Developer - 3 Years Experience
    Skills: Python, LangChain, FastAPI, TensorFlow
    Projects:
    - Built a RAG chatbot using LangChain and FastAPI to query customer support documents.
    """
    
    print("=== Start Candidate Assessment ===")
    
    # Turn 1
    final_state = hr_agent_app.invoke(
        {
            "messages": [HumanMessage(content="Evaluate John Doe's resume and generate an interview plan.")],
            "resume_text": sample_resume
        },
        config
    )
    print("\n=== Tailored Interview Plan ===")
    print(final_state["interview_plan"])
    
    # Turn 2: Follow-up conversational check
    follow_up = "What technical questions did you generate? Remind me."
    print(f"\nUser: {follow_up}")
    
    follow_up_state = hr_agent_app.invoke(
        {"messages": [HumanMessage(content=follow_up)]},
        config
    )
    print("\n=== Agent Follow-Up Response ===")
    print(follow_up_state["messages"][-1].content)
```

***

## 5. Practice Exercises

### Practice 1: Adding a Feedback Evaluation Node

Extend the stateful graph by adding a third node called `feedback_evaluator` that runs *after* the `planner` node. The node should format a short summary checklist of topics to check off during the interview.

**Instructions:**

1. Update `AgentState` to include an `eval_checklist` key.
2. Define a function `feedback_evaluator_node(state: AgentState)` that takes the `interview_plan` and creates a checklist.
3. Add the node to the graph and update the edges: `planner` $\rightarrow$ `evaluator` $\rightarrow$ `END`.

<Accordion title="Solution">
  ```python theme={null}
  # 1. Update state schema
  class AgentState(TypedDict):
      messages: Annotated[list[BaseMessage], add_messages]
      resume_text: str
      analysis_notes: str
      interview_plan: str
      eval_checklist: str # New Key

  # 2. Define Evaluator Node
  def feedback_evaluator_node(state: AgentState):
      print("\n[Node: Evaluator] Generating evaluation checklist...")
      plan = state.get("interview_plan", "")
      prompt = [
          HumanMessage(content=f"Create a short checklist of skills to grade based on this plan:\n\n{plan}")
      ]
      response = llm.invoke(prompt)
      return {
          "messages": [response],
          "eval_checklist": response.content
      }

  # 3. Add to Graph Builder
  builder = StateGraph(AgentState)
  builder.add_node("analyzer", resume_analyzer_node)
  builder.add_node("planner", interview_planner_node)
  builder.add_node("evaluator", feedback_evaluator_node) # New Node

  builder.add_edge(START, "analyzer")
  builder.add_edge("analyzer", "planner")
  builder.add_edge("planner", "evaluator") # Updated Edge
  builder.add_edge("evaluator", END) # New Edge

  # Compile
  hr_agent_app = builder.compile(checkpointer=MemorySaver())
  ```
</Accordion>
