Back to Blog
•By Sumit Pandey, PhD•8 min read

Build an AI Agent in Python: Tool Calling Explained with Code

Build a local Python AI agent with Ollama. Learn tool schemas, argument validation, agent loops, and testing with a working inventory example.

A customer asks whether a monitor is in stock. A chat model could write a convincing answer, but it has no way to know what is on your shelf. Give it a function that reads stock records, though, and the problem changes. To build an AI agent in Python, you let the model ask for a lookup, your code return the actual record, and the model explain the result. That small exchange is what tool calling adds to a chatbot.

We'll build an agent that reads a fictional shop inventory and answers questions about prices and stock. It won't browse the internet, buy anything, or change a record. Keeping the example this small lets us follow every decision and see exactly which part the model handles. Download the starter projects, or open agent.py, if you want the complete file beside you while reading. The examples run through a local Ollama server.

What makes this an AI agent?

A normal chat request ends when the model returns some text. In our example, the model can instead return a request to call lookup_inventory. Python checks that request, runs the function, adds the result to the conversation, and asks the model what to do next. The model might answer or request another lookup. We call this an agent loop because the application keeps going based on the model's next action, within limits our code sets.

The ReAct paper explores combining reasoning with actions and observations. Our program uses the same broad pattern of acting on a request and returning an observation, without reproducing the research setup. The key point: a model's request is data until our Python dispatcher accepts it. The model never executes a Python function by itself.

Question: Is a monitor in stock?
Model requests: lookup_inventory(item="monitor")
Python returns: {"price_eur": 199, "stock": 7, ...}
Model answers using that returned record

Get the local model running

Install Ollama, open the app on your Mac or start its service on Linux, and download qwen3:4b. If you haven't run a local model before, the Ollama setup guide walks through it. This is a small model for learning the loop, not the best tool-calling model for every task. Ollama's tool-calling documentation describes the message format we use. The agent and its HTTP helper use only the Python standard library.

ollama pull qwen3:4b
unzip ai-blog-starters.zip
cd ai-blog-starters
python3 agent.py "How much is a monitor, and is it in stock?"

The program prints tool results to the terminal before its final answer, so you can compare the wording with the record it actually received. Our sample monitor costs EUR 199 and has seven units available. Those are fictional inventory facts, not current shop data. Your model may phrase its answer differently or make an unhelpful request, which is why we will test the dispatcher separately from generation.

Write the tool before describing it

Start with the Python operation you want to allow. Our inventory is a dictionary containing keyboards, monitors, and mice, and each record has a price and stock count. In a real shop, this function would query your database using the current user's permissions. For this tutorial, a dictionary keeps the result predictable. What matters is that the value comes from application data rather than from the model's memory.

INVENTORY = {
    "keyboard": {"item": "keyboard", "price_eur": 49, "stock": 12},
    "monitor": {"item": "monitor", "price_eur": 199, "stock": 7},
    "mouse": {"item": "mouse", "price_eur": 25, "stock": 0},
}

The model needs a description of how to request that operation. We give Ollama a tool schema with the function's name, a short description, and one required item argument. The enum lists the three supported values, which helps the model choose a valid request. It doesn't replace validation, since the returned arguments still come from the model. This is the TOOLS entry from agent.py, where the enum is built from the inventory keys:

TOOLS = [{
    "type": "function",
    "function": {
        "name": "lookup_inventory",
        "description": "Read the price and stock for keyboard, monitor, or mouse.",
        "parameters": {
            "type": "object",
            "properties": {"item": {"type": "string", "enum": list(INVENTORY)}},
            "required": ["item"],
            "additionalProperties": False,
        },
    },
}]

Check the arguments in Python

Our dispatcher accepts only the named inventory tool and requires exactly one argument, item. It then checks the type and membership before reading a record. That is ordinary Python validation, and it matters even when the schema says the same thing. An unexpected function name never becomes a dynamic import or an eval call. If a request fails these checks, this first version stops with an error so we can see what went wrong instead of quietly guessing a result.

