Topic 5 of 15 · Core patterns

Function / tool calling.

Topic 4 constrained what the model outputs. This lesson constrains what it can do with that output: give the model a menu of callable functions, let it decide which one to call and with what arguments, execute the function yourself, and hand the result back. Every agent in this series — Topic 8 onward — is built on this one loop.

The mental model

The model doesn't call your function. It asks you to.

This is the single most important thing to internalize before writing any tool-calling code: the LLM never executes anything. It returns a structured request — "call get_weather with {"city": "Bengaluru"}" — and your code is the one that actually runs it, the same way a server receiving a Retrofit-shaped request still has to implement the handler. The model proposes; your code disposes.

You send messages
+ a list of tools
Model responds with
a tool call request
Your code executes
the real function
You send the result
back as a new message
Section 0

Anatomy of a Tool

A tool definition is exactly the schema work from Topic 4, described from the other direction: instead of shaping the model's output, you're shaping the arguments to a function the model can request. Same Pydantic model, new job.

from pydantic import BaseModel, Field

class GetWeatherArgs(BaseModel):
    city: str = Field(description="City name, e.g. 'Bengaluru'")
    unit: str = Field(default="celsius", description="'celsius' or 'fahrenheit'")

def get_weather(city: str, unit: str = "celsius") -> dict:
    """The real implementation — a fake lookup here, a real API call in production."""
    return {"city": city, "temp": 28, "unit": unit, "condition": "clear"}

# OpenAI's SDK can derive the JSON schema straight from the Pydantic model
tool_schema = {
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get the current weather for a city.",
        "parameters": GetWeatherArgs.model_json_schema(),
    },
}
nameWhat the model refers to when requesting the call — must match the key you dispatch on in your own code.
descriptionThe single biggest lever on whether the model picks this tool correctly. Vague descriptions cause wrong-tool selection the same way a vague system prompt causes wrong behavior (Topic 3).
parametersA JSON Schema — usually generated from a Pydantic model, exactly like Topic 4's response_format.
Section 1

The Tool-Calling Loop

One call is rarely the whole story. The full loop: send messages + tools, check if the model wants to call something, execute it, append the result, call again — until the model responds with text instead of a tool call.

import json
from openai import OpenAI

client = OpenAI()
TOOLS = {"get_weather": get_weather}  # dispatch table: name -> real function

messages = [{"role": "user", "content": "What's the weather in Bengaluru right now?"}]

while True:
    response = client.chat.completions.create(
        model="gpt-4.1-mini",
        messages=messages,
        tools=[tool_schema],
    )
    message = response.choices[0].message
    messages.append(message)  # the model's turn, including any tool_calls

    if not message.tool_calls:
        print(message.content)  # final answer — exit the loop
        break

    for call in message.tool_calls:
        args = json.loads(call.function.arguments)
        result = TOOLS[call.function.name](**args)  # your code actually runs it
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result),
        })
Notice the shape: this is structurally identical to a recursive Retrofit retry loop, except the "retry decision" is made by the model instead of your error-handling code. The tool_call_id matters — it's how the model matches your result back to the specific call it made, especially once Section 4 introduces multiple calls in one turn.
Section 2

Three Providers, One Loop

The shape is the same everywhere: describe tools, get a request back, execute, send the result. The field names differ.

OpenAI
r = client.chat.completions.create(
    model="gpt-4.1-mini",
    messages=messages,
    tools=[tool_schema],
)
call = r.choices[0].message.tool_calls[0]
name = call.function.name
args = json.loads(
    call.function.arguments)
Gemini
r = client.models.generate_content(
    model="gemini-2.5-flash",
    contents=contents,
    config={"tools": [{
        "function_declarations":
            [gemini_schema]}]},
)
call = r.candidates[0].content.parts[0]
    .function_call
name, args = call.name, dict(call.args)
Claude
r = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=300,
    tools=[claude_tool_schema],
    messages=messages,
)
call = next(b for b in r.content
            if b.type == "tool_use")
name, args = call.name, call.input
ProviderSending the result back
OpenAI{"role": "tool", "tool_call_id": ..., "content": ...}
GeminiA function_response part appended to the next contents turn
ClaudeA tool_result content block, keyed by the call's id, in the next user turn
Section 3

Multiple Tools & Tool Choice

Real assistants offer several tools at once and let the model pick — or you can force a specific one, the same way Topic 4's Claude example forced a single tool for structured extraction.

tool_choice valueBehavior
"auto" (default)Model decides whether to call a tool at all, and which one.
"none"Model must respond with text only, even if tools are listed — useful for A/B testing whether tools help.
"required"Model must call some tool, but picks which one.
{"type": "function", "function": {"name": "get_weather"}}Force this exact tool — the Topic 4 structured-extraction trick.
More tools listed isn't free — each one's name, description, and schema costs prompt tokens on every call, and too many similar tools measurably hurts selection accuracy. Five well-described tools usually outperform twenty vague ones.
Section 4

Parallel Tool Calls

Modern models can request several tool calls in a single turn — "get the weather in Bengaluru and Mumbai" produces two tool_calls at once instead of two separate round trips. Your loop from Section 1 already handles this: it iterates message.tool_calls, so multiple calls just mean multiple loop iterations before the next model turn.

for call in message.tool_calls:            # could be 1, could be 5
    args = json.loads(call.function.arguments)
    result = TOOLS[call.function.name](**args)
    messages.append({"role": "tool", "tool_call_id": call.id,
                      "content": json.dumps(result)})
