Amazon Bedrock and LangGraph – Simple Agents and Tool Use


Amazon Bedrock and LangGraph – Simple Agents and Tool Use

Amazon provides numerous frontier models through its Bedrock platform. Combining these models with LangGraph allows us to create simple agents with tool access and memory checkpoints with minimal boilerplate code. In this article, we are going to explore Amazon Bedrock and LangGraph to create agents and give them tool access such as RAG and web search.

Demo of multi-turn web search using Bedrock and LangGraph agent.
Figure 1. Demo of multi-turn web search using Bedrock and LangGraph agent.

This article covers three different code walkthroughs of increasing complexity:

  • The first one combines Bedrock models and LangGraph to create a simple agent with tool access.
  • The second one creates a simple multi-turn application with Bedrock, LangGraph, and Tavily web search.
  • Finally, we will explore how to combine these agents while giving them access to multiple tools, such as RAG, web & URL search.

Note that we are not going to create any complex agentic pipelines here. Rather, we will have just one agent that makes decisions about which tools to call based on the user’s query.

The AWS Bedrock Samples

The codebase in this article is part of the aws-bedrock-samples repository that I am currently maintaining. As of writing this, it contains various examples for combining Amazon Bedrock models for simple API calls, RAG, and LangGraph with tool use.

I am constantly updating the codebase with new examples, each folder with its own requirements and README files for easier setup.

I would highly recommend visiting the last two articles in the series as well:

If you are just getting started with Amazon Bedrock, this might be the right place for you to start. It shows how to combine Bedrock models with external libraries, tools, and more.

Here, we will be working with a stable version of the codebase that comes as a downloadable zip file with this article.

Project Directory Setup

Before moving forward, let’s take a look at the project directory setup.

├── bedrock_api_call
│   ├── bedrock_chat_doc_single_turn.py
│   ├── bedrock_chat_image_single_turn.py
│   ├── bedrock_chat_multi_turn.py
│   ├── bedrock_chat_text_multi_turn_sim.py
│   ├── bedrock_chat_text_single_turn.py
│   ├── bedrock_chat_video_single_turn.py
│   ├── README.md
│   └── requirements.txt
├── bedrock_langchain
│   ├── multi_tool
│   │   ├── README.md
│   │   ├── run_chat.py
│   │   └── tools.py
│   ├── requirements.txt
│   └── simple_web_search
│       ├── README.md
│       ├── web_search_langchain_multi_turn.py
│       └── web_search_langchain.py
├── bedrock_rag_chat
│   ├── requirements.txt
│   └── text_rag
│       ├── embeddings.py
│       ├── README.md
│       └── run_chat.py
├── input
│   ├── images
│   │   ├── image_1.jpg
│   │   └── image_2.jpg
│   ├── pdfs
│   │   ├── nn_wiki.pdf
│   │   └── video_games_wiki.pdf
│   └── videos
│       └── video_1.mp4
└── README.md
  • Although there are several directories, in this article, we will focus on the bedrock_langchain directory.
  • It contains two subdirectories, simple_web_search and multi_tool. We will get into the details of these when exploring the codebase.

This article comes with a stable zip file version of the codebase that anyone can download and start working with.

Download Code

Setup

All the subdirectories contain their own requirements file. However, before moving forward, we need to set up the environment file. Create a .env file in the project’s root directory.

AWS_BEDROCK_API_KEY=your-bedrock-api-key
MODEL_ID=your-model-id
EMBEDDING_MODEL_ID=embedding-model-id
TAVILY_API_KEY=your-tavily-api-key

We need the above to execute the code. You can find the instructions to set up the Bedrock API key in the previous articles mentioned above. We also need to set up the Tavily API key for web search. Tavily allows 1000 API calls per month in the free tier.

Let’s say we choose the Amazon Nova Pro and the Titan Text Embedding model after setting up the above API key. The .env file will look like the following:

AWS_BEDROCK_API_KEY=your-bedrock-api-key
MODEL_ID=amazon.nova-pro-v1:0
EMBEDDING_MODEL_ID=amazon.titan-embed-text-v2:0
TAVILY_API_KEY=your-tavily-api-key

You can choose any model ID here.

That’s all we need for the setup. Let’s move to the implementation.

Bedrock and LangGraph Web Search Examples

We will start the code for combining Amazon Bedrock and web search. The code for this is present in the bedrock_langchain/simple_web_search directory.

