AI Agent Tool Use: Function Calling, Schemas and Error Handling
Generative AILesson 2 of 47 min read
What you'll learn
- Describe a tool to a model with a name, a description and a typed parameter schema
- Validate model-generated arguments before running any real code
- Return structured, readable errors that let the model repair its own call
- Decide how many retries and which error classes deserve them
On this page
A courier company wants a support assistant that can look up a parcel and move its delivery day. Imagine the first version let the model write the tool call as free text, and a customer typed "reschedule my parcel to the 9th". The model dutifully produced reschedule_delivery(code=1001, day=9), the code looked up an integer key in a dictionary of strings, and the whole conversation died with a raw KeyError. Nobody had designed what happens between "the model wrote a call" and "the code ran it". That gap is the subject of this lesson.
What function calling really is
Function calling (also called tool use) is a contract with three pieces. You describe each tool to the model: its name, what it is for, and the parameters it takes. The model replies not with prose but with a structured request such as {"tool": "track_parcel", "arguments": {"code": "PK-1001"}}. Your program, not the model, checks that request and runs the matching function. Providers differ in the exact wire format, but the three pieces are the same everywhere, and none of them involves the model executing anything.
The description is not decoration. The model chooses between tools by reading these descriptions, so "Look up the current status of a parcel from its tracking code" works better than "parcel tool". Likewise a parameter named day with a note that 1 means Monday prevents a whole class of off-by-one guesses. Think of the schema as documentation written for a reader who cannot ask follow-up questions.
Describing tools and validating calls
The file below keeps a tool schema as plain data, in the same spirit as the JSON Schema documents most providers accept, and a validate function that turns any problem into one sentence. Writing it by hand keeps the idea visible; in a real project you would usually use a schema library for the same job.
import json
TOOL_SPECS = {
"track_parcel": {
"description": "Look up the current status of a parcel from its tracking code.",
"params": {"code": {"type": "string", "prefix": "PK-"}},
"required": ["code"],
},
"reschedule_delivery": {
"description": "Move a parcel's delivery to another day of the coming week (1 = Monday).",
"params": {
"code": {"type": "string", "prefix": "PK-"},
"day": {"type": "integer", "min": 1, "max": 7},
},
"required": ["code", "day"],
},
}
PY_TYPES = {"string": str, "integer": int}
def validate(tool, args):
"""Return None if the call is well-formed, else one sentence the model can act on."""
spec = TOOL_SPECS.get(tool)
if spec is None:
return f"unknown tool '{tool}'; available tools: {sorted(TOOL_SPECS)}"
if not isinstance(args, dict):
return "arguments must be a JSON object"
problems = []
for name in spec["required"]:
if name not in args:
problems.append(f"missing required argument '{name}'")
for name, value in args.items():
rule = spec["params"].get(name)
if rule is None:
problems.append(f"unexpected argument '{name}'")
continue
wrong_type = not isinstance(value, PY_TYPES[rule["type"]]) or isinstance(value, bool)
if wrong_type:
problems.append(f"'{name}' must be a {rule['type']}, got {type(value).__name__}")
continue
if "min" in rule and not rule["min"] <= value <= rule["max"]:
problems.append(f"'{name}' must be between {rule['min']} and {rule['max']}, got {value}")
if "prefix" in rule and not value.startswith(rule["prefix"]):
problems.append(f"'{name}' must start with '{rule['prefix']}', got '{value}'")
return "; ".join(problems) or NoneNotice what validate refuses to do: it never raises, and it collects every problem rather than stopping at the first one, so the model can fix all of them in a single retry. It also names the allowed values ("must be between 1 and 7", "available tools: ..."). An error that says only "invalid input" gives the model nothing to repair with.
The check isinstance(value, bool) is there because in Python True is an integer. A model that sends "day": true should not slip through as day 1.
Running tools behind a safe door
Validation is only half the door. Even a well-formed call can fail for legitimate reasons: the parcel does not exist, or it has already been delivered. Those are not bugs, they are observations the model needs. The next part of the file turns every outcome, good or bad, into a small dictionary with a stable shape.
PARCELS = {
"PK-1001": {"status": "at depot", "day": 3},
"PK-1002": {"status": "delivered", "day": 1},
}
def track_parcel(code):
return PARCELS[code] # raises KeyError for unknown codes
def reschedule_delivery(code, day):
parcel = PARCELS[code]
if parcel["status"] == "delivered":
raise ValueError("parcel was already delivered")
parcel["day"] = day
return {"code": code, "new_day": day}
IMPLS = {"track_parcel": track_parcel, "reschedule_delivery": reschedule_delivery}
def call_tool(tool, args):
"""The only door between the model and real code. Never raises; always returns a dict."""
problem = validate(tool, args)
if problem:
return {"ok": False, "error": "invalid_arguments", "detail": problem}
try:
return {"ok": True, "data": IMPLS[tool](**args)}
except KeyError as exc:
return {"ok": False, "error": "not_found", "detail": f"no parcel with code {exc.args[0]}"}
except ValueError as exc:
return {"ok": False, "error": "refused", "detail": str(exc)}
except Exception as exc: # last resort: do not let a tool bug kill the loop
return {"ok": False, "error": "tool_crashed", "detail": type(exc).__name__}
scripted_calls = [
("track_parcel", {"code": 1001}),
("track_parcel", {"code": "PK-1001"}),
("reschedule_delivery", {"code": "PK-1001", "day": 9}),
("reschedule_delivery", {"code": "PK-1001", "day": 5}),
("reschedule_delivery", {"code": "PK-1002", "day": 5}),
("track_parcel", {"code": "PK-9999"}),
("teleport_parcel", {}),
]
for tool, args in scripted_calls:
print(tool, json.dumps(args))
print(" ", json.dumps(call_tool(tool, args)))Output
track_parcel {"code": 1001}
{"ok": false, "error": "invalid_arguments", "detail": "'code' must be a string, got int"}
track_parcel {"code": "PK-1001"}
{"ok": true, "data": {"status": "at depot", "day": 3}}
reschedule_delivery {"code": "PK-1001", "day": 9}
{"ok": false, "error": "invalid_arguments", "detail": "'day' must be between 1 and 7, got 9"}
reschedule_delivery {"code": "PK-1001", "day": 5}
{"ok": true, "data": {"code": "PK-1001", "new_day": 5}}
reschedule_delivery {"code": "PK-1002", "day": 5}
{"ok": false, "error": "refused", "detail": "parcel was already delivered"}
track_parcel {"code": "PK-9999"}
{"ok": false, "error": "not_found", "detail": "no parcel with code PK-9999"}
teleport_parcel {}
{"ok": false, "error": "invalid_arguments", "detail": "unknown tool 'teleport_parcel'; available tools: ['reschedule_delivery', 'track_parcel']"}Walk through the output. The first call fails validation because 1001 is an integer, and the message says so. The second succeeds. The third has a day outside the range and is rejected before any code runs, so PARCELS is untouched. The fourth succeeds and actually changes the data. The fifth is a legal call that the business rule refuses, and the sixth is a legal call for a parcel that does not exist. The last asks for a tool that was never offered, and the reply lists the real ones.
The categories matter. invalid_arguments means the model should fix its call. not_found means it should ask the customer for a different code. refused means retrying is pointless and the model should explain. tool_crashed means something on your side broke, and the right move is usually to stop and report, not to loop.
Giving the model a chance to repair
When the error is the model's own formatting slip, one or two retries are cheap and effective. The loop below stands in for a model that reads the error text and corrects itself, and it retries only the error class that retrying can fix.
print("--- repair loop ---")
def repairing_model(call, error):
"""Fake model: reads the error text and fixes the arguments (a real model works from the message)."""
tool, args = call
fixed = dict(args)
if "must be a string" in error and isinstance(args.get("code"), int):
fixed["code"] = f"PK-{args['code']}"
return (tool, fixed)
def call_with_repair(call, max_retries=2):
for attempt in range(1, max_retries + 2):
result = call_tool(*call)
print(f"attempt {attempt}: ok={result['ok']}")
if result["ok"] or result["error"] != "invalid_arguments":
return result # success, or an error retrying cannot fix
call = repairing_model(call, result["detail"])
return {"ok": False, "error": "gave_up", "detail": "still invalid after retries"}
print(call_with_repair(("track_parcel", {"code": 1001})))
print(call_with_repair(("track_parcel", {"colour": "red"})))Output
--- repair loop ---
attempt 1: ok=False
attempt 2: ok=True
{'ok': True, 'data': {'status': 'at depot', 'day': 5}}
attempt 1: ok=False
attempt 2: ok=False
attempt 3: ok=False
{'ok': False, 'error': 'gave_up', 'detail': 'still invalid after retries'}The first call needs two attempts: the fake model sees "must be a string" and builds PK-1001. The second call has an argument that does not exist, and the stand-in model cannot fix that, so the loop gives up after the retry budget instead of spinning. Real models are better at repair than this stub, but the budget is what keeps a confused one from repeating itself indefinitely.
| Error class | Who can fix it | Retry? | What the model should do next |
|---|---|---|---|
| invalid_arguments | The model | Yes, within a small budget | Correct the argument named in the message |
| not_found | The user | No | Ask the user to confirm the identifier |
| refused (business rule) | Nobody right now | No | Explain the rule and offer an alternative |
| tool_crashed / timeout | Your team | Maybe once, if the call is safe to repeat | Report that the lookup is unavailable |
Common mistakes
- Letting exceptions escape the tool layer. A raw
KeyErroror stack trace ends the whole agent run and may leak internal names into the conversation. Catch insidecall_tooland return a structured result. - Vague error messages. "Bad request" produces the same bad call again. The symptom is two or three identical attempts in the log followed by a give-up. Name the field and the allowed range.
- Retrying everything. Repeating a non-idempotent call such as "send payment" after a timeout can run it twice. Retry only validation errors, or calls you know are safe to repeat.
- Skipping validation because the schema was given to the model. The schema is a request, not a guarantee. Models do send wrong types, extra fields and values out of range. Symptom: sporadic
TypeErroror silently wrong data. - Exposing too many tools at once. With dozens of similar tools the model picks the wrong one more often. Group related actions or offer only the tools relevant to the current step.
How this shows up in real projects and interviews
In real systems the tool layer is where most of the engineering effort ends up: schemas, validation, permissions, timeouts, idempotency keys and logging of every call and result. When reviewers read an agent codebase they look at this layer first, because it decides what the system can break. Expect interview questions such as "what do you do when the model emits malformed arguments" (validate, return a readable error, cap retries) and "how do you stop a tool from doing something the user should not be able to do" (check permissions in the tool, using the real user's identity, never trusting the model's claim). The wording of the instructions that sit around all this is covered in Prompt Engineering.
Try it yourself
Add a third tool, cancel_delivery(code, reason), to courier_tools.py. The reason must be one of three allowed strings. Extend validate to support an "enum" rule, add the tool to IMPLS, and write four scripted calls: one valid, one with a reason outside the list, one for a delivered parcel, and one for an unknown code. Check that each produces a different error class.
Show a hint
An enum rule is just another branch in validate: if "enum" is in the rule and the value is not in it, add a problem that lists the allowed values. Business-rule failures belong in the implementation as a ValueError, as in reschedule_delivery.
Key takeaways
- A tool is a name, a plain-language description and a typed parameter schema; the model only requests calls, your code runs them.
- Validate every model-generated call before executing it, and report all problems at once in a sentence the model can act on.
- Return every outcome as a structured result with an error class, so the loop and the model can tell fixable errors from final ones.
- Retry only errors that retrying can fix, and cap the retries; never blindly repeat calls with side effects.
Test yourself
A trick from this lesson's ride: A tool call is a request slip, not an action. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is function calling in LLMs?
Function calling is a way for a model to answer with a structured request, a tool name plus arguments, instead of free text. Your code validates the request, runs the real function and sends the result back. The model never executes anything itself.
How do I handle invalid tool arguments from an LLM?
Validate before running anything, collect all problems, and return them as a short, specific error the model can read. Allow a small number of retries for validation errors only. After that, stop and report failure rather than looping.
Should I trust the arguments an LLM generates for a tool call?
No. Treat them like input from an untrusted user: check types, ranges and allowed values, and enforce permissions inside the tool using the real user's identity. A schema in the prompt reduces mistakes but does not prevent them.