Module 2: Tools and Tool Calling

Project: An Assistant with External Tools

Project overview

In the previous seven capsules you learned to create tools with @tool, bind them to models with bind_tools(), implement the full tool execution loop (model→tool→model), handle parallel tool calls, use tool calling as a structured extraction mechanism, and build robust error handling with retry, validation, and graceful degradation. You saw each concept on its own, in isolated examples. Now you're going to combine all of it into a real system.

In this project you build a conversational assistant with access to external tools. The assistant has three tools: one to check the weather in any city, a calculator for math operations, and a web search for general questions. When the user asks something, the model decides which tools it needs — it could be one, two, or all three at once. If the user asks "What's the weather in Madrid and what's 15% of 240?", the model calls get_weather and calculator in parallel, receives both results, and generates an answer that weaves all the information together.

The assistant doesn't just call tools — it also handles failures. If a tool fails (a timeout, data not found, or an unexpected error), the system doesn't collapse. The error is caught, reported to the model, and the assistant tells the user what went wrong while it keeps working with the remaining tools. That resilience is what separates a prototype from a reliable system.

The result is an interactive terminal chat where you can talk to an assistant that actually does things: checks the weather, calculates, looks up information — and does it robustly.


Project goal

Build a conversational terminal assistant with access to three external tools, one that handles parallel tool calls and errors gracefully.

By the time you finish this project:

  • 🔧 You'll know how to create multiple tools with @tool and bind them to a model
  • 🔧 You'll implement a complete tool execution loop that handles any combination of tool calls
  • 🔧 You'll handle parallel tool calls when the model decides to call several tools at once
  • 🔧 You'll build error handling that lets the system keep working when a tool fails
  • 🔧 You'll have a working terminal chat that demonstrates everything the module taught

Technical specs

Tech stack

ComponentVersionPurpose
Python3.11+Runtime
LangChainv1.2+LLM framework
langchain-openailatestModel provider
python-dotenvlatestEnvironment variables

Initial setup

Before you start, make sure you have the dependencies installed:

pip install langchain langchain-openai python-dotenv

Create a .env file at the root of your project:

# .env
OPENAI_API_KEY=sk-...

Project structure

tools-assistant/
├── .env                  # API key
├── assistant.py          # Main code (everything in one file)
└── requirements.txt      # Dependencies
# requirements.txt
langchain>=0.3.0
langchain-openai>=0.3.0
python-dotenv>=1.0.0

All the code goes into a single assistant.py file. The goal is to integrate the module's concepts, not to design an architecture.


Step 1: Create the 3 tools

The assistant needs three tools with distinct capabilities. Each one has argument validation and error handling built in — the patterns you learned in capsule 07.

Tool 1: Weather (mock)

Simulates a weather lookup. In a real system, this would call an API like OpenWeatherMap. We use a mock so the project works without extra API keys.

from langchain_core.tools import tool

WEATHER_DATA = {
    "Madrid": {"temp": 22, "condition": "sunny", "humidity": 45},
    "London": {"temp": 14, "condition": "cloudy", "humidity": 78},
    "Tokyo": {"temp": 28, "condition": "humid", "humidity": 82},
    "New York": {"temp": 18, "condition": "partly cloudy", "humidity": 55},
    "San Francisco": {"temp": 16, "condition": "foggy", "humidity": 72},
    "Buenos Aires": {"temp": 25, "condition": "sunny", "humidity": 50},
    "Mexico City": {"temp": 20, "condition": "rainy", "humidity": 65},
    "Bogotá": {"temp": 15, "condition": "cloudy", "humidity": 70},
}

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city.
    Available cities: Madrid, London, Tokyo, New York,
    San Francisco, Buenos Aires, Mexico City, Bogotá.
    """
    city_normalized = city.strip().title()

    if not city_normalized:
        return "Error: provide the name of a city."

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

    available = ", ".join(sorted(WEATHER_DATA.keys()))
    return (
        f"I have no weather data for '{city}'. "
        f"Available cities: {available}"
    )

Tool 2: Calculator

Evaluates math expressions. It validates the allowed characters to prevent arbitrary code execution (a real risk with eval).

import math

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression.
    Supports: +, -, *, /, **, (), sqrt(), abs(), round().
    Examples: '15 * 0.16', 'sqrt(144)', '2**10', 'round(3.14159, 2)'.
    """
    if not expression or not expression.strip():
        return "Error: empty expression. Provide something like '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: the expression '{expression}' contains disallowed characters. "
            f"Use only numbers and operators (+, -, *, /, **, ())."
        )

    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: division by zero."
    except Exception as e:
        return f"Error evaluating '{expression}': {e}"

