v3_ai_assistant / py /tools /agent_tools.py
Julian Vanecek
Initial commit: AI Assistant Multi-Agent System for HuggingFace Spaces
bb80caa
"""
Agent tools and information management
This module contains:
- Agent metadata and configuration
- Agent singleton registry
- Functions to get available agents
- Agent switching tool creation
- Tool conversion utilities for OpenAI function format
"""
import inspect
import logging
from typing import Dict, List, Optional, Callable, Any, get_type_hints
from langchain.agents import Tool
from langchain.tools import StructuredTool
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# Agent information dictionary - single source of truth for all agents
AGENT_INFO = {
"document_reader": {
"display_name": "Document Reader",
"description": "Handles documentation queries with RAG capabilities",
"tools": {
"read": ["search_version_documentation", "search_all_versions", "list_available_versions"],
"write": [] # No write operations
}
},
"profile_settings": {
"display_name": "Profile Settings",
"description": "Manages user preferences and settings",
"tools": {
"read": ["view_profile", "list_preferences", "view_notification_settings"],
"write": ["update_preference", "update_notifications", "reset_profile"]
}
}
}
# Global agent singleton registry
_AGENT_INSTANCES: Dict[str, Any] = {}
_LLM: Optional[ChatOpenAI] = None
_API_GATEWAY = None
_CURRENT_CONTEXT: Dict[str, Any] = {} # Store context for switching tools
def initialize_agents(llm: ChatOpenAI, api_gateway=None) -> None:
"""
Initialize all agent singletons
Args:
llm: The language model to use for all agents
api_gateway: Optional API gateway for certain agents
"""
global _AGENT_INSTANCES, _LLM, _API_GATEWAY
# Avoid circular imports by importing here
from agents.document_reader import DocumentReaderAgent
from agents.profile_settings import ProfileSettingsAgent
_LLM = llm
_API_GATEWAY = api_gateway
logger.info("Initializing agent singletons...")
# Create agent instances (only implemented ones)
_AGENT_INSTANCES = {
"document_reader": DocumentReaderAgent(llm),
"profile_settings": ProfileSettingsAgent(llm),
}
logger.info(f"Initialized {len(_AGENT_INSTANCES)} agents")
def get_agent(agent_id: str) -> Optional[Any]:
"""
Get agent instance by ID
Args:
agent_id: The agent identifier
Returns:
Agent instance or None if not found
"""
return _AGENT_INSTANCES.get(agent_id)
def run_agent(agent_id: str, message: str, context: Optional[Dict] = None) -> Dict[str, Any]:
"""
Run an agent with a message
Args:
agent_id: The agent identifier
message: The message to process
context: Optional context dictionary
Returns:
Agent response dictionary
"""
agent = get_agent(agent_id)
if not agent:
raise ValueError(f"Unknown agent: {agent_id}")
# Store context globally for switching tools to access
global _CURRENT_CONTEXT
_CURRENT_CONTEXT = {
"message": message,
"context": context or {}
}
return agent.run(message, context)
def get_available_agents(exclude_agent_id: Optional[str] = None) -> Dict[str, Dict]:
"""
Get all available agents, optionally excluding one
Args:
exclude_agent_id: Agent ID to exclude from results
Returns:
Dictionary of agent_id -> agent_info
"""
return {
agent_id: info
for agent_id, info in AGENT_INFO.items()
if agent_id != exclude_agent_id
}
def create_agent_switching_tool(target_agent_id: str, target_agent_name: str) -> Callable:
"""
Create a tool function for switching to a specific agent
Args:
target_agent_id: ID of the agent to switch to
target_agent_name: Display name of the agent
Returns:
Callable function that performs the agent switch
"""
def switch_to_agent(reason: str = "") -> str:
"""Switch conversation to another agent.
Args:
reason: Brief explanation of why switching (e.g., 'User wants to update profile settings')
"""
# Get current context
global _CURRENT_CONTEXT
message = _CURRENT_CONTEXT.get("message", "")
context = _CURRENT_CONTEXT.get("context", {})
logger.info(f"Switching from current agent to {target_agent_id}")
# Log the transfer
transfer_msg = f"Transferring to {target_agent_name}"
if reason:
transfer_msg += f": {reason}"
try:
# Run the target agent with the current message
result = run_agent(target_agent_id, message, context)
# Return a special marker with the target agent's response
# This tells BaseAgent to update agent_id and use this response
return f"__SWITCH_AGENT__|{target_agent_id}|{transfer_msg}\n\n{result.get('output', '')}"
except Exception as e:
logger.error(f"Error during agent switch: {e}")
return f"__SWITCH_AGENT__|{target_agent_id}|{transfer_msg}\n\n[Error: Failed to run {target_agent_name} - {str(e)}]"
# Set function metadata for LangChain
switch_to_agent.__name__ = f"switch_to_{target_agent_id}"
switch_to_agent.__doc__ = f"Transfer conversation to {target_agent_name} agent. Always provide a reason parameter explaining why you're switching (e.g., 'User wants to update profile settings')."
return switch_to_agent
def create_switching_tools_for_agent(current_agent_id: str) -> List[StructuredTool]:
"""
Create all agent switching tools for a given agent
Args:
current_agent_id: ID of the current agent
Returns:
List of StructuredTool objects for switching to other agents
"""
tools = []
other_agents = get_available_agents(exclude_agent_id=current_agent_id)
for agent_id, agent_info in other_agents.items():
# Create a dynamic Pydantic model for this specific agent
class SwitchToAgentInput(BaseModel):
reason: str = Field(
default="",
description=f"Brief explanation of why switching to {agent_info['display_name']} (e.g., 'User wants to update profile settings')"
)
# Set the class name dynamically for better debugging
SwitchToAgentInput.__name__ = f"SwitchTo{agent_id.title().replace('_', '')}Input"
switch_func = create_agent_switching_tool(
agent_id,
agent_info['display_name']
)
tool = StructuredTool.from_function(
func=switch_func,
name=f"switch_to_{agent_id}",
description=f"Transfer to {agent_info['display_name']} - {agent_info['description']}",
args_schema=SwitchToAgentInput
)
tools.append(tool)
return tools
def get_agent_tools(agent_id: str) -> Dict[str, List[str]]:
"""
Get the tools (read and write) for a specific agent
Args:
agent_id: ID of the agent
Returns:
Dictionary with 'read' and 'write' tool lists
"""
if agent_id not in AGENT_INFO:
raise ValueError(f"Unknown agent ID: {agent_id}")
return AGENT_INFO[agent_id]["tools"]
def is_write_tool(agent_id: str, tool_name: str) -> bool:
"""
Check if a tool is a write operation (requires confirmation)
Args:
agent_id: ID of the agent
tool_name: Name of the tool
Returns:
True if the tool is a write operation
"""
agent_tools = get_agent_tools(agent_id)
return tool_name in agent_tools.get("write", [])
def convert_tool_to_openai_function(tool: Any) -> Dict[str, Any]:
"""
Convert a LangChain Tool or StructuredTool to OpenAI function format.
Handles both Tool and StructuredTool instances appropriately.
"""
# Check if this is a StructuredTool with args_schema
if hasattr(tool, 'args_schema') and tool.args_schema:
# Use the Pydantic schema directly
schema = tool.args_schema.schema()
# Extract properties and required fields from Pydantic schema
properties = {}
required = []
for field_name, field_info in schema.get('properties', {}).items():
properties[field_name] = field_info
# Get required fields from schema
required = schema.get('required', [])
return {
"name": tool.name,
"description": tool.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required
}
}
# Fall back to signature introspection for regular Tool
func = tool.func
sig = inspect.signature(func)
type_hints = get_type_hints(func)
# Build properties from function parameters
properties = {}
required = []
for param_name, param in sig.parameters.items():
# Skip 'self' parameter if it exists
if param_name == 'self':
continue
# Determine the type
param_type = type_hints.get(param_name, type(param.default) if param.default != param.empty else str)
# Convert Python types to JSON Schema types
if param_type == str:
json_type = "string"
elif param_type == int:
json_type = "integer"
elif param_type == float:
json_type = "number"
elif param_type == bool:
json_type = "boolean"
elif param_type == list or str(param_type).startswith('typing.List'):
json_type = "array"
elif param_type == dict or str(param_type).startswith('typing.Dict'):
json_type = "object"
else:
json_type = "string" # Default fallback
# Build property definition
prop_def = {"type": json_type}
# Add description from docstring if available
if func.__doc__:
# Simple extraction - could be enhanced with docstring parsing
prop_def["description"] = f"Parameter: {param_name}"
# Add default value if present
if param.default != param.empty:
prop_def["default"] = param.default
else:
# If no default, it's required
required.append(param_name)
properties[param_name] = prop_def
# Return the OpenAI function schema
return {
"name": tool.name,
"description": tool.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required
}
}