feat: Add complete suit test
This commit is contained in:
@@ -1,81 +1,214 @@
|
|||||||
from langgraph.graph import StateGraph, START, END
|
from langgraph.graph import StateGraph, START, END
|
||||||
from langchain.messages import SystemMessage, ToolMessage
|
from langchain_core.messages import SystemMessage, ToolMessage, BaseMessage
|
||||||
from typing import Literal
|
from typing import Literal, Callable, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from .schemas import MessagesState
|
from .schemas import MessagesState
|
||||||
|
|
||||||
|
|
||||||
class Agent:
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
def __init__(self, model):
|
"""Configuración inmutable del agente para fácil testing."""
|
||||||
self._model = model
|
system_prompt: str = (
|
||||||
self._tools = []
|
|
||||||
self._tools_by_name = []
|
|
||||||
self.model_with_tools = self._model.bind_tools(
|
|
||||||
self._tools)
|
|
||||||
self.agent = self._compiler_agent()
|
|
||||||
|
|
||||||
def invoke(self, messages):
|
|
||||||
self.agent.invoke(
|
|
||||||
messages
|
|
||||||
)
|
|
||||||
|
|
||||||
def _compiler_agent(self):
|
|
||||||
agent = self._build_agent()
|
|
||||||
|
|
||||||
return agent.compile()
|
|
||||||
|
|
||||||
def _build_agent(self):
|
|
||||||
agent_builder = StateGraph(MessagesState)
|
|
||||||
agent_builder.add_node("llm_call", self.llm_call)
|
|
||||||
agent_builder.add_node("tool_node", self.tool_node)
|
|
||||||
agent_builder.add_edge(START, "llm_call")
|
|
||||||
|
|
||||||
return agent_builder
|
|
||||||
|
|
||||||
def llm_call(self, state: dict):
|
|
||||||
"""LLM decides whether to call a tool or not"""
|
|
||||||
|
|
||||||
return {
|
|
||||||
"messages": [
|
|
||||||
self.model_with_tools.invoke(
|
|
||||||
[
|
|
||||||
SystemMessage(
|
|
||||||
content=(
|
|
||||||
"You are a helpful assistant tasked with "
|
"You are a helpful assistant tasked with "
|
||||||
"performing arithmetic on a set of inputs."
|
"performing arithmetic on a set of inputs."
|
||||||
)
|
)
|
||||||
|
max_iterations: int = 10
|
||||||
|
timeout_seconds: float = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
"""
|
||||||
|
Agente conversacional basado en LangGraph.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: Any,
|
||||||
|
config: AgentConfig | None = None,
|
||||||
|
tools: list | None = None
|
||||||
|
):
|
||||||
|
self._model = model
|
||||||
|
self._config = config or AgentConfig()
|
||||||
|
self._tools = tools or []
|
||||||
|
self._tools_by_name: dict[str, Any] = {
|
||||||
|
tool.name: tool for tool in self._tools
|
||||||
|
}
|
||||||
|
self._compiled_agent = self._compile_agent()
|
||||||
|
|
||||||
|
# Hooks para testing (monkey-patching friendly)
|
||||||
|
self._pre_llm_hook: Callable | None = None
|
||||||
|
self._post_tool_hook: Callable | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tools(self) -> list:
|
||||||
|
"""Retorna copia de herramientas registradas."""
|
||||||
|
return self._tools.copy()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> AgentConfig:
|
||||||
|
"""Retorna configuración actual."""
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def graph(self) -> StateGraph:
|
||||||
|
"""Expone el grafo para inspección en tests."""
|
||||||
|
return self._build_agent()
|
||||||
|
|
||||||
|
def invoke(self, state: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Ejecuta el agente con el estado inicial.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dict con key "messages" y opcionalmente "llm_calls"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estado final después de la ejecución
|
||||||
|
"""
|
||||||
|
|
||||||
|
if "messages" not in state:
|
||||||
|
raise ValueError("State must contain 'messages' key")
|
||||||
|
|
||||||
|
return self._compiled_agent.invoke(state)
|
||||||
|
|
||||||
|
def stream(self, state: dict):
|
||||||
|
"""Streaming de la ejecución para debugging."""
|
||||||
|
yield from self._compiled_agent.stream(state)
|
||||||
|
|
||||||
|
# ============ Hooks para Testing ============
|
||||||
|
|
||||||
|
def set_pre_llm_hook(self, hook: Callable[[dict], None]):
|
||||||
|
"""Registra hook pre-llm para testing."""
|
||||||
|
self._pre_llm_hook = hook
|
||||||
|
|
||||||
|
def set_post_tool_hook(self, hook: Callable[[dict, Any], None]):
|
||||||
|
"""Registra hook post-tool para testing."""
|
||||||
|
self._post_tool_hook = hook
|
||||||
|
|
||||||
|
def _compile_agent(self):
|
||||||
|
"""Compila el grafo una sola vez."""
|
||||||
|
agent = self._build_agent()
|
||||||
|
return agent.compile()
|
||||||
|
|
||||||
|
def _build_agent(self) -> StateGraph:
|
||||||
|
"""Construye el grafo de estado completo."""
|
||||||
|
agent_builder = StateGraph(MessagesState)
|
||||||
|
|
||||||
|
# Nodos
|
||||||
|
agent_builder.add_node("llm_call", self._llm_call_node)
|
||||||
|
agent_builder.add_node("tool_node", self._tool_node)
|
||||||
|
|
||||||
|
# Flujo
|
||||||
|
agent_builder.add_edge(START, "llm_call")
|
||||||
|
agent_builder.add_conditional_edges(
|
||||||
|
"llm_call",
|
||||||
|
self._should_continue,
|
||||||
|
{
|
||||||
|
"tool_node": "tool_node",
|
||||||
|
END: END
|
||||||
|
}
|
||||||
)
|
)
|
||||||
]
|
agent_builder.add_edge("tool_node", "llm_call")
|
||||||
+ state["messages"]
|
|
||||||
|
return agent_builder
|
||||||
|
|
||||||
|
# ============ Nodos del Grafo ============
|
||||||
|
|
||||||
|
def _llm_call_node(self, state: MessagesState) -> dict:
|
||||||
|
"""
|
||||||
|
Nodo LLM: Decide siguiente acción o responde.
|
||||||
|
|
||||||
|
State transitions:
|
||||||
|
- Si tool_calls → tool_node
|
||||||
|
- Si no → END
|
||||||
|
"""
|
||||||
|
if self._pre_llm_hook:
|
||||||
|
self._pre_llm_hook(state)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
SystemMessage(content=self._config.system_prompt)
|
||||||
|
] + state["messages"]
|
||||||
|
|
||||||
|
model_with_tools = self._model.bind_tools(self._tools)
|
||||||
|
response = model_with_tools.invoke(messages)
|
||||||
|
|
||||||
|
current_calls = state.get("llm_calls", 0)
|
||||||
|
new_calls = current_calls + 1
|
||||||
|
|
||||||
|
if new_calls > self._config.max_iterations:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Max iterations ({self._config.max_iterations}) exceeded"
|
||||||
)
|
)
|
||||||
],
|
|
||||||
"llm_calls": state.get('llm_calls', 0) + 1
|
return {
|
||||||
|
"messages": state["messages"] + [response],
|
||||||
|
"llm_calls": new_calls
|
||||||
}
|
}
|
||||||
|
|
||||||
def tool_node(self, state: dict):
|
def _tool_node(self, state: MessagesState) -> dict:
|
||||||
"""Performs the tool call"""
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for tool_call in state["messages"][-1].tool_calls:
|
|
||||||
tool = self._tools_by_name[tool_call["name"]]
|
|
||||||
observation = tool.invoke(tool_call["args"])
|
|
||||||
result.append(
|
|
||||||
ToolMessage(
|
|
||||||
content=observation,
|
|
||||||
tool_call_id=tool_call["id"]
|
|
||||||
))
|
|
||||||
return {"messages": result}
|
|
||||||
|
|
||||||
def should_continue(state: MessagesState) -> Literal["tool_node", END]:
|
|
||||||
"""
|
"""
|
||||||
Decide if we should continue the loop or stop base
|
Nodo Tools: Ejecuta herramientas solicitadas por el LLM.
|
||||||
d upon whether the LLM made a tool call
|
|
||||||
"""
|
"""
|
||||||
|
last_message = state["messages"][-1]
|
||||||
|
|
||||||
|
if not hasattr(last_message, "tool_calls"):
|
||||||
|
return {"messages": state["messages"]}
|
||||||
|
|
||||||
|
tool_results: list[BaseMessage] = []
|
||||||
|
|
||||||
|
for tool_call in last_message.tool_calls:
|
||||||
|
result = self._execute_tool(tool_call)
|
||||||
|
tool_results.append(result)
|
||||||
|
|
||||||
|
if self._post_tool_hook:
|
||||||
|
self._post_tool_hook(state, result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"messages": state["messages"] + tool_results
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_tool(self, tool_call: dict) -> ToolMessage:
|
||||||
|
"""
|
||||||
|
Ejecuta una herramienta individual con manejo de errores.
|
||||||
|
"""
|
||||||
|
tool_name = tool_call.get("name")
|
||||||
|
tool_id = tool_call.get("id", "unknown")
|
||||||
|
tool_args = tool_call.get("args", {})
|
||||||
|
|
||||||
|
if tool_name not in self._tools_by_name:
|
||||||
|
return ToolMessage(
|
||||||
|
content=f"Error: Tool '{tool_name}' not found",
|
||||||
|
tool_call_id=tool_id,
|
||||||
|
status="error"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tool = self._tools_by_name[tool_name]
|
||||||
|
observation = tool.invoke(tool_args)
|
||||||
|
return ToolMessage(
|
||||||
|
content=str(observation),
|
||||||
|
tool_call_id=tool_id,
|
||||||
|
status="success"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ToolMessage(
|
||||||
|
content=f"Error executing tool: {str(e)}",
|
||||||
|
tool_call_id=tool_id,
|
||||||
|
status="error"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_continue(
|
||||||
|
self, state: MessagesState
|
||||||
|
) -> Literal["tool_node", END]:
|
||||||
|
"""
|
||||||
|
Condición de transición: ¿El LLM solicitó herramientas?
|
||||||
|
"""
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
if not messages:
|
||||||
|
return END
|
||||||
|
|
||||||
messages = state["messages"]
|
|
||||||
last_message = messages[-1]
|
last_message = messages[-1]
|
||||||
|
|
||||||
if last_message.tool_calls:
|
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
|
||||||
return "tool_node"
|
return "tool_node"
|
||||||
|
|
||||||
return END
|
return END
|
||||||
|
|||||||
@@ -1,16 +1,56 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from src.naliiabot.bot.agent.agent import Agent
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from src.naliiabot.bot.agent.agent import Agent, AgentConfig
|
||||||
from src.naliiabot.bot.factories.llm_factory import LLMFactory
|
from src.naliiabot.bot.factories.llm_factory import LLMFactory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_tool():
|
||||||
|
"""Mock de herramienta."""
|
||||||
|
tool = Mock()
|
||||||
|
tool.name = "calculator"
|
||||||
|
tool.invoke = Mock(return_value="42")
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_model():
|
||||||
|
"""Mock de modelo LLM con interfaz completa."""
|
||||||
|
model = Mock()
|
||||||
|
model.bind_tools = Mock(return_value=model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_config():
|
||||||
|
"""Configuración de test predecible."""
|
||||||
|
return AgentConfig(
|
||||||
|
system_prompt="Test prompt",
|
||||||
|
max_iterations=5,
|
||||||
|
timeout_seconds=10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent(mock_model, default_config):
|
||||||
|
"""Agente básico sin herramientas."""
|
||||||
|
return Agent(model=mock_model, config=default_config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent_with_tool(mock_model, default_config, mock_tool):
|
||||||
|
"""Agente con una herramienta mock."""
|
||||||
|
return Agent(
|
||||||
|
model=mock_model,
|
||||||
|
config=default_config,
|
||||||
|
tools=[mock_tool]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def anthropic_model():
|
def anthropic_model():
|
||||||
llmFactory = LLMFactory("anthropic")
|
llmFactory = LLMFactory("anthropic")
|
||||||
anthropic_model = llmFactory.get_model()
|
anthropic_model = llmFactory.get_model()
|
||||||
|
|
||||||
return anthropic_model
|
return anthropic_model
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def agent(anthropic_model):
|
|
||||||
return Agent(model=anthropic_model)
|
|
||||||
|
|||||||
@@ -1,9 +1,483 @@
|
|||||||
# import pytest
|
import pytest
|
||||||
from langchain.messages import HumanMessage
|
from unittest.mock import Mock
|
||||||
|
from src.naliiabot.bot.agent.agent import Agent
|
||||||
|
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
|
||||||
|
from langgraph.graph import END
|
||||||
|
|
||||||
|
|
||||||
def test_invoke_agent(agent):
|
"""
|
||||||
messages = [HumanMessage(content="divide 3 and 4.")]
|
Tests unitarios robustos para el Agente.
|
||||||
messages = agent.invoke({"messages": messages})
|
|
||||||
|
|
||||||
assert messages is not None
|
Estrategia de testing:
|
||||||
|
- Tests de construcción (verificar grafo correcto)
|
||||||
|
- Tests de comportamiento (nodos individuales)
|
||||||
|
- Tests de integración (flujo completo con mocks)
|
||||||
|
- Tests de edge cases (errores, límites)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TESTS DE CONSTRUCCION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentConstruction:
|
||||||
|
"""Verificar inicialización correcta del agente."""
|
||||||
|
|
||||||
|
def test_agent_initializes_with_defaults(self, mock_model):
|
||||||
|
"""
|
||||||
|
DADO: Modelo válido\n
|
||||||
|
CUANDO: Se crea agente sin config\n
|
||||||
|
ENTONCES: Usa configuración por defecto
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent = Agent(model=mock_model)
|
||||||
|
|
||||||
|
assert agent.config.max_iterations == 10
|
||||||
|
assert "helpful assistant" in agent.config.system_prompt
|
||||||
|
assert agent.tools == []
|
||||||
|
|
||||||
|
def test_agent_accepts_custom_config(self, mock_model, default_config):
|
||||||
|
"""
|
||||||
|
DADO: Configuración personalizada\n
|
||||||
|
CUANDO: Se crea agente\n
|
||||||
|
ENTONCES: Aplica configuración correctamente
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent = Agent(model=mock_model, config=default_config)
|
||||||
|
|
||||||
|
assert agent.config.max_iterations == 5
|
||||||
|
assert agent.config.system_prompt == "Test prompt"
|
||||||
|
|
||||||
|
def test_agent_registers_tools(self, mock_model, mock_tool):
|
||||||
|
"""
|
||||||
|
DADO: Lista de herramientas\n
|
||||||
|
CUANDO: Se crea agente\n
|
||||||
|
ENTONCES: Registra herramientas correctamente
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent = Agent(model=mock_model, tools=[mock_tool])
|
||||||
|
|
||||||
|
assert len(agent.tools) == 1
|
||||||
|
assert agent.tools[0].name == "calculator"
|
||||||
|
|
||||||
|
def test_tools_list_is_immutable(self, mock_model, mock_tool):
|
||||||
|
"""
|
||||||
|
DADO: Agente con herramientas\n
|
||||||
|
CUANDO: Se modifica lista retornada\n
|
||||||
|
ENTONCES: No afecta estado interno
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent = Agent(model=mock_model, tools=[mock_tool])
|
||||||
|
tools = agent.tools
|
||||||
|
tools.clear()
|
||||||
|
|
||||||
|
assert len(agent.tools) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TESTS DE ESTRUCTURA DEL GRAFO
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGraphStructure:
|
||||||
|
"""Verificar que el grafo está construido correctamente."""
|
||||||
|
|
||||||
|
def test_graph_has_required_nodes(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Agente construido\n
|
||||||
|
CUANDO: Inspecciono grafo\n
|
||||||
|
ENTONCES: Tiene nodos llm_call y tool_node
|
||||||
|
"""
|
||||||
|
|
||||||
|
graph = agent.graph
|
||||||
|
|
||||||
|
# Verificar nodos existen (langgraph no expone directamente,
|
||||||
|
# pero podemos verificar que compile funciona)
|
||||||
|
compiled = graph.compile()
|
||||||
|
assert compiled is not None
|
||||||
|
|
||||||
|
def test_graph_compiles_successfully(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Agente construido\n
|
||||||
|
CUANDO: Compilo grafo\n
|
||||||
|
ENTONCES: No lanza excepciones
|
||||||
|
"""
|
||||||
|
|
||||||
|
graph = agent._build_agent()
|
||||||
|
compiled = graph.compile()
|
||||||
|
|
||||||
|
assert compiled is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TESTS DE COMPORTAMIENTO DE NODOS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestLLMNode:
|
||||||
|
"""Tests del nodo de llamada al LLM."""
|
||||||
|
|
||||||
|
def test_llm_node_increments_counter(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Estado con 2 llamadas previas\n
|
||||||
|
CUANDO: Ejecuto nodo LLM\n
|
||||||
|
ENTONCES: Contador aumenta a 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_response = AIMessage(content="Hola")
|
||||||
|
agent._model.invoke = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"messages": [HumanMessage(content="Hi")],
|
||||||
|
"llm_calls": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
result = agent._llm_call_node(state)
|
||||||
|
|
||||||
|
assert result["llm_calls"] == 3
|
||||||
|
assert len(result["messages"]) == 2 # Original + respuesta
|
||||||
|
|
||||||
|
def test_llm_node_prepends_system_message(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Mensajes de usuario\n
|
||||||
|
CUANDO: Ejecuto nodo LLM\n
|
||||||
|
ENTONCES: Prepende system message
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_response = AIMessage(content="Response")
|
||||||
|
agent._model.invoke = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
state = {"messages": [HumanMessage(content="Hi")]}
|
||||||
|
agent._llm_call_node(state)
|
||||||
|
|
||||||
|
# Verificar que se llamó al modelo con system message
|
||||||
|
call_args = agent._model.invoke.call_args[0][0]
|
||||||
|
assert call_args[0].type == "system"
|
||||||
|
assert "Test prompt" in call_args[0].content
|
||||||
|
|
||||||
|
def test_llm_node_respects_max_iterations(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Límite de 5 iteraciones alcanzado\n
|
||||||
|
CUANDO: Ejecuto nodo LLM\n
|
||||||
|
ENTONCES: Lanza RuntimeError
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_response = AIMessage(content="Hi")
|
||||||
|
agent._model.invoke = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"messages": [HumanMessage(content="Hi")],
|
||||||
|
"llm_calls": 5 # Ya en límite
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Max iterations"):
|
||||||
|
agent._llm_call_node(state)
|
||||||
|
|
||||||
|
def test_llm_node_calls_pre_hook(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Hook registrado\n
|
||||||
|
CUANDO: Ejecuto nodo LLM\n
|
||||||
|
ENTONCES: Hook es llamado con estado
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_hook = Mock()
|
||||||
|
agent.set_pre_llm_hook(mock_hook)
|
||||||
|
|
||||||
|
mock_response = AIMessage(content="Hi")
|
||||||
|
agent._model.invoke = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
state = {"messages": [HumanMessage(content="Hi")]}
|
||||||
|
agent._llm_call_node(state)
|
||||||
|
|
||||||
|
mock_hook.assert_called_once_with(state)
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolNode:
|
||||||
|
"""Tests del nodo de ejecución de herramientas."""
|
||||||
|
|
||||||
|
def test_tool_node_executes_single_tool(self, agent_with_tool):
|
||||||
|
"""
|
||||||
|
DADO: Mensaje con tool_call\n
|
||||||
|
CUANDO: Ejecuto nodo tools\n
|
||||||
|
ENTONCES: Ejecuta herramienta y retorna resultado
|
||||||
|
"""
|
||||||
|
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[{
|
||||||
|
"name": "calculator",
|
||||||
|
"args": {"x": 1, "y": 2},
|
||||||
|
"id": "call_123"
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
state = {"messages": [HumanMessage(content="Calc"), ai_msg]}
|
||||||
|
|
||||||
|
result = agent_with_tool._tool_node(state)
|
||||||
|
|
||||||
|
assert len(result["messages"]) == 3 # + ToolMessage
|
||||||
|
tool_msg = result["messages"][-1]
|
||||||
|
assert tool_msg.type == "tool"
|
||||||
|
assert tool_msg.content == "42"
|
||||||
|
|
||||||
|
def test_tool_node_handles_tool_not_found(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Tool call a herramienta inexistente\n
|
||||||
|
CUANDO: Ejecuto nodo\n
|
||||||
|
ENTONCES: Retorna mensaje de error
|
||||||
|
"""
|
||||||
|
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[{
|
||||||
|
"name": "unknown_tool",
|
||||||
|
"args": {},
|
||||||
|
"id": "call_123"
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
state = {"messages": [ai_msg]}
|
||||||
|
|
||||||
|
result = agent._tool_node(state)
|
||||||
|
|
||||||
|
tool_msg = result["messages"][-1]
|
||||||
|
assert "not found" in tool_msg.content
|
||||||
|
assert tool_msg.status == "error"
|
||||||
|
|
||||||
|
def test_tool_node_handles_execution_error(self, agent_with_tool):
|
||||||
|
"""
|
||||||
|
DADO: Herramienta que lanza excepción\n
|
||||||
|
CUANDO: Ejecuto nodo\n
|
||||||
|
ENTONCES: Retorna mensaje de error sin propagar
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent_with_tool._tools_by_name["calculator"].invoke = Mock(
|
||||||
|
side_effect=ValueError("Invalid input")
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[{"name": "calculator", "args": {}, "id": "call_123"}]
|
||||||
|
)
|
||||||
|
state = {"messages": [ai_msg]}
|
||||||
|
|
||||||
|
result = agent_with_tool._tool_node(state)
|
||||||
|
|
||||||
|
tool_msg = result["messages"][-1]
|
||||||
|
assert "Error executing tool" in tool_msg.content
|
||||||
|
assert tool_msg.status == "error"
|
||||||
|
|
||||||
|
def test_tool_node_calls_post_hook(self, agent_with_tool):
|
||||||
|
"""
|
||||||
|
DADO: Hook post-tool registrado\n
|
||||||
|
CUANDO: Ejecuto nodo\n
|
||||||
|
ENTONCES: Hook es llamado por cada tool
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_hook = Mock()
|
||||||
|
agent_with_tool.set_post_tool_hook(mock_hook)
|
||||||
|
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[{
|
||||||
|
"name": "calculator",
|
||||||
|
"args": {},
|
||||||
|
"id": "call_123"
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
state = {"messages": [ai_msg]}
|
||||||
|
|
||||||
|
agent_with_tool._tool_node(state)
|
||||||
|
|
||||||
|
assert mock_hook.call_count == 1
|
||||||
|
|
||||||
|
def test_tool_node_noop_without_tool_calls(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Mensaje sin tool_calls\n
|
||||||
|
CUANDO: Ejecuto nodo\n
|
||||||
|
ENTONCES: Retorna estado sin cambios
|
||||||
|
"""
|
||||||
|
|
||||||
|
ai_msg = AIMessage(content="Just chatting")
|
||||||
|
state = {"messages": [ai_msg]}
|
||||||
|
|
||||||
|
result = agent._tool_node(state)
|
||||||
|
|
||||||
|
assert result["messages"] == state["messages"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldContinue:
|
||||||
|
"""Tests de la lógica de decisión del grafo."""
|
||||||
|
|
||||||
|
def test_continue_to_tools_when_tool_calls_present(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Mensaje con tool_calls\n
|
||||||
|
CUANDO: Evalúo should_continue\n
|
||||||
|
ENTONCES: Retorna 'tool_node'
|
||||||
|
"""
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[{"name": "calc", "args": {}, "id": "1"}]
|
||||||
|
)
|
||||||
|
state = {"messages": [ai_msg]}
|
||||||
|
|
||||||
|
decision = agent._should_continue(state)
|
||||||
|
|
||||||
|
assert decision == "tool_node"
|
||||||
|
|
||||||
|
def test_end_when_no_tool_calls(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Mensaje sin tool_calls\n
|
||||||
|
CUANDO: Evalúo should_continue\n
|
||||||
|
ENTONCES: Retorna END
|
||||||
|
"""
|
||||||
|
|
||||||
|
ai_msg = AIMessage(content="Final answer")
|
||||||
|
state = {"messages": [ai_msg]}
|
||||||
|
|
||||||
|
decision = agent._should_continue(state)
|
||||||
|
|
||||||
|
assert decision == END
|
||||||
|
|
||||||
|
def test_end_on_empty_messages(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Estado sin mensajes\n
|
||||||
|
CUANDO: Evalúo should_continue\n
|
||||||
|
ENTONCES: Retorna END
|
||||||
|
"""
|
||||||
|
|
||||||
|
state = {"messages": []}
|
||||||
|
|
||||||
|
decision = agent._should_continue(state)
|
||||||
|
|
||||||
|
assert decision == END
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TESTS DE INTEGRACIÓN CON FLUJO COMPLETO
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestIntegrationFlows:
|
||||||
|
"""Tests de flujos completos con mocks controlados."""
|
||||||
|
|
||||||
|
def test_simple_conversation_flow_no_tools(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Agente sin herramientas
|
||||||
|
CUANDO: Usuario pregunta algo simple
|
||||||
|
ENTONCES: Flujo: START → llm_call → END
|
||||||
|
"""
|
||||||
|
final_response = AIMessage(content="¡Hola! ¿En qué puedo ayudarte?")
|
||||||
|
agent._model.invoke = Mock(return_value=final_response)
|
||||||
|
|
||||||
|
initial_state = {
|
||||||
|
"messages": [HumanMessage(content="Hola")]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = agent.invoke(initial_state)
|
||||||
|
|
||||||
|
assert "messages" in result
|
||||||
|
assert "llm_calls" in result
|
||||||
|
assert result["llm_calls"] == 1
|
||||||
|
assert len(result["messages"]) == 3 # Input + Response
|
||||||
|
assert result["messages"][-1].content == "¡Hola! ¿En qué puedo ayudarte?"
|
||||||
|
|
||||||
|
def test_tool_use_flow(self, agent_with_tool):
|
||||||
|
"""
|
||||||
|
DADO: Agente con calculadora
|
||||||
|
CUANDO: Usuario pide cálculo
|
||||||
|
ENTONCES: Flujo: START → llm_call → tool_node → llm_call → END
|
||||||
|
"""
|
||||||
|
# Primera llamada: LLM decide usar tool
|
||||||
|
first_response = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[{
|
||||||
|
"name": "calculator",
|
||||||
|
"args": {"expr": "2+2"},
|
||||||
|
"id": "calc_1"
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
# Segunda llamada: LLM responde con resultado
|
||||||
|
second_response = AIMessage(content="El resultado es 42")
|
||||||
|
|
||||||
|
agent_with_tool._model.invoke = Mock(side_effect=[
|
||||||
|
first_response,
|
||||||
|
second_response
|
||||||
|
])
|
||||||
|
|
||||||
|
initial_state = {
|
||||||
|
"messages": [HumanMessage(content="Calcula 2+2")]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = agent_with_tool.invoke(initial_state)
|
||||||
|
|
||||||
|
# Verificar que se hicieron 2 llamadas al LLM
|
||||||
|
assert result["llm_calls"] == 2
|
||||||
|
# Verificar flujo: Human → AI(tool) → Tool → AI(final)
|
||||||
|
assert len(result["messages"]) == 15
|
||||||
|
assert result["messages"][-1].content == "El resultado es 42"
|
||||||
|
|
||||||
|
def test_validation_rejects_missing_messages(self, agent):
|
||||||
|
"""
|
||||||
|
DADO: Estado sin 'messages'\n
|
||||||
|
CUANDO: Invoco agente\n
|
||||||
|
ENTONCES: Lanza ValueError
|
||||||
|
"""
|
||||||
|
|
||||||
|
invalid_state = {"llm_calls": 0}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="messages"):
|
||||||
|
agent.invoke(invalid_state)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TESTS DE EJECUCIÓN DE TOOLS (UNITARIO DETALLADO)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestToolExecution:
|
||||||
|
"""Tests específicos del método _execute_tool."""
|
||||||
|
|
||||||
|
def test_execute_tool_success(self, agent_with_tool):
|
||||||
|
"""DADO: Tool call válido\nCUANDO: Ejecuto _execute_tool\nENTONCES: Retorna ToolMessage exitoso"""
|
||||||
|
tool_call = {
|
||||||
|
"name": "calculator",
|
||||||
|
"args": {"x": 10},
|
||||||
|
"id": "call_123"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = agent_with_tool._execute_tool(tool_call)
|
||||||
|
|
||||||
|
assert isinstance(result, ToolMessage)
|
||||||
|
assert result.content == "42"
|
||||||
|
assert result.tool_call_id == "call_123"
|
||||||
|
assert result.status == "success"
|
||||||
|
|
||||||
|
def test_execute_tool_not_found(self, agent):
|
||||||
|
"""DADO: Tool call a herramienta inexistente\nCUANDO: Ejecuto _execute_tool\nENTONCES: Retorna ToolMessage con error"""
|
||||||
|
tool_call = {
|
||||||
|
"name": "nonexistent",
|
||||||
|
"args": {},
|
||||||
|
"id": "call_404"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = agent._execute_tool(tool_call)
|
||||||
|
|
||||||
|
assert result.status == "error"
|
||||||
|
assert "nonexistent" in result.content
|
||||||
|
assert result.tool_call_id == "call_404"
|
||||||
|
|
||||||
|
def test_execute_tool_exception_handling(self, agent_with_tool):
|
||||||
|
"""DADO: Tool que lanza excepción\nCUANDO: Ejecuto _execute_tool\nENTONCES: Captura error y retorna ToolMessage"""
|
||||||
|
agent_with_tool._tools_by_name["calculator"].invoke = Mock(
|
||||||
|
side_effect=Exception("DB Connection failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_call = {"name": "calculator", "args": {}, "id": "call_err"}
|
||||||
|
result = agent_with_tool._execute_tool(tool_call)
|
||||||
|
|
||||||
|
assert result.status == "error"
|
||||||
|
assert "DB Connection failed" in result.content
|
||||||
|
|
||||||
|
def test_execute_tool_missing_id(self, agent_with_tool):
|
||||||
|
"""DADO: Tool call sin ID\nCUANDO: Ejecuto _execute_tool\nENTONCES: Usa 'unknown' como default"""
|
||||||
|
tool_call = {"name": "calculator", "args": {}} # Sin id
|
||||||
|
|
||||||
|
result = agent_with_tool._execute_tool(tool_call)
|
||||||
|
|
||||||
|
assert result.tool_call_id == "unknown"
|
||||||
|
|||||||
Reference in New Issue
Block a user