Multiple Tools
Beyond a Single Function
Every example so far has offered the model exactly one function. Real applications typically register several — a customer support assistant might have tools for looking up an order, checking a return policy, and issuing a refund; a data analysis assistant might have tools for querying a database, running a calculation, and generating a chart. This lesson covers what changes, and what doesn't, once more than one tool is available: how the model chooses among them, how a single turn can request several calls at once, how to dispatch each call to the right implementation, and how tool design changes once multiple tools have to coexist without confusing each other.
Registering Several Tools
tools = [
{
"type": "function",
"name": "get_order_status",
"description": "Look up the current status of a customer's order by order ID.",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "get_return_policy",
"description": "Look up the return policy for a given product category.",
"parameters": {
"type": "object",
"properties": {"category": {"type": "string", "enum": ["electronics", "clothing", "furniture", "other"]}},
"required": ["category"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "issue_refund",
"description": "Issue a refund for a specific order. Only use this after confirming the order is eligible for a refund.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount": {"type": "number", "minimum": 0},
"reason": {"type": "string"},
},
"required": ["order_id", "amount", "reason"],
"additionalProperties": False,
},
},
]
Registering all three tools in a single tools list makes all of them simultaneously available to the model for a given request — the model is free to call none, one, two, or all three within a single turn, depending entirely on what the user's request actually calls for. Nothing in this list structure implies an order or a required sequence; the model decides which functions are relevant and in what order to call them (or whether one call's result should inform whether it calls another), based purely on the conversation and each tool's description.
How the Model Chooses Among Tools
The model's tool selection is driven entirely by matching the user's apparent intent against each available tool's description and its parameters' descriptions — there is no separate configuration for "priority" or "preference" among tools beyond how clearly and distinctly each one is described. This has a direct, practical consequence: two tools with vague or overlapping descriptions make correct selection harder, since the model has less to go on when deciding which one actually fits the request.
# Two poorly distinguished tools — the model may struggle to choose reliably
poorly_distinguished = [
{"type": "function", "name": "lookup_info", "description": "Looks up information.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}},
{"type": "function", "name": "get_data", "description": "Gets data.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}},
]
# The same two tools, clearly distinguished
well_distinguished = [
{"type": "function", "name": "lookup_order_status", "description": "Look up the current shipping status of a specific order by its order ID.", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"], "additionalProperties": False}},
{"type": "function", "name": "lookup_product_specs", "description": "Look up technical specifications for a product by its product name or SKU.", "parameters": {"type": "object", "properties": {"product_name": {"type": "string"}}, "required": ["product_name"], "additionalProperties": False}},
]
The poorly_distinguished pair illustrates a genuine risk: lookup_info and get_data are different function names but nearly indistinguishable in what they claim to do, and a model given both, along with an ambiguous query, has little principled basis for choosing correctly and consistently between them. The well_distinguished pair fixes this not by adding more tools or more parameters, but simply by making each tool's actual purpose specific and non-overlapping — this is the single highest-leverage change available when tool selection seems unreliable, and it is worth checking before assuming the problem lies elsewhere (in the model, the prompt, or the conversation history).
Handling Several Function Calls in One Turn
A single response can contain more than one function_call item — the model asking for the order status of two different orders in one turn, for instance, or looking up both the order status and the applicable return policy before deciding what to tell the user.
def get_order_status(order_id: str) -> dict:
fake_orders = {"ORD-1": "shipped", "ORD-2": "delivered"}
return {"order_id": order_id, "status": fake_orders.get(order_id, "not_found")}
def get_return_policy(category: str) -> dict:
fake_policies = {"electronics": "30 days, unopened", "clothing": "60 days, with tags"}
return {"category": category, "policy": fake_policies.get(category, "Standard 14-day policy")}
available_functions = {
"get_order_status": get_order_status,
"get_return_policy": get_return_policy,
}
input_messages = [{"role": "user", "content": "What's the status of order ORD-1, and what's the return policy for electronics?"}]
response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)
function_calls = [item for item in response.output if item.type == "function_call"]
print(f"Number of function calls requested: {len(function_calls)}")
for call in function_calls:
args = json.loads(call.arguments)
function_to_run = available_functions[call.name]
result = function_to_run(**args)
input_messages.append(call)
input_messages.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
})
final_response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)
print(final_response.output_text)
Given this combined question, function_calls will typically contain two items — one for get_order_status and one for get_return_policy — both of which need to be executed and both of whose results need to be appended to input_messages (each with its own matching call_id) before the follow-up request is made. This is precisely why Lesson 3's loop iterated over function_calls as a list rather than assuming exactly one: a single user message can reasonably require several distinct pieces of information gathered independently, and the API surfaces that as multiple function-call items in one response rather than forcing several separate round trips for what is conceptually one combined request.
The Dispatch Pattern: A Function Registry
The available_functions dictionary shown above — mapping each tool's name string to the actual Python callable that implements it — is the standard pattern for dispatching among multiple tools, and it scales cleanly as more tools are added.
def dispatch_function_call(call, available_functions: dict) -> dict:
if call.name not in available_functions:
return {"success": False, "error": f"Unknown function: {call.name}"}
args = json.loads(call.arguments)
function_to_run = available_functions[call.name]
try:
result = function_to_run(**args)
return {"success": True, "result": result}
except Exception as e:
return {"success": False, "error": str(e)}
available_functions = {
"get_order_status": get_order_status,
"get_return_policy": get_return_policy,
# issue_refund intentionally omitted here — see the note below
}
call_result = dispatch_function_call(function_calls[0], available_functions)
Checking call.name not in available_functions explicitly, and returning a structured error rather than raising a KeyError, matters more with multiple tools than with a single one: as the number of registered tools grows, so does the chance that a tool listed in tools (and therefore something the model might request) has no corresponding entry in available_functions — a mismatch that indicates a real configuration bug (a tool was added to one list but not the other), and one that should surface as a controlled error result rather than an unhandled crash. Note also that issue_refund is deliberately omitted from available_functions in this example even though it's still listed in tools — this is intentional, and Lesson 5 covers exactly why a tool the model can request shouldn't necessarily be a tool your code executes unconditionally, particularly one with real side effects like issuing a refund.
Grouping and Organizing Tools as They Grow
Once an application has more than a handful of tools, keeping their definitions and implementations organized becomes worth deliberate structure rather than one long flat list.
ORDER_TOOLS = [
{"type": "function", "name": "get_order_status", "description": "...", "parameters": {}},
{"type": "function", "name": "issue_refund", "description": "...", "parameters": {}},
]
POLICY_TOOLS = [
{"type": "function", "name": "get_return_policy", "description": "...", "parameters": {}},
]
ORDER_FUNCTIONS = {"get_order_status": get_order_status}
POLICY_FUNCTIONS = {"get_return_policy": get_return_policy}
def build_toolset(*groups: list) -> list:
combined = []
for group in groups:
combined.extend(group)
return combined
def build_registry(*function_dicts: dict) -> dict:
combined = {}
for function_dict in function_dicts:
combined.update(function_dict)
return combined
active_tools = build_toolset(ORDER_TOOLS, POLICY_TOOLS)
active_functions = build_registry(ORDER_FUNCTIONS, POLICY_FUNCTIONS)
Organizing tools into named groups by area of responsibility (order-related tools, policy-related tools, and so on, mirroring how the underlying features of the application are likely already organized) rather than one undifferentiated list makes it straightforward to enable or disable whole groups of related tools for a given deployment — a customer-facing assistant might only need ORDER_TOOLS and POLICY_TOOLS active, while an internal admin tool might additionally enable a REFUND_TOOLS group that end users should never have access to. This grouping pattern becomes increasingly valuable as the number of tools grows, and it previews the kind of tool organization Unit 9 discusses at greater scale for built-in and remote tool sources.
Testing Multi-Tool Dispatch
def test_dispatch_routes_to_correct_function():
class FakeCall:
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
def fake_get_order_status(order_id):
return {"order_id": order_id, "status": "shipped"}
registry = {"get_order_status": fake_get_order_status}
call = FakeCall(name="get_order_status", arguments='{"order_id": "ORD-1"}')
outcome = dispatch_function_call(call, registry)
assert outcome["success"] is True
assert outcome["result"]["status"] == "shipped"
print("PASS: dispatch correctly routes a known function call to its implementation")
def test_dispatch_handles_unknown_function():
class FakeCall:
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
call = FakeCall(name="delete_everything", arguments="{}")
outcome = dispatch_function_call(call, {})
assert outcome["success"] is False
assert "Unknown function" in outcome["error"]
print("PASS: dispatch correctly reports an unregistered function name as an error rather than crashing")
test_dispatch_routes_to_correct_function()
test_dispatch_handles_unknown_function()
These two tests check the dispatch logic's two most important behaviors in isolation — correctly routing a known call to its implementation, and correctly reporting (rather than crashing on) an unrecognized function name — without needing any real model call, following the same fake-object testing pattern this course has applied consistently since Unit 5. As the number of registered tools grows, tests like these are what catch a mismatch between tools and available_functions during development, rather than that mismatch surfacing for the first time against a real user request in production.
Designing Functions to Be Safely Callable in Any Order
Because multiple function calls in one turn are executed by your own loop (typically in whatever order they appear in function_calls, though nothing guarantees the model always lists them in a meaningful sequence), it's worth designing individual tool functions so that their correctness doesn't depend on being called in a particular order relative to other tools in the same turn. get_order_status and get_return_policy from earlier in this lesson are safe in this sense — each is a read-only lookup with no dependency on the other having run first or on any shared mutable state. A function that writes to shared state, on the other hand, needs more careful thought.
# Risky: this function's correctness depends on being called after a specific
# other function, but nothing in the tool-calling loop enforces that ordering.
account_balance = {"ACC-1": 100.0}
def apply_discount(account_id: str, percentage: float) -> dict:
# Assumes some other step already validated eligibility — but if the model
# calls this without first calling the validation tool, or calls it twice
# in the same turn, there's nothing here to catch that.
account_balance[account_id] *= (1 - percentage / 100)
return {"account_id": account_id, "new_balance": account_balance[account_id]}
# Safer: the function validates its own precondition rather than assuming
# some other call already ran first.
def apply_discount_safely(account_id: str, percentage: float, eligibility_verified: bool) -> dict:
if not eligibility_verified:
return {"success": False, "error": "Discount eligibility must be verified before applying a discount."}
if account_id not in account_balance:
return {"success": False, "error": f"Unknown account: {account_id}"}
account_balance[account_id] *= (1 - percentage / 100)
return {"success": True, "account_id": account_id, "new_balance": account_balance[account_id]}
apply_discount_safely() takes its precondition (eligibility_verified) as an explicit argument rather than silently assuming some other function already ran and left the system in the right state — this makes the function's actual requirement visible in its schema (the model has to explicitly indicate it has verified eligibility, typically because it called a separate verification tool first and passed that result along) rather than being an invisible assumption baked into the implementation that breaks silently the first time a model calls the functions in an unexpected order, or calls one of them without the other. This is a small example of a broader principle worth carrying into any multi-tool design with functions that mutate state: treat each function's real preconditions as something to check explicitly and fail clearly on, not something to assume is already true because of an ordering that isn't actually enforced anywhere.
Common Mistakes
Registering overlapping or vaguely distinguished tools, giving the model too little basis to reliably choose the correct one for a given request.
Assuming a response contains at most one function call once multiple tools are registered, rather than iterating over every function_call item present, which breaks the moment the model reasonably requests two or more calls in a single turn.
Letting tools and available_functions drift out of sync as tools are added or removed, creating a class of bug where the model can request a call your code has no way to actually execute.
Building one large, undifferentiated tool list rather than grouping related tools, making it harder to enable or restrict specific sets of tools for different deployments or user roles.
Best Practices
Give every tool a specific, non-overlapping description, checking this first whenever tool selection seems unreliable, since it is usually the highest-leverage fix available.
Always iterate over all function_call items in a response, never assuming a fixed count, since a single turn can reasonably require several independent calls.
Maintain tools and available_functions (or an equivalent registry) together, and test that every tool name in one has a corresponding, correctly named entry in the other.
Group related tools by area of responsibility as the number of registered tools grows, making it straightforward to compose different subsets of tools for different deployments or user roles.