Módulo 2: Tools y Tool Calling

Proyecto: Asistente con Herramientas Externas

Descripción del proyecto

En las siete cápsulas anteriores aprendiste a crear tools con @tool, vincularlas a modelos con bind_tools(), implementar el tool execution loop completo (model→tool→model), manejar parallel tool calls, usar tool calling como mecanismo de structured extraction, y construir error handling robusto con retry, validación, y graceful degradation. Cada concepto lo viste por separado, con ejemplos aislados. Ahora vas a combinar todo en un sistema real.

En este proyecto construyes un asistente conversacional con acceso a herramientas externas. El asistente tiene tres herramientas: una para consultar el clima de cualquier ciudad, una calculadora para operaciones matemáticas, y un buscador web para preguntas generales. Cuando el usuario hace una pregunta, el modelo decide qué tools necesita — puede ser una, dos, o las tres simultáneamente. Si el usuario pregunta "¿Qué clima hace en Madrid y cuánto es 15% de 240?", el modelo llama get_weather y calculator en paralelo, recibe ambos resultados, y genera una respuesta que integra toda la información.

El asistente no solo llama tools — también maneja fallos. Si una tool falla (por un timeout, datos no encontrados, o un error inesperado), el sistema no se colapsa. El error se captura, se reporta al modelo, y el asistente informa al usuario qué salió mal mientras sigue funcionando con las tools restantes. Esta resiliencia es lo que separa un prototipo de un sistema confiable.

El resultado es un chat interactivo en terminal donde puedes conversar con un asistente que realmente hace cosas: consulta clima, calcula, busca información — y lo hace de forma robusta.


Objetivo del proyecto

Construir un asistente conversacional en terminal con acceso a tres herramientas externas, que maneja parallel tool calls y errores de forma graceful.

Al completar este proyecto:

  • 🔧 Sabrás crear múltiples tools con @tool y vincularlas a un modelo
  • 🔧 Implementarás un tool execution loop completo que maneja cualquier combinación de tool calls
  • 🔧 Manejarás parallel tool calls cuando el modelo decide llamar múltiples tools simultáneamente
  • 🔧 Construirás error handling que permite al sistema seguir funcionando cuando una tool falla
  • 🔧 Tendrás un chat funcional en terminal que demuestra todo lo aprendido en el módulo

Especificaciones técnicas

Stack tecnológico

ComponenteVersiónPropósito
Python3.11+Runtime
LangChainv1.2+Framework de LLMs
langchain-openailatestProveedor de modelo
python-dotenvlatestVariables de entorno

Setup inicial

Antes de empezar, asegúrate de tener las dependencias instaladas:

pip install langchain langchain-openai python-dotenv

Crea un archivo .env en la raíz de tu proyecto:

# .env
OPENAI_API_KEY=sk-...

Estructura del proyecto

asistente-herramientas/
├── .env                  # API key
├── assistant.py          # Código principal (todo en un archivo)
└── requirements.txt      # Dependencias
# requirements.txt
langchain>=0.3.0
langchain-openai>=0.3.0
python-dotenv>=1.0.0

Todo el código va en un solo archivo assistant.py. El objetivo es integrar los conceptos del módulo, no diseñar arquitectura.


Paso 1: Crear las 3 herramientas

El asistente necesita tres herramientas con capacidades distintas. Cada tool tiene validación de argumentos y manejo de errores integrado — los patrones que aprendiste en la cápsula 07.

Tool 1: Weather (mock)

Simula una consulta de clima. En un sistema real, esto llamaría a una API como OpenWeatherMap. Usamos un mock para que el proyecto funcione sin API keys adicionales.

from langchain_core.tools import tool