This folder contains two scripts:

  • web_search_langchain.py: A simple single-turn web search agent created with LangChain, Bedrock, and Tavily web search. The user asks a question and the agent answers.
  • web_search_langchain_multi_turn.py: This is a more complete version with multi-turn chat, memory management, and the ability to search the web multiple times when prompted.

Before moving to the code, let’s install the requirements for these scripts:

pip install -r requirements.txt

Single-Turn Web Search

Let’s start with the simple example present in the web_search_langchain.py file. It establishes the basics that we can use for creating more complex codebases.

"""
Simple web search with LangChain and Bedrock models.
"""
from langchain_aws import ChatBedrockConverse
from langchain_tavily import TavilySearch
from langchain.agents import create_agent
from dotenv import load_dotenv
from termcolor import cprint

import os

load_dotenv()

os.environ['AWS_BEARER_TOKEN_BEDROCK'] = os.getenv('AWS_BEDROCK_API_KEY')

MODEL_ID = os.getenv('MODEL_ID')

# 1. Initialize the Bedrock model
llm = ChatBedrockConverse(
    model_id=MODEL_ID,
    region_name="us-east-1",
    streaming=True
)

# 2. Initialize a web search tool
search_tool = TavilySearch(max_results=3)
tools = [search_tool]

# 3. Create the agent with LangGraph
agent = create_agent(llm, tools)

# 4. Invoke the agent stream until.
user_input = input("Enter your query: ")

stream = agent.stream_events(
    {"messages": [("user", user_input)]},
    version="v3"
)

for kind, item in stream.interleave("messages", "tool_calls"):
    if kind == "messages":
        for token in item.text:
            cprint(token, "yellow", end="", flush=True)
    elif kind == "tool_calls":
        cprint(f"\nTool call: {item.tool_name}({item.input})", "green", attrs=["bold"])
        for delta in item.output_deltas:
            cprint(delta, "cyan", end="", flush=True)
        cprint(f"\nTool result: {item.output}", "green", attrs=["bold"])
    print()

final_state = stream.output
print()

Most of the heavy lifting is handled by the LangChain APIs:

  • ChatBedrockConverse: Supports initialization of any Bedrock model with streaming capability.
  • TavilySearch: Exposes Tavily web search as a tool for the agents.
  • create_agent from langchain.agents: A wrapper to convert any LLM that we initialize into an agent that can access tools, call them in multiple turns, and provide an answer.

Lines 18 to 30 show how we initialize an LLM using LangChain, define the web search tool, and create an agent.

Next, we ask the user for an input, based on which the agent either answers it directly or plans to execute a web search.

Starting from line 40, we stream the response to the terminal.

Let’s execute the code and see it in action. Execute the following within the bedrock_langchain/simple_web_search directory.

python web_search_langchain.py

Let’s take a look at the output:

The agent is able to find the relevant information via web search based on the user query.
Figure 2. The agent is able to find the relevant information via web search based on the user query.

In the above screenshot, we have asked the model to find which models were released in the current week. After analyzing, it decided to call the tavily_search tool with “models released this week” as the query.

It compiled all the information and gave the response. This specific script does not support multi-turn chat. We will see to that in the next example.

Multi-Turn Web Search Chat

Next, we will create a multi-turn chat agent with the web search tool. The code for this is present in the bedrock_langchain/simple_web_search directory in the web_search_langchain_multi_turn.py file.

The following block contains the entire code.

"""
Multi-turn web search with LangChain and Bedrock models with history.
"""

from langchain_aws import ChatBedrockConverse
from langchain_tavily import TavilySearch
from langchain.agents import create_agent
from dotenv import load_dotenv
from termcolor import cprint
from langgraph.checkpoint.memory import MemorySaver

import os

load_dotenv()

def load_environment():
    os.environ['AWS_BEARER_TOKEN_BEDROCK'] = os.getenv('AWS_BEDROCK_API_KEY')
    MODEL_ID = os.getenv('MODEL_ID')
    return MODEL_ID

# 1. Initialize the Bedrock model
def create_model(model_id):
    llm = ChatBedrockConverse(
        model_id=model_id,
        region_name="us-east-1",
        streaming=True
    )
    return llm

# 2. Initialize a web search tool
def create_tools():
    search_tool = TavilySearch(max_results=3)
    tools = [search_tool]
    return tools

# 3. Create the agent with LangGraph
def create_langgraph_agent(llm, tools):
    agent = create_agent(
        llm, 
        tools,
        checkpointer=MemorySaver() # Enable automatic memory persitence.
    )
    return agent

def create_config():
    config = {"configurable": {"thread_id": "conversation-123"}}
    return config

