diff --git a/README.md b/README.md index c399a0e835..a18bb12190 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Agno is designed with three core principles: Here's why you should build Agents with Agno: -- **Lightning Fast**: Agent creation is 6000x faster than LangGraph (see [performance](#performance)). +- **Lightning Fast**: Agent creation is ~10,000x faster than LangGraph (see [performance](#performance)). - **Model Agnostic**: Use any model, any provider, no lock-in. - **Multi Modal**: Native support for text, image, audio and video. - **Multi Agent**: Delegate tasks across a team of specialized agents. @@ -222,12 +222,12 @@ python agent_team.py Agno is designed for high performance agentic systems: -- Agent instantiation: <5μs on average (5000x faster than LangGraph). -- Memory footprint: <0.01Mib on average (50x less memory than LangGraph). +- Agent instantiation: <5μs on average (~10,000x faster than LangGraph). +- Memory footprint: <0.01Mib on average (~50x less memory than LangGraph). > Tested on an Apple M4 Mackbook Pro. -While an Agent's performance is bottlenecked by inference, we must do everything possible to minimize execution time, reduce memory usage, and parallelize tool calls. These numbers are may seem minimal, but they add up even at medium scale. +While an Agent's performance is bottlenecked by inference, we must do everything possible to minimize execution time, reduce memory usage, and parallelize tool calls. These numbers are may seem trivial, but they add up even at medium scale. ### Instantiation time diff --git a/cookbook/models/openai/storage.py b/cookbook/models/openai/storage.py index f40f85e1be..750bb9b90f 100644 --- a/cookbook/models/openai/storage.py +++ b/cookbook/models/openai/storage.py @@ -13,5 +13,6 @@ tools=[DuckDuckGoTools()], add_history_to_messages=True, ) -agent.print_response("How many people live in Canada?") -agent.print_response("What is their national anthem called?") +agent.cli_app() +# agent.print_response("How many people live in Canada?") +# agent.print_response("What is their national anthem called?") diff --git a/cookbook/models/perplexity/basic_stream.py b/cookbook/models/perplexity/basic_stream.py index 325e810c5a..0d6c5b2c8e 100644 --- a/cookbook/models/perplexity/basic_stream.py +++ b/cookbook/models/perplexity/basic_stream.py @@ -1,6 +1,7 @@ from typing import Iterator # noqa from agno.agent import Agent, RunResponse # noqa from agno.models.perplexity import Perplexity + agent = Agent(model=Perplexity(id="sonar"), markdown=True) # Get the response in a variable diff --git a/libs/agno/agno/agent/agent.py b/libs/agno/agno/agent/agent.py index b734ab2f20..47c2c96480 100644 --- a/libs/agno/agno/agent/agent.py +++ b/libs/agno/agno/agent/agent.py @@ -2124,19 +2124,29 @@ def get_run_messages( # 3. Add history to run_messages if self.add_history_to_messages: + from copy import deepcopy + history: List[Message] = self.memory.get_messages_from_last_n_runs( last_n=self.num_history_responses, skip_role=self.get_system_message_role() ) if len(history) > 0: - logger.debug(f"Adding {len(history)} messages from history") + # Create a deep copy of the history messages to avoid modifying the original messages + history_copy = [deepcopy(msg) for msg in history] + + # Tag each message as coming from history + for _msg in history_copy: + _msg.from_history = True + + logger.debug(f"Adding {len(history_copy)} messages from history") + if self.run_response.extra_data is None: - self.run_response.extra_data = RunResponseExtraData(history=history) + self.run_response.extra_data = RunResponseExtraData(history=history_copy) else: if self.run_response.extra_data.history is None: - self.run_response.extra_data.history = history + self.run_response.extra_data.history = history_copy else: - self.run_response.extra_data.history.extend(history) - run_messages.messages += history + self.run_response.extra_data.history.extend(history_copy) + run_messages.messages += history_copy # 4.Add user message to run_messages user_message: Optional[Message] = None @@ -2501,7 +2511,7 @@ def update_run_response_with_reasoning( def aggregate_metrics_from_messages(self, messages: List[Message]) -> Dict[str, Any]: aggregated_metrics: Dict[str, Any] = defaultdict(list) - # Use a defaultdict(list) to collect all values for each assisntant message + # Use a defaultdict(list) to collect all values for each assistant message for m in messages: if m.role == "assistant" and m.metrics is not None: for k, v in m.metrics.items(): diff --git a/libs/agno/agno/memory/agent.py b/libs/agno/agno/memory/agent.py index 235a953c75..35fd9fecaf 100644 --- a/libs/agno/agno/memory/agent.py +++ b/libs/agno/agno/memory/agent.py @@ -134,39 +134,37 @@ def get_messages(self) -> List[Dict[str, Any]]: def get_messages_from_last_n_runs( self, last_n: Optional[int] = None, skip_role: Optional[str] = None ) -> List[Message]: - """Returns the messages from the last_n runs + """Returns the messages from the last_n runs, excluding previously tagged history messages. Args: last_n: The number of runs to return from the end of the conversation. skip_role: Skip messages with this role. Returns: - A list of Messages in the last_n runs. + A list of Messages from the specified runs, excluding history messages. """ - if last_n is None: - logger.debug("Getting messages from all previous runs") - messages_from_all_history = [] - for prev_run in self.runs: - if prev_run.response and prev_run.response.messages: - if skip_role: - prev_run_messages = [m for m in prev_run.response.messages if m.role != skip_role] - else: - prev_run_messages = prev_run.response.messages - messages_from_all_history.extend(prev_run_messages) - logger.debug(f"Messages from previous runs: {len(messages_from_all_history)}") - return messages_from_all_history - - logger.debug(f"Getting messages from last {last_n} runs") - messages_from_last_n_history = [] - for prev_run in self.runs[-last_n:]: - if prev_run.response and prev_run.response.messages: - if skip_role: - prev_run_messages = [m for m in prev_run.response.messages if m.role != skip_role] - else: - prev_run_messages = prev_run.response.messages - messages_from_last_n_history.extend(prev_run_messages) - logger.debug(f"Messages from last {last_n} runs: {len(messages_from_last_n_history)}") - return messages_from_last_n_history + if not self.runs: + return [] + + runs_to_process = self.runs if last_n is None else self.runs[-last_n:] + messages_from_history = [] + + for run in runs_to_process: + if not (run.response and run.response.messages): + continue + + for message in run.response.messages: + # Skip messages with specified role + if skip_role and message.role == skip_role: + continue + # Skip messages that were tagged as history in previous runs + if hasattr(message, "from_history") and message.from_history: + continue + + messages_from_history.append(message) + + logger.debug(f"Getting messages from previous runs: {len(messages_from_history)}") + return messages_from_history def get_message_pairs( self, user_role: str = "user", assistant_role: Optional[List[str]] = None diff --git a/libs/agno/agno/models/message.py b/libs/agno/agno/models/message.py index f256fa464e..5a9ff183ef 100644 --- a/libs/agno/agno/models/message.py +++ b/libs/agno/agno/models/message.py @@ -56,6 +56,8 @@ class Message(BaseModel): stop_after_tool_call: bool = False # When True, the message will be added to the agent's memory. add_to_agent_memory: bool = True + # This flag is enabled when a message is fetched from the agent's memory. + from_history: bool = False # Metrics for the message. metrics: Dict[str, Any] = Field(default_factory=dict) # The references added to the message for RAG diff --git a/libs/agno/agno/models/perplexity/perplexity.py b/libs/agno/agno/models/perplexity/perplexity.py index b2dd6b6808..7163f94b3c 100644 --- a/libs/agno/agno/models/perplexity/perplexity.py +++ b/libs/agno/agno/models/perplexity/perplexity.py @@ -19,7 +19,6 @@ class Perplexity(OpenAILike): max_tokens (int): The maximum number of tokens. Defaults to 1024. """ - id: str = "sonar" name: str = "Perplexity" provider: str = "Perplexity: " + id diff --git a/libs/agno/agno/playground/async_router.py b/libs/agno/agno/playground/async_router.py index 1bb0459ef0..ff9f2f4be2 100644 --- a/libs/agno/agno/playground/async_router.py +++ b/libs/agno/agno/playground/async_router.py @@ -125,17 +125,12 @@ async def create_agent_run( session_id: Optional[str] = Form(None), user_id: Optional[str] = Form(None), files: Optional[List[UploadFile]] = File(None), - image: Optional[UploadFile] = File(None), ): logger.debug(f"AgentRunRequest: {message} {session_id} {user_id} {agent_id}") agent = get_agent_by_id(agent_id, agents) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") - if files: - if agent.knowledge is None: - raise HTTPException(status_code=404, detail="KnowledgeBase not found") - if session_id is not None: logger.debug(f"Continuing session: {session_id}") else: @@ -143,6 +138,7 @@ async def create_agent_run( # Create a new instance of this agent new_agent_instance = agent.deep_copy(update={"session_id": session_id}) + new_agent_instance.session_name = None if user_id is not None: new_agent_instance.user_id = user_id @@ -151,72 +147,82 @@ async def create_agent_run( else: new_agent_instance.monitoring = False - base64_image: Optional[Image] = None - if image: - base64_image = await process_image(image) + base64_images: List[Image] = [] if files: for file in files: - if file.content_type == "application/pdf": - from agno.document.reader.pdf_reader import PDFReader - - contents = await file.read() - pdf_file = BytesIO(contents) - pdf_file.name = file.filename - file_content = PDFReader().read(pdf_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - elif file.content_type == "text/csv": - from agno.document.reader.csv_reader import CSVReader - - contents = await file.read() - csv_file = BytesIO(contents) - csv_file.name = file.filename - file_content = CSVReader().read(csv_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - elif file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - from agno.document.reader.docx_reader import DocxReader - - contents = await file.read() - docx_file = BytesIO(contents) - docx_file.name = file.filename - file_content = DocxReader().read(docx_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - elif file.content_type == "text/plain": - from agno.document.reader.text_reader import TextReader - - contents = await file.read() - text_file = BytesIO(contents) - text_file.name = file.filename - file_content = TextReader().read(text_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - - elif file.content_type == "application/json": - from agno.document.reader.json_reader import JSONReader - - contents = await file.read() - json_file = BytesIO(contents) - json_file.name = file.filename - file_content = JSONReader().read(json_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) + if file.content_type in ["image/png", "image/jpeg", "image/jpg", "image/webp"]: + try: + base64_image = await process_image(file) + base64_images.append(base64_image) + except Exception as e: + logger.error(f"Error processing image {file.filename}: {e}") + continue else: - raise HTTPException(status_code=400, detail="Unsupported file type") + # Check for knowledge base before processing documents + if new_agent_instance.knowledge is None: + raise HTTPException(status_code=404, detail="KnowledgeBase not found") + + if file.content_type == "application/pdf": + from agno.document.reader.pdf_reader import PDFReader + + contents = await file.read() + pdf_file = BytesIO(contents) + pdf_file.name = file.filename + file_content = PDFReader().read(pdf_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "text/csv": + from agno.document.reader.csv_reader import CSVReader + + contents = await file.read() + csv_file = BytesIO(contents) + csv_file.name = file.filename + file_content = CSVReader().read(csv_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + from agno.document.reader.docx_reader import DocxReader + + contents = await file.read() + docx_file = BytesIO(contents) + docx_file.name = file.filename + file_content = DocxReader().read(docx_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "text/plain": + from agno.document.reader.text_reader import TextReader + + contents = await file.read() + text_file = BytesIO(contents) + text_file.name = file.filename + file_content = TextReader().read(text_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + + elif file.content_type == "application/json": + from agno.document.reader.json_reader import JSONReader + + contents = await file.read() + json_file = BytesIO(contents) + json_file.name = file.filename + file_content = JSONReader().read(json_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + else: + raise HTTPException(status_code=400, detail="Unsupported file type") if stream: return StreamingResponse( - chat_response_streamer(new_agent_instance, message, images=[base64_image] if base64_image else None), + chat_response_streamer(new_agent_instance, message, images=base64_images if base64_images else None), media_type="text/event-stream", ) else: run_response = cast( RunResponse, await new_agent_instance.arun( - message, - images=[base64_image] if base64_image else None, + message=message, + images=base64_images if base64_images else None, stream=False, ), ) diff --git a/libs/agno/agno/playground/sync_router.py b/libs/agno/agno/playground/sync_router.py index b1388f8919..ca9322b7d7 100644 --- a/libs/agno/agno/playground/sync_router.py +++ b/libs/agno/agno/playground/sync_router.py @@ -95,7 +95,8 @@ def chat_response_streamer(agent: Agent, message: str, images: Optional[List[Ima def process_image(file: UploadFile) -> Image: content = file.file.read() - + if not content: + raise HTTPException(status_code=400, detail="Empty file") return Image(content=content) @playground_router.post("/agents/{agent_id}/runs") @@ -107,17 +108,12 @@ def create_agent_run( session_id: Optional[str] = Form(None), user_id: Optional[str] = Form(None), files: Optional[List[UploadFile]] = File(None), - image: Optional[UploadFile] = File(None), ): logger.debug(f"AgentRunRequest: {message} {agent_id} {stream} {monitor} {session_id} {user_id} {files}") agent = get_agent_by_id(agent_id, agents) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") - if files: - if agent.knowledge is None: - raise HTTPException(status_code=404, detail="KnowledgeBase not found") - if session_id is not None: logger.debug(f"Continuing session: {session_id}") else: @@ -135,73 +131,81 @@ def create_agent_run( else: new_agent_instance.monitoring = False - base64_image: Optional[Image] = None - if image: - base64_image = process_image(image) + base64_images: List[Image] = [] if files: for file in files: - if file.content_type == "application/pdf": - from agno.document.reader.pdf_reader import PDFReader - - contents = file.file.read() - pdf_file = BytesIO(contents) - pdf_file.name = file.filename - file_content = PDFReader().read(pdf_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - elif file.content_type == "text/csv": - from agno.document.reader.csv_reader import CSVReader - - contents = file.file.read() - csv_file = BytesIO(contents) - csv_file.name = file.filename - file_content = CSVReader().read(csv_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - elif file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - from agno.document.reader.docx_reader import DocxReader - - contents = file.file.read() - docx_file = BytesIO(contents) - docx_file.name = file.filename - file_content = DocxReader().read(docx_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - elif file.content_type == "text/plain": - from agno.document.reader.text_reader import TextReader - - contents = file.file.read() - text_file = BytesIO(contents) - text_file.name = file.filename - file_content = TextReader().read(text_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - - elif file.content_type == "application/json": - from agno.document.reader.json_reader import JSONReader - - content = file.read() - json_file = BytesIO(content) - json_file.name = file.filename - file_content = JSONReader().read(json_file) - if agent.knowledge is not None: - agent.knowledge.load_documents(file_content) - + if file.content_type in ["image/png", "image/jpeg", "image/jpg", "image/webp"]: + try: + base64_image = process_image(file) + base64_images.append(base64_image) + except Exception as e: + logger.error(f"Error processing image {file.filename}: {e}") + continue else: - raise HTTPException(status_code=400, detail="Unsupported file type") + # Check for knowledge base before processing documents + if new_agent_instance.knowledge is None: + raise HTTPException(status_code=404, detail="KnowledgeBase not found") + + if file.content_type == "application/pdf": + from agno.document.reader.pdf_reader import PDFReader + + contents = file.file.read() + pdf_file = BytesIO(contents) + pdf_file.name = file.filename + file_content = PDFReader().read(pdf_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "text/csv": + from agno.document.reader.csv_reader import CSVReader + + contents = file.file.read() + csv_file = BytesIO(contents) + csv_file.name = file.filename + file_content = CSVReader().read(csv_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + from agno.document.reader.docx_reader import DocxReader + + contents = file.file.read() + docx_file = BytesIO(contents) + docx_file.name = file.filename + file_content = DocxReader().read(docx_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "text/plain": + from agno.document.reader.text_reader import TextReader + + contents = file.file.read() + text_file = BytesIO(contents) + text_file.name = file.filename + file_content = TextReader().read(text_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + elif file.content_type == "application/json": + from agno.document.reader.json_reader import JSONReader + + contents = file.file.read() + json_file = BytesIO(contents) + json_file.name = file.filename + file_content = JSONReader().read(json_file) + if new_agent_instance.knowledge is not None: + new_agent_instance.knowledge.load_documents(file_content) + else: + raise HTTPException(status_code=400, detail="Unsupported file type") if stream: return StreamingResponse( - chat_response_streamer(new_agent_instance, message, images=[base64_image] if base64_image else None), + chat_response_streamer(new_agent_instance, message, images=base64_images if base64_images else None), media_type="text/event-stream", ) else: run_response = cast( RunResponse, new_agent_instance.run( - message, - images=[base64_image] if base64_image else None, + message=message, + images=base64_images if base64_images else None, stream=False, ), ) diff --git a/libs/agno/agno/tools/local_file_system.py b/libs/agno/agno/tools/local_file_system.py index 28c06f43e7..875dade191 100644 --- a/libs/agno/agno/tools/local_file_system.py +++ b/libs/agno/agno/tools/local_file_system.py @@ -53,6 +53,8 @@ def write_file( filename, file_ext = os.path.splitext(filename) extension = extension or file_ext.lstrip(".") + logger.debug(f"Writing file to local system: {filename}") + extension = (extension or self.default_extension).lstrip(".") # Create directory if it doesn't exist diff --git a/libs/agno/agno/tools/moviepy_video.py b/libs/agno/agno/tools/moviepy_video.py index 46e1576977..73eeebbcc9 100644 --- a/libs/agno/agno/tools/moviepy_video.py +++ b/libs/agno/agno/tools/moviepy_video.py @@ -227,6 +227,7 @@ def extract_audio(self, video_path: str, output_path: str) -> str: str: Path to the extracted audio file """ try: + logger.debug(f"Extracting audio from {video_path}") video = VideoFileClip(video_path) video.audio.write_audiofile(output_path) logger.info(f"Audio extracted to {output_path}") @@ -244,6 +245,7 @@ def create_srt(self, transcription: str, output_path: str) -> str: str: Path to the created SRT file, or error message if failed """ try: + logger.debug(f"Creating SRT file at {output_path}") # Since we're getting SRT format from Whisper API now, # we can just write it directly to file with open(output_path, "w", encoding="utf-8") as f: diff --git a/libs/agno/agno/tools/newspaper.py b/libs/agno/agno/tools/newspaper.py index 6838282a7d..e42c682ddc 100644 --- a/libs/agno/agno/tools/newspaper.py +++ b/libs/agno/agno/tools/newspaper.py @@ -1,4 +1,5 @@ from agno.tools import Toolkit +from agno.utils.log import logger try: from newspaper import Article @@ -27,6 +28,7 @@ def get_article_text(self, url: str) -> str: """ try: + logger.debug(f"Reading news: {url}") article = Article(url) article.download() article.parse() diff --git a/libs/agno/agno/tools/newspaper4k.py b/libs/agno/agno/tools/newspaper4k.py index 86ec26315f..97ea14972c 100644 --- a/libs/agno/agno/tools/newspaper4k.py +++ b/libs/agno/agno/tools/newspaper4k.py @@ -68,6 +68,7 @@ def read_article(self, url: str) -> str: """ try: + logger.debug(f"Reading news: {url}") article_data = self.get_article_data(url) if not article_data: return f"Error reading article from {url}: No data found." diff --git a/libs/agno/agno/tools/openbb.py b/libs/agno/agno/tools/openbb.py index 009abaa1cd..7fdd213609 100644 --- a/libs/agno/agno/tools/openbb.py +++ b/libs/agno/agno/tools/openbb.py @@ -56,6 +56,7 @@ def get_stock_price(self, symbol: str) -> str: str: The current stock prices or error message. """ try: + logger.debug(f"Fetching current price for {symbol}") result = self.obb.equity.price.quote(symbol=symbol, provider=self.provider).to_polars() # type: ignore clean_results = [] for row in result.to_dicts(): @@ -109,6 +110,7 @@ def get_price_targets(self, symbol: str) -> str: str: JSON containing consensus price target and recommendations. """ try: + logger.debug(f"Fetching price targets for {symbol}") result = self.obb.equity.estimates.consensus(symbol=symbol, provider=self.provider).to_polars() # type: ignore return json.dumps(result.to_dicts(), indent=2, default=str) except Exception as e: @@ -126,6 +128,7 @@ def get_company_news(self, symbol: str, num_stories: int = 10) -> str: str: JSON containing company news and press releases. """ try: + logger.debug(f"Fetching news for {symbol}") result = self.obb.news.company(symbol=symbol, provider=self.provider, limit=num_stories).to_polars() # type: ignore clean_results = [] if len(result) > 0: @@ -147,6 +150,7 @@ def get_company_profile(self, symbol: str) -> str: str: JSON containing company profile and overview. """ try: + logger.debug(f"Fetching company profile for {symbol}") result = self.obb.equity.profile(symbol=symbol, provider=self.provider).to_polars() # type: ignore return json.dumps(result.to_dicts(), indent=2, default=str) except Exception as e: diff --git a/libs/agno/agno/tools/pubmed.py b/libs/agno/agno/tools/pubmed.py index a3b9a065fc..31d4b8884b 100644 --- a/libs/agno/agno/tools/pubmed.py +++ b/libs/agno/agno/tools/pubmed.py @@ -5,6 +5,7 @@ import httpx from agno.tools import Toolkit +from agno.utils.log import logger class PubmedTools(Toolkit): @@ -64,6 +65,7 @@ def search_pubmed(self, query: str, max_results: int = 10) -> str: str: A JSON string containing the search results. """ try: + logger.debug(f"Searching PubMed for: {query}") ids = self.fetch_pubmed_ids(query, self.max_results or max_results, self.email) details_root = self.fetch_details(ids) articles = self.parse_details(details_root) diff --git a/libs/agno/agno/tools/reddit.py b/libs/agno/agno/tools/reddit.py index ff76943846..32fc7041f8 100644 --- a/libs/agno/agno/tools/reddit.py +++ b/libs/agno/agno/tools/reddit.py @@ -137,6 +137,7 @@ def get_top_posts(self, subreddit: str, time_filter: str = "week", limit: int = return "Please provide Reddit API credentials" try: + logger.debug(f"Getting top posts from r/{subreddit}") posts = self.reddit.subreddit(subreddit).top(time_filter=time_filter, limit=limit) top_posts: List[Dict[str, Union[str, int, float]]] = [ { @@ -191,6 +192,7 @@ def get_trending_subreddits(self) -> str: return "Please provide Reddit API credentials" try: + logger.debug("Getting trending subreddits") popular_subreddits = self.reddit.subreddits.popular(limit=5) trending: List[str] = [subreddit.display_name for subreddit in popular_subreddits] return json.dumps({"trending_subreddits": trending}) @@ -209,6 +211,7 @@ def get_subreddit_stats(self, subreddit: str) -> str: return "Please provide Reddit API credentials" try: + logger.debug(f"Getting stats for r/{subreddit}") sub = self.reddit.subreddit(subreddit) stats: Dict[str, Union[str, int, bool, float]] = { "display_name": sub.display_name, diff --git a/libs/agno/agno/tools/sql.py b/libs/agno/agno/tools/sql.py index 24c2f22068..f4012057d7 100644 --- a/libs/agno/agno/tools/sql.py +++ b/libs/agno/agno/tools/sql.py @@ -69,6 +69,7 @@ def list_tables(self) -> str: return json.dumps(self.tables) try: + logger.debug("listing tables in the database") table_names = inspect(self.db_engine).get_table_names() logger.debug(f"table_names: {table_names}") return json.dumps(table_names) @@ -87,6 +88,7 @@ def describe_table(self, table_name: str) -> str: """ try: + logger.debug(f"Describing table: {table_name}") table_names = inspect(self.db_engine) table_schema = table_names.get_columns(table_name) return json.dumps([str(column) for column in table_schema]) diff --git a/libs/agno/agno/tools/telegram.py b/libs/agno/agno/tools/telegram.py index bf7e5b45c1..77f65cf2a5 100644 --- a/libs/agno/agno/tools/telegram.py +++ b/libs/agno/agno/tools/telegram.py @@ -30,6 +30,7 @@ def send_message(self, message: str) -> str: :param message: The message to send. :return: The response from the API. """ + logger.debug(f"Sending telegram message: {message}") response = self._call_post_method("sendMessage", json={"chat_id": self.chat_id, "text": message}) try: response.raise_for_status() diff --git a/libs/agno/agno/tools/trello.py b/libs/agno/agno/tools/trello.py index 439953da2e..c7f9b4ad7c 100644 --- a/libs/agno/agno/tools/trello.py +++ b/libs/agno/agno/tools/trello.py @@ -106,6 +106,8 @@ def get_board_lists(self, board_id: str) -> str: if not self.client: return "Trello client not initialized" + logger.debug(f"Getting lists for board {board_id}") + board = self.client.get_board(board_id) lists = board.list_lists() @@ -131,6 +133,8 @@ def move_card(self, card_id: str, list_id: str) -> str: if not self.client: return "Trello client not initialized" + logger.debug(f"Moving card {card_id} to list {list_id}") + card = self.client.get_card(card_id) card.change_list(list_id) @@ -153,6 +157,8 @@ def get_cards(self, list_id: str) -> str: if not self.client: return "Trello client not initialized" + logger.debug(f"Getting cards for list {list_id}") + trello_list = self.client.get_list(list_id) cards = trello_list.list_cards() @@ -250,6 +256,8 @@ def list_boards(self, board_filter: str = "all") -> str: if not self.client: return "Trello client not initialized" + logger.debug(f"Listing boards with filter: {board_filter}") + boards = self.client.list_boards(board_filter=board_filter) boards_list = [] diff --git a/libs/agno/agno/tools/yfinance.py b/libs/agno/agno/tools/yfinance.py index c196e34518..cfd1bce9f9 100644 --- a/libs/agno/agno/tools/yfinance.py +++ b/libs/agno/agno/tools/yfinance.py @@ -1,6 +1,7 @@ import json from agno.tools import Toolkit +from agno.utils.log import logger try: import yfinance as yf @@ -54,6 +55,7 @@ def get_current_stock_price(self, symbol: str) -> str: str: The current stock price or error message. """ try: + logger.debug(f"Fetching current price for {symbol}") stock = yf.Ticker(symbol) # Use "regularMarketPrice" for regular market hours, or "currentPrice" for pre/post market current_price = stock.info.get("regularMarketPrice", stock.info.get("currentPrice")) @@ -75,6 +77,8 @@ def get_company_info(self, symbol: str) -> str: if company_info_full is None: return f"Could not fetch company info for {symbol}" + logger.debug(f"Fetching company info for {symbol}") + company_info_cleaned = { "Name": company_info_full.get("shortName"), "Symbol": company_info_full.get("symbol"), @@ -125,6 +129,7 @@ def get_historical_stock_prices(self, symbol: str, period: str = "1mo", interval str: The current stock price or error message. """ try: + logger.debug(f"Fetching historical prices for {symbol}") stock = yf.Ticker(symbol) historical_price = stock.history(period=period, interval=interval) return historical_price.to_json(orient="index") @@ -154,6 +159,7 @@ def get_stock_fundamentals(self, symbol: str) -> str: - '52_week_low': The 52-week low price of the stock. """ try: + logger.debug(f"Fetching fundamentals for {symbol}") stock = yf.Ticker(symbol) info = stock.info fundamentals = { @@ -184,6 +190,7 @@ def get_income_statements(self, symbol: str) -> str: dict: JSON containing income statements or an empty dictionary. """ try: + logger.debug(f"Fetching income statements for {symbol}") stock = yf.Ticker(symbol) financials = stock.financials return financials.to_json(orient="index") @@ -200,6 +207,7 @@ def get_key_financial_ratios(self, symbol: str) -> str: dict: JSON containing key financial ratios. """ try: + logger.debug(f"Fetching key financial ratios for {symbol}") stock = yf.Ticker(symbol) key_ratios = stock.info return json.dumps(key_ratios, indent=2) @@ -216,6 +224,7 @@ def get_analyst_recommendations(self, symbol: str) -> str: str: JSON containing analyst recommendations. """ try: + logger.debug(f"Fetching analyst recommendations for {symbol}") stock = yf.Ticker(symbol) recommendations = stock.recommendations return recommendations.to_json(orient="index") @@ -233,6 +242,7 @@ def get_company_news(self, symbol: str, num_stories: int = 3) -> str: str: JSON containing company news and press releases. """ try: + logger.debug(f"Fetching company news for {symbol}") news = yf.Ticker(symbol).news return json.dumps(news[:num_stories], indent=2) except Exception as e: @@ -250,6 +260,7 @@ def get_technical_indicators(self, symbol: str, period: str = "3mo") -> str: str: JSON containing technical indicators. """ try: + logger.debug(f"Fetching technical indicators for {symbol}") indicators = yf.Ticker(symbol).history(period=period) return indicators.to_json(orient="index") except Exception as e: diff --git a/libs/agno/agno/tools/youtube.py b/libs/agno/agno/tools/youtube.py index 7a78ebba87..7c04a0f924 100644 --- a/libs/agno/agno/tools/youtube.py +++ b/libs/agno/agno/tools/youtube.py @@ -4,6 +4,7 @@ from urllib.request import urlopen from agno.tools import Toolkit +from agno.utils.log import logger try: from youtube_transcript_api import YouTubeTranscriptApi @@ -70,6 +71,8 @@ def get_youtube_video_data(self, url: str) -> str: if not url: return "No URL provided" + logger.debug(f"Getting video data for youtube video: {url}") + try: video_id = self.get_youtube_video_id(url) except Exception: @@ -112,6 +115,8 @@ def get_youtube_video_captions(self, url: str) -> str: if not url: return "No URL provided" + logger.debug(f"Getting captions for youtube video: {url}") + try: video_id = self.get_youtube_video_id(url) except Exception: @@ -144,6 +149,8 @@ def get_video_timestamps(self, url: str) -> str: if not url: return "No URL provided" + logger.debug(f"Getting timestamps for youtube video: {url}") + try: video_id = self.get_youtube_video_id(url) except Exception: diff --git a/libs/agno/agno/tools/zendesk.py b/libs/agno/agno/tools/zendesk.py index 153c01928f..b9ba78a567 100644 --- a/libs/agno/agno/tools/zendesk.py +++ b/libs/agno/agno/tools/zendesk.py @@ -60,6 +60,8 @@ def search_zendesk(self, search_string: str) -> str: if not self.username or not self.password or not self.company_name: return "Username, password, or company name not provided." + logger.debug(f"Searching Zendesk for: {search_string}") + auth = (self.username, self.password) url = f"https://{self.company_name}.zendesk.com/api/v2/help_center/articles/search.json?query={search_string}" try: diff --git a/libs/agno/tests/unit/playground/__init__.py b/libs/agno/tests/unit/playground/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..9a4df427c7 --- /dev/null +++ b/libs/agno/tests/unit/playground/test_image_support_file_upload.py @@ -0,0 +1,301 @@ +""" +Unit tests for playground file upload functionality. +""" + +import io +from unittest.mock import Mock, patch + +import pytest +from fastapi.testclient import TestClient + +from agno.agent import Agent +from agno.media import Image +from agno.models.openai import OpenAIChat +from agno.playground import Playground + +# --- Fixtures --- + + +@pytest.fixture +def mock_pdf_reader(): + """Mock the PDFReader to avoid actual PDF parsing.""" + with patch("agno.document.reader.pdf_reader.PDFReader") as mock: + # Configure the mock to return some dummy text content + mock.return_value.read.return_value = ["This is mock PDF content"] + yield mock + + +@pytest.fixture +def mock_agent(): + """Creates a mock agent with knowledge base disabled.""" + agent = Agent( + name="Test Agent", + agent_id="test-agent", + model=OpenAIChat(id="gpt-4"), + ) + # Create mock run method + mock_run = Mock(return_value={"status": "ok", "response": "Mocked response"}) + agent.run = mock_run + + # Create a copy of the agent that will be returned by deep_copy + copied_agent = Agent( + name="Test Agent", + agent_id="test-agent", + model=OpenAIChat(id="gpt-4"), + ) + copied_agent.run = mock_run # Use the same mock for the copy + + # Mock deep_copy to return our prepared copy + agent.deep_copy = Mock(return_value=copied_agent) + + return agent + + +@pytest.fixture +def mock_agent_with_knowledge(mock_agent): + """Creates a mock agent with knowledge base enabled.""" + mock_agent.knowledge = Mock() + mock_agent.knowledge.load_documents = Mock() + + # Ensure the deep_copied agent also has knowledge + copied_agent = mock_agent.deep_copy() + copied_agent.knowledge = Mock() + copied_agent.knowledge.load_documents = Mock() + mock_agent.deep_copy.return_value = copied_agent + + return mock_agent + + +@pytest.fixture +def test_app(mock_agent): + """Creates a TestClient with our playground router.""" + app = Playground(agents=[mock_agent]).get_app(use_async=False) + return TestClient(app) + + +@pytest.fixture +def mock_image_file(): + """Creates a mock image file.""" + content = b"fake image content" + file_obj = io.BytesIO(content) + return ("files", ("test.jpg", file_obj, "image/jpeg")) + + +@pytest.fixture +def mock_pdf_file(): + """Creates a mock PDF file.""" + content = b"fake pdf content" + file_obj = io.BytesIO(content) + return ("files", ("test.pdf", file_obj, "application/pdf")) + + +@pytest.fixture +def mock_csv_file(): + """Creates a mock CSV file.""" + content = b"col1,col2\nval1,val2" + file_obj = io.BytesIO(content) + return ("files", ("test.csv", file_obj, "text/csv")) + + +@pytest.fixture +def mock_docx_file(): + """Creates a mock DOCX file.""" + content = b"fake docx content" + file_obj = io.BytesIO(content) + return ("files", ("test.docx", file_obj, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")) + + +@pytest.fixture +def mock_text_file(): + """Creates a mock text file.""" + content = b"Sample text content" + file_obj = io.BytesIO(content) + return ("files", ("test.txt", file_obj, "text/plain")) + + +@pytest.fixture +def mock_json_file(): + """Creates a mock JSON file.""" + content = b'{"key": "value"}' + file_obj = io.BytesIO(content) + return ("files", ("test.json", file_obj, "application/json")) + + +# --- Test Cases --- + + +def test_no_file_upload(test_app, mock_agent): + """Test basic message without file upload.""" + data = { + "message": "Hello", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data) + assert response.status_code == 200 + + # Get the copied agent that was actually used + copied_agent = mock_agent.deep_copy() + # Verify agent.run was called with correct parameters + copied_agent.run.assert_called_once_with(message="Hello", stream=False, images=None) + + +def test_single_image_upload(test_app, mock_agent, mock_image_file): + """Test uploading a single image file.""" + data = { + "message": "Analyze this image", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + files = [mock_image_file] + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) + assert response.status_code == 200 + + # Get the copied agent that was actually used + copied_agent = mock_agent.deep_copy() + # Verify agent.run was called with an image + copied_agent.run.assert_called_once() + call_args = copied_agent.run.call_args[1] + assert call_args["message"] == "Analyze this image" + assert call_args["stream"] is False + assert isinstance(call_args["images"], list) + assert len(call_args["images"]) == 1 + assert isinstance(call_args["images"][0], Image) + + +def test_multiple_image_upload(test_app, mock_agent, mock_image_file): + """Test uploading multiple image files.""" + data = { + "message": "Analyze these images", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + files = [mock_image_file] * 3 # Upload 3 images + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) + assert response.status_code == 200 + + # Get the copied agent that was actually used + copied_agent = mock_agent.deep_copy() + # Verify agent.run was called with multiple images + copied_agent.run.assert_called_once() + call_args = copied_agent.run.call_args[1] + assert len(call_args["images"]) == 3 + assert all(isinstance(img, Image) for img in call_args["images"]) + + +def test_pdf_upload_with_knowledge(test_app, mock_agent_with_knowledge, mock_pdf_file, mock_pdf_reader): + """Test uploading a PDF file with knowledge base enabled.""" + data = { + "message": "Analyze this PDF", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + files = [mock_pdf_file] + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) + assert response.status_code == 200 + + # Get the copied agent that was actually used + copied_agent = mock_agent_with_knowledge.deep_copy() + # Verify knowledge.load_documents was called + copied_agent.knowledge.load_documents.assert_called_once_with(["This is mock PDF content"]) + # Verify agent.run was called without images + copied_agent.run.assert_called_once_with(message="Analyze this PDF", stream=False, images=None) + + +def test_pdf_upload_without_knowledge(test_app, mock_pdf_file): + """Test uploading a PDF file without knowledge base.""" + data = { + "message": "Analyze this PDF", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + 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()["detail"] + + +def test_mixed_file_upload(test_app, mock_agent_with_knowledge, mock_image_file, mock_pdf_file, mock_pdf_reader): + """Test uploading both image and PDF files.""" + data = { + "message": "Analyze these files", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + files = [mock_image_file, mock_pdf_file] + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) + assert response.status_code == 200 + + # Get the copied agent that was actually used + copied_agent = mock_agent_with_knowledge.deep_copy() + # Verify knowledge.load_documents was called for PDF + copied_agent.knowledge.load_documents.assert_called_once_with(["This is mock PDF content"]) + # Verify agent.run was called with image + copied_agent.run.assert_called_once() + call_args = copied_agent.run.call_args[1] + assert len(call_args["images"]) == 1 + assert isinstance(call_args["images"][0], Image) + + +def test_unsupported_file_type(test_app, mock_agent_with_knowledge): + """Test uploading an unsupported file type.""" + data = { + "message": "Analyze this file", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + 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()["detail"] + + +def test_empty_file_upload(test_app): + """Test uploading an empty file.""" + data = { + "message": "Analyze this file", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + empty_file = ("files", ("empty.jpg", io.BytesIO(b""), "image/jpeg")) + files = [empty_file] + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) + assert response.status_code == 200 + + +def test_document_upload_with_knowledge(test_app, mock_agent_with_knowledge): + """Test uploading various document types with knowledge base enabled.""" + data = { + "message": "Analyze these documents", + "stream": "false", + "monitor": "false", + "user_id": "test_user", + } + + # Test each document type + document_files = [ + ("files", ("test.csv", io.BytesIO(b"col1,col2\nval1,val2"), "text/csv")), + ("files", ("test.txt", io.BytesIO(b"text content"), "text/plain")), + ("files", ("test.json", io.BytesIO(b'{"key":"value"}'), "application/json")), + ( + "files", + ( + "test.docx", + io.BytesIO(b"docx content"), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ), + ] + + for doc_file in document_files: + files = [doc_file] + response = test_app.post("/v1/playground/agents/test-agent/runs", data=data, files=files) + assert response.status_code == 200 diff --git a/libs/agno/tests/unit/tools/__init__.py b/libs/agno/tests/unit/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2