WEATHER_DATA = {
    "Madrid": {"temp": 22, "condition": "soleado", "humidity": 45},
    "London": {"temp": 14, "condition": "nublado", "humidity": 78},
    "Tokyo": {"temp": 28, "condition": "húmedo", "humidity": 82},
    "New York": {"temp": 18, "condition": "parcialmente nublado", "humidity": 55},
    "San Francisco": {"temp": 16, "condition": "niebla", "humidity": 72},
    "Buenos Aires": {"temp": 25, "condition": "soleado", "humidity": 50},
    "Ciudad de México": {"temp": 20, "condition": "lluvioso", "humidity": 65},
    "Bogotá": {"temp": 15, "condition": "nublado", "humidity": 70},
}

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad.
    Ciudades disponibles: Madrid, London, Tokyo, New York,
    San Francisco, Buenos Aires, Ciudad de México, Bogotá.
    """
    city_normalized = city.strip().title()

    if not city_normalized:
        return "Error: proporciona el nombre de una ciudad."

    for key in WEATHER_DATA:
        if key.lower() == city_normalized.lower():
            data = WEATHER_DATA[key]
            return (
                f"Clima en {key}: {data['temp']}°C, {data['condition']}, "
                f"humedad {data['humidity']}%"
            )

    available = ", ".join(sorted(WEATHER_DATA.keys()))
    return (
        f"No tengo datos de clima para '{city}'. "
        f"Ciudades disponibles: {available}"
    )

Tool 2: Calculator

Evalúa expresiones matemáticas. Usa una validación de caracteres permitidos para evitar ejecución de código arbitrario (un riesgo real con eval).

import math

@tool
def calculator(expression: str) -> str:
    """Evalúa una expresión matemática.
    Soporta: +, -, *, /, **, (), sqrt(), abs(), round().
    Ejemplos: '15 * 0.16', 'sqrt(144)', '2**10', 'round(3.14159, 2)'.
    """
    if not expression or not expression.strip():
        return "Error: expresión vacía. Proporciona una expresión como '15 * 0.16'."

    safe_dict = {
        "sqrt": math.sqrt,
        "abs": abs,
        "round": round,
        "pow": pow,
        "pi": math.pi,
        "e": math.e,
    }

    allowed_chars = set("0123456789+-*/.() ,epiabsqrtoundw")

    if not all(c in allowed_chars for c in expression.lower().replace(" ", "")):
        return (
            f"Error: la expresión '{expression}' contiene caracteres no permitidos. "
            f"Usa solo números y operadores (+, -, *, /, **, ())."
        )

    try:
        result = eval(expression, {"__builtins__": {}}, safe_dict)
        if isinstance(result, float):
            if result == int(result):
                return str(int(result))
            return str(round(result, 6))
        return str(result)
    except ZeroDivisionError:
        return "Error: división por cero."
    except Exception as e:
        return f"Error al evaluar '{expression}': {e}"

Tool 3: Web Search (mock)

Simula una búsqueda web. En un sistema real, esto usaría la API de DuckDuckGo, Tavily, o Brave Search.

@tool
def web_search(query: str) -> str:
    """Busca información en la web sobre cualquier tema.
    Útil para preguntas generales, definiciones, datos actuales.
    """
    if not query or len(query.strip()) < 3:
        return "Error: la búsqueda necesita al menos 3 caracteres."

    query_lower = query.lower()

    knowledge_base = {
        "python": (
            "Python es un lenguaje de programación de alto nivel, interpretado y de propósito "
            "general. Creado por Guido van Rossum, lanzado en 1991. Es el lenguaje más popular "
            "para AI/ML, data science y scripting. Última versión estable: 3.12."
        ),
        "langchain": (
            "LangChain es un framework open-source para construir aplicaciones con LLMs. "
            "Proporciona interfaces para modelos, tools, agents y workflows. "
            "Versión actual: v1.2+. Ecosistema: LangChain, LangGraph, LangSmith."
        ),
        "fastapi": (
            "FastAPI es un framework web moderno para Python, basado en type hints. "
            "Genera documentación OpenAPI automáticamente. Es async-first y uno de los "
            "frameworks más rápidos de Python."
        ),
        "docker": (
            "Docker es una plataforma de contenedores que empaqueta aplicaciones con "
            "todas sus dependencias. Permite ejecutar aplicaciones de forma consistente "
            "en cualquier entorno. Docker Hub tiene miles de imágenes pre-construidas."
        ),
        "react": (
            "React es una biblioteca de JavaScript para construir interfaces de usuario. "
            "Creada por Meta (Facebook). Usa un Virtual DOM para renderizado eficiente. "
            "Es la biblioteca frontend más usada en el mundo."
        ),
        "kubernetes": (
            "Kubernetes (K8s) es un sistema de orquestación de contenedores open-source. "
            "Automatiza el despliegue, escalado y gestión de aplicaciones containerizadas. "
            "Originalmente diseñado por Google."
        ),
    }

    for keyword, info in knowledge_base.items():
        if keyword in query_lower:
            return f"Resultado de búsqueda para '{query}':\n{info}"

    return (
        f"Resultados limitados para '{query}'. No encontré información específica "
        f"en mi base de datos. En un sistema real, esto consultaría DuckDuckGo o "
        f"una API de búsqueda web."
    )

Probemos las tres tools por separado:

print(get_weather.invoke({"city": "Madrid"}))
# Output esperado: Clima en Madrid: 22°C, soleado, humedad 45%

print(calculator.invoke({"expression": "sqrt(144) + 10"}))
# Output esperado: 22

print(web_search.invoke({"query": "qué es LangChain"}))
# Output esperado: Resultado de búsqueda para 'qué es LangChain':
# LangChain es un framework open-source...

Paso 2: Bind tools e implementar el execution loop

Ahora vinculamos las tres tools al modelo y construimos el loop que las ejecuta. Este es el patrón que aprendiste en la cápsula 04, aplicado a un sistema real.

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage

tools = [get_weather, calculator, web_search]
tools_by_name = {t.name: t for t in tools}

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)

La función de ejecución de tools:

def execute_tool_calls(response, tools_map):
    """Ejecuta todos los tool calls de una respuesta.
    Retorna lista de ToolMessages.
    """
    tool_messages = []

    for tc in response.tool_calls:
        tool_name = tc["name"]
        tool_args = tc["args"]
        tool_id = tc["id"]

        if tool_name not in tools_map:
            result = (
                f"Error: herramienta '{tool_name}' no disponible. "
                f"Herramientas válidas: {', '.join(tools_map.keys())}"
            )
        else:
            try:
                result = tools_map[tool_name].invoke(tool_args)
            except Exception as e:
                result = f"Error ejecutando '{tool_name}': {type(e).__name__}: {e}"

        tool_messages.append(ToolMessage(content=str(result), tool_call_id=tool_id))

    return tool_messages

Probemos con una pregunta que requiere una sola tool:

messages = [HumanMessage(content="¿Qué clima hace en Tokyo?")]

response = model_with_tools.invoke(messages)
print(f"Tool calls: {[(tc['name'], tc['args']) for tc in response.tool_calls]}")

if response.tool_calls:
    tool_results = execute_tool_calls(response, tools_by_name)
    messages.append(response)
    messages.extend(tool_results)

    final = model_with_tools.invoke(messages)
    print(f"Respuesta: {final.content}")
# Output esperado:
# Tool calls: [('get_weather', {'city': 'Tokyo'})]
# Respuesta: En Tokyo el clima es de 28°C, húmedo, con una humedad del 82%.

Paso 3: Agregar manejo de parallel tool calls

Cuando el usuario pide múltiples datos — "¿Qué clima hace en Madrid y en London?" — el modelo genera múltiples tool calls simultáneamente. La función execute_tool_calls del paso anterior ya maneja esto naturalmente: itera sobre todos los tool calls en response.tool_calls.

Verifiquemos que funciona:

messages = [HumanMessage(content="¿Qué clima hace en Madrid y en London?")]

response = model_with_tools.invoke(messages)
print(f"Número de tool calls: {len(response.tool_calls)}")
for tc in response.tool_calls:
    print(f"  → {tc['name']}({tc['args']})")

if response.tool_calls:
    tool_results = execute_tool_calls(response, tools_by_name)
    messages.append(response)
    messages.extend(tool_results)

    final = model_with_tools.invoke(messages)
    print(f"\nRespuesta: {final.content}")
# Output esperado:
# Número de tool calls: 2
#   → get_weather({'city': 'Madrid'})
#   → get_weather({'city': 'London'})
#
# Respuesta: En Madrid hace 22°C y está soleado con humedad del 45%.
# En London hace 14°C, está nublado con humedad del 78%.

¿Y con tools de tipos diferentes?

messages = [
    HumanMessage(content="¿Cuánto es 1500 * 0.16 y qué clima hace en San Francisco?")
]

response = model_with_tools.invoke(messages)
print(f"Tool calls: {[(tc['name'], tc['args']) for tc in response.tool_calls]}")

if response.tool_calls:
    tool_results = execute_tool_calls(response, tools_by_name)
    messages.append(response)
    messages.extend(tool_results)

    final = model_with_tools.invoke(messages)
    print(f"\nRespuesta: {final.content}")
# Output esperado:
# Tool calls: [('calculator', {'expression': '1500 * 0.16'}), ('get_weather', {'city': 'San Francisco'})]
#
# Respuesta: 1500 × 0.16 = 240. En San Francisco hay 16°C con niebla y humedad del 72%.

El modelo decide automáticamente qué tools necesita y las llama en paralelo. Tu código no necesita lógica especial — simplemente ejecuta cada tool call que encuentre en la lista.


Paso 4: Agregar error handling con graceful fallback

Ahora hagamos el sistema resiliente. La función execute_tool_calls ya tiene try/except básico, pero necesitamos asegurarnos de que el loop completo maneja todos los edge cases: tools inexistentes, errores en la ejecución, y límite de iteraciones.

def run_assistant(messages, model_with_tools, tools_map, max_iterations=5):
    """Ejecuta el loop completo del asistente con error handling.
    Retorna la respuesta final como string.
    """
    for iteration in range(max_iterations):
        try:
            response = model_with_tools.invoke(messages)
        except Exception as e:
            return f"Error al comunicarse con el modelo: {e}"

        messages.append(response)

        if not response.tool_calls:
            return response.content

        tool_names = [tc["name"] for tc in response.tool_calls]
        print(f"  🔧 Tools: {', '.join(tool_names)}")

        for tc in response.tool_calls:
            tool_name = tc["name"]
            tool_args = tc["args"]
            tool_id = tc["id"]

            if tool_name not in tools_map:
                result = (
                    f"Error: herramienta '{tool_name}' no disponible. "
                    f"Herramientas válidas: {', '.join(tools_map.keys())}"
                )
                print(f"    ❌ {tool_name}: no existe")
            else:
                try:
                    result = tools_map[tool_name].invoke(tool_args)
                    preview = result[:50] + "..." if len(result) > 50 else result
                    print(f"    ✅ {tool_name}: {preview}")
                except Exception as e:
                    result = (
                        f"Error en '{tool_name}': {type(e).__name__}: {e}. "
                        f"La herramienta no está disponible en este momento."
                    )
                    print(f"    ❌ {tool_name}: {e}")

            messages.append(ToolMessage(content=str(result), tool_call_id=tool_id))

    return "El asistente alcanzó el límite de iteraciones sin generar respuesta final."

Probemos con un caso que involucra error:

messages = [
    HumanMessage(content="¿Qué clima hace en Atlantis y cuánto es 100/0?")
]

answer = run_assistant(messages, model_with_tools, tools_by_name)
print(f"\nRespuesta: {answer}")
# Output esperado:
#   🔧 Tools: get_weather, calculator
#     ✅ get_weather: No tengo datos de clima para 'Atlantis'. Ci...
#     ✅ calculator: Error: división por cero.
#
# Respuesta: No tengo datos de clima para Atlantis — las ciudades disponibles
# son Madrid, London, Tokyo, entre otras. En cuanto al cálculo, 100/0 no es
# posible porque no se puede dividir por cero.

El sistema no crashea: la tool de weather retorna un mensaje informativo sobre la ciudad no encontrada, la calculadora reporta la división por cero, y el modelo integra ambos resultados en una respuesta coherente.


Paso 5: Chat loop interactivo en terminal

El último paso une todo en un chat conversacional. El asistente mantiene historial de mensajes para que el modelo tenga contexto de la conversación completa.

SYSTEM_PROMPT = """Eres un asistente útil con acceso a herramientas.

