gateway/modules/features/chatBot/utils/toolRegistry.py
2025-10-03 13:03:22 +02:00

305 lines
9.5 KiB
Python

"""Tool registry for auto-discovering and managing chatbot tools.
This module provides a central registry that automatically discovers all tools
in the chatbotTools directory structure and provides methods to query them.
The registry is built in-memory at startup and does not require a database.
"""
import importlib
import inspect
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
from langchain_core.tools import BaseTool
logger = logging.getLogger(__name__)
@dataclass
class ToolMetadata:
"""Metadata about a discovered chatbot tool.
Attributes:
tool_id: Unique identifier (e.g., 'shared.tavily_search')
name: Function name of the tool
category: Category of the tool ('shared' or 'customer')
description: Tool description from docstring
tool_instance: The actual LangChain tool instance
module_path: Full Python module path
"""
tool_id: str
name: str
category: str
description: str
tool_instance: BaseTool
module_path: str
def __str__(self) -> str:
"""Return a pretty-printed string representation for logging."""
return (
f"ToolMetadata(\n"
f" tool_id='{self.tool_id}',\n"
f" name='{self.name}',\n"
f" category='{self.category}',\n"
f" description='{self.description}',\n"
f" module_path='{self.module_path}'\n"
f")"
)
class ToolRegistry:
"""Central registry for all chatbot tools.
This class discovers and catalogs all tools decorated with @tool in the
chatbotTools directory structure. Tools are automatically discovered at
initialization by scanning the filesystem.
The registry provides methods to query tools by ID, category, or get all tools.
"""
def __init__(self) -> None:
"""Initialize an empty tool registry."""
self._tools: Dict[str, ToolMetadata] = {}
self._initialized: bool = False
def initialize(self) -> None:
"""Discover and register all tools from the chatbotTools directory.
This method scans both sharedTools and customerTools directories,
imports all tool*.py modules, and extracts functions decorated with @tool.
This method is idempotent - calling it multiple times has no effect
after the first initialization.
"""
if self._initialized:
logger.debug("Tool registry already initialized, skipping")
return
logger.info("Initializing tool registry...")
# Get base path to chatbotTools directory
base_path = Path(__file__).parent.parent / "chatbotTools"
if not base_path.exists():
logger.warning(f"chatbotTools directory not found at {base_path}")
self._initialized = True
return
# Discover tools in each category
self._discover_category(
category_path=base_path / "sharedTools", category="shared"
)
self._discover_category(
category_path=base_path / "customerTools", category="customer"
)
self._initialized = True
logger.info(f"Tool registry initialized with {len(self._tools)} tools")
def _discover_category(self, *, category_path: Path, category: str) -> None:
"""Discover all tools in a specific category directory.
Args:
category_path: Path to the category directory (sharedTools or customerTools)
category: Category name ('shared' or 'customer')
"""
if not category_path.exists():
logger.warning(f"Category directory not found: {category_path}")
return
logger.debug(f"Discovering tools in category: {category}")
# Find all tool*.py files (excluding __init__.py)
tool_files = [
f for f in category_path.glob("tool*.py") if f.name != "__init__.py"
]
for tool_file in tool_files:
self._import_and_register_tools(
tool_file=tool_file, category=category, category_path=category_path
)
logger.debug(f"Discovered {len(tool_files)} tool files in {category}")
def _import_and_register_tools(
self, *, tool_file: Path, category: str, category_path: Path
) -> None:
"""Import a tool module and register all discovered tools.
Args:
tool_file: Path to the tool Python file
category: Category name ('shared' or 'customer')
category_path: Path to the category directory
"""
# Construct module name
module_name = (
f"modules.features.chatBot.chatbotTools.{category}Tools.{tool_file.stem}"
)
try:
# Import the module
module = importlib.import_module(module_name)
# Find all BaseTool instances in the module
tools_found = 0
for name, obj in inspect.getmembers(module):
if isinstance(obj, BaseTool):
self._register_tool(
tool_instance=obj,
name=name,
category=category,
module_path=module_name,
)
tools_found += 1
if tools_found == 0:
logger.warning(f"No tools found in {module_name}")
else:
logger.debug(f"Loaded {tools_found} tool(s) from {module_name}")
except ImportError as e:
logger.error(
f"Import error loading tools from {module_name}: {str(e)}. "
f"This tool will not be available."
)
except Exception as e:
logger.error(
f"Unexpected error loading tools from {module_name}: {type(e).__name__}: {str(e)}"
)
def _register_tool(
self, *, tool_instance: BaseTool, name: str, category: str, module_path: str
) -> None:
"""Register a single tool in the registry.
Args:
tool_instance: The LangChain tool instance
name: Function name of the tool
category: Category name ('shared' or 'customer')
module_path: Full Python module path
"""
tool_id = f"{category}.{name}"
# Check for duplicate tool IDs
if tool_id in self._tools:
logger.warning(f"Duplicate tool ID detected: {tool_id}, overwriting")
metadata = ToolMetadata(
tool_id=tool_id,
name=name,
category=category,
description=tool_instance.description or "",
tool_instance=tool_instance,
module_path=module_path,
)
self._tools[tool_id] = metadata
logger.debug(f"Registered tool: {tool_id}")
def get_all_tools(self) -> List[ToolMetadata]:
"""Get all registered tools.
Returns:
List of all tool metadata objects
"""
return list(self._tools.values())
def get_tool(self, *, tool_id: str) -> Optional[ToolMetadata]:
"""Get a specific tool by its ID.
Args:
tool_id: The tool identifier (e.g., 'shared.tavily_search')
Returns:
Tool metadata if found, None otherwise
"""
return self._tools.get(tool_id)
def get_tools_by_category(self, *, category: str) -> List[ToolMetadata]:
"""Get all tools in a specific category.
Args:
category: Category name ('shared' or 'customer')
Returns:
List of tool metadata for the specified category
"""
return [t for t in self._tools.values() if t.category == category]
def list_tool_ids(self) -> List[str]:
"""Get a list of all registered tool IDs.
Returns:
List of tool ID strings
"""
return list(self._tools.keys())
def get_tool_instances(self, *, tool_ids: List[str]) -> List[BaseTool]:
"""Get actual tool instances for a list of tool IDs.
This is useful for filtering tools based on user permissions.
Args:
tool_ids: List of tool IDs to retrieve
Returns:
List of BaseTool instances for the specified IDs
"""
instances = []
for tool_id in tool_ids:
metadata = self.get_tool(tool_id=tool_id)
if metadata:
instances.append(metadata.tool_instance)
else:
logger.warning(f"Tool ID not found in registry: {tool_id}")
return instances
@property
def is_initialized(self) -> bool:
"""Check if the registry has been initialized.
Returns:
True if initialized, False otherwise
"""
return self._initialized
# Global registry instance
_registry: Optional[ToolRegistry] = None
def get_registry() -> ToolRegistry:
"""Get the global tool registry instance.
This function ensures the registry is initialized on first access.
Subsequent calls return the same instance.
Returns:
The global ToolRegistry instance
"""
global _registry
if _registry is None:
_registry = ToolRegistry()
if not _registry.is_initialized:
_registry.initialize()
return _registry
def reinitialize_registry() -> ToolRegistry:
"""Force reinitialize the tool registry.
This is useful for testing or when tools are added dynamically.
Returns:
The reinitialized ToolRegistry instance
"""
global _registry
_registry = ToolRegistry()
_registry.initialize()
return _registry