Tool 3: Web Search (mock)

Simulates a web search. In a real system, this would use the DuckDuckGo, Tavily, or Brave Search API.

@tool
def web_search(query: str) -> str:
    """Search the web for information on any topic.
    Useful for general questions, definitions, and current facts.
    """
    if not query or len(query.strip()) < 3:
        return "Error: the search needs at least 3 characters."

    query_lower = query.lower()

    knowledge_base = {
        "python": (
            "Python is a high-level, interpreted, general-purpose programming "
            "language. Created by Guido van Rossum, released in 1991. It's the most "
            "popular language for AI/ML, data science and scripting. Latest stable version: 3.12."
        ),
        "langchain": (
            "LangChain is an open-source framework for building applications with LLMs. "
            "It provides interfaces for models, tools, agents and workflows. "
            "Current version: v1.2+. Ecosystem: LangChain, LangGraph, LangSmith."
        ),
        "fastapi": (
            "FastAPI is a modern web framework for Python, built on type hints. "
            "It generates OpenAPI documentation automatically. It's async-first and one of "
            "the fastest Python frameworks."
        ),
        "docker": (
            "Docker is a container platform that packages applications together with "
            "all their dependencies. It lets you run applications consistently "
            "in any environment. Docker Hub hosts thousands of pre-built images."
        ),
        "react": (
            "React is a JavaScript library for building user interfaces. "
            "Created by Meta (Facebook). It uses a Virtual DOM for efficient rendering. "
            "It's the most widely used frontend library in the world."
        ),
        "kubernetes": (
            "Kubernetes (K8s) is an open-source container orchestration system. "
            "It automates the deployment, scaling and management of containerized applications. "
            "Originally designed by Google."
        ),
    }

    for keyword, info in knowledge_base.items():
        if keyword in query_lower:
            return f"Search result for '{query}':\n{info}"

    return (
        f"Limited results for '{query}'. I couldn't find specific information "
        f"in my database. In a real system, this would query DuckDuckGo or "
        f"a web search API."
    )

Let's test the three tools separately:

print(get_weather.invoke({"city": "Madrid"}))
# Expected output: Weather in Madrid: 22°C, sunny, humidity 45%

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

print(web_search.invoke({"query": "what is LangChain"}))
# Expected output: Search result for 'what is LangChain':
# LangChain is an open-source framework...

Step 2: Bind the tools and implement the execution loop

Now we bind the three tools to the model and build the loop that runs them. This is the pattern you learned in capsule 04, applied to a real system.

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)

The tool execution function:

def execute_tool_calls(response, tools_map):
    """Run every tool call in a response.
    Returns a list of 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: tool '{tool_name}' unavailable. "
                f"Valid tools: {', '.join(tools_map.keys())}"
            )
        else:
            try:
                result = tools_map[tool_name].invoke(tool_args)
            except Exception as e:
                result = f"Error running '{tool_name}': {type(e).__name__}: {e}"

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

    return tool_messages

Let's try a question that needs a single tool:

messages = [HumanMessage(content="What's the weather in 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"Answer: {final.content}")
# Expected output:
# Tool calls: [('get_weather', {'city': 'Tokyo'})]
# Answer: In Tokyo the weather is 28°C and humid, with 82% humidity.

Step 3: Add parallel tool call handling

When the user asks for several pieces of data — "What's the weather in Madrid and in London?" — the model generates several tool calls at once. The execute_tool_calls function from the previous step already handles this naturally: it iterates over every tool call in response.tool_calls.

Let's verify it works:

messages = [HumanMessage(content="What's the weather in Madrid and in London?")]

response = model_with_tools.invoke(messages)
print(f"Number of 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"\nAnswer: {final.content}")
# Expected output:
# Number of tool calls: 2
#   → get_weather({'city': 'Madrid'})
#   → get_weather({'city': 'London'})
#
# Answer: Madrid is 22°C and sunny with 45% humidity.
# London is 14°C and cloudy with 78% humidity.

And with tools of different kinds?

messages = [
    HumanMessage(content="What's 1500 * 0.16 and what's the weather in 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"\nAnswer: {final.content}")
# Expected output:
# Tool calls: [('calculator', {'expression': '1500 * 0.16'}), ('get_weather', {'city': 'San Francisco'})]
#
# Answer: 1500 × 0.16 = 240. San Francisco is 16°C and foggy with 72% humidity.

The model automatically decides which tools it needs and calls them in parallel. Your code needs no special logic — it just runs every tool call it finds in the list.


Step 4: Add error handling with graceful fallback

Now let's make the system resilient. The execute_tool_calls function already has basic try/except, but we need to make sure the full loop handles every edge case: nonexistent tools, execution errors, and the iteration limit.

def run_assistant(messages, model_with_tools, tools_map, max_iterations=5):
    """Run the assistant's full loop with error handling.
    Returns the final answer as a string.
    """
    for iteration in range(max_iterations):
        try:
            response = model_with_tools.invoke(messages)
        except Exception as e:
            return f"Error communicating with the model: {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: tool '{tool_name}' unavailable. "
                    f"Valid tools: {', '.join(tools_map.keys())}"
                )
                print(f"    ❌ {tool_name}: does not exist")
            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 in '{tool_name}': {type(e).__name__}: {e}. "
                        f"The tool is unavailable right now."
                    )
                    print(f"    ❌ {tool_name}: {e}")

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

    return "The assistant hit the iteration limit without producing a final answer."

Let's try a case that involves an error:

messages = [
    HumanMessage(content="What's the weather in Atlantis and what's 100/0?")
]

answer = run_assistant(messages, model_with_tools, tools_by_name)
print(f"\nAnswer: {answer}")
# Expected output:
#   🔧 Tools: get_weather, calculator
#     ✅ get_weather: I have no weather data for 'Atlantis'. Ava...
#     ✅ calculator: Error: division by zero.
#
# Answer: I don't have weather data for Atlantis — the available cities are
# Madrid, London, Tokyo, among others. As for the calculation, 100/0 isn't
# possible because you can't divide by zero.

The system doesn't crash: the weather tool returns an informative message about the unknown city, the calculator reports the division by zero, and the model weaves both results into a coherent answer.


Step 5: An interactive terminal chat loop

The last step ties everything together into a conversational chat. The assistant keeps a message history so the model has the context of the whole conversation.

SYSTEM_PROMPT = """You are a helpful assistant with access to tools.

Available tools:
- get_weather: look up the weather in cities
- calculator: evaluate math expressions
- web_search: search for information on any topic

Rules:
- Use the tools whenever the question calls for it
- If a tool fails, tell the user and suggest alternatives
- Answer in English, concisely and directly
- You can call several tools if the question needs it"""


def chat():
    """The assistant's main loop, with tools."""
    print("=" * 55)
    print("  Assistant with External Tools")
    print("  Type 'exit' to quit")
    print("  Type 'tools' to see the available tools")
    print("  Type 'clear' to wipe the history")
    print("=" * 55)

    from langchain_core.messages import SystemMessage

    history = [SystemMessage(content=SYSTEM_PROMPT)]

    while True:
        try:
            user_input = input("\nYou: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nSee you around!")
            break

        if not user_input:
            continue

        if user_input.lower() in ("exit", "quit"):
            print("\nSee you around!")
            break

        if user_input.lower() == "tools":
            print("\nAvailable tools:")
            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🗑️  History cleared.")
            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}")

The complete code

This is the full assistant.py file. Copy it, configure your .env, and run it with python assistant.py:

"""
Assistant with External Tools
Module 2 — LangChain & LangGraph: From Chains to Agents

Requires: 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,
)


# --- Tools ---

WEATHER_DATA = {
    "Madrid": {"temp": 22, "condition": "sunny", "humidity": 45},
    "London": {"temp": 14, "condition": "cloudy", "humidity": 78},
    "Tokyo": {"temp": 28, "condition": "humid", "humidity": 82},
    "New York": {"temp": 18, "condition": "partly cloudy", "humidity": 55},
    "San Francisco": {"temp": 16, "condition": "foggy", "humidity": 72},
    "Buenos Aires": {"temp": 25, "condition": "sunny", "humidity": 50},
    "Mexico City": {"temp": 20, "condition": "rainy", "humidity": 65},
    "Bogotá": {"temp": 15, "condition": "cloudy", "humidity": 70},
}


@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city.
    Available cities: Madrid, London, Tokyo, New York,
    San Francisco, Buenos Aires, Mexico City, Bogotá.
    """
    city_normalized = city.strip().title()

    if not city_normalized:
        return "Error: provide the name of a city."

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

    available = ", ".join(sorted(WEATHER_DATA.keys()))
    return (
        f"I have no weather data for '{city}'. "
        f"Available cities: {available}"
    )


@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression.
    Supports: +, -, *, /, **, (), sqrt(), abs(), round().
    Examples: '15 * 0.16', 'sqrt(144)', '2**10', 'round(3.14159, 2)'.
    """
    if not expression or not expression.strip():
        return "Error: empty expression. Provide something like '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: the expression '{expression}' contains disallowed characters. "
            f"Use only numbers and operators (+, -, *, /, **, ())."
        )

    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: division by zero."
    except Exception as e:
        return f"Error evaluating '{expression}': {e}"


@tool
def web_search(query: str) -> str:
    """Search the web for information on any topic.
    Useful for general questions, definitions, and current facts.
    """
    if not query or len(query.strip()) < 3:
        return "Error: the search needs at least 3 characters."

    query_lower = query.lower()

    knowledge_base = {
        "python": (
            "Python is a high-level, interpreted, general-purpose programming "
            "language. Created by Guido van Rossum, released in 1991. It's the "
            "most popular language for AI/ML, data science and scripting. Latest stable "
            "version: 3.12."
        ),
        "langchain": (
            "LangChain is an open-source framework for building applications with LLMs. "
            "It provides interfaces for models, tools, agents and workflows. "
            "Current version: v1.2+. Ecosystem: LangChain, LangGraph, LangSmith."
        ),
        "fastapi": (
            "FastAPI is a modern web framework for Python, built on type hints. "
            "It generates OpenAPI documentation automatically. It's async-first and one of "
            "the fastest Python frameworks."
        ),
        "docker": (
            "Docker is a container platform that packages applications together with "
            "all their dependencies. It lets you run applications consistently "
            "in any environment. Docker Hub hosts thousands of pre-built images."
        ),
        "react": (
            "React is a JavaScript library for building user interfaces. "
            "Created by Meta (Facebook). It uses a Virtual DOM for efficient rendering. "
            "It's the most widely used frontend library in the world."
        ),
        "kubernetes": (
            "Kubernetes (K8s) is an open-source container orchestration system. "
            "It automates the deployment, scaling and management of containerized applications. "
            "Originally designed by Google."
        ),
    }

    for keyword, info in knowledge_base.items():
        if keyword in query_lower:
            return f"Search result for '{query}':\n{info}"

    return (
        f"Limited results for '{query}'. I couldn't find specific information "
        f"in my database. In a real system, this would query DuckDuckGo or "
        f"a web search API."
    )


# --- Model configuration ---

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):
    """Run the assistant's full loop with error handling.
    Mutates messages in place and returns the final answer.
    """
    for iteration in range(max_iterations):
        try:
            response = model_with_tools.invoke(messages)
        except Exception as e:
            return f"Error communicating with the model: {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: tool '{tool_name}' unavailable. "
                    f"Valid tools: {', '.join(tools_map.keys())}"
                )
                print(f"    ❌ {tool_name}: does not exist")
            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 in '{tool_name}': {type(e).__name__}: {e}. "
                        f"The tool is unavailable right now."
                    )
                    print(f"    ❌ {tool_name}: {e}")

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

    return "The assistant hit the iteration limit without producing a final answer."


# --- Chat loop ---

SYSTEM_PROMPT = """You are a helpful assistant with access to tools.