Herramientas disponibles:
- get_weather: consulta el clima de ciudades
- calculator: evalúa expresiones matemáticas
- web_search: busca información sobre cualquier tema

Reglas:
- Usa las herramientas cuando la pregunta lo requiera
- Si una herramienta falla, informa al usuario y sugiere alternativas
- Responde en español de forma concisa y directa
- Puedes llamar múltiples herramientas si la pregunta lo necesita"""


def chat():
    """Loop principal del asistente con herramientas."""
    print("=" * 55)
    print("  Asistente con Herramientas Externas")
    print("  Escribe 'salir' para terminar")
    print("  Escribe 'tools' para ver herramientas disponibles")
    print("  Escribe 'clear' para limpiar historial")
    print("=" * 55)

    from langchain_core.messages import SystemMessage

    history = [SystemMessage(content=SYSTEM_PROMPT)]

    while True:
        try:
            user_input = input("\nTú: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\n¡Hasta luego!")
            break

        if not user_input:
            continue

        if user_input.lower() in ("salir", "exit", "quit"):
            print("\n¡Hasta luego!")
            break

        if user_input.lower() == "tools":
            print("\nHerramientas disponibles:")
            for name, t in tools_by_name.items():
                print(f"  🔧 {name}: {t.description[:70]}")
            continue

        if user_input.lower() == "clear":
            history = [SystemMessage(content=SYSTEM_PROMPT)]
            print("\n🗑️  Historial limpiado.")
            continue

        history.append(HumanMessage(content=user_input))

        answer = run_assistant(history, model_with_tools, tools_by_name)

        history.append(AIMessage(content=answer))

        print(f"\n🤖 {answer}")

Código completo

Este es el archivo assistant.py completo. Cópialo, configura tu .env, y ejecútalo con python assistant.py:

"""
Asistente con Herramientas Externas
Módulo 2 — LangChain & LangGraph: From Chains to Agents