def chat(agent, config, user_input):
    stream = agent.stream_events(
        {"messages": [("user", user_input)]},
        version="v3",
        config=config
    )

    for kind, item in stream.interleave("messages", "tool_calls"):
        if kind == "messages":
            cprint("Assistant: ", "magenta", attrs=["bold"], end="")
            for token in item.text:
                cprint(token, "yellow", end="", flush=True)
        elif kind == "tool_calls":
            cprint(f"\nTool call: {item.tool_name}({item.input})", "green", attrs=["bold"])
            for delta in item.output_deltas:
                cprint(delta, "cyan", end="", flush=True)
            cprint(f"\nTool result: {item.output}", "green", attrs=["bold"])
        print()

    final_state = stream.output
    print()


# 4. Invoke the agent stream until user types "quit" or "exit".
if __name__ == "__main__":
    MODEL_ID = load_environment()
    llm = create_model(MODEL_ID)
    tools = create_tools()
    config = create_config()
    agent = create_langgraph_agent(llm, tools)

    cprint("Welcome to the multi-turn web search with LangChain and Bedrock models!", "blue", attrs=["bold"])
    cprint("Type 'quit' or 'exit' to end the conversation.", "blue", attrs=["bold"])
    print()

    while True:
        cprint("USER: ", "blue", attrs=["bold"], end="")
        user_input = input()
        if user_input.lower() in ["quit", "exit"]:
            break

        chat(agent, config, user_input)

The code is very similar to what we covered in the previous script. One change is that all the logic is wrapped around functions.

  • load_environment() loads the necessary environment variables and initializes the Bedrock model ID.
  • create_model() initializes the Bedrock model.
  • create_tools() creates the tools list that is part of the LangChain tools.
  • create_langgraph_agent() initializes the LLM agent. This time we use the built-in MemorySaver() class to store session chat history as the user converses with the model. We will lose all session data when the user exits the chat.
  • create_config() function creates a configurable thread that is essential to maintain the session history.
  • The chat() function accepts the agent, config, and user_input and streams the output to the terminal.

Finally, we have the main code block, which combines everything and runs the chat loop until the user types either exit or quit.

The following video shows the agent in action.

Video 1. Mulit-turn web search with Bedrock model and LangGraph agent.

We asked the agent to search for specific topics from the internet, and it successfully did that. One of the more impressive actions is when we ask it to do multiple searches in a loop, compile everything, and give a final answer.

Bedrock and LangGraph Agents with Multiple Tool Use

Let’s get to the more fun part now. In this section, we will create custom tools for the agent. Along with the pre-defined ones like web search, we will also create a RAG tool and a URL search tool.

The code for this workflow lives in the bedrock_langchain/multi_tool directory. Here, we have two Python files:

  • tools.py: This contains all the tool-related code that we give the agent access to.
  • run_chat.py: This is the executable script to start the chat workflow.

Defining the Agent Tools

First, let’s take a look at the code in tools.py:

from langchain_tavily import TavilySearch
from langchain_community.document_loaders import (
    PyPDFLoader, 
    TextLoader, 
    DirectoryLoader,
    WebBaseLoader
)
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter  
from langchain_aws import BedrockEmbeddings
from dotenv import load_dotenv

import os

load_dotenv()

# Set the API key as an environment variable
os.environ['AWS_BEARER_TOKEN_BEDROCK'] = os.getenv('AWS_BEDROCK_API_KEY')

class TavilySearchTool:
    """
    A wrapper class for the TavilySearch tool.
    """

    def __init__(self, max_results=3):
        self.max_results = max_results
       
    def create_search_tool(self):
        """
        Performs a search using the TavilySearch tool.
        """
        tool = TavilySearch(max_results=self.max_results)
        return tool