def dispatch(call):
    function = call.get("function", {})
    if function.get("name") != "lookup_inventory":
        raise ValueError("Unknown tool requested.")
    args = function.get("arguments")
    if not isinstance(args, dict) or set(args) != {"item"}:
        raise ValueError("Expected exactly one item argument.")
    item = args["item"]
    if not isinstance(item, str) or item not in INVENTORY:
        raise ValueError("Unknown inventory item.")
    return dict(INVENTORY[item])

Note the isinstance(args, dict) check. Ollama returns tool-call arguments as a JSON object, which arrives in Python as a dict. OpenAI-style APIs return the same field as a JSON string, so if you port this code to one of them, call json.loads on it before validating.

This dispatcher is an allowlist, not a complete permissions system. In a shared application, knowing that lookup_inventory exists doesn't mean a particular user may read a particular warehouse. You would check those permissions inside the operation, just as you would for a normal API request. The same applies to a future tool that changes an order: validation checks the request's shape, while authorization decides whether this person may make the change.

Keep the tool request in the conversation

After a model requests a tool, preserve its assistant message before adding the tool's response. That gives the next request the complete exchange: the question, the requested operation, and the returned result. Ollama's REST format uses role: "tool" and tool_name for the result message. Don't append the JSON as another user message, because the model then loses the link between its request and the information returned for it.

messages.append(message)
for call in message.get("tool_calls") or []:
    result = dispatch(call)
    messages.append({
        "role": "tool",
        "tool_name": call["function"]["name"],
        "content": json.dumps(result),
    })

The complete loop in agent.py repeats the chat request up to four rounds and accepts at most three calls in a single response. When the model returns no tool calls, the application returns its answer. When it keeps requesting tools beyond the limit, the program stops rather than running forever. These limits are choices for the example; change them after looking at traces of the tasks you actually want to support. Step through one full loop here, including a request the dispatcher rejects:

Follow one agent loop.

Pick a question and press Play, or step through it yourself. Watch the request leave the model and the record come back.

MESSAGES
  1. systemUse lookup_inventory for all stock and price questions. Do not guess inventory facts.
  2. userHow much is a monitor, and is it in stock?
Round 1 of 4max 3 calls per reply
DISPATCHER
Waiting for a tool request.
  • Tool name is lookup_inventory
  • Exactly one argument: item
  • "monitor" is in INVENTORY
INVENTORY RECORD

Not read yet.

The application sends the system instruction, the question and the tool schema. Round 1 of 4 begins.

Records and error messages are copied from agent.py. This replay does not call a model; run the script to see real tool requests.

Test the operation and the conversation separately

You can test the dispatcher without running a model at all: a valid monitor request should return EUR 199 and stock seven, while an unknown tool, unknown item, or extra argument should fail. The starter's tests also feed a fake chat response through the loop and check that the next request contains the assistant's call followed by the tool result. Those tests check our application's behaviour. They can't show that a real model will consistently choose the correct function.

python3 -m unittest discover -s tests -p 'test_agent.py' -v
python3 agent.py "Is the mouse available?"
python3 agent.py "What is the price of the keyboard?"

For the mouse, a correct answer should say it is out of stock; for the keyboard, the price is EUR 49. Check the tool trace as well as the final sentence. If the model answers without making a necessary lookup, that is a failure even if it happens to guess correctly. We ran these against the local model, but model wording and tool choices can change, so keep a small set of questions and expected records before adding tools or switching models. The RAG evaluation tutorial shows how to turn that habit into a repeatable test set.

Where to take this next

A useful next version would replace the dictionary with a real read-only lookup and return a timestamp or record identifier alongside the data. That lets you trace which stock record supported an answer. You could then add an explicit recovery path for a malformed tool request, while keeping the round limit and logging what happened. Add tools when they solve a real task; adding them all at once makes it harder to tell why an agent chose the wrong action.

If the information lives in documents rather than structured records, the PDF RAG tutorial covers a different way to give the model evidence. Both approaches put application-controlled information into the prompt before asking for an answer. The difference is how you obtain it: a tool performs a precise lookup or calculation, while retrieval finds relevant passages. You can combine them once each part works well enough to test on its own.

Share this article