feat: Implemented Registry Pattern

This commit is contained in:
2026-02-15 15:58:21 -05:00
parent 548d1eed2b
commit e28c42ddd8
6 changed files with 1276 additions and 4 deletions

View File

@@ -0,0 +1,475 @@
import pytest
from unittest.mock import Mock, MagicMock
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
from src.naliiabot.bot.agent.agent import Agent, AgentConfig
"""
Tests robustos para las tools/herramientas de la clase Agent.
Estrategia:
- Tests de ejecución de herramientas individuales
- Tests de manejo de errores
- Tests del nodo de tools
- Tests de integración con el flujo del agente
"""
# ============================================================================
# TESTS DE EJECUCION DE HERRAMIENTAS INDIVIDUALES
# ============================================================================
class TestExecuteTool:
"""Pruebas para la ejecución de herramientas individuales."""
def test_execute_tool_success(self, agent_with_tool):
"""
DADO: Una herramienta registrada en el agente
CUANDO: Se ejecuta exitosamente
ENTONCES: Retorna ToolMessage con status 'success'
"""
tool_call = {
"name": "calculator",
"id": "tool_123",
"args": {"x": 5, "y": 7}
}
result = agent_with_tool._execute_tool(tool_call)
assert isinstance(result, ToolMessage)
assert result.status == "success"
assert result.tool_call_id == "tool_123"
assert result.content == "42"
def test_execute_tool_not_found(self, agent_with_tool):
"""
DADO: Una herramienta que no existe
CUANDO: Se intenta ejecutar
ENTONCES: Retorna ToolMessage con error
"""
tool_call = {
"name": "nonexistent_tool",
"id": "tool_456",
"args": {}
}
result = agent_with_tool._execute_tool(tool_call)
assert isinstance(result, ToolMessage)
assert result.status == "error"
assert "not found" in result.content.lower()
def test_execute_tool_with_exception(self, mock_model, default_config):
"""
DADO: Una herramienta que lanza excepción
CUANDO: Se ejecuta
ENTONCES: Captura el error y retorna ToolMessage con estado error
"""
failing_tool = Mock()
failing_tool.name = "failing_tool"
failing_tool.invoke = Mock(side_effect=ValueError("Tool error"))
agent = Agent(
model=mock_model,
config=default_config,
tools=[failing_tool]
)
tool_call = {
"name": "failing_tool",
"id": "tool_789",
"args": {"param": "value"}
}
result = agent._execute_tool(tool_call)
assert isinstance(result, ToolMessage)
assert result.status == "error"
assert "Tool error" in result.content
def test_execute_tool_with_no_args(self, agent_with_tool):
"""
DADO: Una herramienta sin argumentos en tool_call
CUANDO: Se ejecuta
ENTONCES: Usa dict vacío como argumentos
"""
tool_call = {
"name": "calculator",
"id": "tool_abc"
# Sin 'args'
}
result = agent_with_tool._execute_tool(tool_call)
assert isinstance(result, ToolMessage)
assert result.tool_call_id == "tool_abc"
# ============================================================================
# TESTS DEL NODO DE HERRAMIENTAS (TOOL NODE)
# ============================================================================
class TestToolNode:
"""Pruebas para el nodo que ejecuta herramientas."""
def test_tool_node_with_tool_calls(self, agent_with_tool):
"""
DADO: Un estado con mensaje AIMessage que contiene tool_calls
CUANDO: Se ejecuta el tool_node
ENTONCES: Retorna estado actualizado con ToolMessages
"""
# Mock del modelo para generar tool_call
ai_message = AIMessage(
content="Using calculator",
tool_calls=[
{
"name": "calculator",
"id": "call_1",
"args": {"x": 10}
}
]
)
state = {
"messages": [
HumanMessage(content="Calculate"),
ai_message
]
}
result = agent_with_tool._tool_node(state)
assert "messages" in result
assert len(result["messages"]) == 3 # original 2 + 1 ToolMessage
assert isinstance(result["messages"][-1], ToolMessage)
def test_tool_node_without_tool_calls(self, agent):
"""
DADO: Un estado con mensaje sin tool_calls
CUANDO: Se ejecuta el tool_node
ENTONCES: Retorna estado sin cambios
"""
state = {
"messages": [
HumanMessage(content="Hello"),
AIMessage(content="Hi there!")
]
}
result = agent._tool_node(state)
assert result["messages"] == state["messages"]
def test_tool_node_multiple_tool_calls(self, mock_model, default_config):
"""
DADO: Un AIMessage con múltiples tool_calls
CUANDO: Se ejecuta el tool_node
ENTONCES: Ejecuta todas las herramientas y retorna múltiples ToolMessages
"""
tool1 = Mock()
tool1.name = "tool_a"
tool1.invoke = Mock(return_value="Result A")
tool2 = Mock()
tool2.name = "tool_b"
tool2.invoke = Mock(return_value="Result B")
agent = Agent(
model=mock_model,
config=default_config,
tools=[tool1, tool2]
)
ai_message = AIMessage(
content="Using tools",
tool_calls=[
{"name": "tool_a", "id": "call_1", "args": {}},
{"name": "tool_b", "id": "call_2", "args": {}}
]
)
state = {
"messages": [ai_message]
}
result = agent._tool_node(state)
# 1 AIMessage + 2 ToolMessages
assert len(result["messages"]) == 3
assert all(isinstance(msg, (AIMessage, ToolMessage)) for msg in result["messages"])
def test_tool_node_post_hook_called(self, agent_with_tool):
"""
DADO: Un agente con post_tool_hook registrado
CUANDO: Se ejecuta una herramienta
ENTONCES: Se invoca el hook después de cada ejecución
"""
hook = Mock()
agent_with_tool.set_post_tool_hook(hook)
ai_message = AIMessage(
content="Test",
tool_calls=[
{
"name": "calculator",
"id": "call_1",
"args": {}
}
]
)
state = {
"messages": [ai_message]
}
agent_with_tool._tool_node(state)
# Hook debe ser llamado una vez por herramienta
assert hook.call_count == 1
# ============================================================================
# TESTS DE INTEGRACIÓN CON FLUJO COMPLETO
# ============================================================================
class TestToolsIntegration:
"""Pruebas de integración de tools en el flujo completo del agente."""
def test_agent_invokes_tool_in_flow(self, agent_with_tool):
"""
DADO: Un agente con herramientas configurado
CUANDO: Se invocan herramientas en la conversación
ENTONCES: El estado se actualiza con los resultados
"""
# Mock del modelo para retornar un AIMessage con tool_calls
ai_response = AIMessage(
content="I'll use the calculator",
tool_calls=[
{
"name": "calculator",
"id": "call_x",
"args": {"result": "42"}
}
]
)
agent_with_tool._model.bind_tools().invoke.return_value = ai_response
state = {
"messages": [HumanMessage(content="Calculate 2+2")]
}
result = agent_with_tool.invoke(state)
assert "messages" in result
# Debe contener original + AIMessage + ToolMessage
assert len(result["messages"]) >= 2
def test_tools_are_bound_to_model(self, agent_with_tool):
"""
DADO: Un agente con herramientas
CUANDO: Se invoca el LLM
ENTONCES: Las herramientas están vinculadas al modelo
"""
initial_state = {
"messages": [HumanMessage(content="Hi")]
}
try:
agent_with_tool.invoke(initial_state)
except:
# Es ok si falla por mocks incompletos
pass
# Verificar que bind_tools fue llamado
agent_with_tool._model.bind_tools.assert_called()
def test_tools_property_returns_copy(self, agent_with_tool):
"""
DADO: Un agente con herramientas
CUANDO: Se accede a la propiedad tools
ENTONCES: Retorna una copia de la lista de herramientas
"""
tools_copy = agent_with_tool.tools
assert len(tools_copy) == 1
assert tools_copy[0].name == "calculator"
# Modificar la copia no afecta al agente
tools_copy.clear()
assert len(agent_with_tool.tools) == 1
def test_multiple_tools_independently(self, mock_model, default_config):
"""
DADO: Un agente con múltiples herramientas diferentes
CUANDO: Se ejecutan herramientas
ENTONCES: Cada una se ejecuta de forma independiente
"""
weather_tool = Mock()
weather_tool.name = "get_weather"
weather_tool.invoke = Mock(return_value="Sunny 25°C")
time_tool = Mock()
time_tool.name = "get_time"
time_tool.invoke = Mock(return_value="14:30")
agent = Agent(
model=mock_model,
config=default_config,
tools=[weather_tool, time_tool]
)
# Ejecutar weather_tool
result1 = agent._execute_tool({
"name": "get_weather",
"id": "w1",
"args": {}
})
assert "Sunny" in result1.content
# Ejecutar time_tool
result2 = agent._execute_tool({
"name": "get_time",
"id": "t1",
"args": {}
})
assert "14:30" in result2.content
# ============================================================================
# TESTS DE EDGE CASES
# ============================================================================
class TestToolsEdgeCases:
"""Pruebas de casos extremos y edge cases."""
def test_tool_returns_none(self, mock_model, default_config):
"""
DADO: Una herramienta que retorna None
CUANDO: Se ejecuta
ENTONCES: Se convierte a string adecuadamente
"""
tool = Mock()
tool.name = "none_tool"
tool.invoke = Mock(return_value=None)
agent = Agent(
model=mock_model,
config=default_config,
tools=[tool]
)
result = agent._execute_tool({
"name": "none_tool",
"id": "n1",
"args": {}
})
assert result.content == "None"
assert result.status == "success"
def test_tool_returns_complex_object(self, mock_model, default_config):
"""
DADO: Una herramienta que retorna objeto complejo
CUANDO: Se ejecuta
ENTONCES: Se serializa a string correctamente
"""
tool = Mock()
tool.name = "complex_tool"
tool.invoke = Mock(return_value={"key": "value", "nested": {"data": 123}})
agent = Agent(
model=mock_model,
config=default_config,
tools=[tool]
)
result = agent._execute_tool({
"name": "complex_tool",
"id": "c1",
"args": {}
})
assert isinstance(result.content, str)
assert "key" in result.content
assert "value" in result.content
def test_empty_tools_list(self, mock_model, default_config):
"""
DADO: Un agente sin herramientas
CUANDO: Se intenta ejecutar una herramienta
ENTONCES: Retorna error apropiado
"""
agent = Agent(
model=mock_model,
config=default_config,
tools=[]
)
result = agent._execute_tool({
"name": "any_tool",
"id": "e1",
"args": {}
})
assert result.status == "error"
assert "not found" in result.content.lower()
def test_tool_with_special_characters_in_response(self, mock_model, default_config):
"""
DADO: Una herramienta que retorna strings especiales
CUANDO: Se ejecuta
ENTONCES: Se preservan los caracteres especiales
"""
tool = Mock()
tool.name = "special_tool"
tool.invoke = Mock(return_value="Response with special chars: ñ, é, 中文, 🚀")
agent = Agent(
model=mock_model,
config=default_config,
tools=[tool]
)
result = agent._execute_tool({
"name": "special_tool",
"id": "s1",
"args": {}
})
assert "ñ" in result.content
assert "中文" in result.content
def test_tool_call_missing_name(self, agent):
"""
DADO: Un tool_call sin campo 'name'
CUANDO: Se ejecuta
ENTONCES: Maneja el error gracefully
"""
result = agent._execute_tool({
"id": "m1",
"args": {}
# Sin 'name'
})
assert result.status == "error"
def test_tool_call_empty_args_dict(self, agent_with_tool):
"""
DADO: Un tool_call con args vacío
CUANDO: Se ejecuta
ENTONCES: Invoca la herramienta con dict vacío
"""
agent_with_tool._tools[0].invoke = Mock(return_value="Empty args result")
result = agent_with_tool._execute_tool({
"name": "calculator",
"id": "ea1",
"args": {}
})
agent_with_tool._tools[0].invoke.assert_called_with({})
assert result.status == "success"

