From 13ad3b5dcea4a75ccca2a5d3bd33a12ae5b19268 Mon Sep 17 00:00:00 2001 From: Siddhant Rai <47355538+siiddhantt@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:20:29 +0530 Subject: [PATCH] feat: enhance logging and error handling across various tools; update DuckDuckGo dependency (#2282) Co-authored-by: Alex --- application/agents/tools/brave.py | 9 +- application/agents/tools/duckduckgo.py | 185 +++++++++++++----- application/agents/tools/mcp_tool.py | 4 +- application/agents/tools/postgres.py | 25 +-- application/agents/tools/telegram.py | 15 +- .../agents/workflows/workflow_engine.py | 6 +- application/api/answer/routes/base.py | 10 +- application/error.py | 22 +++ application/llm/google_ai.py | 18 +- application/requirements.txt | 2 +- 10 files changed, 223 insertions(+), 73 deletions(-) diff --git a/application/agents/tools/brave.py b/application/agents/tools/brave.py index 33843ac0..4888fd44 100644 --- a/application/agents/tools/brave.py +++ b/application/agents/tools/brave.py @@ -1,6 +1,11 @@ +import logging + import requests + from application.agents.tools.base import Tool +logger = logging.getLogger(__name__) + class BraveSearchTool(Tool): """ @@ -41,7 +46,7 @@ class BraveSearchTool(Tool): """ Performs a web search using the Brave Search API. """ - print(f"Performing Brave web search for: {query}") + logger.info(f"Brave web search: {query}") url = f"{self.base_url}/web/search" @@ -94,7 +99,7 @@ class BraveSearchTool(Tool): """ Performs an image search using the Brave Search API. """ - print(f"Performing Brave image search for: {query}") + logger.info(f"Brave image search: {query}") url = f"{self.base_url}/images/search" diff --git a/application/agents/tools/duckduckgo.py b/application/agents/tools/duckduckgo.py index 87c1bc7e..19f59069 100644 --- a/application/agents/tools/duckduckgo.py +++ b/application/agents/tools/duckduckgo.py @@ -1,5 +1,14 @@ +import logging +import time +from typing import Any, Dict, Optional + from application.agents.tools.base import Tool -from duckduckgo_search import DDGS + +logger = logging.getLogger(__name__) + +MAX_RETRIES = 3 +RETRY_DELAY = 2.0 +DEFAULT_TIMEOUT = 15 class DuckDuckGoSearchTool(Tool): @@ -10,71 +19,123 @@ class DuckDuckGoSearchTool(Tool): def __init__(self, config): self.config = config + self.timeout = config.get("timeout", DEFAULT_TIMEOUT) + + def _get_ddgs_client(self): + from ddgs import DDGS + + return DDGS(timeout=self.timeout) + + def _execute_with_retry(self, operation, operation_name: str) -> Dict[str, Any]: + last_error = None + for attempt in range(1, MAX_RETRIES + 1): + try: + results = operation() + return { + "status_code": 200, + "results": list(results) if results else [], + "message": f"{operation_name} completed successfully.", + } + except Exception as e: + last_error = e + error_str = str(e).lower() + if "ratelimit" in error_str or "429" in error_str: + if attempt < MAX_RETRIES: + delay = RETRY_DELAY * attempt + logger.warning( + f"{operation_name} rate limited, retrying in {delay}s (attempt {attempt}/{MAX_RETRIES})" + ) + time.sleep(delay) + continue + logger.error(f"{operation_name} failed: {e}") + break + return { + "status_code": 500, + "results": [], + "message": f"{operation_name} failed: {str(last_error)}", + } def execute_action(self, action_name, **kwargs): actions = { "ddg_web_search": self._web_search, "ddg_image_search": self._image_search, + "ddg_news_search": self._news_search, } - - if action_name in actions: - return actions[action_name](**kwargs) - else: + if action_name not in actions: raise ValueError(f"Unknown action: {action_name}") + return actions[action_name](**kwargs) def _web_search( self, - query, - max_results=5, - ): - print(f"Performing DuckDuckGo web search for: {query}") + query: str, + max_results: int = 5, + region: str = "wt-wt", + safesearch: str = "moderate", + timelimit: Optional[str] = None, + ) -> Dict[str, Any]: + logger.info(f"DuckDuckGo web search: {query}") - try: - results = DDGS().text( + def operation(): + client = self._get_ddgs_client() + return client.text( query, - max_results=max_results, + region=region, + safesearch=safesearch, + timelimit=timelimit, + max_results=min(max_results, 20), ) - return { - "status_code": 200, - "results": results, - "message": "Web search completed successfully.", - } - except Exception as e: - return { - "status_code": 500, - "message": f"Web search failed: {str(e)}", - } + return self._execute_with_retry(operation, "Web search") def _image_search( self, - query, - max_results=5, - ): - print(f"Performing DuckDuckGo image search for: {query}") + query: str, + max_results: int = 5, + region: str = "wt-wt", + safesearch: str = "moderate", + timelimit: Optional[str] = None, + ) -> Dict[str, Any]: + logger.info(f"DuckDuckGo image search: {query}") - try: - results = DDGS().images( - keywords=query, - max_results=max_results, + def operation(): + client = self._get_ddgs_client() + return client.images( + query, + region=region, + safesearch=safesearch, + timelimit=timelimit, + max_results=min(max_results, 50), ) - return { - "status_code": 200, - "results": results, - "message": "Image search completed successfully.", - } - except Exception as e: - return { - "status_code": 500, - "message": f"Image search failed: {str(e)}", - } + return self._execute_with_retry(operation, "Image search") + + def _news_search( + self, + query: str, + max_results: int = 5, + region: str = "wt-wt", + safesearch: str = "moderate", + timelimit: Optional[str] = None, + ) -> Dict[str, Any]: + logger.info(f"DuckDuckGo news search: {query}") + + def operation(): + client = self._get_ddgs_client() + return client.news( + query, + region=region, + safesearch=safesearch, + timelimit=timelimit, + max_results=min(max_results, 20), + ) + + return self._execute_with_retry(operation, "News search") def get_actions_metadata(self): return [ { "name": "ddg_web_search", - "description": "Perform a web search using DuckDuckGo.", + "description": "Search the web using DuckDuckGo. Returns titles, URLs, and snippets.", "parameters": { "type": "object", "properties": { @@ -84,7 +145,15 @@ class DuckDuckGoSearchTool(Tool): }, "max_results": { "type": "integer", - "description": "Number of results to return (default: 5)", + "description": "Number of results (default: 5, max: 20)", + }, + "region": { + "type": "string", + "description": "Region code (default: wt-wt for worldwide, us-en for US)", + }, + "timelimit": { + "type": "string", + "description": "Time filter: d (day), w (week), m (month), y (year)", }, }, "required": ["query"], @@ -92,17 +161,43 @@ class DuckDuckGoSearchTool(Tool): }, { "name": "ddg_image_search", - "description": "Perform an image search using DuckDuckGo.", + "description": "Search for images using DuckDuckGo. Returns image URLs and metadata.", "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "Search query", + "description": "Image search query", }, "max_results": { "type": "integer", - "description": "Number of results to return (default: 5, max: 50)", + "description": "Number of results (default: 5, max: 50)", + }, + "region": { + "type": "string", + "description": "Region code (default: wt-wt for worldwide)", + }, + }, + "required": ["query"], + }, + }, + { + "name": "ddg_news_search", + "description": "Search for news articles using DuckDuckGo. Returns recent news.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "News search query", + }, + "max_results": { + "type": "integer", + "description": "Number of results (default: 5, max: 20)", + }, + "timelimit": { + "type": "string", + "description": "Time filter: d (day), w (week), m (month)", }, }, "required": ["query"], diff --git a/application/agents/tools/mcp_tool.py b/application/agents/tools/mcp_tool.py index 201e3d0b..f70dbe80 100644 --- a/application/agents/tools/mcp_tool.py +++ b/application/agents/tools/mcp_tool.py @@ -28,6 +28,8 @@ from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAu from pydantic import AnyHttpUrl, ValidationError from redis import Redis +logger = logging.getLogger(__name__) + mongo = MongoDB.get_client() db = mongo[settings.MONGO_DB_NAME] @@ -263,7 +265,7 @@ class MCPTool(Tool): finally: loop.close() except Exception as e: - print(f"Error occurred while running async operation: {e}") + logger.error(f"Error occurred while running async operation: {e}") raise def discover_tools(self) -> List[Dict]: diff --git a/application/agents/tools/postgres.py b/application/agents/tools/postgres.py index 2877ebad..045bec62 100644 --- a/application/agents/tools/postgres.py +++ b/application/agents/tools/postgres.py @@ -1,6 +1,12 @@ +import logging + import psycopg2 + from application.agents.tools.base import Tool +logger = logging.getLogger(__name__) + + class PostgresTool(Tool): """ PostgreSQL Database Tool @@ -17,17 +23,12 @@ class PostgresTool(Tool): "postgres_execute_sql": self._execute_sql, "postgres_get_schema": self._get_schema, } - - if action_name in actions: - return actions[action_name](**kwargs) - else: + if action_name not in actions: raise ValueError(f"Unknown action: {action_name}") + return actions[action_name](**kwargs) def _execute_sql(self, sql_query): - """ - Executes an SQL query against the PostgreSQL database using a connection string. - """ - conn = None # Initialize conn to None for error handling + conn = None try: conn = psycopg2.connect(self.connection_string) cur = conn.cursor() @@ -54,14 +55,14 @@ class PostgresTool(Tool): except psycopg2.Error as e: error_message = f"Database error: {e}" - print(f"Database error: {e}") + logger.error(error_message) return { "status_code": 500, "message": "Failed to execute SQL query.", "error": error_message, } finally: - if conn: # Ensure connection is closed even if errors occur + if conn: conn.close() def _get_schema(self, db_name): @@ -110,14 +111,14 @@ class PostgresTool(Tool): except psycopg2.Error as e: error_message = f"Database error: {e}" - print(f"Database error: {e}") + logger.error(error_message) return { "status_code": 500, "message": "Failed to retrieve database schema.", "error": error_message, } finally: - if conn: # Ensure connection is closed even if errors occur + if conn: conn.close() def get_actions_metadata(self): diff --git a/application/agents/tools/telegram.py b/application/agents/tools/telegram.py index 06350ae9..a06eed4b 100644 --- a/application/agents/tools/telegram.py +++ b/application/agents/tools/telegram.py @@ -1,6 +1,11 @@ +import logging + import requests + from application.agents.tools.base import Tool +logger = logging.getLogger(__name__) + class TelegramTool(Tool): """ @@ -18,21 +23,19 @@ class TelegramTool(Tool): "telegram_send_message": self._send_message, "telegram_send_image": self._send_image, } - - if action_name in actions: - return actions[action_name](**kwargs) - else: + if action_name not in actions: raise ValueError(f"Unknown action: {action_name}") + return actions[action_name](**kwargs) def _send_message(self, text, chat_id): - print(f"Sending message: {text}") + logger.info(f"Telegram: sending message to {chat_id}") url = f"https://api.telegram.org/bot{self.token}/sendMessage" payload = {"chat_id": chat_id, "text": text} response = requests.post(url, data=payload) return {"status_code": response.status_code, "message": "Message sent"} def _send_image(self, image_url, chat_id): - print(f"Sending image: {image_url}") + logger.info(f"Telegram: sending image to {chat_id}") url = f"https://api.telegram.org/bot{self.token}/sendPhoto" payload = {"chat_id": chat_id, "photo": image_url} response = requests.post(url, data=payload) diff --git a/application/agents/workflows/workflow_engine.py b/application/agents/workflows/workflow_engine.py index ef372f98..5444458a 100644 --- a/application/agents/workflows/workflow_engine.py +++ b/application/agents/workflows/workflow_engine.py @@ -18,6 +18,7 @@ from application.core.json_schema_utils import ( JsonSchemaValidationError, normalize_json_schema_payload, ) +from application.error import sanitize_api_error from application.templates.namespaces import NamespaceManager from application.templates.template_engine import TemplateEngine, TemplateRenderError @@ -99,6 +100,7 @@ class WorkflowEngine: log_entry["state_snapshot"] = dict(self.state) self.execution_log.append(log_entry) + user_friendly_error = sanitize_api_error(e) yield { "type": "workflow_step", "node_id": node.id, @@ -106,9 +108,9 @@ class WorkflowEngine: "node_title": node.title, "status": "failed", "state_snapshot": dict(self.state), - "error": str(e), + "error": user_friendly_error, } - yield {"type": "error", "error": str(e)} + yield {"type": "error", "error": user_friendly_error} break log_entry["state_snapshot"] = dict(self.state) self.execution_log.append(log_entry) diff --git a/application/api/answer/routes/base.py b/application/api/answer/routes/base.py index 272033ae..2f9c9950 100644 --- a/application/api/answer/routes/base.py +++ b/application/api/answer/routes/base.py @@ -15,6 +15,7 @@ from application.core.model_utils import ( from application.core.mongo_db import MongoDB from application.core.settings import settings +from application.error import sanitize_api_error from application.llm.llm_creator import LLMCreator from application.utils import check_required_fields @@ -240,7 +241,14 @@ class BaseAnswerResource: data = json.dumps({"type": "thought", "thought": line["thought"]}) yield f"data: {data}\n\n" elif "type" in line: - data = json.dumps(line) + if line.get("type") == "error": + sanitized_error = { + "type": "error", + "error": sanitize_api_error(line.get("error", "An error occurred")) + } + data = json.dumps(sanitized_error) + else: + data = json.dumps(line) yield f"data: {data}\n\n" if is_structured and structured_chunks: structured_data = { diff --git a/application/error.py b/application/error.py index 5d42f0ee..b79487c1 100644 --- a/application/error.py +++ b/application/error.py @@ -13,3 +13,25 @@ def response_error(code_status, message=None): def bad_request(status_code=400, message=''): return response_error(code_status=status_code, message=message) + + +def sanitize_api_error(error) -> str: + """ + Convert technical API errors to user-friendly messages. + Works with both Exception objects and error message strings. + """ + error_str = str(error).lower() + if "503" in error_str or "unavailable" in error_str or "high demand" in error_str: + return "The AI service is temporarily unavailable due to high demand. Please try again in a moment." + if "429" in error_str or "rate limit" in error_str or "quota" in error_str: + return "Rate limit exceeded. Please wait a moment before trying again." + if "401" in error_str or "unauthorized" in error_str or "invalid api key" in error_str: + return "Authentication error. Please check your API configuration." + if "timeout" in error_str or "timed out" in error_str: + return "The request timed out. Please try again." + if "connection" in error_str or "network" in error_str: + return "Network error. Please check your connection and try again." + original = str(error) + if len(original) > 200 or "{" in original or "traceback" in error_str: + return "An error occurred while processing your request. Please try again later." + return original diff --git a/application/llm/google_ai.py b/application/llm/google_ai.py index 36892309..ca984ba7 100644 --- a/application/llm/google_ai.py +++ b/application/llm/google_ai.py @@ -158,7 +158,11 @@ class GoogleLLM(BaseLLM): if isinstance(content, list): parts = [] for item in content: - if isinstance(item, dict) and "text" in item and item["text"] is not None: + if ( + isinstance(item, dict) + and "text" in item + and item["text"] is not None + ): parts.append(item["text"]) return "\n".join(parts) return "" @@ -236,7 +240,9 @@ class GoogleLLM(BaseLLM): raise ValueError(f"Unexpected content type: {type(content)}") if parts: cleaned_messages.append(types.Content(role=role, parts=parts)) - system_instruction = "\n\n".join(system_instructions) if system_instructions else None + system_instruction = ( + "\n\n".join(system_instructions) if system_instructions else None + ) return cleaned_messages, system_instruction def _clean_schema(self, schema_obj): @@ -336,7 +342,10 @@ class GoogleLLM(BaseLLM): return f"function_call:{name}" function_response = getattr(part, "function_response", None) if function_response: - name = getattr(function_response, "name", "") or "function_response" + name = ( + getattr(function_response, "name", "") + or "function_response" + ) return f"function_response:{name}" if isinstance(message, dict): content = message.get("content") @@ -507,6 +516,9 @@ class GoogleLLM(BaseLLM): yield {"type": "thought", "thought": chunk_text} else: yield chunk_text + except Exception as e: + logging.error(f"GoogleLLM: Stream error: {e}", exc_info=True) + raise finally: if hasattr(response, "close"): response.close() diff --git a/application/requirements.txt b/application/requirements.txt index 9565d40b..de5ee3d1 100644 --- a/application/requirements.txt +++ b/application/requirements.txt @@ -10,7 +10,7 @@ docling>=2.16.0 rapidocr>=1.4.0 onnxruntime>=1.19.0 docx2txt==0.9 -duckduckgo-search==8.1.1 +ddgs>=8.0.0 ebooklib==0.20 escodegen==1.0.11 esprima==4.0.1