DEV Community

Cover image for 🚀 Building a Smart Cisco Webex Bot: Harnessing LangGraph's Stateful LLM Agents for AI-Powered Assistance 🤖✨
Evgenii Fedotov
Evgenii Fedotov

Posted on • Edited on

🚀 Building a Smart Cisco Webex Bot: Harnessing LangGraph's Stateful LLM Agents for AI-Powered Assistance 🤖✨

Introduction: The Power of AI-Assisted Communication in Webex

In today's fast-paced digital workplace, effective communication is key. Imagine having an intelligent assistant in your Cisco Webex Teams that can search the web, retrieve user information, and perform calculations - all while maintaining context across conversations. That's exactly what we're going to build in this tutorial!

By the end of this guide, you'll have created a powerful AI-powered Webex bot using LangGraph's stateful large language model (LLM) agents. This bot will enhance your team's interactions within Webex by providing intelligent responses and performing complex tasks efficiently.

Project Overview: What We're Building and Why

Our Webex Bot AI Assistant will leverage the following key technologies:

  • 🧠 LangChain & LangGraph: For building stateful, multi-actor AI applications
  • 🚀 OpenAI: Providing the underlying language model
  • 🤖 Webex Bot lib: For seamless integration with Webex Teams leveraging WebSockets
  • 📊 SQLite: Lightweight database for maintaining conversation history

