Feedback

Chat Icon

Local AI Engineering with Ollama

Run, understand, customize, fine-tune, and build agentic apps on your own hardware

Building Advanced Agents: Function-Calling
91%

The Shape of a Tool Call, End to End

Before we look at the code, here's the minimum viable example, distilled. A tool in LangChain is a Python function decorated with @tool. The decorator inspects the function's signature, builds a JSON schema for it, and registers the function as something the model can invoke.

Let's run through a simple example to understand before we dive into more complex code (change model and base_url to match your setup). Here is the example we're going to run; it's in the companion toolkit repo:

# repl/test_agent.py
from langchain_core.tools import tool
from langchain.agents import create_agent
from langchain_ollama import ChatOllama


@tool
def add_numbers(a: int, b: int) -> int:
    """Add two integers and return the result."""
    # Debugging
    print("I'm being called with", a, b)  
    return a + b


llm = ChatOllama(
        model="granite3.3:2b", 
        base_url="http://localhost:11434"
    )
agent = create_agent(model=llm, tools=[add_numbers])

response = agent.invoke(
    {
        "messages": 
        [
            {
                "role": "user", 
                "content": "What is 17 plus 25?"
            }
        ]
    }
)
print(response["messages"][-1].content)

When you run this with uv run test_agent, the model will produce the correct answer, 42, even though it has no built-in math ability:

# Change directory
cd $HOME/companion/code/repl

# Run the agent
uv run test_agent.py 

# Output:
# I'm being called with 17 25
# The sum of 17 and 25 is 42.

When you run this, what happens internally is more than a single model call. The agent runs a small loop: the model reads the user's question, decides to call add_numbers with a=17, b=25, the agent runs the function, the result (42) gets fed back to the model as a tool message, and the model produces a final reply like 17 plus 25 equals 42. The user sees one answer; the loop fired three messages internally to produce it.

Two things make this work. The first is that the model has to be tool-capable. Not every model supports function/tool calling; the architecture has to have been trained for it. You can check with ollama show $MODEL and look for tools in the Capabilities section. granite3.3:2b supports tools; smaller or older models often don't. The second is that the docstring of each tool function is what the model reads to decide whether to call it. A vague docstring leads to skipped tool calls or wrong tools fired. Short, specific, action-oriented docstrings give the best results.

To our existing code, we will add 2 weather tools backed by the free Open-Meteo HTTP API.

Step 1: Write a Private Helper for Geocoding

Weather APIs want latitude/longitude, not place names. We write one helper that turns "Paris" into (48.85, 2.35):

def _get_coordinates(location: str) -> tuple[float, float]:
    response = httpx.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={
            "name": location,
            "count": 1,
            ...
        },
        timeout=10,
    )
    response.raise_for_status()
    ...

Two important details:

  • The leading underscore (_get_coordinates) is a Python convention for "private". This function is not exposed to the model. Only the two @tool-decorated functions below are.
  • response.raise_for_status() turns HTTP errors (4xx/5xx) into Python exceptions. We want exceptions because the agent's middleware (later) knows how to handle them.

Step 2: Define Real Tools with @tool

A tool is just a normal function decorated with @tool. Here's how we define the air quality tool:

@tool
def get_air_quality(location: str) -> str:
    """Get current air quality (PM10 and PM2.5) for a named location."""
    if DEBUG:
        print(f"Tool called: get_air_quality({location})")
    latitude, longitude = _get_coordinates(location)
    response = httpx.get(
        "https://air-quality-api.open-meteo.com/v1/air-quality",
        params={
            "latitude": latitude,
            "longitude": longitude,
            "hourly": "pm10,pm2_5",
            "forecast_days": 1,
        },
        timeout=10,
    )
    response.raise_for_status()
    data = response.json()
    if (
        "hourly" in data
        and "pm10" in data["hourly"]
        and "pm2_5" in data["hourly"]
    ):
        pm10 = data["hourly"]["pm10"][
            0
        ]  # [0] = current hour
        pm2_5 = data["hourly"]["pm2_5"][0]
        result = f"PM10: {pm10} μg/m³, PM2.5: {pm2_5} μg/m³"
    else:
        result = "Air quality data not available"
    return f

Local AI Engineering with Ollama

Run, understand, customize, fine-tune, and build agentic apps on your own hardware

Enroll now to unlock all content and receive all future updates for free.