Module 7 — Tools and function calling
Retrieval lets the model quote the policy. Tools let the model do things — convert 42 EUR to USD at yesterday's rate, check whether that amount clears the per-diem ceiling, append a row to a spreadsheet. This module wires the assistant's first three tools and covers the failure modes that quietly break agents in module 8.
What a tool is, exactly
A tool in LangChain is a Python function with a name, a docstring, a typed signature and a return value. The typed signature is what the model reads to decide when to call it and what arguments to pass.
from langchain_core.tools import tool
@tool
def convert_currency(amount: float, source: str, target: str) -> float:
"""Convert an amount from source currency to target currency at
today's mid-market rate. Currencies are three-letter ISO codes,
e.g. EUR, USD, GBP."""
rate = _fetch_rate(source, target) # your provider
return round(amount * rate, 2)
Four things the model uses, in order of importance:
- The docstring, which is what the model reads to decide when to call this tool. Write it like a user manual: what the tool does, what its arguments mean, in what units, what it returns. A vague docstring is the number-one cause of a model refusing to call a perfectly good tool.
- The parameter names and types, which become the JSON schema the model produces arguments against.
amount: float, notamount. - The tool name (
convert_currencyby default, or an explicit@tool("convert_currency")), which is how the model references it in its output. - The return type, which the model treats as the observation it can reason on.
Binding tools to a model
You do not "call" a tool from the model. You bind a list of tools to the model, and the model's reply either contains a normal message or a tool call: a name and a JSON-validated argument dict.
from langchain_ollama import ChatOllama # or ChatOpenAI, ChatAnthropic, ...
model = ChatOllama(model="qwen2.5:7b-instruct", temperature=0)
tools = [convert_currency, check_per_diem_ceiling, append_spreadsheet_row]
model_with_tools = model.bind_tools(tools)
reply = model_with_tools.invoke(
"How much is 42.30 EUR in USD today?"
)
reply is an AIMessage. If the model decided to call a tool, its tool_calls attribute holds the list:
reply.tool_calls
# [{'name': 'convert_currency',
# 'args': {'amount': 42.3, 'source': 'EUR', 'target': 'USD'},
# 'id': 'call_a1b2c3'}]
The model does not execute the tool. It produces the intent; your code executes and feeds the result back. Module 8 will wrap that loop in an agent runtime.
Executing a tool call and returning the observation
Between the model's decision and the tool's return, you do three things: look up the tool by name, call it, and wrap the result in a ToolMessage that carries the same id back to the model.
from langchain_core.messages import ToolMessage
tools_by_name = {t.name: t for t in tools}
for call in reply.tool_calls:
fn = tools_by_name[call["name"]]
result = fn.invoke(call["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
# then hand the whole message list back to the model to get the final answer
final = model_with_tools.invoke(messages)
The tool_call_id is not decoration. It is how the model links the observation to the specific call it made, especially when it decided to call several tools in parallel. Losing that id and pasting a raw string into the history breaks parallel calls silently.
Validating arguments
Native function calling produces JSON that matches the schema, which is usually right. When it is wrong, the wrapper raises — but a wrong-but-valid call still gets through. Two habits contain this.
Typed arguments do half the work. amount: float rejects "forty two". source: Literal["EUR", "USD", "GBP"] rejects unknown currencies without your tool touching a network. Use the Pydantic-friendly typing that the tool decorator understands, not Any.
The tool defends its own contract. Inside convert_currency, check that amount > 0, that the pair is supported, and raise a typed exception on failure. Do not return None, do not return an error string — a ToolException is what module 8's agent will catch and use to re-plan.
from langchain_core.tools import ToolException
@tool
def convert_currency(amount: float, source: str, target: str) -> float:
"""..."""
if amount <= 0:
raise ToolException("amount must be positive")
if source == target:
return round(amount, 2)
...
The three tools of the expense assistant
@tool
def check_per_diem_ceiling(category: str, amount_eur: float, city: str) -> dict:
"""Check whether an expense in EUR clears the per-diem ceiling
for a given category ('meal', 'transport', 'lodging') in a city.
Returns {'ceiling_eur': float, 'over_ceiling': bool}."""
...
@tool
def append_spreadsheet_row(
date: str, category: str, amount_eur: float, city: str, note: str,
) -> str:
"""Append a validated expense line to the team's tracking sheet.
Returns the URL of the created row. Only call this after
check_per_diem_ceiling has approved the amount."""
...
The docstrings tell the model when to call the tool and in what order — "only call this after check_per_diem_ceiling has approved" is the sentence that keeps the assistant from writing an over-ceiling line to the sheet. The model does not read your comments; it reads docstrings.
append_spreadsheet_row writes state. If the agent loop retries — module 8 will retry — you must be able to distinguish two identical calls from one call retried. Add a client-generated key (request_id: str) that the sheet API deduplicates on, and log it. Otherwise a network flake creates two rows for one dinner.
In summary
- A tool is a typed Python function with a docstring; the docstring is what the model reads to decide when to call it, and it is the number-one cause of a tool being ignored.
model.bind_tools([...])returns a model whose reply carriestool_calls; the model produces the intent, your code executes and returns the result as aToolMessagewith the sametool_call_id.- Constrain arguments with types and
Literal, defend the contract withToolException, never returnNoneon failure. - Tools with side effects need an idempotency key so that an agent-loop retry does not create duplicate rows.
Next module: the loop itself — the agent that observes, decides, calls a tool, reads the result and iterates until it can answer, with explicit limits so it does not run forever.