Requiere: pip install langchain langchain-openai python-dotenv
"""

import math

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import (
    HumanMessage,
    AIMessage,
    SystemMessage,
    ToolMessage,
)


# --- Herramientas ---

WEATHER_DATA = {
    "Madrid": {"temp": 22, "condition": "soleado", "humidity": 45},
    "London": {"temp": 14, "condition": "nublado", "humidity": 78},
    "Tokyo": {"temp": 28, "condition": "húmedo", "humidity": 82},
    "New York": {"temp": 18, "condition": "parcialmente nublado", "humidity": 55},
    "San Francisco": {"temp": 16, "condition": "niebla", "humidity": 72},
    "Buenos Aires": {"temp": 25, "condition": "soleado", "humidity": 50},
    "Ciudad de México": {"temp": 20, "condition": "lluvioso", "humidity": 65},
    "Bogotá": {"temp": 15, "condition": "nublado", "humidity": 70},
}


@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad.
    Ciudades disponibles: Madrid, London, Tokyo, New York,
    San Francisco, Buenos Aires, Ciudad de México, Bogotá.
    """
    city_normalized = city.strip().title()

    if not city_normalized:
        return "Error: proporciona el nombre de una ciudad."

    for key in WEATHER_DATA:
        if key.lower() == city_normalized.lower():
            data = WEATHER_DATA[key]
            return (
                f"Clima en {key}: {data['temp']}°C, {data['condition']}, "
                f"humedad {data['humidity']}%"
            )

    available = ", ".join(sorted(WEATHER_DATA.keys()))
    return (
        f"No tengo datos de clima para '{city}'. "
        f"Ciudades disponibles: {available}"
    )