Available tools:
- get_weather: look up the weather in cities
- calculator: evaluate math expressions
- web_search: search for information on any topic

Rules:
- Use the tools whenever the question calls for it
- If a tool fails, tell the user and suggest alternatives
- Answer in English, concisely and directly
- You can call several tools if the question needs it"""


def chat():
    """The assistant's main loop, with tools."""
    print("=" * 55)
    print("  Assistant with External Tools")
    print("  Type 'exit' to quit")
    print("  Type 'tools' to see the available tools")
    print("  Type 'clear' to wipe the history")
    print("=" * 55)

    history = [SystemMessage(content=SYSTEM_PROMPT)]

    while True:
        try:
            user_input = input("\nYou: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nSee you around!")
            break

        if not user_input:
            continue

        if user_input.lower() in ("exit", "quit"):
            print("\nSee you around!")
            break

        if user_input.lower() == "tools":
            print("\nAvailable tools:")
            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🗑️  History cleared.")
            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()

Run it:

python assistant.py

Success criteria

Your project is complete when you meet all four criteria:

  • The assistant calls tools correctly based on the question — a weather question → get_weather, a math question → calculator, a general question → web_search
  • Parallel calls work — when you ask for "the weather in Madrid and in London" or "what's 100*2 and what's the weather in Tokyo", the model generates several tool calls at once and the results get woven into one coherent answer
  • A failing tool doesn't crash the system — if you ask for the weather in a nonexistent city or divide by zero, the assistant reports the error and keeps working
  • The final output integrates results from multiple tools — the answer mentions data from both tools whenever two or more were called

How to test it

Test 1: A single tool

You: What's the weather in Buenos Aires?
  🔧 Tools: get_weather
    ✅ get_weather: Weather in Buenos Aires: 25°C, sunny, humi...

🤖 Buenos Aires is 25°C and sunny, with 50% humidity.

Test 2: Parallel tool calls (same tool)

You: What's the weather in Madrid and in Tokyo?
  🔧 Tools: get_weather, get_weather
    ✅ get_weather: Weather in Madrid: 22°C, sunny, humidity 45%
    ✅ get_weather: Weather in Tokyo: 28°C, humid, humidity 82%

🤖 Madrid is 22°C and sunny (45% humidity).
   Tokyo is 28°C and humid (82% humidity).

Test 3: Parallel tool calls (different tools)

You: What's 1500 * 1.16 and what is FastAPI?
  🔧 Tools: calculator, web_search
    ✅ calculator: 1740
    ✅ web_search: Search result for 'what is FastAPI'...

🤖 1500 × 1.16 = 1,740. FastAPI is a modern web framework for Python
   built on type hints, async-first and very fast.

Test 4: Error handling

You: What's the weather in Atlantis and what's 10/0?
  🔧 Tools: get_weather, calculator
    ✅ get_weather: I have no weather data for 'Atlantis'. Ava...
    ✅ calculator: Error: division by zero.

🤖 I have no weather data for Atlantis — the available cities are
   Bogotá, Buenos Aires, London, Madrid, Mexico City, New York,
   San Francisco and Tokyo. Also, 10/0 can't be calculated because
   division by zero is undefined.

Test 5: Questions with no tools

You: Hi, how are you?

🤖 Hi! I'm doing well, thanks. What can I help you with? I can look up
   the weather, run calculations, or search for information about tech.

Test 6: Conversation history

You: What's the weather in Madrid?
  🔧 Tools: get_weather
    ✅ get_weather: Weather in Madrid: 22°C, sunny, humidity 45%

🤖 Madrid is 22°C and sunny, with 45% humidity.

You: And in London?
  🔧 Tools: get_weather
    ✅ get_weather: Weather in London: 14°C, cloudy, humidity 78%

🤖 London is 14°C and cloudy, with 78% humidity.

You: Which of the two is warmer?