434
tests/test_tool_registry.py Normal file
View File

@@ -0,0 +1,434 @@
"""
Tests para el patrón Registry de herramientas.
Prueba la funcionalidad del ToolRegistry y la integración con el Agent.
"""
import pytest
from src.naliiabot.bot.tools.tool_registry import (
ToolRegistry,
ToolRegistryBuilder,
BaseTool
)
from src.naliiabot.bot.tools.tools import (
CalculatorTool,
GreeterTool,
WeatherTool,
TimeTool
)
from src.naliiabot.bot.agent.agent import Agent, AgentConfig
# ============================================================================
# TESTS DEL BASETOOL
# ============================================================================
class TestBaseTool:
"""Pruebas para la clase base BaseTool."""
def test_calculator_tool_properties(self):
"""Verificar que CalculatorTool implementa correctamente BaseTool."""
tool = CalculatorTool()
assert tool.name == "calculator"
assert "mathematical" in tool.description.lower()
assert "properties" in tool.args_schema
assert "required" in tool.args_schema
def test_calculator_add(self):
"""Prueba la operación de suma."""
tool = CalculatorTool()
result = tool.invoke(a=5, b=3, operation="add")
assert "8" in result
assert "add" in result
def test_calculator_subtract(self):
"""Prueba la operación de resta."""
tool = CalculatorTool()
result = tool.invoke(a=10, b=4, operation="subtract")
assert "6" in result
def test_calculator_multiply(self):
"""Prueba la operación de multiplicación."""
tool = CalculatorTool()
result = tool.invoke(a=6, b=7, operation="multiply")
assert "42" in result
def test_calculator_divide(self):
"""Prueba la operación de división."""
tool = CalculatorTool()
result = tool.invoke(a=20, b=4, operation="divide")
assert "5" in result
def test_calculator_divide_by_zero(self):
"""Prueba el manejo de división por cero."""
tool = CalculatorTool()
result = tool.invoke(a=10, b=0, operation="divide")
assert "Error" in result or "zero" in result.lower()
def test_greeter_tool_formal(self):
"""Prueba el saludo formal."""
tool = GreeterTool()
result = tool.invoke(name="Alice", tone="formal")
assert "Alice" in result
assert "pleasure" in result.lower()
def test_greeter_tool_casual(self):
"""Prueba el saludo casual."""
tool = GreeterTool()
result = tool.invoke(name="Bob", tone="casual")
assert "Bob" in result
assert "up" in result.lower()
def test_greeter_tool_friendly(self):
"""Prueba el saludo amigable."""
tool = GreeterTool()
result = tool.invoke(name="Charlie", tone="friendly")
assert "Charlie" in result
def test_weather_tool(self):
"""Prueba la herramienta de clima."""
tool = WeatherTool()
result = tool.invoke(location="Madrid", units="celsius")
assert "Madrid" in result
assert "Weather" in result or "temperature" in result.lower()
def test_time_tool_24h_format(self):
"""Prueba la herramienta de hora en formato 24h."""
tool = TimeTool()
result = tool.invoke(format="24h", timezone="UTC")
assert "UTC" in result
assert ":" in result # Contiene al menos un separador de hora
def test_time_tool_12h_format(self):
"""Prueba la herramienta de hora en formato 12h."""
tool = TimeTool()
result = tool.invoke(format="12h", timezone="EST")
assert "EST" in result
assert ("AM" in result or "PM" in result)
# ============================================================================
# TESTS DEL TOOL REGISTRY
# ============================================================================
class TestToolRegistry:
"""Pruebas para el ToolRegistry."""
def test_registry_empty_on_init(self):
"""Verificar que el registro inicia vacío."""
registry = ToolRegistry()
assert len(registry) == 0
assert registry.list_tools() == {}
def test_register_single_tool(self):
"""Registrar una sola herramienta."""
registry = ToolRegistry()
tool = CalculatorTool()
registry.register(tool)
assert len(registry) == 1
assert registry.has_tool("calculator")
assert registry.get_tool("calculator") == tool
def test_register_multiple_tools(self):
"""Registrar múltiples herramientas."""
registry = ToolRegistry()
tools = [CalculatorTool(), GreeterTool(), WeatherTool()]
registry.register_multiple(tools)
assert len(registry) == 3
assert registry.has_tool("calculator")
assert registry.has_tool("greeter")
assert registry.has_tool("get_weather")
def test_register_duplicate_tool_raises_error(self):
"""Intentar registrar una herramienta duplicada debe fallar."""
registry = ToolRegistry()
registry.register(CalculatorTool())
with pytest.raises(ValueError, match="already registered"):
registry.register(CalculatorTool())
def test_register_non_basetool_raises_error(self):
"""Registrar algo que no es BaseTool debe fallar."""
registry = ToolRegistry()
with pytest.raises(TypeError, match="must be instance of BaseTool"):
registry.register("not a tool")
def test_get_tool_existing(self):
"""Obtener una herramienta existente."""
registry = ToolRegistry()
tool = CalculatorTool()
registry.register(tool)
retrieved = registry.get_tool("calculator")
assert retrieved == tool
def test_get_tool_non_existing(self):
"""Obtener una herramienta que no existe retorna None."""
registry = ToolRegistry()
result = registry.get_tool("nonexistent")
assert result is None
def test_get_all_tools(self):
"""Obtener todas las herramientas."""
registry = ToolRegistry()
tools = [CalculatorTool(), GreeterTool()]
registry.register_multiple(tools)
all_tools = registry.get_all_tools()
assert len(all_tools) == 2
assert all(isinstance(t, BaseTool) for t in all_tools)
def test_unregister_tool(self):
"""Desregistrar una herramienta."""
registry = ToolRegistry()
registry.register(CalculatorTool())
registry.unregister("calculator")
assert len(registry) == 0
assert not registry.has_tool("calculator")
def test_unregister_non_existing_raises_error(self):
"""Desregistrar una herramienta que no existe debe fallar."""
registry = ToolRegistry()
with pytest.raises(KeyError, match="not found"):
registry.unregister("nonexistent")
def test_list_tools(self):
"""Listar todas las herramientas con descripciones."""
registry = ToolRegistry()
registry.register(CalculatorTool())
registry.register(GreeterTool())
tools_list = registry.list_tools()
assert isinstance(tools_list, dict)
assert "calculator" in tools_list
assert "greeter" in tools_list
assert len(tools_list) == 2
def test_clear_registry(self):
"""Limpiar el registro."""
registry = ToolRegistry()
registry.register_multiple([
CalculatorTool(),
GreeterTool(),
WeatherTool()
])
registry.clear()
assert len(registry) == 0
def test_contains_operator(self):
"""Usar el operador 'in' para verificar existencia."""
registry = ToolRegistry()
registry.register(CalculatorTool())
assert "calculator" in registry
assert "greeter" not in registry
def test_get_langchain_tools(self):
"""Obtener herramientas en formato LangChain."""
registry = ToolRegistry()
registry.register_multiple([
CalculatorTool(),
GreeterTool()
])
langchain_tools = registry.get_langchain_tools()
assert len(langchain_tools) == 2
assert all(hasattr(t, 'name') for t in langchain_tools)
assert all(hasattr(t, 'description') for t in langchain_tools)
def test_registry_repr(self):
"""Prueba la representación en string del registro."""
registry = ToolRegistry()
registry.register(CalculatorTool())
repr_str = repr(registry)
assert "ToolRegistry" in repr_str
assert "calculator" in repr_str
# ============================================================================
# TESTS DEL TOOL REGISTRY BUILDER
# ============================================================================
class TestToolRegistryBuilder:
"""Pruebas para el ToolRegistryBuilder."""
def test_builder_fluent_interface(self):
"""Verificar la interfaz fluida del builder."""
registry = (ToolRegistryBuilder()
.add(CalculatorTool())
.add(GreeterTool())
.build())
assert isinstance(registry, ToolRegistry)
assert len(registry) == 2
def test_builder_add_multiple(self):
"""Añadir múltiples herramientas con builder."""
registry = (ToolRegistryBuilder()
.add_multiple([
CalculatorTool(),
GreeterTool(),
WeatherTool()
])
.build())
assert len(registry) == 3
def test_builder_mixed_operations(self):
"""Mezclar operaciones add y add_multiple."""
registry = (ToolRegistryBuilder()
.add(CalculatorTool())
.add_multiple([GreeterTool(), WeatherTool()])
.add(TimeTool())
.build())
assert len(registry) == 4
assert registry.has_tool("calculator")
assert registry.has_tool("greeter")
assert registry.has_tool("get_weather")
assert registry.has_tool("get_time")
# ============================================================================
# TESTS DE INTEGRACIÓN CON AGENT
# ============================================================================
class TestAgentWithRegistry:
"""Pruebas de integración del Agent con ToolRegistry."""
def test_agent_accepts_tool_registry(self, mock_model):
"""El agente debe aceptar un ToolRegistry."""
registry = ToolRegistry()
registry.register_multiple([
CalculatorTool(),
GreeterTool()
])
agent = Agent(model=mock_model, tools=registry)
assert len(agent.tools) == 2
def test_agent_registry_list_conversion(self, mock_model):
"""El agente debe convertir ToolRegistry a lista internamente."""
registry = ToolRegistry()
registry.register(CalculatorTool())
agent = Agent(model=mock_model, tools=registry)
# Internamente debe tener la lista de tools
assert len(agent._tools) == 1
def test_agent_with_builder_registry(self, mock_model):
"""Usar Agent con ToolRegistry creado con Builder."""
registry = (ToolRegistryBuilder()
.add(CalculatorTool())
.add(WeatherTool())
.build())
agent = Agent(model=mock_model, tools=registry)
assert len(agent.tools) == 2
def test_agent_still_accepts_list_of_tools(self, mock_model):
"""El agente debe seguir aceptando listas de tools."""
tools = [CalculatorTool(), GreeterTool()]
agent = Agent(model=mock_model, tools=tools)
assert len(agent.tools) == 2
def test_agent_none_tools_default(self, mock_model):
"""El agente debe funcionar sin tools."""
agent = Agent(model=mock_model, tools=None)
assert len(agent.tools) == 0
# ============================================================================
# TESTS DE CASOS DE USO PRÁCTICOS
# ============================================================================
class TestPracticalUseCases:
"""Casos de uso prácticos del ToolRegistry."""
def test_build_customer_service_registry(self):
"""Construir un registro para un agente de servicio al cliente."""
registry = (ToolRegistryBuilder()
.add(GreeterTool())
.add(WeatherTool())
.add(TimeTool())
.build())
assert registry.has_tool("greeter")
assert len(registry) == 3
def test_dynamic_tool_addition(self):
"""Añadir herramientas dinámicamente."""
registry = ToolRegistry()
# Fase 1: registrar herramientas básicas
registry.register(CalculatorTool())
assert len(registry) == 1
# Fase 2: registrar herramientas adicionales
registry.register(GreeterTool())
assert len(registry) == 2
# Fase 3: remover si es necesario
registry.unregister("calculator")
assert len(registry) == 1
def test_tool_registry_documentation(self):
"""Generar documentación de herramientas disponibles."""
registry = (ToolRegistryBuilder()
.add_multiple([
CalculatorTool(),
GreeterTool(),
WeatherTool(),
TimeTool()
])
.build())
tools_doc = registry.list_tools()
assert len(tools_doc) == 4
for tool_name, description in tools_doc.items():
assert isinstance(tool_name, str)
assert isinstance(description, str)
assert len(description) > 0