@tool
def calculator(expression: str) -> str:
    """Evalúa una expresión matemática.
    Soporta: +, -, *, /, **, (), sqrt(), abs(), round().
    Ejemplos: '15 * 0.16', 'sqrt(144)', '2**10', 'round(3.14159, 2)'.
    """
    if not expression or not expression.strip():
        return "Error: expresión vacía. Proporciona una expresión como '15 * 0.16'."

    safe_dict = {
        "sqrt": math.sqrt,
        "abs": abs,
        "round": round,
        "pow": pow,
        "pi": math.pi,
        "e": math.e,
    }

    allowed_chars = set("0123456789+-*/.() ,epiabsqrtoundw")

    if not all(c in allowed_chars for c in expression.lower().replace(" ", "")):
        return (
            f"Error: la expresión '{expression}' contiene caracteres no permitidos. "
            f"Usa solo números y operadores (+, -, *, /, **, ())."
        )

    try:
        result = eval(expression, {"__builtins__": {}}, safe_dict)
        if isinstance(result, float):
            if result == int(result):
                return str(int(result))
            return str(round(result, 6))
        return str(result)
    except ZeroDivisionError:
        return "Error: división por cero."
    except Exception as e:
        return f"Error al evaluar '{expression}': {e}"


@tool
def web_search(query: str) -> str:
    """Busca información en la web sobre cualquier tema.
    Útil para preguntas generales, definiciones, datos actuales.
    """
    if not query or len(query.strip()) < 3:
        return "Error: la búsqueda necesita al menos 3 caracteres."

    query_lower = query.lower()

    knowledge_base = {
        "python": (
            "Python es un lenguaje de programación de alto nivel, interpretado y de "
            "propósito general. Creado por Guido van Rossum, lanzado en 1991. Es el "
            "lenguaje más popular para AI/ML, data science y scripting. Última versión "
            "estable: 3.12."
        ),
        "langchain": (
            "LangChain es un framework open-source para construir aplicaciones con LLMs. "
            "Proporciona interfaces para modelos, tools, agents y workflows. "
            "Versión actual: v1.2+. Ecosistema: LangChain, LangGraph, LangSmith."
        ),
        "fastapi": (
            "FastAPI es un framework web moderno para Python, basado en type hints. "
            "Genera documentación OpenAPI automáticamente. Es async-first y uno de los "
            "frameworks más rápidos de Python."
        ),
        "docker": (
            "Docker es una plataforma de contenedores que empaqueta aplicaciones con "
            "todas sus dependencias. Permite ejecutar aplicaciones de forma consistente "
            "en cualquier entorno. Docker Hub tiene miles de imágenes pre-construidas."
        ),
        "react": (
            "React es una biblioteca de JavaScript para construir interfaces de usuario. "
            "Creada por Meta (Facebook). Usa un Virtual DOM para renderizado eficiente. "
            "Es la biblioteca frontend más usada en el mundo."
        ),
        "kubernetes": (
            "Kubernetes (K8s) es un sistema de orquestación de contenedores open-source. "
            "Automatiza el despliegue, escalado y gestión de aplicaciones containerizadas. "
            "Originalmente diseñado por Google."
        ),
    }

    for keyword, info in knowledge_base.items():
        if keyword in query_lower:
            return f"Resultado de búsqueda para '{query}':\n{info}"

    return (
        f"Resultados limitados para '{query}'. No encontré información específica "
        f"en mi base de datos. En un sistema real, esto consultaría DuckDuckGo o "
        f"una API de búsqueda web."
    )