🤖 Madrid is warmer at 22°C, compared to London's 14°C.

The model remembers the previous answers because the message history carries the whole conversation.


Common errors

1. ModuleNotFoundError: No module named 'langchain_openai'

Cause: You didn't install the provider package.

pip install langchain-openai

2. AuthenticationError: Incorrect API key

Cause: The API key in .env is invalid. Check that the format is OPENAI_API_KEY=sk-proj-... (no quotes). Make sure load_dotenv() runs before you create the model.

3. The model doesn't call tools when it should

Cause: The user's prompt is ambiguous, or the model decides to answer directly. Tool descriptions are the "guide" the model uses to decide — if they're vague, the model won't know when to use them.

Solution: Make your tool descriptions more specific. For example, change "Calculates things" to "Evaluates math expressions. Supports: +, -, *, /, **, sqrt().".

4. KeyError when looking up a tool by name

KeyError: 'search_web'

Cause: The model calls a tool with a name different from the registered one. Your tool is called web_search but the model calls it search_web.

Solution: Always check that the name exists before executing:

if tool_name not in tools_map:
    result = f"Error: tool '{tool_name}' unavailable."

5. ToolMessage without tool_call_id

ValueError: ToolMessage must have a tool_call_id

Cause: You forgot to pass the tool_call_id when building the ToolMessage. Every ToolMessage must carry the ID of the tool call it answers.

Solution:

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

6. The history grows and I start getting token errors

Cause: Every message in the history eats input tokens. A long conversation can blow past the model's context window.

Solution: Cap the history while keeping the system prompt:

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

7. The calculator runs malicious code

Cause: eval() can run any Python code if you don't restrict it.

Solution: The project's implementation already handles this with two measures: {"__builtins__": {}} as the global scope (which disables imports and dangerous built-ins), and a check on allowed characters before evaluating. Never use eval() without these restrictions.

8. The assistant gets stuck in an infinite tool call loop

Cause: The model keeps calling tools without producing a final answer. It can happen when the tools' results don't satisfy the model.

Solution: The max_iterations in run_assistant prevents this. If the model hasn't produced an answer after 5 iterations, the loop stops with a cutoff message.


Ideas to take it further

If you finished the project and want to push further:

  • 🚀 Real APIs — Replace the mocks with real APIs: OpenWeatherMap (weather), DuckDuckGo Search (search), an exchange rates API (currency conversion)
  • 🚀 Streaming — Show the answer token by token using model_with_tools.stream() instead of invoke(). You'll need to accumulate the tool call chunks the way you learned in capsule 05
  • 🚀 More tools — Add a translation tool, a unit converter, or a technical documentation lookup
  • 🚀 ToolException with a handler — Migrate your tools' error messages to ToolException with handle_tool_error for structured monitoring
  • 🚀 Retry with backoff — Add retry logic with exponential backoff for the tools that call external APIs (using the pattern from capsule 07)
  • 🚀 Execution metadata — Add a tracker that records how many tools were called, which ones failed, and how long each one took

Connection to the next module

In this module you built the tool execution loop by hand: you wrote the for that iterates over tool calls, you ran each tool, you created the ToolMessages, and you decided how many iterations to allow. It works, but it's code you rewrite every time you want a model to use tools.

In Module 3: Agents with create_agent, you'll learn to automate all of it. create_agent(model, tools) builds an agent that implements the tool execution loop automatically using the ReAct pattern — the model reasons about what to do, acts by calling tools, observes the results, and repeats until it has the answer. The same assistant you built here could be rewritten in ~5 lines with create_agent. But because you built the loop by hand first, you'll understand exactly what create_agent is doing under the hood.


Project resources

  1. LangChain Tools — Tool concepts in LangChain
  2. Tool Calling How-To — The official tool calling guide
  3. Tool Error Handling — Handling errors in tools
  4. Parallel Tool Calls — How to handle multiple simultaneous tool calls
  5. ToolMessage API Reference — Reference for the ToolMessage class
  6. LangChain create_agent — A preview of what's coming in Module 3: automating the loop you just built

Module 2 — LangChain & LangGraph: From Chains to Agents