From 0d2145f63392e2c23ccb455bbb6dac09b6e51775 Mon Sep 17 00:00:00 2001 From: Rhys Campbell Date: Mon, 24 Feb 2025 19:43:25 +0800 Subject: [PATCH 1/5] Web Reader - Do not ignore http error (#2178) ## Description **Summary of changes**: Pass http error on so we know that 4xx or 5xx error are occurring and not silently failing **Related issues**: N/A **Motivation and context**: Currently if a url returns an error (403 in my case) the payload was small with no parse-able contnet **Environment or dependencies**: None **Impact on metrics**: (If applicable) I assume none Fixes # (issue) --- libs/agno/agno/document/reader/website_reader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/agno/agno/document/reader/website_reader.py b/libs/agno/agno/document/reader/website_reader.py index f66f890c9..3d9d73169 100644 --- a/libs/agno/agno/document/reader/website_reader.py +++ b/libs/agno/agno/document/reader/website_reader.py @@ -116,6 +116,7 @@ def crawl(self, url: str, starting_depth: int = 1) -> Dict[str, str]: try: logger.debug(f"Crawling: {current_url}") response = httpx.get(current_url, timeout=10) + response.raise_for_status() soup = BeautifulSoup(response.content, "html.parser") # Extract main content From 6d02194091f9f7f26f1a5d0b9d725b1b598772b7 Mon Sep 17 00:00:00 2001 From: Abdullah Enes <101020733+enesgules@users.noreply.github.com> Date: Mon, 24 Feb 2025 15:46:08 +0300 Subject: [PATCH 2/5] [VectorDB] upstash vectordb integration (#2076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - **Summary of changes**: Add Upstash Vector as a VectorDB Fixes #1941 --- ## Type of change Please check the options that are relevant: - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Model update (Addition or modification of models) - [ ] Other (please describe): --- ## Checklist - [x] Adherence to standards: Code complies with Agno’s style guidelines and best practices. - [x] Formatting and validation: You have run `./scripts/format.sh` and `./scripts/validate.sh` to ensure code is formatted and linted. - [x] Self-review completed: A thorough review has been performed by the contributor(s). - [x] Documentation: Docstrings and comments have been added or updated for any complex logic. - [x] Examples and guides: Relevant cookbook examples have been included or updated (if applicable). - [x] Tested in a clean environment: Changes have been tested in a clean environment to confirm expected behavior. - [ ] Tests (optional): Tests have been added or updated to cover any new or changed functionality. --- ## Additional Notes You must create and index in [Upstash Console](https://console.upstash.com) install the `upstash-vector` package before using the vectordb. --- .../knowledge/vector_dbs/upstash_db.py | 30 ++ libs/agno/agno/vectordb/upstashdb/__init__.py | 1 + .../agno/agno/vectordb/upstashdb/upstashdb.py | 331 ++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 cookbook/agent_concepts/knowledge/vector_dbs/upstash_db.py create mode 100644 libs/agno/agno/vectordb/upstashdb/__init__.py create mode 100644 libs/agno/agno/vectordb/upstashdb/upstashdb.py diff --git a/cookbook/agent_concepts/knowledge/vector_dbs/upstash_db.py b/cookbook/agent_concepts/knowledge/vector_dbs/upstash_db.py new file mode 100644 index 000000000..b88387769 --- /dev/null +++ b/cookbook/agent_concepts/knowledge/vector_dbs/upstash_db.py @@ -0,0 +1,30 @@ +# install upstash-vector - `uv pip install upstash-vector` +# Add OPENAI_API_KEY to your environment variables for the agent response + +from agno.agent import Agent +from agno.knowledge.pdf_url import PDFUrlKnowledgeBase +from agno.vectordb.upstashdb.upstashdb import UpstashVectorDb + +# How to connect to an Upstash Vector index +# - Create a new index in Upstash Console with the correct dimension +# - Fetch the URL and token from Upstash Console +# - Replace the values below or use environment variables + +# Initialize Upstash DB +vector_db = UpstashVectorDb( + url="UPSTASH_VECTOR_REST_URL", + token="UPSTASH_VECTOR_REST_TOKEN", +) + +# Create a new PDFUrlKnowledgeBase +knowledge_base = PDFUrlKnowledgeBase( + urls=["https://phi-public.s3.amazonaws.com/recipes/ThaiRecipes.pdf"], + vector_db=vector_db, +) + +# Load the knowledge base - after first run, comment out +knowledge_base.load(recreate=False, upsert=True) + +# Create and use the agent +agent = Agent(knowledge=knowledge_base, show_tool_calls=True) +agent.print_response("What are some tips for cooking glass noodles?", markdown=True) diff --git a/libs/agno/agno/vectordb/upstashdb/__init__.py b/libs/agno/agno/vectordb/upstashdb/__init__.py new file mode 100644 index 000000000..1505ad2a5 --- /dev/null +++ b/libs/agno/agno/vectordb/upstashdb/__init__.py @@ -0,0 +1 @@ +from agno.vectordb.upstashdb.upstashdb import UpstashVectorDb diff --git a/libs/agno/agno/vectordb/upstashdb/upstashdb.py b/libs/agno/agno/vectordb/upstashdb/upstashdb.py new file mode 100644 index 000000000..d0f8cdd51 --- /dev/null +++ b/libs/agno/agno/vectordb/upstashdb/upstashdb.py @@ -0,0 +1,331 @@ +from typing import Any, Dict, List, Optional + +try: + from upstash_vector import Index, Vector + from upstash_vector.types import InfoResult +except ImportError: + raise ImportError( + "The `upstash-vector` package is not installed, please install using `pip install upstash-vector`" + ) + +from agno.document import Document +from agno.embedder import Embedder +from agno.reranker.base import Reranker +from agno.utils.log import logger +from agno.vectordb.base import VectorDb + +DEFAULT_NAMESPACE = "" + + +class UpstashVectorDb(VectorDb): + """ + This class provides an interface to Upstash Vector database with support for both + custom embeddings and Upstash's hosted embedding models. + + Args: + url (str): The Upstash Vector database URL. + token (str): The Upstash Vector API token. + retries (Optional[int], optional): Number of retry attempts for operations. Defaults to 3. + retry_interval (Optional[float], optional): Time interval between retries in seconds. Defaults to 1.0. + dimension (Optional[int], optional): The dimension of the embeddings. Defaults to None. + embedder (Optional[Embedder], optional): The embedder to use. If None, uses Upstash hosted embedding models. + namespace (Optional[str], optional): The namespace to use. Defaults to DEFAULT_NAMESPACE. + reranker (Optional[Reranker], optional): The reranker to use. Defaults to None. + **kwargs: Additional keyword arguments. + """ + + def __init__( + self, + url: str, + token: str, + retries: Optional[int] = 3, + retry_interval: Optional[float] = 1.0, + dimension: Optional[int] = None, + embedder: Optional[Embedder] = None, + namespace: Optional[str] = DEFAULT_NAMESPACE, + reranker: Optional[Reranker] = None, + **kwargs: Any, + ) -> None: + self._index: Optional[Index] = None + self.url: str = url + self.token: str = token + self.retries: int = retries if retries is not None else 3 + self.retry_interval: float = retry_interval if retry_interval is not None else 1.0 + self.dimension: Optional[int] = dimension + self.namespace: str = namespace if namespace is not None else DEFAULT_NAMESPACE + self.kwargs: Dict[str, Any] = kwargs + self.use_upstash_embeddings: bool = embedder is None + + if embedder is None: + logger.warning( + "You have not provided an embedder, using Upstash hosted embedding models. " + "Make sure you created your index with an embedding model." + ) + self.embedder: Optional[Embedder] = embedder + self.reranker: Optional[Reranker] = reranker + + @property + def index(self) -> Index: + """The Upstash Vector index. + Returns: + upstash_vector.Index: The Upstash Vector index. + """ + if self._index is None: + self._index = Index( + url=self.url, + token=self.token, + retries=self.retries, + retry_interval=self.retry_interval, + ) + if self._index is None: + raise ValueError("Failed to initialize Upstash index") + + info = self._index.info() + if info is None: + raise ValueError("Failed to get index info") + + index_dimension = info.dimension + if self.dimension is not None and index_dimension != self.dimension: + raise ValueError( + f"Index dimension {index_dimension} does not match provided dimension {self.dimension}" + ) + return self._index + + def exists(self) -> bool: + """Check if the index exists and is accessible. + + Returns: + bool: True if the index exists and is accessible, False otherwise. + + Raises: + Exception: If there's an error communicating with Upstash. + """ + try: + self.index.info() + return True + except Exception as e: + logger.error(f"Error checking index existence: {str(e)}") + return False + + def create(self) -> None: + """You can create indexes via Upstash Console.""" + logger.warning( + "Indexes can only be created through the Upstash Console or the developer API. Please create an index before using this vector database." + ) + pass + + def drop(self) -> None: + """You can drop indexes via Upstash Console.""" + logger.warning( + "Indexes can only be dropped through the Upstash Console. Make sure you have an existing index before performing operations." + ) + pass + + def drop_namespace(self, namespace: Optional[str] = None) -> None: + """Delete a namespace from the index. + Args: + namespace (Optional[str], optional): The namespace to drop. Defaults to None, which uses the instance namespace. + """ + _namespace = self.namespace if namespace is None else namespace + if self.namespace_exists(_namespace): + self.index.delete_namespace(_namespace) + else: + logger.error(f"Namespace {_namespace} does not exist.") + + def get_all_namespaces(self) -> List[str]: + """Get all namespaces in the index. + Returns: + List[str]: A list of namespaces. + """ + return self.index.list_namespaces() + + def doc_exists(self, document: Document) -> bool: + """Check if a document exists in the index. + Args: + document (Document): The document to check. + Returns: + bool: True if the document exists, False otherwise. + """ + if document.id is None: + logger.error("Document ID cannot be None") + return False + documents_to_fetch = [document.id] + response = self.index.fetch(ids=documents_to_fetch) + return len(response) > 0 + + def name_exists(self, name: str) -> bool: + """You can check if an index exists in Upstash Console. + Args: + name (str): The name of the index to check. + Returns: + bool: True if the index exists, False otherwise. (Name is not used.) + """ + logger.warning( + f"You can check if an index with name {name} exists in Upstash Console." + "The token and url parameters you provided are used to connect to a specific index." + ) + return self.exists() + + def namespace_exists(self, namespace: str) -> bool: + """Check if an namespace exists. + Args: + namespace (str): The name of the namespace to check. + Returns: + bool: True if the namespace exists, False otherwise. + """ + namespaces = self.index.list_namespaces() + return namespace in namespaces + + def upsert( + self, documents: List[Document], filters: Optional[Dict[str, Any]] = None, namespace: Optional[str] = None + ) -> None: + """Upsert documents into the index. + + Args: + documents (List[Document]): The documents to upsert. + filters (Optional[Dict[str, Any]], optional): The filters for the upsert. Defaults to None. + namespace (Optional[str], optional): The namespace for the documents. Defaults to None, which uses the instance namespace. + """ + _namespace = self.namespace if namespace is None else namespace + vectors = [] + + for document in documents: + if document.id is None: + logger.error(f"Document ID must not be None. Skipping document: {document.content[:100]}...") + continue + + document.meta_data["text"] = document.content + + if not self.use_upstash_embeddings: + if self.embedder is None: + logger.error("Embedder is None but use_upstash_embeddings is False") + continue + + document.embed(embedder=self.embedder) + if document.embedding is None: + logger.error(f"Failed to generate embedding for document: {document.id}") + continue + + vector = Vector( + id=document.id, vector=document.embedding, metadata=document.meta_data, data=document.content + ) + else: + vector = Vector(id=document.id, data=document.content, metadata=document.meta_data) + vectors.append(vector) + + if not vectors: + logger.warning("No valid documents to upsert") + return + + self.index.upsert(vectors, namespace=_namespace) + + def upsert_available(self) -> bool: + """Check if upsert operation is available. + Returns: + True + """ + return True + + def insert(self, documents: List[Document], filters: Optional[Dict[str, Any]] = None) -> None: + """Insert documents into the index. + This method is not supported by Upstash. Use `upsert` instead. + Args: + documents (List[Document]): The documents to insert. + filters (Optional[Dict[str, Any]], optional): The filters for the insert. Defaults to None. + Raises: + NotImplementedError: This method is not supported by Upstash. + """ + raise NotImplementedError("Upstash does not support insert operations. Use upsert instead.") + + def search( + self, + query: str, + limit: int = 5, + filters: Optional[Dict[str, Any]] = None, + namespace: Optional[str] = None, + ) -> List[Document]: + """Search for documents in the index. + Args: + query (str): The query string to search for. + limit (int, optional): Maximum number of results to return. Defaults to 5. + filters (Optional[Dict[str, Any]], optional): Metadata filters for the search. + namespace (Optional[str], optional): The namespace to search in. Defaults to None, which uses the instance namespace. + Returns: + List[Document]: List of matching documents. + """ + _namespace = self.namespace if namespace is None else namespace + + filter_str = "" if filters is None else str(filters) + + if not self.use_upstash_embeddings and self.embedder is not None: + dense_embedding = self.embedder.get_embedding(query) + + if dense_embedding is None: + logger.error(f"Error getting embedding for Query: {query}") + return [] + + response = self.index.query( + vector=dense_embedding, + namespace=_namespace, + top_k=limit, + filter=filter_str, + include_data=True, + include_metadata=True, + include_vectors=True, + ) + else: + response = self.index.query( + data=query, + namespace=_namespace, + top_k=limit, + filter=filter_str, + include_data=True, + include_metadata=True, + include_vectors=True, + ) + + if response is None: + logger.info(f"No results found for query: {query}") + return [] + + search_results = [] + for result in response: + if result.data is not None and result.id is not None and result.vector is not None: + search_results.append( + Document( + content=result.data, + id=result.id, + meta_data=result.metadata or {}, + embedding=result.vector, + ) + ) + + if self.reranker: + search_results = self.reranker.rerank(query=query, documents=search_results) + + return search_results + + def delete(self, namespace: Optional[str] = None, delete_all: bool = False) -> bool: + """Clear the index. + Args: + namespace (Optional[str], optional): The namespace to clear. Defaults to None, which uses the instance namespace. + delete_all (bool, optional): Whether to delete all documents in the index. Defaults to False. + Returns: + bool: True if the index was deleted, False otherwise. + """ + _namespace = self.namespace if namespace is None else namespace + response = self.index.reset(namespace=_namespace, all=delete_all) + return True if response.lower().strip() == "success" else False + + def get_index_info(self) -> InfoResult: + """Get information about the index. + Returns: + InfoResult: Information about the index including size, vector count, etc. + """ + return self.index.info() + + def optimize(self) -> None: + """Optimize the index. + This method is empty as Upstash automatically optimizes indexes. + """ + pass From 99dc540e741d7e39c980374e1cf574a3bcd72076 Mon Sep 17 00:00:00 2001 From: zszazi <41579863+zszazi@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:51:29 -0600 Subject: [PATCH 3/5] Ft: Webex Integration Tool (#1906) ## Description **Please include:** - **Summary of changes**: Clearly describe the key changes in this PR and their purpose. * Added Webex tool integration https://www.webex.com/ - **Motivation and context**: Explain the reason for the changes and the problem they solve. * Good to have Webex Integration alongside slack and discord - **Environment or dependencies**: Specify any changes in dependencies or environment configurations required for this update. * NA - **Impact on AI/ML components**: (If applicable) Describe changes to AI/ML models and include performance metrics (e.g., accuracy, F1-score). * NA --- cookbook/tools/webex_tool.py | 30 +++++ libs/agno/agno/tools/webex.py | 67 ++++++++++ libs/agno/tests/unit/tools/test_webex.py | 161 +++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 cookbook/tools/webex_tool.py create mode 100644 libs/agno/agno/tools/webex.py create mode 100644 libs/agno/tests/unit/tools/test_webex.py diff --git a/cookbook/tools/webex_tool.py b/cookbook/tools/webex_tool.py new file mode 100644 index 000000000..b131da4bc --- /dev/null +++ b/cookbook/tools/webex_tool.py @@ -0,0 +1,30 @@ +""" +Run `pip install openai webexpythonsdk` to install dependencies. +To get the Webex Teams Access token refer to - https://developer.webex.com/docs/bots + +Steps: + +1. Sign up for Webex Teams and go to the Webex [Developer Portal](https://developer.webex.com/) +2. Create the Bot + 2.1 Click in the top-right on your profile → My Webex Apps → Create a Bot. + 2.2 Enter Bot Name, Username, Icon, and Description, then click Add Bot. +3. Get the Access Token + 3.1 Copy the Access Token shown on the confirmation page (displayed once). + 3.2 If lost, regenerate it via My Webex Apps → Edit Bot → Regenerate Access Token. +4. Set the WEBEX_ACCESS_TOKEN environment variable +5. Launch Webex itself and add your bot to a space like the Welcome space. Use the bot's email address (e.g. test@webex.bot) +""" + +import os + +from agno.agent import Agent +from agno.tools.webex import WebexTools + +agent = Agent(tools=[WebexTools()], show_tool_calls=True) + +#List all space in Webex +agent.print_response("List all space on our Webex", markdown=True) + +#Send a message to a Space in Webex +agent.print_response("Send a funny ice-breaking message to the webex Welcome space", markdown=True) + diff --git a/libs/agno/agno/tools/webex.py b/libs/agno/agno/tools/webex.py new file mode 100644 index 000000000..712343c39 --- /dev/null +++ b/libs/agno/agno/tools/webex.py @@ -0,0 +1,67 @@ +import json +import os +from typing import Optional + +from agno.tools.toolkit import Toolkit +from agno.utils.log import logger + +try: + from webexpythonsdk import WebexAPI + from webexpythonsdk.exceptions import ApiError +except ImportError: + logger.error("Webex tools require the `webexpythonsdk` package. Run `pip install webexpythonsdk` to install it.") + +class WebexTools(Toolkit): + def __init__(self, send_message: bool = True, list_rooms: bool = True, access_token: Optional[str] = None): + super().__init__(name="webex") + if access_token is None: + access_token = os.getenv("WEBEX_ACCESS_TOKEN") + if access_token is None: + raise ValueError("Webex access token is not set. Please set the WEBEX_ACCESS_TOKEN environment variable.") + + self.client = WebexAPI(access_token=access_token) + if send_message: + self.register(self.send_message) + if list_rooms: + self.register(self.list_rooms) + + def send_message(self, room_id: str, text: str) -> str: + """ + Send a message to a Webex Room. + Args: + room_id (str): The Room ID to send the message to. + text (str): The text of the message to send. + Returns: + str: A JSON string containing the response from the Webex. + """ + try: + response = self.client.messages.create(roomId=room_id, text=text) + return json.dumps(response.json_data) + except ApiError as e: + logger.error(f"Error sending message: {e} in room: {room_id}") + return json.dumps({"error": str(e)}) + + + def list_rooms(self) -> str: + """ + List all rooms in the Webex. + Returns: + str: A JSON string containing the list of rooms. + """ + try: + response = self.client.rooms.list() + rooms_list = [ + { + "id": room.id, + "title": room.title, + "type": room.type, + "isPublic": room.isPublic, + "isReadOnly": room.isReadOnly, + } + for room in response + ] + + return json.dumps({"rooms": rooms_list}, indent=4) + except ApiError as e: + logger.error(f"Error listing rooms: {e}") + return json.dumps({"error": str(e)}) diff --git a/libs/agno/tests/unit/tools/test_webex.py b/libs/agno/tests/unit/tools/test_webex.py new file mode 100644 index 000000000..89c77ea56 --- /dev/null +++ b/libs/agno/tests/unit/tools/test_webex.py @@ -0,0 +1,161 @@ +"""Unit tests for WebexTools class.""" + +import json +from unittest.mock import Mock, patch + +import pytest +from webexpythonsdk import WebexAPI +from webexpythonsdk.exceptions import RateLimitError +from requests import Response + +from agno.tools.webex import WebexTools + + +@pytest.fixture +def mock_webex_api(): + """Create a mock Webex API client.""" + with patch("agno.tools.webex.WebexAPI") as mock_api: + # Create mock for nested attributes + mock_client = Mock(spec=WebexAPI) + mock_messages = Mock() + mock_rooms = Mock() + + # Set up the nested structure + mock_client.messages = mock_messages + mock_client.rooms = mock_rooms + + mock_api.return_value = mock_client + return mock_client + + +@pytest.fixture +def webex_tools(mock_webex_api): + """Create WebexTools instance with mocked API.""" + with patch.dict("os.environ", {"WEBEX_TEAMS_ACCESS_TOKEN": "test_token"}): + tools = WebexTools() + tools.client = mock_webex_api + return tools + + +def test_init_with_api_token(): + """Test initialization with provided API token.""" + with patch("agno.tools.webex.WebexAPI") as mock_api: + tools = WebexTools(access_token="test_token") + mock_api.assert_called_once_with(access_token="test_token") + + +def test_init_with_env_var(): + """Test initialization with environment variable.""" + with patch("agno.tools.webex.WebexAPI") as mock_api: + with patch.dict("os.environ", {"WEBEX_TEAMS_ACCESS_TOKEN": "env_token"}): + tools = WebexTools() + mock_api.assert_called_once_with(access_token="env_token") + + +def test_init_without_token(): + """Test initialization without API token.""" + with patch.dict("os.environ", clear=True): + with pytest.raises(ValueError, match="Webex access token is not set"): + WebexTools() + + +def test_init_with_selective_tools(): + """Test initialization with only selected tools.""" + with patch.dict("os.environ", {"WEBEX_TEAMS_ACCESS_TOKEN": "test_token"}): + tools = WebexTools( + send_message=True, + list_rooms=False, + ) + + assert "send_message" in [func.name for func in tools.functions.values()] + assert "list_rooms" not in [func.name for func in tools.functions.values()] + + +def test_send_message_success(webex_tools, mock_webex_api): + """Test successful message sending.""" + mock_response = Mock() + mock_response.json_data = { + "id": "msg123", + "roomId": "room123", + "text": "Test message", + "created": "2024-01-01T10:00:00.000Z" + } + + mock_webex_api.messages.create.return_value = mock_response + + result = webex_tools.send_message("room123", "Test message") + result_data = json.loads(result) + + assert result_data["id"] == "msg123" + assert result_data["roomId"] == "room123" + assert result_data["text"] == "Test message" + mock_webex_api.messages.create.assert_called_once_with(roomId="room123", text="Test message") + + +def test_list_rooms_success(webex_tools, mock_webex_api): + """Test successful room listing.""" + # Create mock room objects + mock_room1 = Mock() + mock_room1.id = "room123" + mock_room1.title = "Test Room 1" + mock_room1.type = "group" + mock_room1.isPublic = True + mock_room1.isReadOnly = False + + mock_room2 = Mock() + mock_room2.id = "room456" + mock_room2.title = "Test Room 2" + mock_room2.type = "direct" + mock_room2.isPublic = False + mock_room2.isReadOnly = True + + # Set up the mock return value + mock_webex_api.rooms.list.return_value = [mock_room1, mock_room2] + + result = webex_tools.list_rooms() + result_data = json.loads(result) + + assert len(result_data["rooms"]) == 2 + assert result_data["rooms"][0]["id"] == "room123" + assert result_data["rooms"][0]["title"] == "Test Room 1" + assert result_data["rooms"][1]["id"] == "room456" + assert result_data["rooms"][1]["title"] == "Test Room 2" + + +def test_list_rooms_failure(webex_tools, mock_webex_api): + """Test room listing failure.""" + response = Response() + response.status_code = 429 # Rate limit status code + response.reason = "Too Many Requests" + mock_webex_api.rooms.list.side_effect = RateLimitError(response) + + result = webex_tools.list_rooms() + result_data = json.loads(result) + + assert "error" in result_data + assert "Too Many Requests" in str(result_data["error"]) + + +def test_list_rooms_empty(webex_tools, mock_webex_api): + """Test listing when no rooms are available.""" + mock_webex_api.rooms.list.return_value = [] + + result = webex_tools.list_rooms() + result_data = json.loads(result) + + assert len(result_data["rooms"]) == 0 + + +def test_send_message_rate_limit(webex_tools, mock_webex_api): + """Test sending empty message.""" + response = Response() + response.status_code = 429 # Rate limit status code + response.reason = "Too Many Requests" + mock_webex_api.messages.create.side_effect = RateLimitError(response) + + result = webex_tools.send_message("room123", "") + result_data = json.loads(result) + + assert "error" in result_data + assert "Too Many Requests" in str(result_data["error"]) + From cd8c469635db55e186c401c0a3ad2b2c8e109fa8 Mon Sep 17 00:00:00 2001 From: Yash Pratap Solanky <101447028+ysolanky@users.noreply.github.com> Date: Mon, 24 Feb 2025 10:34:17 -0500 Subject: [PATCH 4/5] playground-memory-fix-ag-2740 (#2216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - **Summary of changes**: Include memory in the fields to copy --- ## Type of change Please check the options that are relevant: - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Model update (Addition or modification of models) - [ ] Other (please describe): --- ## Checklist - [ ] Adherence to standards: Code complies with Agno’s style guidelines and best practices. - [ ] Formatting and validation: You have run `./scripts/format.sh` and `./scripts/validate.sh` to ensure code is formatted and linted. - [ ] Self-review completed: A thorough review has been performed by the contributor(s). - [ ] Documentation: Docstrings and comments have been added or updated for any complex logic. - [ ] Examples and guides: Relevant cookbook examples have been included or updated (if applicable). - [ ] Tested in a clean environment: Changes have been tested in a clean environment to confirm expected behavior. - [ ] Tests (optional): Tests have been added or updated to cover any new or changed functionality. --- ## Additional Notes Include any deployment notes, performance implications, security considerations, or other relevant information (e.g., screenshots or logs if applicable). Co-authored-by: ysolanky --- libs/agno/agno/agent/agent.py | 2 +- libs/agno/pyproject.toml | 1 + libs/agno/tests/unit/reader/test_text_reader.py | 11 ++++++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/libs/agno/agno/agent/agent.py b/libs/agno/agno/agent/agent.py index a81f639e5..49dd85c57 100644 --- a/libs/agno/agno/agent/agent.py +++ b/libs/agno/agno/agent/agent.py @@ -2284,7 +2284,7 @@ def deep_copy(self, *, update: Optional[Dict[str, Any]] = None) -> Agent: from dataclasses import fields # Do not copy agent_session and session_name to the new agent - excluded_fields = ["agent_session", "session_name", "memory"] + excluded_fields = ["agent_session", "session_name"] # Extract the fields to set for the new Agent fields_for_new_agent: Dict[str, Any] = {} diff --git a/libs/agno/pyproject.toml b/libs/agno/pyproject.toml index 405bd732f..b17fe871e 100644 --- a/libs/agno/pyproject.toml +++ b/libs/agno/pyproject.toml @@ -283,6 +283,7 @@ module = [ "tweepy.*", "twilio.*", "tzlocal.*", + "upstash_vector.*", "uvicorn.*", "vertexai.*", "voyageai.*", diff --git a/libs/agno/tests/unit/reader/test_text_reader.py b/libs/agno/tests/unit/reader/test_text_reader.py index 970d65eed..71e052fc6 100644 --- a/libs/agno/tests/unit/reader/test_text_reader.py +++ b/libs/agno/tests/unit/reader/test_text_reader.py @@ -1,7 +1,8 @@ -import pytest from io import BytesIO from pathlib import Path +import pytest + from agno.document.base import Document from agno.document.reader.text_reader import TextReader @@ -76,7 +77,7 @@ def test_empty_text_file(tmp_path): reader = TextReader() documents = reader.read(text_path) - + # No chunks can be extracted from an empty file assert len(documents) == 0 @@ -112,8 +113,8 @@ def test_invalid_encoding(tmp_path): # Test handling of invalid encoding text_path = tmp_path / "invalid.txt" try: - with open(text_path, 'wb') as f: - f.write(b'\xFF\xFE\x00\x00') # Invalid UTF-8 + with open(text_path, "wb") as f: + f.write(b"\xff\xfe\x00\x00") # Invalid UTF-8 reader = TextReader() documents = reader.read(text_path) @@ -129,7 +130,7 @@ def test_cp950_encoding(tmp_path): test_data = "中文測試" # Chinese test text text_path = tmp_path / "cp950.txt" try: - with open(text_path, 'w', encoding='cp950') as f: + with open(text_path, "w", encoding="cp950") as f: f.write(test_data) reader = TextReader() From 6ac44dfc639e6b7cca162a10c4cf0d80417ec2aa Mon Sep 17 00:00:00 2001 From: Anurag Date: Mon, 24 Feb 2025 21:16:07 +0530 Subject: [PATCH 5/5] =?UTF-8?q?Update=20error=20response=20format=20in=20p?= =?UTF-8?q?layground=20to=20use=20detail=20key=20instead=20=E2=80=A6=20(#2?= =?UTF-8?q?215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …of message ## Description - Get playground errors in detail. --- .../tools/{webex_tool.py => webex_tools.py} | 9 ++++---- libs/agno/agno/exceptions.py | 4 ++++ libs/agno/agno/models/openai/chat.py | 16 +++++++------- libs/agno/agno/playground/playground.py | 4 ++-- libs/agno/agno/tools/webex.py | 20 +++++++++--------- libs/agno/pyproject.toml | 3 +++ .../test_image_support_file_upload.py | 4 ++-- libs/agno/tests/unit/tools/test_webex.py | 21 +++++++++---------- 8 files changed, 44 insertions(+), 37 deletions(-) rename cookbook/tools/{webex_tool.py => webex_tools.py} (85%) diff --git a/cookbook/tools/webex_tool.py b/cookbook/tools/webex_tools.py similarity index 85% rename from cookbook/tools/webex_tool.py rename to cookbook/tools/webex_tools.py index b131da4bc..c9d96c94f 100644 --- a/cookbook/tools/webex_tool.py +++ b/cookbook/tools/webex_tools.py @@ -22,9 +22,10 @@ agent = Agent(tools=[WebexTools()], show_tool_calls=True) -#List all space in Webex +# List all space in Webex agent.print_response("List all space on our Webex", markdown=True) -#Send a message to a Space in Webex -agent.print_response("Send a funny ice-breaking message to the webex Welcome space", markdown=True) - +# Send a message to a Space in Webex +agent.print_response( + "Send a funny ice-breaking message to the webex Welcome space", markdown=True +) diff --git a/libs/agno/agno/exceptions.py b/libs/agno/agno/exceptions.py index 1eb4c7d58..f558a2e22 100644 --- a/libs/agno/agno/exceptions.py +++ b/libs/agno/agno/exceptions.py @@ -43,8 +43,12 @@ class AgnoError(Exception): def __init__(self, message: str, status_code: int = 500): super().__init__(message) + self.message = message self.status_code = status_code + def __str__(self) -> str: + return str(self.message) + class ModelProviderError(AgnoError): """Exception raised when a model provider returns an error.""" diff --git a/libs/agno/agno/models/openai/chat.py b/libs/agno/agno/models/openai/chat.py index a96eaf870..9043e83c1 100644 --- a/libs/agno/agno/models/openai/chat.py +++ b/libs/agno/agno/models/openai/chat.py @@ -297,7 +297,7 @@ def invoke(self, messages: List[Message]) -> Union[ChatCompletion, ParsedChatCom except RateLimitError as e: logger.error(f"Rate limit error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except APIConnectionError as e: logger.error(f"API connection error from OpenAI API: {e}") @@ -305,7 +305,7 @@ def invoke(self, messages: List[Message]) -> Union[ChatCompletion, ParsedChatCom except APIStatusError as e: logger.error(f"API status error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except Exception as e: logger.error(f"Error from OpenAI API: {e}") @@ -339,7 +339,7 @@ async def ainvoke(self, messages: List[Message]) -> Union[ChatCompletion, Parsed except RateLimitError as e: logger.error(f"Rate limit error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except APIConnectionError as e: logger.error(f"API connection error from OpenAI API: {e}") @@ -347,7 +347,7 @@ async def ainvoke(self, messages: List[Message]) -> Union[ChatCompletion, Parsed except APIStatusError as e: logger.error(f"API status error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except Exception as e: logger.error(f"Error from OpenAI API: {e}") @@ -374,7 +374,7 @@ def invoke_stream(self, messages: List[Message]) -> Iterator[ChatCompletionChunk except RateLimitError as e: logger.error(f"Rate limit error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except APIConnectionError as e: logger.error(f"API connection error from OpenAI API: {e}") @@ -382,7 +382,7 @@ def invoke_stream(self, messages: List[Message]) -> Iterator[ChatCompletionChunk except APIStatusError as e: logger.error(f"API status error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except Exception as e: logger.error(f"Error from OpenAI API: {e}") @@ -411,7 +411,7 @@ async def ainvoke_stream(self, messages: List[Message]) -> AsyncIterator[ChatCom except RateLimitError as e: logger.error(f"Rate limit error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except APIConnectionError as e: logger.error(f"API connection error from OpenAI API: {e}") @@ -419,7 +419,7 @@ async def ainvoke_stream(self, messages: List[Message]) -> AsyncIterator[ChatCom except APIStatusError as e: logger.error(f"API status error from OpenAI API: {e}") raise ModelProviderError( - message=e.response.text, status_code=e.response.status_code, model_name=self.name, model_id=self.id + message=e.response.json().get("error", {}).get("message", "Unknown model error"), status_code=e.response.status_code, model_name=self.name, model_id=self.id ) from e except Exception as e: logger.error(f"Error from OpenAI API: {e}") diff --git a/libs/agno/agno/playground/playground.py b/libs/agno/agno/playground/playground.py index 53620b0f8..59cd058fa 100644 --- a/libs/agno/agno/playground/playground.py +++ b/libs/agno/agno/playground/playground.py @@ -56,7 +56,7 @@ def get_app(self, use_async: bool = True, prefix: str = "/v1") -> FastAPI: async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: return JSONResponse( status_code=exc.status_code, - content={"message": str(exc.detail)}, + content={"detail": str(exc.detail)}, ) async def general_exception_handler(request: Request, call_next): @@ -65,7 +65,7 @@ async def general_exception_handler(request: Request, call_next): except Exception as e: return JSONResponse( status_code=e.status_code if hasattr(e, "status_code") else 500, - content={"message": str(e)}, + content={"detail": str(e)}, ) self.api_app.middleware("http")(general_exception_handler) diff --git a/libs/agno/agno/tools/webex.py b/libs/agno/agno/tools/webex.py index 712343c39..2a9db0ed9 100644 --- a/libs/agno/agno/tools/webex.py +++ b/libs/agno/agno/tools/webex.py @@ -11,6 +11,7 @@ except ImportError: logger.error("Webex tools require the `webexpythonsdk` package. Run `pip install webexpythonsdk` to install it.") + class WebexTools(Toolkit): def __init__(self, send_message: bool = True, list_rooms: bool = True, access_token: Optional[str] = None): super().__init__(name="webex") @@ -18,7 +19,7 @@ def __init__(self, send_message: bool = True, list_rooms: bool = True, access_to access_token = os.getenv("WEBEX_ACCESS_TOKEN") if access_token is None: raise ValueError("Webex access token is not set. Please set the WEBEX_ACCESS_TOKEN environment variable.") - + self.client = WebexAPI(access_token=access_token) if send_message: self.register(self.send_message) @@ -40,7 +41,6 @@ def send_message(self, room_id: str, text: str) -> str: except ApiError as e: logger.error(f"Error sending message: {e} in room: {room_id}") return json.dumps({"error": str(e)}) - def list_rooms(self) -> str: """ @@ -51,14 +51,14 @@ def list_rooms(self) -> str: try: response = self.client.rooms.list() rooms_list = [ - { - "id": room.id, - "title": room.title, - "type": room.type, - "isPublic": room.isPublic, - "isReadOnly": room.isReadOnly, - } - for room in response + { + "id": room.id, + "title": room.title, + "type": room.type, + "isPublic": room.isPublic, + "isReadOnly": room.isReadOnly, + } + for room in response ] return json.dumps({"rooms": rooms_list}, indent=4) diff --git a/libs/agno/pyproject.toml b/libs/agno/pyproject.toml index b17fe871e..d018692c2 100644 --- a/libs/agno/pyproject.toml +++ b/libs/agno/pyproject.toml @@ -64,6 +64,7 @@ googlemaps = ["googlemaps"] todoist = ["todoist-api-python"] elevenlabs = ["elevenlabs"] fal = ["fal_client"] +webex = ["webexpythonsdk"] # Dependencies for Storage sql = ["sqlalchemy"] @@ -120,6 +121,7 @@ tools = [ "agno[todoist]", "agno[elevenlabs]", "agno[fal]", + "agno[webex]", ] # All storage @@ -288,6 +290,7 @@ module = [ "vertexai.*", "voyageai.*", "weaviate.*", + "webexpythonsdk.*", "wikipedia.*", "yfinance.*", "youtube_transcript_api.*", diff --git a/libs/agno/tests/unit/playground/test_image_support_file_upload.py b/libs/agno/tests/unit/playground/test_image_support_file_upload.py index de7e639e5..9a4df427c 100644 --- a/libs/agno/tests/unit/playground/test_image_support_file_upload.py +++ b/libs/agno/tests/unit/playground/test_image_support_file_upload.py @@ -217,7 +217,7 @@ def test_pdf_upload_without_knowledge(test_app, mock_pdf_file): files = [mock_pdf_file] response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) assert response.status_code == 404 - assert "KnowledgeBase not found" in response.json()["message"] + assert "KnowledgeBase not found" in response.json()["detail"] def test_mixed_file_upload(test_app, mock_agent_with_knowledge, mock_image_file, mock_pdf_file, mock_pdf_reader): @@ -254,7 +254,7 @@ def test_unsupported_file_type(test_app, mock_agent_with_knowledge): files = [("files", ("test.xyz", io.BytesIO(b"content"), "application/xyz"))] response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) assert response.status_code == 400 - assert "Unsupported file type" in response.json()["message"] + assert "Unsupported file type" in response.json()["detail"] def test_empty_file_upload(test_app): diff --git a/libs/agno/tests/unit/tools/test_webex.py b/libs/agno/tests/unit/tools/test_webex.py index 89c77ea56..8aa5f19fb 100644 --- a/libs/agno/tests/unit/tools/test_webex.py +++ b/libs/agno/tests/unit/tools/test_webex.py @@ -4,9 +4,9 @@ from unittest.mock import Mock, patch import pytest +from requests import Response from webexpythonsdk import WebexAPI from webexpythonsdk.exceptions import RateLimitError -from requests import Response from agno.tools.webex import WebexTools @@ -19,11 +19,11 @@ def mock_webex_api(): mock_client = Mock(spec=WebexAPI) mock_messages = Mock() mock_rooms = Mock() - + # Set up the nested structure mock_client.messages = mock_messages mock_client.rooms = mock_rooms - + mock_api.return_value = mock_client return mock_client @@ -31,7 +31,7 @@ def mock_webex_api(): @pytest.fixture def webex_tools(mock_webex_api): """Create WebexTools instance with mocked API.""" - with patch.dict("os.environ", {"WEBEX_TEAMS_ACCESS_TOKEN": "test_token"}): + with patch.dict("os.environ", {"WEBEX_ACCESS_TOKEN": "test_token"}): tools = WebexTools() tools.client = mock_webex_api return tools @@ -40,15 +40,15 @@ def webex_tools(mock_webex_api): def test_init_with_api_token(): """Test initialization with provided API token.""" with patch("agno.tools.webex.WebexAPI") as mock_api: - tools = WebexTools(access_token="test_token") + WebexTools(access_token="test_token") mock_api.assert_called_once_with(access_token="test_token") def test_init_with_env_var(): """Test initialization with environment variable.""" with patch("agno.tools.webex.WebexAPI") as mock_api: - with patch.dict("os.environ", {"WEBEX_TEAMS_ACCESS_TOKEN": "env_token"}): - tools = WebexTools() + with patch.dict("os.environ", {"WEBEX_ACCESS_TOKEN": "env_token"}): + WebexTools() mock_api.assert_called_once_with(access_token="env_token") @@ -61,7 +61,7 @@ def test_init_without_token(): def test_init_with_selective_tools(): """Test initialization with only selected tools.""" - with patch.dict("os.environ", {"WEBEX_TEAMS_ACCESS_TOKEN": "test_token"}): + with patch.dict("os.environ", {"WEBEX_ACCESS_TOKEN": "test_token"}): tools = WebexTools( send_message=True, list_rooms=False, @@ -78,9 +78,9 @@ def test_send_message_success(webex_tools, mock_webex_api): "id": "msg123", "roomId": "room123", "text": "Test message", - "created": "2024-01-01T10:00:00.000Z" + "created": "2024-01-01T10:00:00.000Z", } - + mock_webex_api.messages.create.return_value = mock_response result = webex_tools.send_message("room123", "Test message") @@ -158,4 +158,3 @@ def test_send_message_rate_limit(webex_tools, mock_webex_api): assert "error" in result_data assert "Too Many Requests" in str(result_data["error"]) -