# --- Configuración del modelo ---

tools = [get_weather, calculator, web_search]
tools_by_name = {t.name: t for t in tools}

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)


# --- Tool execution loop ---

def run_assistant(messages, model_with_tools, tools_map, max_iterations=5):
    """Ejecuta el loop completo del asistente con error handling.
    Modifica messages in-place y retorna la respuesta final.
    """
    for iteration in range(max_iterations):
        try:
            response = model_with_tools.invoke(messages)
        except Exception as e:
            return f"Error al comunicarse con el modelo: {e}"

        messages.append(response)

        if not response.tool_calls:
            return response.content

        tool_names = [tc["name"] for tc in response.tool_calls]
        print(f"  🔧 Tools: {', '.join(tool_names)}")

        for tc in response.tool_calls:
            tool_name = tc["name"]
            tool_args = tc["args"]
            tool_id = tc["id"]

            if tool_name not in tools_map:
                result = (
                    f"Error: herramienta '{tool_name}' no disponible. "
                    f"Herramientas válidas: {', '.join(tools_map.keys())}"
                )
                print(f"    ❌ {tool_name}: no existe")
            else:
                try:
                    result = tools_map[tool_name].invoke(tool_args)
                    preview = result[:50] + "..." if len(result) > 50 else result
                    print(f"    ✅ {tool_name}: {preview}")
                except Exception as e:
                    result = (
                        f"Error en '{tool_name}': {type(e).__name__}: {e}. "
                        f"La herramienta no está disponible en este momento."
                    )
                    print(f"    ❌ {tool_name}: {e}")

            messages.append(ToolMessage(content=str(result), tool_call_id=tool_id))

    return "El asistente alcanzó el límite de iteraciones sin generar respuesta final."


# --- Chat loop ---

SYSTEM_PROMPT = """Eres un asistente útil con acceso a herramientas.

Herramientas disponibles:
- get_weather: consulta el clima de ciudades
- calculator: evalúa expresiones matemáticas
- web_search: busca información sobre cualquier tema

Reglas:
- Usa las herramientas cuando la pregunta lo requiera
- Si una herramienta falla, informa al usuario y sugiere alternativas
- Responde en español de forma concisa y directa
- Puedes llamar múltiples herramientas si la pregunta lo necesita"""


def chat():
    """Loop principal del asistente con herramientas."""
    print("=" * 55)
    print("  Asistente con Herramientas Externas")
    print("  Escribe 'salir' para terminar")
    print("  Escribe 'tools' para ver herramientas disponibles")
    print("  Escribe 'clear' para limpiar historial")
    print("=" * 55)

    history = [SystemMessage(content=SYSTEM_PROMPT)]

    while True:
        try:
            user_input = input("\nTú: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\n¡Hasta luego!")
            break

        if not user_input:
            continue

        if user_input.lower() in ("salir", "exit", "quit"):
            print("\n¡Hasta luego!")
            break

        if user_input.lower() == "tools":
            print("\nHerramientas disponibles:")
            for name, t in tools_by_name.items():
                print(f"  🔧 {name}: {t.description[:70]}")
            continue

        if user_input.lower() == "clear":
            history = [SystemMessage(content=SYSTEM_PROMPT)]
            print("\n🗑️  Historial limpiado.")
            continue

        history.append(HumanMessage(content=user_input))

        answer = run_assistant(history, model_with_tools, tools_by_name)

        history.append(AIMessage(content=answer))

        print(f"\n🤖 {answer}")


