How Hirenix teaches
One chapter. 90 minutes.
Interview-ready.
Every concept starts with a real-world problem — the kind that actually shows up in production code. Nothing to cram; it just clicks. Every question comes with a model answer: exactly what to say in the room, and why. Then an AI mock interview on the same chapter.
- 📖Concept in 5 minutesNo jargon — straight to the point
- 🛠️Real-world problemThe kind production code throws at you
- 💬Model answerExactly what to say in the room
- 🧠FlashcardsRevise in 10 minutes
- 🤖AI mock interviewIt asks follow-ups too
- 📊Weak topicsSee exactly where you're stuck

The difference isn’t the content — it’s the filter. Only what’s actually used in production and actually asked in interviews. Textbook topics the industry never touches don’t make the cut.
What you’ll learn
- ●What is function calling?
- ●Tool definitions (JSON Schema)
- The tool-call loopFree account
- Parallel tool callsFree account
- Real-world toolsFree account
- Tool securityFree account
- Error handling in toolsFree account
- Structured outputFree account
- Function calling vs RAGFree account
- RecapFree account
- ●Project: AI Assistant with tools
What is function calling?
Think of an LLM like a brilliant friend stuck in a sealed room with no phone, no internet, and no clock. Ask them today's weather, your bank balance, or what time it is — they cannot check anything real. They will still answer, confidently, by guessing from what they remember. That guess can sound completely right and still be completely wrong.
In function calling, you hand that friend a list of tools they're allowed to ask for — "here's a phone that calls a weather API," "here's a line to the database." The friend still can't dial the phone themselves. All they can do is say "please dial the weather API for Karachi" — and it's you who actually picks up the phone, makes the call, and reports back the real answer. Only then can the friend give a grounded, correct reply.
That's the whole idea. Ch4 (RAG) gave the LLM knowledge — text to read before answering. Chapter 5 gives it actions — the ability to trigger real code that checks live data or does something in the world.
The 7-step flow every function-calling system follows:
- You describe your available tools to the model as JSON Schema (name, description, expected arguments).
- The user asks a question.
- The model reads the question and the tool list, and decides whether a tool fits.
- If one does, the model returns a tool call: a function name + arguments, as JSON — not an execution, just a request.
- Your code looks at that request, decides whether to honor it, and actually runs the real Python function.
- Your code sends the function's real result back to the model as a new message.
- The model reads that result and writes the final, natural-language answer.
🌍 Real-world example: Ask a plain LLM "what's the weather in Karachi right now?" and it will invent a plausible-sounding temperature — it has no way to know. Give it a
get_weathertool, and instead of guessing it returns{"name": "get_weather", "arguments": {"city": "Karachi"}}. Your code calls the real weather API, gets30°C, Sunny, and hands that back — now the model's final answer is actually true.
💡 Tool / Function = a real piece of code (a Python function, a database query, an API call) that the model is told exists and may request, but never runs by itself.
💡 Tool call = the model's request to run a specific tool with specific arguments, returned as structured JSON — not the tool actually running.
💡 Tool definition = the JSON Schema description (name, description, parameters) you give the model so it knows what tools exist and what arguments each one needs.
⚠️ RULE 3 — the single most important thing in this chapter: the model never executes anything. It only returns a tool name and a JSON blob of arguments. Whether that function actually runs is a decision made entirely by your code — you can validate the arguments, reject the call, log it, ask a human to confirm it, or run it. The model is a text predictor that got very good at predicting "here is the JSON you should run next" — nothing more. Every security guarantee in a tool-using system comes from this one fact.
Standard definition: Function calling (tool use) is a feature where you describe available functions to an LLM as JSON Schema; when a user's request matches one, the model returns a tool call — a function name and arguments as JSON — which your application code is responsible for validating and executing, then feeding the real result back to the model for its final answer.
from openai import OpenAI
import json
client = OpenAI()
# Step 1: describe the tool as JSON Schema (this is what the model "sees")
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a given city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. Karachi"}
},
"required": ["city"]
}
}
}
]
# The REAL function — this is the only thing that actually runs
def get_weather(city):
# in production this would call a real weather API
return {"city": city, "temp_c": 30, "condition": "Sunny"}
available_functions = {"get_weather": get_weather}
messages = [{"role": "user", "content": "What's the weather in Karachi?"}]
# Step 2-4: ask the model, it may return a tool call instead of text
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools
)
message = response.choices[0].message
if message.tool_calls:
messages.append(message) # record the model's tool-call request in history
for tool_call in message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
# Step 5: YOUR code decides to run it, and actually runs it
result = available_functions[func_name](**func_args)
# Step 6: send the real result back, tagged to this exact tool call
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
# Step 7: ask again — now the model has real data and writes the final answer
final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools
)
print(final.choices[0].message.content)
else:
print(message.content)Tool definitions (JSON Schema)
Think of every drawer in a big toolbox having a label. A drawer labeled "misc stuff" is useless — nobody knows what's inside without opening it. A drawer labeled "Phillips screwdrivers, sizes 1-3, for electronics" tells you exactly when to reach for it. A tool definition is that label, written for the model instead of a human.
In function calling, before the model can ever call your function, you describe it as a small JSON Schema object with four parts: name (the function's identifier, e.g. get_current_weather), description (a sentence telling the model what the tool does and when to use it), parameters (a type: object with a properties map — one entry per argument, each with its own type and description, plus an optional enum for a fixed list of allowed values), and required (the list of argument names that must be present). Nothing here is executable — it is pure text the model reads before deciding anything.
Here is the part beginners miss: the description fields are not comments for the next developer. They are prompt engineering — the ONLY information the model has to decide (a) whether this tool applies to the user's request at all, and (b) which tool to pick when several are available, and (c) what to put in each argument. A vague description like "Gets weather info" gives the model almost nothing to match against, so it either picks the wrong tool, calls the right tool with garbage arguments, or doesn't call it when it should. A precise description like "Get the CURRENT real-time weather for a specific city. Use this ONLY for today's weather, not forecasts." tells the model exactly when this tool fires and when it doesn't (e.g. it now knows NOT to use it for a 5-day forecast question).
🌍 Real-world example: Two tools named
search_ordersandsearch_productswith descriptions that both just say "searches the database" will get confused constantly — the model has no way to tell them apart. Rewrite them as "Search a customer's past orders by order ID or date range" and "Search the product catalog by name or category" and the model routes correctly almost every time, with zero code changes.
💡 JSON Schema = a standard way to describe the shape of JSON data (what fields exist, their types, which are required) — function calling reuses this exact format for tool parameters.
💡 enum = a fixed list of allowed values for a field (e.g.
["celsius", "fahrenheit"]) — use it whenever a parameter only makes sense as one of a small known set; it stops the model from inventing invalid values.
💡 required = the array of parameter names the model MUST fill in before the call is considered valid; anything not listed is optional.
One more habit worth building early: keep the number of tools small and each one narrowly scoped. A model choosing between 3 clearly distinct tools makes far fewer mistakes than one choosing between 15 overlapping ones. If two tools do almost the same thing, merge them or make their descriptions sharply distinguish the cases each one covers.
Standard definition: A tool definition is a JSON Schema object (name, description, parameters, required) that describes a function to the model; the description fields are prompt engineering that directly drive tool selection and argument accuracy, so vague descriptions are the single biggest cause of wrong tool calls.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
# BAD: vague description — model can't tell WHEN to use this
"description": "Gets weather info",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"}
},
"required": ["location"]
}
}
},
{
"type": "function",
"function": {
"name": "get_current_weather",
# GOOD: says what it does AND when (and when NOT) to use it
"description": (
"Get the CURRENT real-time weather for a specific city. "
"Use this ONLY when the user asks about today's or right-now "
"weather, not multi-day forecasts."
),
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name only, e.g. 'Mumbai' or 'Delhi' — no country needed"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit the user wants the reply in"
}
},
"required": ["city"]
}
}
}
]Project: AI Assistant with tools
What we're building: An AI Assistant with tools — one assistant, four capabilities: a calculator (LLMs are bad at arithmetic), a weather lookup, a notes save/read tool (the only one that writes to disk), and a fake web search. This is where every piece of this chapter — tool definitions, dispatch table, bounded loop, parallel calls, error handling, and the human confirmation gate — wires into one working system, not four separate demos.
Step 0 — The pipeline, end to end (read first)
User asks a question
-> assistant sees the tool schemas, decides IF/WHICH tools fit
-> assistant message comes back with tool_calls (maybe several, maybe none)
-> code looks each one up in the dispatch table
-> read-only tools run immediately; save_note pauses for human confirmation
-> each call is wrapped in try/except -- errors become tool results, not crashes
-> independent tool_calls from the SAME message run in parallel
-> every result is appended as a {"role": "tool", ...} message, matched by tool_call_id
-> loop repeats until the model replies with no tool_calls -- capped at max_turns
Remember Rule 3 from this chapter: the model never executes anything. Every arrow above after "tool_calls come back" is code you wrote, not the LLM.
Step 1 — Define the five tools as JSON Schema
TOOL_SCHEMAS = [
{"type": "function", "function": {
"name": "calculate",
"description": "Evaluate a pure arithmetic expression, e.g. '18% of 2400' rewritten as '2400*0.18'. Use this for ANY math -- never do arithmetic yourself.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string", "description": "A valid Python arithmetic expression, digits and + - * / ** % ( ) only"}},
"required": ["expression"]}}},
{"type": "function", "function": {
"name": "save_note",
"description": "Save text to a new note file. This WRITES to disk -- only call it when the user clearly wants something saved.",
"parameters": {"type": "object",
"properties": {"title": {"type": "string"}, "content": {"type": "string"}},
"required": ["title", "content"]}}},
# get_weather({city}), web_search({query}), read_note({title}) follow the
# exact same {"type":"function","function":{name, description, parameters}} shape.
]
What's happening: each description is doing real work -- "never do arithmetic yourself" steers the model to calculate instead of guessing in its head; "this WRITES to disk" is a signal the model reasons about before calling save_note. Five tools, narrowly scoped, is deliberate: a sixth vague tool would start stealing calls that belong to these five.
Step 2 — Implement each tool as a plain function
Every tool returns a string (JSON-encoded), never raises past its own boundary, never trusts its caller blindly:
calculate(expression)— parses into an AST, whitelists only arithmetic nodes before callingeval(). A model-supplied string like__import__('os').system(...)is rejected at the AST-walk step, long before it could run.get_weather(city)/web_search(query)— fixed dicts standing in for real APIs (see Bonus); swap either for a real call without touching anything else.read_note/save_note—_note_path()sanitises the title and confines every file to thenotes/folder. This is the sandboxed file path rule from the security topic: the model can never write outsidenotes/, no matter what title it invents.
💡 Least privilege in practice =
calculatecan only do arithmetic,save_notecan only write inside one folder. Neither tool can do more damage than its narrow job, even if the model is tricked into misusing it.
Step 3 — The dispatch table
TOOLS = {"calculate": calculate, "get_weather": get_weather,
"web_search": web_search, "read_note": read_note, "save_note": save_note}
WRITE_TOOLS = {"save_note"}
What's happening: TOOLS maps a tool name (the string the model returns) to the real function that runs it -- this turns "the model said calculate" into calculate(**args) without a long if/elif chain. WRITE_TOOLS is a second, tiny lookup flagging which names need confirmation -- adding a new destructive tool later is one line, not a code change everywhere.
Step 4 — Run one tool call: JSON parse -> lookup -> confirm -> execute -> catch
def execute_tool_call(call):
name, call_id = call.function.name, call.id
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError:
return {"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"error": "invalid JSON arguments from model"})}
if name not in TOOLS:
return {"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"error": f"unknown tool '{name}'"})}
if name in WRITE_TOOLS and not confirm(name, args):
return {"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"error": "user declined this action"})}
try:
result = TOOLS[name](**args)
except Exception as e:
result = json.dumps({"error": f"tool '{name}' raised: {e}"})
return {"role": "tool", "tool_call_id": call_id, "content": result}
What's happening -- four failure modes, all handled without ever raising into the loop: (1) malformed JSON arguments -- caught before **args is even attempted; (2) a hallucinated tool name -- checked against TOOLS; (3) a write tool the human declines -- handled by Step 5's gate; (4) the real function itself throws -- caught by the outer try/except. Every path returns a normal {"role": "tool", ...} message. The model always gets something back and can react -- apologise, retry, or explain the failure -- instead of the whole assistant crashing.
Step 5 — The human confirmation gate (before the write tool only)
def confirm(name, args):
print(f"\nAssistant wants to run '{name}' with {args}")
return input("Allow? [y/N]: ").strip().lower() == "y"
What's happening: this is a CLI stand-in for a real UI's "Confirm" button. save_note is the only tool in WRITE_TOOLS, so it's the only one that ever pauses for a human; the four read-only tools run immediately. Gating everything would make the assistant unusable; gating nothing would let the model write files without anyone agreeing. This is the security chapter's rule made concrete: confirm destructive/irreversible actions, nothing else.
Step 6 — Parallel tool calls, only when they're independent
with ThreadPoolExecutor(max_workers=len(message.tool_calls)) as pool:
results = list(pool.map(execute_tool_call, message.tool_calls))
What's happening: message.tool_calls is a list -- one assistant message can request several tools at once, e.g. weather for two cities plus a calculation. The model only puts multiple calls in ONE message when their answers don't depend on each other, so it's safe to run this list through a thread pool concurrently. When calls ARE dependent (e.g. "look up my note, then calculate 10% of the number in it"), the model does NOT bundle them -- it asks for read_note first, waits for that result to land in messages, then asks for calculate on the next loop iteration. The dependency is enforced by the loop structure itself, not by anything you code by hand.
Step 7 — The bounded loop (max_turns, RULE 1)
def run_assistant(user_input, max_turns=6):
messages = [{"role": "system", "content": "You are a helpful assistant with tools."},
{"role": "user", "content": user_input}]
for _ in range(max_turns):
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages,
tools=TOOL_SCHEMAS, tool_choice="auto")
message = response.choices[0].message
messages.append(message.model_dump())
if not message.tool_calls:
return message.content
with ThreadPoolExecutor(max_workers=len(message.tool_calls)) as pool:
results = list(pool.map(execute_tool_call, message.tool_calls))
messages.extend(results)
return "Reached max_turns without a final answer -- stopping to avoid a runaway loop."
What's happening: for _ in range(max_turns) replaces a bare while True: (the exact bug this chapter's Rule 1 warns about) -- if the model keeps asking for tools forever, the function returns a graceful message on turn 6 instead of looping and billing forever. The assistant's own message is appended to messages before any tool results -- get this order wrong and the API rejects the request, because a tool message must always follow the tool_calls message that asked for it.
Step 8 — Trace ONE full request, message by message
User asks: "What's an 18% tip on Rs.2400, what's the weather in Mumbai and Delhi, and save the tip amount as a note called 'tip-calc'."
messages at each point in run_assistant:
- Start:
[{role: system, ...}, {role: user, content: "What's an 18% tip on Rs.2400, ..."}] - Turn 1 reply -- ONE assistant message, three tool_calls (calculate + get_weather x2, independent -> bundled):
{role: assistant, tool_calls: [{id:"call_1", function:{name:"calculate", arguments:'{"expression":"2400*0.18"}'}}, {id:"call_2", function:{name:"get_weather", arguments:'{"city":"Mumbai"}'}}, {id:"call_3", function:{name:"get_weather", arguments:'{"city":"Delhi"}'}}]}-- appended before any tool runs. - Code runs all three in parallel, three tool messages appended (
tool_call_idmatching each call):{role:tool, tool_call_id:"call_1", content:'{"result":432.0}'},{role:tool, tool_call_id:"call_2", content:'{"temp_c":31,"condition":"Humid, partly cloudy"}'},{role:tool, tool_call_id:"call_3", content:'{"temp_c":38,"condition":"Hot, clear sky"}'}. - Turn 2 reply -- model saw the results, now asks for the write tool:
{role: assistant, tool_calls: [{id:"call_4", function:{name:"save_note", arguments:'{"title":"tip-calc","content":"Tip on Rs.2400 at 18% = Rs.432"}'}}]}. - Confirmation gate fires --
save_noteis inWRITE_TOOLS,confirm()prompts, human types "y":{role:tool, tool_call_id:"call_4", content:'{"saved":true,"title":"tip-calc"}'}appended. - Turn 3 reply -- model has everything it needs, no tool_calls:
{role: assistant, content: "The 18% tip on Rs.2400 is Rs.432 (total Rs.2832). Mumbai is 31C and humid, Delhi is 38C and clear. I've saved the tip calculation to your notes as 'tip-calc'."}.
message.tool_calls is empty -> loop returns message.content. Three model calls, one confirmation, zero exceptions escaping the loop.
Bonus features (resume/recruiter differentiators)
- Real APIs — swap
get_weatherfor OpenWeatherMap,web_searchfor Tavily/Serper; the dispatch table, loop, and confirmation gate don't change -- proof the architecture is provider-agnostic. - Per-tool timeouts — wrap each
TOOLS[name](**args)call in a timeout so one slow tool can't hang the whole turn. - Audit log — append every
(tool name, args, result, approved?)tuple to a log file; the first thing a real security review asks for.
What a recruiter sees in this project
- The full function-calling loop, correctly bounded — most tutorials show
while True:; this shows why that's wrong and how to fix it. - Parallel execution done safely — concurrent only where the model itself proved independence.
- A real human-in-the-loop safety gate — the detail separating a toy demo from something a team would deploy.
- Defense in depth — sandboxed file paths, a whitelisted AST-based calculator instead of raw
eval, and every tool failure turned into a recoverable message instead of a crash.
Standard definition: An AI assistant with tools exposes a small set of narrowly scoped functions to the model as JSON Schema, dispatches any resulting tool_calls through a lookup table with exception handling so every failure becomes a tool result rather than a crash, runs independent calls from the same turn in parallel, gates destructive actions behind explicit human confirmation, and loops -- turn by turn, with a hard max_turns cap -- until the model returns a final answer with no further tool_calls.
import ast
import json
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
from openai import OpenAI
client = OpenAI()
NOTES_DIR = Path("notes")
NOTES_DIR.mkdir(exist_ok=True)
# ---- 1. Tool schemas the model sees ----
TOOL_SCHEMAS = [
{"type": "function", "function": {
"name": "calculate",
"description": "Evaluate a pure arithmetic expression. Use this for ANY math -- never do arithmetic yourself.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string", "description": "e.g. '2400*0.18'"}},
"required": ["expression"]}}},
{"type": "function", "function": {
"name": "get_weather",
"description": "Get current weather for a named city (small fixed set of Indian cities).",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}}, "required": ["city"]}}},
{"type": "function", "function": {
"name": "web_search",
"description": "Search the web for a fact not in your training data or this conversation.",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}}, "required": ["query"]}}},
{"type": "function", "function": {
"name": "read_note",
"description": "Read a previously saved note by its title.",
"parameters": {"type": "object",
"properties": {"title": {"type": "string"}}, "required": ["title"]}}},
{"type": "function", "function": {
"name": "save_note",
"description": "Save text to a new note file. This WRITES to disk -- only call when the user clearly wants something saved.",
"parameters": {"type": "object",
"properties": {"title": {"type": "string"}, "content": {"type": "string"}},
"required": ["title", "content"]}}},
]
# ---- 2. Fake data the tools read from ----
FAKE_WEATHER = {
"mumbai": {"temp_c": 31, "condition": "Humid, partly cloudy"},
"delhi": {"temp_c": 38, "condition": "Hot, clear sky"},
"bengaluru": {"temp_c": 24, "condition": "Light rain"},
}
FAKE_SEARCH = {
"python 3.13 release date": "Python 3.13 released October 7, 2024.",
}
# ---- 3. Tool implementations ----
def calculate(expression):
try:
node = ast.parse(expression, mode="eval")
allowed = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant,
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.USub, ast.Mod)
for n in ast.walk(node):
if not isinstance(n, allowed):
raise ValueError(f"disallowed token: {type(n).__name__}")
return json.dumps({"result": eval(compile(node, "<calc>", "eval"))})
except Exception as e:
return json.dumps({"error": f"could not evaluate '{expression}': {e}"})
def get_weather(city):
data = FAKE_WEATHER.get(city.strip().lower())
if not data:
return json.dumps({"error": f"no weather data for '{city}'"})
return json.dumps(data)
def web_search(query):
result = FAKE_SEARCH.get(query.strip().lower(), f"No indexed result for '{query}'.")
return json.dumps({"result": result})
def _note_path(title):
safe = "".join(c for c in title if c.isalnum() or c in " _-").strip()
return NOTES_DIR / f"{safe}.txt" if safe else None
def read_note(title):
path = _note_path(title)
if not path or not path.exists():
return json.dumps({"error": f"note '{title}' not found"})
return json.dumps({"content": path.read_text()})
def save_note(title, content):
path = _note_path(title)
if not path:
return json.dumps({"error": "invalid title"})
path.write_text(content)
return json.dumps({"saved": True, "title": title})
# ---- 4. Dispatch table + which tools need human confirmation ----
TOOLS = {
"calculate": calculate, "get_weather": get_weather,
"web_search": web_search, "read_note": read_note, "save_note": save_note,
}
WRITE_TOOLS = {"save_note"} # destructive/irreversible -> confirm first
def confirm(name, args):
print(f"\nAssistant wants to run '{name}' with {args}")
return input("Allow? [y/N]: ").strip().lower() == "y"
# ---- 5. Run one tool call safely, always return a tool message ----
def execute_tool_call(call):
name, call_id = call.function.name, call.id
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError:
return {"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"error": "invalid JSON arguments from model"})}
if name not in TOOLS:
return {"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"error": f"unknown tool '{name}'"})}
if name in WRITE_TOOLS and not confirm(name, args):
return {"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"error": "user declined this action"})}
try:
result = TOOLS[name](**args)
except Exception as e:
result = json.dumps({"error": f"tool '{name}' raised: {e}"})
return {"role": "tool", "tool_call_id": call_id, "content": result}
# ---- 6. The bounded loop: multi-turn, parallel-safe, max_turns capped ----
def run_assistant(user_input, max_turns=6):
messages = [
{"role": "system", "content": "You are a helpful assistant with tools."},
{"role": "user", "content": user_input},
]
for _ in range(max_turns):
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages,
tools=TOOL_SCHEMAS, tool_choice="auto",
)
message = response.choices[0].message
messages.append(message.model_dump())
if not message.tool_calls:
return message.content # no more tools requested -> final answer
with ThreadPoolExecutor(max_workers=len(message.tool_calls)) as pool:
results = list(pool.map(execute_tool_call, message.tool_calls))
messages.extend(results)
return "Reached max_turns without a final answer -- stopping to avoid a runaway loop."Function Calling & Toolsinterview questions & answers
10 sample questions below — 131+ in the full bank inside.
Why must the tool_call_id in a tool result message match the tool call it is answering?
The `tool_call_id` is the link between a tool call and its result — if they don't match, the model doesn't know which result belongs to which call, and the conversation breaks.
In simple terms: Imagine you ask three people questions at once and they all reply with just answers — without names or numbers, you won't know who answered what. The `tool_call_id` is like numbering: 'Question 1' → 'Answer 1'. Example: if the model calls tool with `tool_call_id: "call_42"`, your tool result must be `{"tool_call_id": "call_42", "content": "..."}`.
What is audit logging and why does it matter for tool security?
Audit logging means recording every tool call — its name, arguments, timestamp, and outcome. If something goes wrong (data deleted by mistake, wrong email sent), you have a complete trail to trace what happened and debug the issue.
In simple terms: It's like a security camera for your tools. Example: you log `{"tool": "delete_record", "args": {"id": 42}, "timestamp": "2025-02-10T14:30:00Z", "outcome": "deleted"}`. Later, if row 42 shouldn't have been deleted, you know exactly when and what called it, so you can understand if it was a model mistake or a user request.
In one sentence, what is function calling in the context of LLMs?
Function calling lets you describe available functions to an LLM as JSON Schema; when a request matches, the model returns a tool call (function name + JSON arguments) without executing it — your code runs the real function and sends the result back.
In simple terms: Think of it like ordering at a restaurant — you (the user) order, the waiter (model) writes down the order and sends it to the kitchen, the chef (your code) actually cooks it, and the waiter brings the result back. The waiter never cooks; they just relay. Example: a weather tool call returns `{"name": "get_weather", "arguments": {"city": "Delhi"}}` — the model didn't check the weather, just asked for it.
Does the LLM ever actually execute a function or query a database?
No — the model only returns a tool call (function name + arguments). Your code reads that call, decides whether to execute it, actually runs the real function or query, and then sends the result back to the model as a tool message.
In simple terms: The model is like a very smart librarian — it can say 'go fetch book X from shelf 3', but it doesn't fetch the book itself. You fetch it, read it, and tell the librarian what you found. Example: model returns `{"tool_call_id": "call_123", "function": "query_db"}` but doesn't touch your database — you do.
Should you add a timeout to every tool call? Why?
Yes, absolutely. Without a timeout, if a tool hangs (a slow API, a dead connection, an infinite loop), the whole tool-call loop will wait forever — the model never gets a response and the user's request is stuck. A timeout (e.g. 10 seconds) ensures that one slow tool cannot freeze the entire system.
In simple terms: Imagine calling a customer-service number and getting put on hold. If there's no timeout, you'll listen to music forever and never reach anyone else. With a timeout, after 30 seconds you hang up and try a different department. Example: `future.result(timeout=10)` — if the tool takes longer than 10 seconds, a `TimeoutError` is raised, which you catch and return as an error string to the model.
What if the model calls a tool that doesn't exist, or with missing or invalid arguments?
The model can hallucinate tool names or argument values because it's generating text, not validating. Before running any tool, you must validate that the function exists, required arguments are present, and their types are correct. If validation fails, return an error message to the model without even calling the real function.
In simple terms: Imagine asking a warehouse robot to fetch item from shelf 'Atlantis' (doesn't exist) or with quantity set to an empty string. You wouldn't send the robot blindly — you'd check the shelf number and quantity first. Example: `if not city or not isinstance(city, str): return "error: city is required and must be text"` — you catch the bad input *before* the tool ever runs, so the model sees a clear error and can ask for clarification.
Why must a tool's exception be caught and returned as a string, instead of just letting the code raise it?
Because tools interact with the outside world (APIs, databases, networks) which can fail for reasons completely outside your code's control — network timeouts, a server being down, a DB connection dropping. You cannot code your way out of these failures, so catching and returning them as strings keeps the conversation alive and lets the model react, instead of crashing.
In simple terms: The goal is resilience, not perfection. A weather API being unreachable at 2 AM is not a bug in your code — it's reality. You can fix bugs, but you cannot prevent infrastructure failures. Example: `except ConnectionError as e: return json.dumps({"error": f"API down: {e}"})` — the model now knows the weather service is unavailable and can tell the user or try a different approach, instead of the whole request dying.
What happens if a tool throws an exception inside the tool-call loop?
The uncaught exception propagates out of the loop and crashes the entire request — the user sees an error stack trace instead of an answer. To prevent this, you must catch any exception the tool raises and return it as a descriptive error string in the tool result, so the model can read it and react gracefully.
In simple terms: Think of a tool like a delivery driver. If the driver crashes their van (exception), the whole delivery chain fails and the customer hears nothing. Instead, the driver should radio back to dispatch with the problem: 'Road closed, trying alternate route' — now dispatch can reassign or inform the customer. Example: `try: result = get_weather(city) except Exception as e: return json.dumps({"error": str(e)})` — the error becomes part of the normal conversation flow instead of ending it.
What is the difference between an idempotent and a non-idempotent operation?
An idempotent operation has the same effect whether you do it once or many times: reading a value, searching a database, fetching weather data. A non-idempotent operation changes something: sending a payment, sending an email, deleting a file. Retrying idempotent operations is safe; retrying non-idempotent ones without checking first can cause duplicates.
In simple terms: Think of a stamp: if you stamp a paper once or ten times, the same stamp shape appears (idempotent). But if you sign a cheque once or ten times, the bank cashes it ten times (non-idempotent). Example: `get_weather()` is idempotent, so retrying is safe. `send_email()` is non-idempotent, so you must check if it already sent before retrying.
What are the main `tool_choice` options and when would you use each?
Four options: `"auto"` (model decides, default), `"none"` (never call a tool), `"required"` (must call something), or a specific tool name (force that one tool). Use `"auto"` normally, `"none"` to prevent tool use, `"required"` to guarantee action, and specific names to extract structured JSON.
In simple terms: Think of `tool_choice` like a traffic light: green (auto) means the model can go anywhere, red (none) blocks all movement, yellow (required) forces one move, and a specific street name forces a specific direction. Example: to extract a resume into JSON, force the schema tool with `tool_choice: "structured_resume"`.
121+ more Function Calling & Tools questions inside
Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.
Unlock all questions — freeReady to practise Function Calling & Tools?
Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.