class RAGTool:
    """
    A wrapper class for RAG (Retrieval-Augmented Generation) tool.
    """

    def __init__(self, embedding_model_id, bedrock_client):
        self.embedding_model_id = embedding_model_id
        self.bedrock_client = bedrock_client
        self.bedrock_embeddings = BedrockEmbeddings(
            model_id=self.embedding_model_id, client=self.bedrock_client
        )
        self.db = None

    def read_directory(self, folder_path):
        """
        Reads all documents from the folder path.
        """
        loader = DirectoryLoader(folder_path)
        docs = loader.load()
        print(f"Number of documents read from folder: {len(docs)}")
        
        return docs

    def read_pdf(self, file_path):
        """
        Reads PDF documents.
        """
        loader = PyPDFLoader(file_path)
        docs = loader.load()
        
        return docs

    def read_text(self, file_path):
        """
        Reads text documents.
        """
        loader = TextLoader(file_path)
        docs = loader.load()
        
        return docs

    def get_chunks(self, docs):
        """
        Gets chunks of the documents.
        """
        text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
        chunks = text_splitter.split_documents(docs)
        
        return chunks

    def embed_and_store(self, chunks):
        """
        Generates embeddings and stores in ChromaDB.
        """
        self.db = Chroma.from_documents(
            documents=chunks, 
            embedding=self.bedrock_embeddings
        )

    def retrieve(self, query, k=5, show_chunks=False):
        """
        Retrieves relevant chunks from ChromaDB based on query and by appending by new line.
        """
        if not self.db:
            raise ValueError("ChromaDB is not initialized. Please call embed_and_store() first.")

        results = self.db.similarity_search(query, k=k)
        
        # Append the results by new line
        retrieved_text = "\n".join([result.page_content for result in results])
        
        if show_chunks:
            for i, result in enumerate(results):
                print('\n\n')
                print(f"Chunk {i+1}: {result.page_content}")
                print('#' * 100)
        
        return retrieved_text

class URLReaderTool:
    """
    A wrapper class for reading and extracting text from a web page.
    """

    def read_url(self, url):
        """
        Reads a web page and returns its text content.
        """
        loader = WebBaseLoader(url)
        docs = loader.load()

        # Combine all page contents into a single string
        text = "\n".join(doc.page_content for doc in docs)

        return text

if __name__ == "__main__":
    tavily_tool = TavilySearchTool(max_results=3)
    search_tool = tavily_tool.create_search_tool()

    search_results = search_tool.run(tool_input="What is the capital of France?")
    print("Search Results:", search_results)

We define a Python class for each tool.

  • TavilySearchTool: Although LangChain provides Tavily search as a predefined tool, we wrap it around a class to maintain consistency.
  • RAGTool: Giving RAG tool access to an agent is slightly more complex. We have all the class methods for reading files and directories, creating the chunks, and the in-memory vector DB as well. For this, we use the Bedrock Titan Embedding model. The retrieve method is the one that we will register as a tool to the agent. All other class methods will be called upon execution when the user provides a file/folder path.
  • URLReaderTool: This tool can search and return content from any URL that the user provides in a prompt. Instead of managing our own web page reader, we use LangChain’s WebBaseLoader for this.

Note that we are not using the @tool decorator or inheriting LangChain’s BaseTool class. As tools like the RAG tool are more complex in nature given how they work, we are working with a different method here. This will be clearer in the next code block.

Creating the Executable Agent Run Script

The code in run_chat.py contains everything that we need to start the agentic multi-tool execution. Let’s take a look at the code first.

from langchain_aws import ChatBedrockConverse
from langchain.agents import create_agent
from dotenv import load_dotenv
from termcolor import cprint
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.tools import StructuredTool

from tools import TavilySearch, RAGTool, URLReaderTool

import os
import boto3

load_dotenv()

def load_environment():
    os.environ['AWS_BEARER_TOKEN_BEDROCK'] = os.getenv('AWS_BEDROCK_API_KEY')
    MODEL_ID = os.getenv('MODEL_ID')

    bedrock_client = boto3.client(
        service_name='bedrock-runtime', region_name='us-east-1'
    )

    return MODEL_ID, bedrock_client

def create_model(model_id):
    llm = ChatBedrockConverse(
        model_id=model_id,
        region_name="us-east-1",
        streaming=True
    )
    return llm

def create_tools(search_tool, rag_tool):
    retrieve_tool = StructuredTool.from_function(
        name="RAGTool",
        description="A tool that retrieves relevant information from a knowledge base using embeddings.",
        func=rag_tool.retrieve
    )

    url_reader_tool = StructuredTool.from_function(
        name="URLReaderTool",
        description="A tool that reads content from a given URL.",
        func=URLReaderTool().read_url
    )

    tools = [search_tool, retrieve_tool, url_reader_tool]
    return tools

def create_langgraph_agent(llm, tools):
    agent = create_agent(
        llm, 
        tools,
        checkpointer=MemorySaver() # Enable automatic memory persitence.
    )
    return agent

def create_config():
    config = {"configurable": {"thread_id": "conversation-123"}}
    return config