if __name__ == "__main__":
    chat()

Ejecútalo:

python assistant.py

Criterios de éxito

Tu proyecto está completo cuando cumples los cuatro criterios:

  • El asistente llama tools correctamente basado en la pregunta — pregunta sobre clima → get_weather, pregunta de matemáticas → calculator, pregunta general → web_search
  • Parallel calls funcionan — al pedir "clima en Madrid y en London" o "cuánto es 100*2 y qué clima hace en Tokyo", el modelo genera múltiples tool calls simultáneos y los resultados se integran en una respuesta coherente
  • Error en una tool no crashea el sistema — si pides clima de una ciudad inexistente o divides por cero, el asistente informa del error y sigue funcionando
  • Output final integra resultados de múltiples tools — la respuesta menciona los datos de ambas tools cuando se llamaron dos o más

Cómo probar

Test 1: Una sola tool

Tú: ¿Qué clima hace en Buenos Aires?
  🔧 Tools: get_weather
    ✅ get_weather: Clima en Buenos Aires: 25°C, soleado, humed...

🤖 En Buenos Aires hace 25°C, está soleado con una humedad del 50%.

Test 2: Parallel tool calls (misma tool)

Tú: ¿Qué clima hace en Madrid y en Tokyo?
  🔧 Tools: get_weather, get_weather
    ✅ get_weather: Clima en Madrid: 22°C, soleado, humedad 45%
    ✅ get_weather: Clima en Tokyo: 28°C, húmedo, humedad 82%

🤖 En Madrid hace 22°C y está soleado (humedad 45%).
   En Tokyo hace 28°C y está húmedo (humedad 82%).

Test 3: Parallel tool calls (tools diferentes)

Tú: ¿Cuánto es 1500 * 1.16 y qué es FastAPI?
  🔧 Tools: calculator, web_search
    ✅ calculator: 1740
    ✅ web_search: Resultado de búsqueda para 'qué es FastAPI'...

🤖 1500 × 1.16 = 1,740. FastAPI es un framework web moderno para Python
   basado en type hints, async-first y muy rápido.

Test 4: Error handling

Tú: ¿Qué clima hace en Atlantis y cuánto es 10/0?
  🔧 Tools: get_weather, calculator
    ✅ get_weather: No tengo datos de clima para 'Atlantis'. Ci...
    ✅ calculator: Error: división por cero.

🤖 No tengo datos de clima para Atlantis — las ciudades disponibles son
   Buenos Aires, Bogotá, Ciudad de México, London, Madrid, New York,
   San Francisco y Tokyo. Además, 10/0 no se puede calcular porque
   la división por cero no está definida.

Test 5: Preguntas sin tools

Tú: Hola, ¿cómo estás?

🤖 ¡Hola! Estoy bien, gracias. ¿En qué puedo ayudarte? Puedo consultar
   el clima, hacer cálculos, o buscar información sobre tecnología.

Test 6: Historial de conversación

Tú: ¿Qué clima hace en Madrid?
  🔧 Tools: get_weather
    ✅ get_weather: Clima en Madrid: 22°C, soleado, humedad 45%

🤖 En Madrid hace 22°C y está soleado con humedad del 45%.

Tú: ¿Y en London?
  🔧 Tools: get_weather
    ✅ get_weather: Clima en London: 14°C, nublado, humedad 78%

🤖 En London hace 14°C, está nublado con humedad del 78%.

Tú: ¿Cuál de las dos está más cálida?

🤖 Madrid está más cálida con 22°C, frente a los 14°C de London.

El modelo recuerda las respuestas anteriores porque el historial de mensajes incluye toda la conversación.


Errores comunes

1. ModuleNotFoundError: No module named 'langchain_openai'

Causa: No instalaste el paquete del proveedor.

pip install langchain-openai

2. AuthenticationError: Incorrect API key

Causa: La API key en .env es inválida. Verifica que el formato es OPENAI_API_KEY=sk-proj-... (sin comillas). Asegúrate de que load_dotenv() se ejecuta antes de crear el modelo.

3. El modelo no llama tools cuando debería

Causa: El prompt del usuario es ambiguo, o el modelo decide responder directamente. Las descripciones de las tools son la "guía" que el modelo usa para decidir — si son vagas, el modelo no sabrá cuándo usarlas.