# send all results back together in the next request — not one at a time
Performance tip: if your tool functions are I/O-bound (an HTTP call, a DB query), run them concurrently with the asyncio.gather pattern from Topic 1 Section 7 instead of a plain for loop — otherwise five parallel tool calls from the model become five sequential round trips in your code, defeating the point.
Section 5

Errors Inside a Tool

When a tool call fails — bad arguments, a downstream API timeout, a not-found result — don't let the exception crash the loop. Send the error back to the model as the tool result, the same way an API returns a 404 body instead of dropping the connection. The model can often recover: retry with different arguments, apologize, or try a different tool.

for call in message.tool_calls:
    args = json.loads(call.function.arguments)
    try:
        result = TOOLS[call.function.name](**args)
        content = json.dumps(result)
    except Exception as e:
        content = json.dumps({"error": str(e)})  # let the model see and react to this
    messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
Letting exceptions propagateAn unhandled exception inside the loop kills the whole conversation. The model never gets a chance to say "let me try a different city name."
Infinite retry loopsIf the model keeps calling the same failing tool, cap total iterations (e.g. 5-10) and break with a fallback message — the same guardrail Topic 8's agents need more formally.
Section 6

Security: Least Privilege

This is where Topic 3's prompt injection section stops being theoretical. Once a model can call tools, injected text doesn't just make it say something wrong — it can make it do something wrong: send an email, delete a record, transfer data. The defense is the same principle every backend engineer already applies to a service account.

PrincipleApplied to tool calling
Least privilegeOnly give the model tools it needs for the current task. Don't wire up a general-purpose run_sql(query) tool when the task is "look up an order status."
Read vs write separationPrefer separate, narrowly-scoped tools (get_order_status) over broad ones (update_database) — a narrow tool bounds the blast radius of a bad decision or an injection attempt.
Human confirmation for destructive actionsFor anything hard to reverse (send, delete, pay — the same category this session's own safety rules treat carefully), surface the proposed action to a human before executing, don't auto-execute.
Validate arguments server-sideNever trust that the model's arguments are safe just because they matched your schema's types — a syntactically valid city: str can still contain a SQL injection payload if you're not careful downstream.
Threat-model this explicitly before shipping: "if an attacker could fully control the text this model reads, what's the worst tool call they could trigger?" If the answer is unacceptable, the fix is fewer/narrower tools and a human approval step — not a better prompt.
Section 7 · Checkpoint

Capstone: A Two-Tool Assistant

Build the full loop from Section 1 with two tools, forcing at least one multi-step exchange (Section 4's parallel calls or a sequential follow-up) and proper error handling (Section 5).

import json
from pydantic import BaseModel, Field
from openai import OpenAI

class GetWeatherArgs(BaseModel):
    city: str = Field(description="City name")

class CalculateArgs(BaseModel):
    expression: str = Field(description="A simple arithmetic expression, e.g. '28 - 4'")

def get_weather(city: str) -> dict:
    fake_data = {"Bengaluru": 28, "Mumbai": 32, "Delhi": 24}
    if city not in fake_data:
        raise ValueError(f"no weather data for {city}")
    return {"city": city, "temp_celsius": fake_data[city]}

def calculate(expression: str) -> dict:
    allowed = set("0123456789+-*/(). ")
    if not set(expression) <= allowed:
        raise ValueError("expression contains disallowed characters")
    return {"result": eval(expression)}  # safe here only because of the allowlist above

TOOLS = {"get_weather": get_weather, "calculate": calculate}
TOOL_SCHEMAS = [
    {"type": "function", "function": {"name": "get_weather",
        "description": "Get current temperature for a city.",
        "parameters": GetWeatherArgs.model_json_schema()}},
    {"type": "function", "function": {"name": "calculate",
        "description": "Evaluate a simple arithmetic expression.",
        "parameters": CalculateArgs.model_json_schema()}},
]

def run_assistant(user_message: str) -> str:
    client = OpenAI()
    messages = [{"role": "user", "content": user_message}]

    for _ in range(6):  # guardrail: cap iterations, per Section 5
        response = client.chat.completions.create(
            model="gpt-4.1-mini", messages=messages, tools=TOOL_SCHEMAS,
        )
        message = response.choices[0].message
        messages.append(message)

        if not message.tool_calls:
            return message.content

        for call in message.tool_calls:
            args = json.loads(call.function.arguments)
            try:
                result = TOOLS[call.function.name](**args)
                content = json.dumps(result)
            except Exception as e:
                content = json.dumps({"error": str(e)})
            messages.append({"role": "tool", "tool_call_id": call.id, "content": content})

    return "gave up after too many tool calls"


if __name__ == "__main__":
    print(run_assistant(
        "What's the temperature difference between Bengaluru and Mumbai?"
    ))
Expected behavior: the model calls get_weather twice (once per city, possibly in parallel), then calls calculate with the difference, then returns a final text answer like "Mumbai is 4°C warmer than Bengaluru."
  • Ran the assistant and confirmed it made at least two tool calls before answering
  • Broke it on purpose by asking about a city not in fake_data and confirmed the error comes back as a tool result the model can react to, not a crash
  • Lowered the iteration cap to 1 and confirmed it fails gracefully with the fallback message instead of hanging
  • Can explain out loud why calculate's character allowlist matters — what would go wrong if it just called eval() on the raw model-provided string with no check
  • Next up

    Topic 6: Embeddings & Vector Search

    What embeddings are, how similarity search works, and the foundation RAG (Topic 7) is built on.