Key Features:

  • 🔍 Web search capabilities with Tavily Search
  • 👤 Retrieval of Webex user information
  • 🧮 Mathematical operations (e.g., calculating a number's power)
  • 💾 Persistent conversation history for improved context awareness

Prerequisites:

  • Basic knowledge of Python
  • Understanding of bot frameworks (helpful, but not required)
  • Understanding of LangGraph agents (helpful, but not required)

Let's dive in and start building!

Setting Up Your Development Environment

Before we start coding, let's set up our environment:

1. Clone the project repository:

   git clone https://github.com/lieranderl/webexbot-langgraph-assistant-template
Enter fullscreen mode Exit fullscreen mode

2. Create a .env file in the project root with the following variables:

   # LLM ENVIRONMENT
   OPENAI_API_BASE=<OpenAI API base URL>
   OPENAI_API_KEY=<Your OpenAI API key>
   LLM_MODEL=<Name of llm model>

   # LANGCHAIN ENVIRONMENT
   LANGCHAIN_ENDPOINT=<LangSmith endpoint URL>
   LANGCHAIN_API_KEY=<Your LangSmith API key>
   LANGCHAIN_PROJECT=<Name of your LangSmith project>

   # Tools API
   TAVILY_API_KEY=<Your Tavily API key for web search>

   # SQLite Database for Conversation History
   SQL_CONNECTION_STR=checkpoints.db

   # Webex Bot Configuration
   WEBEX_TEAMS_ACCESS_TOKEN=<Your Webex Bot Access Token>
   WEBEX_TEAMS_DOMAIN=<Your Webex Domain>
Enter fullscreen mode Exit fullscreen mode

3. Install dependencies using Poetry:

   poetry install
Enter fullscreen mode Exit fullscreen mode

Now that our environment is set up, let's start building our AI assistant!

Project Structure and SOLID Principles

Our project follows a structure that promotes better organization, maintainability, and adheres to SOLID principles:

project_root/
│
├── src/
│   ├── graph_reactagent/
│   │   ├── __init__.py
│   │   ├── interfaces.py
│   │   ├── graph.py
│   │   ├── invoker.py
│   │   ├── messages_filter.py
│   │   ├── prompt_formatter.py
│   │   └── tools.py
│   │
│   └── webexbot/
│       ├── __init__.py
│       ├── commands.py
│       └── webexbot.py
│
├── tests/
│   ├── __init__.py
│   ├── test_graph_reactagent.py
│   ├── test_messages_filter.py
│   ├── test_tools.py
│   └── test_webexbot.py
│
├── .env
├── .coveragerc
├── pyproject.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

This structure separates concerns and allows for easier extension and modification of individual components. Let's dive into some key files and how they embody SOLID principles:

interfaces.py

The interfaces.py file defines the interfaces (protocols in Python) that our classes will implement. This adheres to the Interface Segregation Principle (the 'I' in SOLID) by providing specific interfaces for different functionalities.

from typing import Protocol, Any, Dict, List
from datetime import datetime
from langchain_core.messages import AnyMessage

class IMessageFilter(Protocol):
    def filter_messages(self, messages: List[AnyMessage]) -> List[AnyMessage]: ...

class IPromptFormatter(Protocol):
    def format_prompt(
        self, messages: List[AnyMessage], system_time: datetime
    ) -> Any: ...

class IGraphInvoker(Protocol):
    def invoke(self, message: str, **kwargs: Any) -> Dict[str, Any]: ...
Enter fullscreen mode Exit fullscreen mode

These interfaces allow us to define the contract that classes must follow without specifying the implementation. This promotes loose coupling and makes our system more modular and easier to extend.

messages_filter.py

The messages_filter.py file contains the implementation of our message filtering logic. This adheres to the Single Responsibility Principle (the 'S' in SOLID) by focusing solely on the task of filtering messages.

from typing import List
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, AnyMessage
from graph_reactagent.interfaces import IMessageFilter


class DefaultMessageFilter(IMessageFilter):
    def filter_messages(self, messages: List[AnyMessage]) -> List[AnyMessage]:
        message_count = min(6, len(messages))
        filtered_messages = messages[-message_count:]

        while message_count > 1:
            if isinstance(filtered_messages[0], HumanMessage):
                break
            message_count -= 1
            while message_count > 0 and isinstance(
                messages[-message_count], (AIMessage, ToolMessage)
            ):
                # we cannot have an assistant message at the start of the chat history
                # if after removal of the first, we have an assistant message,
                # we need to remove the assistant message too
                # all tool messages should be preceded by an assistant message
                # if we remove a tool message, we need to remove the assistant message too
                message_count -= 1
            filtered_messages = messages[-message_count:]
        return filtered_messages
Enter fullscreen mode Exit fullscreen mode

This implementation ensures that we maintain a manageable context window for our AI model by limiting the number of messages passed to it. It also makes sure that tool-related messages are properly contextualized by including the message that triggered the tool use.

prompt_formatter.py

The prompt_formatter.py file handles the formatting of our prompts. This adheres to the Open/Closed Principle (the 'O' in SOLID) by allowing for easy extension of formatting behavior without modifying existing code.

from typing import Any, List
from datetime import datetime
from langchain_core.prompts import ChatPromptTemplate
from .interfaces import IPromptFormatter
from langchain_core.messages import AnyMessage

class DefaultPromptFormatter(IPromptFormatter):
    def __init__(self, prompt: ChatPromptTemplate):
        self.prompt = prompt

    def format_prompt(self, messages: List[AnyMessage], system_time: datetime) -> Any:
        return self.prompt.invoke(
            {
                "messages": messages,
                "system_time": system_time.isoformat(),
            }
        )
Enter fullscreen mode Exit fullscreen mode

This formatter takes a ChatPromptTemplate and uses it to format the messages and system time into a prompt suitable for our AI model. By using a template, we can easily modify the prompt structure without changing the formatting logic.

Crafting the Brain: Building the ReAct Agent with LangGraph

Now that we've covered our supporting files, let's take a deeper look at how we build our ReAct agent in the graph.py file:

from typing import Any, Dict
from datetime import datetime, timezone
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent, ToolNode
from langchain_core.prompts import ChatPromptTemplate
from .interfaces import IMessageFilter, IPromptFormatter
from .messages_filter import DefaultMessageFilter
from .prompt_formatter import DefaultPromptFormatter
from .tools import search, get_webex_user_info, power

SYSTEM_PROMPT = """You are a helpful AI assistant in a Webex chat. 
Your primary tasks include:
1. Searching the web to provide relevant information.
2. Retrieving personal information from Webex.
3. Performing power calculations.
4. Interacting with users to assist with their queries.
Ensure your responses are clear, accurate, and concise. Always aim to provide the most helpful information based on the user's request.
System time: {system_time}"""

class Graph:
    def __init__(
        self,
        model: str,
        tools: list,
        message_filter: IMessageFilter,
        prompt_formatter: IPromptFormatter,
        name: str = "ReAct Agent",
    ):
        self.model = ChatOpenAI(model=model)
        self.tools = ToolNode(tools)
        self.message_filter = message_filter
        self.prompt_formatter = prompt_formatter
        self.name = name

        self.graph = self._create_graph()

    def _create_graph(self):
        graph = create_react_agent(
            self.model, self.tools, state_modifier=self._format_for_model
        )
        graph.name = self.name
        return graph

    def _format_for_model(self, state: Dict[str, Any]) -> Any:
        messages = state["messages"]
        filtered_messages = self.message_filter.filter_messages(messages)
        return self.prompt_formatter.format_prompt(
            filtered_messages, datetime.now(timezone.utc).astimezone()
        )

def create_default_graph():
    prompt = ChatPromptTemplate.from_messages(
        [
            ("system", SYSTEM_PROMPT),
            ("placeholder", "{messages}"),
        ]
    )
    tools = [get_webex_user_info, search, power]
    return Graph(
        model="gpt-4o",
        tools=tools,
        message_filter=DefaultMessageFilter(),
        prompt_formatter=DefaultPromptFormatter(prompt),
        name="Webex Bot Demo ReAct Agent",
    )
Enter fullscreen mode Exit fullscreen mode

This Graph class encapsulates the creation and configuration of our ReAct agent. It uses dependency injection (adhering to the Dependency Inversion Principle, the 'D' in SOLID) to allow for flexible message filtering and prompt formatting.

The _format_for_model method ties together our message filtering and prompt formatting. It first applies the message filter to limit the context window, then uses the prompt formatter to prepare the input for our AI model.

By structuring our code this way, we've created a flexible and extensible system that can easily accommodate changes or additions to our bot's capabilities. For example, if we wanted to implement a different message filtering strategy or change our prompt format, we could do so by creating new classes that implement the IMessageFilter or IPromptFormatter interfaces, respectively, without having to modify the existing Graph class.

This setup creates a powerful ReAct agent that can understand context, use tools, and generate appropriate responses.

Empowering Your Bot: Implementing Custom Tools

Our bot's capabilities are enhanced by custom tools. Let's look at the key tools in our tools.py file:

1. Web Search Tool:

def search(
    query: str, *, config: Annotated[RunnableConfig, InjectedToolArg]
) -> Optional[List[Dict[str, Any]]]:
    """Search for general web results.
       Search for real-time information using the Tavily search engine.

    This function performs a search using the Tavily search engine, which is designed
    to provide comprehensive, accurate, and trusted results. It's particularly useful
    for answering questions about current events.
    """
    max_results: int = config.get("configurable", {}).get("max_results") or 5
    wrapped = TavilySearchResults(max_results=max_results)
    result = wrapped.invoke({"query": query})
    return cast(List[Dict[str, Any]], result)
Enter fullscreen mode Exit fullscreen mode

2. Power Calculation Tool:

def power(a: int, b: int) -> int:
    """Calculate power of a number."""
    return a**b
Enter fullscreen mode Exit fullscreen mode

3. Webex User Info Retrieval Tool:

def get_webex_user_info(
    config: Annotated[RunnableConfig, InjectedToolArg],
) -> Optional[Dict[str, str]]:
    """Get user information: email and user name/display name from Webex SDK"""
    displayName: str = config.get("configurable", {}).get("displayName") or ""
    email: str = config.get("configurable", {}).get("email") or ""

    return {
        "displayName": displayName,
        "email": email,
    }
Enter fullscreen mode Exit fullscreen mode

These tools allow our bot to perform web searches, mathematical calculations, and retrieve user information from Webex.

Maintaining Context: Checkpointing with SQLite

To ensure our bot maintains context across conversations, we use SQLite for checkpointing. Here's how we set it up in our invoker.py file:

class GraphInvoker(IGraphInvoker):
    def __init__(
        self,
        graph: Graph,
        connection_str: str = "",
        llm_model: str = "",
        temperature: float = 0.1,
    ):
        self.graph = graph
        self.connection_str = connection_str or os.getenv("SQL_CONNECTION_STR")
        self.llm_model = llm_model or os.getenv("LLM_MODEL")
        self.temperature = temperature

    def invoke(self, message: str, **kwargs: Any) -> Dict[str, Any]:
        if not self.connection_str:
            raise ValueError("No database connection string provided")
        if not self.llm_model:
            raise ValueError("No LLM model provided")
        with SqliteSaver.from_conn_string(self.connection_str) as saver:
            self.graph.graph.checkpointer = saver
            run_id = uuid4()
            config = RunnableConfig(
                configurable={
                    "model": self.llm_model,
                    "temperature": self.temperature,
                    **kwargs,
                },
                run_id=run_id,
            )
            result = self.graph.graph.invoke(
                input={"messages": [HumanMessage(content=message)]},
                config=config,
            )
            return result
Enter fullscreen mode Exit fullscreen mode

This setup ensures that the conversation state persists between interactions.

Bringing It All Together: Integrating with Webex Teams

Now, let's integrate our AI assistant with Webex Teams.

First, we need to create and register the bot on Webex. For instructions on how to create and register your bot, please follow the Webex Bot guide

In our webexbot.py file:

def create_bot():
    load_dotenv()

    graph = create_default_graph()
    invoker = GraphInvoker(graph)
    openai_command = OpenAI(invoker)

    return WebexBot(
        teams_bot_token=os.getenv("WEBEX_TEAMS_ACCESS_TOKEN"),
        approved_domains=[os.getenv("WEBEX_TEAMS_DOMAIN")],
        bot_name="AI-Assistant",
        bot_help_subtitle="",
        threads=False,
        help_command=openai_command,
    )

if __name__ == "__main__":
    bot = create_bot()
    bot.run()
Enter fullscreen mode Exit fullscreen mode

And in our commands.py file:

class OpenAI(Command):
    def __init__(self, invoker: IGraphInvoker):
        super().__init__()
        self.invoker = invoker

    def execute(self, message, attachment_actions, activity):
        response = self.invoker.invoke(
            message,
            thread_id=activity["target"]["globalId"],
            email=activity["actor"]["id"],
            displayName=activity["actor"]["displayName"],
            max_results=5,
        )
        return response["messages"][-1].content
Enter fullscreen mode Exit fullscreen mode

This setup allows our bot to process all text inputs using our ReAct agent and respond within Webex Teams.

🚀 Running your AI Assistant

This project uses Poetry for dependency management and Poe the Poet for task running. Read more in readme

To start your Webex Bot AI Assistant, use the following command:

poe start
Enter fullscreen mode Exit fullscreen mode

or

poetry run python -m webexbot.webexbot
Enter fullscreen mode Exit fullscreen mode

Your bot is now live and ready to assist users in Webex Teams!

Example Interaction

Image description

Supercharging Development: Tracing and Analyzing with LangSmith

To gain insights into your bot's performance and behavior, we've integrated LangSmith for tracing. This allows you to visualize the decision-making process of your AI assistant and optimize its performance.

Image description

Conclusion: Your Webex Bot AI Assistant in Action

Congratulations! You've successfully built a powerful Webex Bot AI Assistant using LangGraph's stateful LLM agents. Your bot can now:

  • Understand and maintain context in conversations
  • Perform web searches for up-to-date information
  • Retrieve Webex user information
  • Calculate a number's power
  • Provide intelligent responses to user queries

This AI-powered assistant will significantly enhance communication and productivity within your Webex Teams environment.

Customizing Your Webex Bot AI Assistant

While the Webex Bot AI Assistant we've built provides a solid foundation, you may want to adapt it for your specific use cases. Here are some ways you can customize the bot to better suit your needs:

1. Adding New Tools

One of the most straightforward ways to extend your bot's capabilities is by adding new tools. Here's how you can do this:

1. Define a new tool function in tools.py:
def new_custom_tool(query: str, config: Annotated[RunnableConfig, InjectedToolArg]) -> str:
    # Implement your custom tool logic here
    return f"Result of custom tool for query: {query}"
Enter fullscreen mode Exit fullscreen mode
2. Add the new tool to the create_default_graph function in graph.py:
def create_default_graph():
    # ... existing code ...
    tools = [get_webex_user_info, search, power, new_custom_tool]
    return Graph(
        model="gpt-4o",
        tools=tools,
        message_filter=DefaultMessageFilter(),
        prompt_formatter=DefaultPromptFormatter(prompt),
        name="Webex Bot Demo ReAct Agent",
    )
Enter fullscreen mode Exit fullscreen mode
3. Update the system prompt in graph.py to inform the AI about the new tool:
SYSTEM_PROMPT = """You are a helpful AI assistant in a Webex chat. 
Your primary tasks include:
1. Searching the web to provide relevant information.
2. Retrieving personal information from Webex.
3. Performing power calculations.
4. [Description of your new custom tool]
5. Interacting with users to assist with their queries.
Ensure your responses are clear, accurate, and concise. Always aim to provide the most helpful information based on the user's request.
System time: {system_time}"""
Enter fullscreen mode Exit fullscreen mode

2. Customizing Message Filtering

If you need a different message filtering strategy, you can create a new class that implements the IMessageFilter interface:

class CustomMessageFilter(IMessageFilter):
    def filter_messages(self, messages: List[AnyMessage]) -> List[AnyMessage]:
        # Implement your custom filtering logic here
        return messages  # This is just a placeholder
Enter fullscreen mode Exit fullscreen mode

Then, update the create_default_graph function to use your new filter:

def create_default_graph():
    # ... existing code ...
    return Graph(
        model="gpt-4o",
        tools=tools,
        message_filter=CustomMessageFilter(),
        prompt_formatter=DefaultPromptFormatter(prompt),
        name="Webex Bot Demo ReAct Agent",
    )
Enter fullscreen mode Exit fullscreen mode

3. Modifying the Prompt Format

To change how the AI interprets messages, you can modify the prompt format. Create a new class implementing the IPromptFormatter interface:

class CustomPromptFormatter(IPromptFormatter):
    def __init__(self, prompt: ChatPromptTemplate):
        self.prompt = prompt

    def format_prompt(self, messages: List[AnyMessage], system_time: datetime) -> Any:
        # Implement your custom prompt formatting logic here
        return self.prompt.invoke(
            {
                "messages": messages,
                "system_time": system_time.isoformat(),
                # Add any additional context you want to include
            }
        )
Enter fullscreen mode Exit fullscreen mode

Update the create_default_graph function to use your new formatter:

def create_default_graph():
    # ... existing code ...
    custom_prompt = ChatPromptTemplate.from_messages(
        [
            ("system", YOUR_CUSTOM_SYSTEM_PROMPT),
            ("placeholder", "{messages}"),
        ]
    )
    return Graph(
        model="gpt-4o",
        tools=tools,
        message_filter=DefaultMessageFilter(),
        prompt_formatter=CustomPromptFormatter(custom_prompt),
        name="Webex Bot Demo ReAct Agent",
    )
Enter fullscreen mode Exit fullscreen mode

4. Integrating with External Services

You can enhance your bot by integrating it with external services relevant to your organization. For example, you might add a tool to query a company database or interact with a project management system.

Here's a simple example of how you might create a tool to interact with a hypothetical API:

import requests

def query_company_database(query: str, config: Annotated[RunnableConfig, InjectedToolArg]) -> str:
    api_key = config.get("configurable", {}).get("company_db_api_key")
    if not api_key:
        return "Error: API key for company database not provided"

    response = requests.get(f"https://api.company-database.com/query?q={query}", headers={"Authorization": f"Bearer {api_key}"})
    if response.status_code == 200:
        return response.json()["result"]
    else:
        return f"Error querying company database: {response.status_code}"
Enter fullscreen mode Exit fullscreen mode

Remember to add the new API key to your .env file and update your bot's configuration to pass it to the tool.

By leveraging these customization techniques, you can adapt the Webex Bot AI Assistant to better fit your organization's specific needs and workflows. Whether you're adding new capabilities, changing how the bot processes messages, or integrating with your existing systems, the modular design of the bot makes these customizations straightforward to implement.

Troubleshooting

If you encounter any issues while setting up or running your bot, check these common problems:

  1. API Key Issues: Ensure all API keys in your .env file are correct and up-to-date.
  2. Dependency Conflicts: Make sure you're using the correct versions of all libraries. Check the pyproject.toml file for version specifications.
  3. Webex Integration: If your bot isn't responding in Webex, verify that your bot's access token is correct and that it has the necessary permissions.

For more specific issues, consult the documentation of the relevant libraries or reach out to the community on dev.to or GitHub.

Resources

Remember, building AI-powered bots is an iterative process. Don't be afraid to experiment, learn, and improve your bot over time. Good luck with your project!

Top comments (0)