Solución: Haz las descripciones de tools más específicas. Por ejemplo, cambia "Calcula cosas" por "Evalúa expresiones matemáticas. Soporta: +, -, *, /, **, sqrt().".

4. KeyError al buscar una tool por nombre

KeyError: 'search_web'

Causa: El modelo llama una tool con un nombre diferente al registrado. Tu tool se llama web_search pero el modelo la llama search_web.

Solución: Siempre valida que el nombre existe antes de ejecutar:

if tool_name not in tools_map:
    result = f"Error: herramienta '{tool_name}' no disponible."

5. ToolMessage sin tool_call_id

ValueError: ToolMessage must have a tool_call_id

Causa: Olvidaste pasar el tool_call_id al crear el ToolMessage. Cada ToolMessage debe incluir el ID del tool call al que responde.

Solución:

messages.append(ToolMessage(
    content=result,
    tool_call_id=tc["id"]    # ← del tool call original
))

6. El historial crece y empiezo a recibir errores de tokens

Causa: Cada mensaje en el historial consume tokens de input. Una conversación larga puede exceder el context window del modelo.

Solución: Limita el historial manteniendo el system prompt:

MAX_HISTORY = 20
if len(history) > MAX_HISTORY:
    system = history[0]
    history = [system] + history[-(MAX_HISTORY - 1):]

7. La calculadora ejecuta código malicioso

Causa: eval() puede ejecutar cualquier código Python si no se restringe.

Solución: La implementación del proyecto ya maneja esto con dos medidas: {"__builtins__": {}} como global scope (deshabilita imports y funciones built-in peligrosas), y una validación de caracteres permitidos antes de evaluar. Nunca uses eval() sin estas restricciones.

8. El asistente entra en loop infinito de tool calls

Causa: El modelo sigue llamando tools sin generar respuesta final. Puede ocurrir cuando los resultados de las tools no satisfacen al modelo.

Solución: El max_iterations en run_assistant previene esto. Si el modelo no genera respuesta después de 5 iteraciones, el loop se detiene con un mensaje de corte.


Ideas para extender

Si terminaste el proyecto y quieres ir más allá:

  • 🚀 APIs reales — Reemplaza los mocks con APIs reales: OpenWeatherMap (clima), DuckDuckGo Search (búsqueda), una API de exchange rates (conversión de monedas)
  • 🚀 Streaming — Muestra la respuesta token por token usando model_with_tools.stream() en vez de invoke(). Necesitarás acumular los tool call chunks como aprendiste en la cápsula 05
  • 🚀 Más tools — Agrega tools de traducción, conversor de unidades, o lookup de documentación técnica
  • 🚀 ToolException con handler — Migra los error messages de tus tools a ToolException con handle_tool_error para monitoreo estructurado
  • 🚀 Retry con backoff — Agrega retry logic con backoff exponencial para las tools que llaman APIs externas (usando el patrón de la cápsula 07)
  • 🚀 Metadata de ejecución — Agrega un tracker que registre cuántas tools se llamaron, cuáles fallaron, y cuánto tardó cada una

Conexión con el siguiente módulo

En este módulo construiste el tool execution loop manualmente: tú escribiste el for que itera sobre tool calls, tú ejecutaste cada tool, tú creaste los ToolMessage, y tú decidiste cuántas iteraciones permitir. Funciona, pero es código que repites cada vez que quieres que un modelo use tools.

En el Módulo 3: Agents con create_agent, aprenderás a automatizar todo esto. create_agent(model, tools) crea un agente que implementa el tool execution loop automáticamente usando el patrón ReAct — el modelo razona sobre qué hacer, actúa llamando tools, observa los resultados, y repite hasta que tiene la respuesta. El mismo asistente que construiste aquí podría reescribirse en ~5 líneas con create_agent. Pero porque construiste el loop manualmente primero, entenderás exactamente qué hace create_agent por debajo.


Recursos para el proyecto

  1. LangChain Tools — Conceptos de tools en LangChain
  2. Tool Calling How-To — Guía oficial de tool calling
  3. Tool Error Handling — Manejo de errores en tools
  4. Parallel Tool Calls — Cómo manejar múltiples tool calls simultáneos
  5. ToolMessage API Reference — Referencia de la clase ToolMessage
  6. LangChain create_agent — Preview de lo que viene en el Módulo 3: automatizar el loop que construiste

Módulo 2 — LangChain & LangGraph: From Chains to Agents