feat: Implemented Registry Pattern
This commit is contained in:
475
tests/test_naliia_agent_tools.py
Normal file
475
tests/test_naliia_agent_tools.py
Normal 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"
|
||||
Reference in New Issue
Block a user