def chat(agent, config, user_input):
    stream = agent.stream_events(
        {"messages": [("user", user_input)]},
        version="v3",
        config=config
    )

    for kind, item in stream.interleave("messages", "tool_calls"):
        if kind == "messages":
            cprint("Assistant: ", "magenta", attrs=["bold"], end="")
            for token in item.text:
                cprint(token, "yellow", end="", flush=True)
        elif kind == "tool_calls":
            cprint(f"\nTool call: {item.tool_name}({item.input})", "green", attrs=["bold"])
            for delta in item.output_deltas:
                cprint(delta, "cyan", end="", flush=True)
            cprint(f"\nTool result: {item.output}", "green", attrs=["bold"])
        print()

    final_state = stream.output
    print()


if __name__ == "__main__":
    MODEL_ID, bedrock_client = load_environment()

    llm = create_model(MODEL_ID)
    search_tool = TavilySearch(max_results=3)
    rag_tool = RAGTool(
        embedding_model_id=os.getenv('EMBEDDING_MODEL_ID'), bedrock_client=bedrock_client
    )
    tools = create_tools(search_tool, rag_tool)
    config = create_config()
    agent = create_langgraph_agent(llm, tools)

    cprint("Welcome to the multi-turn web search with LangChain and Bedrock models!", "blue", attrs=["bold"])
    cprint("Type 'quit' or 'exit' to end the conversation.", "blue", attrs=["bold"])
    print()

    # Ask the user to pass PDF, text, or folder path to read documents and create ChromaDB
    folder_path = input("Enter the folder path to read documents (or press Enter to skip): ")
    file_path_pdf = input("Enter the PDF file path to read document (or press Enter to skip): ")
    file_path_text = input("Enter the text file path to read document (or press Enter to skip): ")
    
    # If either of the paths is empty, set it to "None"
    if file_path_pdf == "":
        file_path_pdf = "None"
    if file_path_text == "":
        file_path_text = "None"
    if folder_path == "":
        folder_path = "None"

    if folder_path != "None":
        # Read documents from folder
        docs_from_folder = rag_tool.read_directory(folder_path)
    else:
        docs_from_folder = []
    if file_path_pdf != "None":
        # Read PDF document
        docs_from_pdf = rag_tool.read_pdf(file_path_pdf)
    else:
        docs_from_pdf = []
    if file_path_text !="None":
        # Read text document
        docs_from_text = rag_tool.read_text(file_path_text)
    else:
        docs_from_text = []

    # Combine all documents
    all_docs = docs_from_folder + docs_from_pdf + docs_from_text

    chunks = rag_tool.get_chunks(all_docs)
    
    # Generate embeddings and store in ChromaDB
    db = rag_tool.embed_and_store(chunks)


    while True:
        cprint("USER: ", "blue", attrs=["bold"], end="")
        user_input = input()
        if user_input.lower() in ["quit", "exit"]:
            break

        chat(agent, config, user_input)

The code remains similar to what we saw in the case of multi-turn web search one. The following are some of the changes and points to take care of:

  • The create_tools function creates StructuredTool for the two custom tools that we defined, which are RAG and URL search. While creating the structured tools, we can provide a name and description that the agent has access to. The func parameter accepts the class method, which is the executable one. Internally, LangChain tools will advertise that specific method to the agents along with its description and parameters. This is a cleaner method when we have custom classes where most class methods are helper ones, and there is one clear executable method which the agent should invoke as the tool.
  • The other changes that happen are in the main block. Here, before the while loop starts, we ask the user for either a folder, file, or text file path that we use to create a vector DB. Next, we start the execution and let the agent invoke the tools based on the user’s prompts.

Let’s execute the code and see it in action.

python run_chat.py

The following video shows how the agent works for different prompts.

Video 2. Multiple tool calls using Bedrock and LangGraph agent based on user query.

The above videos show how the agent calls each tool based on the user’s prompts, consolidates everything, and gives a final answer. Overall, it is working well.

Further Improvements

Here, we have created a simple agent that can work on multiple tools. However, to make it truly agentic, we should have multiple agents calling each tool, and one orchestrator agent that hands over the tasks, consolidates the answers, and gives a final response.

We will try to achieve that in the next article.

Summary and Conclusion

In this article, we created simple agents using Bedrock and LangGraph. We started with web search and multi-turn chat. Next, we moved to create an agent that has access to multiple tools and calls each at its own discretion based on the user’s prompt. We also discussed how we are going to improve it in a future article. I hope this article was worth your time.

If you have any questions, thoughts, or suggestions, please leave them in the comment section. I will surely address them.

You can contact me using the Contact section. You can also find me on LinkedIn, and X.

Liked it? Take a second to support Sovit Ranjan Rath on Patreon!
Become a patron at Patreon!

Leave a Reply

Your email address will not be published. Required fields are marked *