Define functions, execute calls safely, and return tool results to the model.
Write JSON Schema–based function declarations that the model reads to know what tools are available, what arguments each requires, and what types are expected. You'll author a complete schema for a `get_weather` tool and a `search_database` tool that the chatbot will use throughout this lesson.
How to write JSON Schema–based function declarations that tell the model what tools exist, what arguments they take, and what values are valid.
Why this matters: Every tool-calling chatbot you build starts here — a weak schema means the model calls the wrong tool, passes bad arguments, or crashes your function silently.
Your chatbot can only call a tool if the model knows it exists, what it does, and what arguments it expects. That knowledge comes entirely from the you write.
A is a –based object you attach to the API request. It requires three parts: a name for invoking the tool, a description explaining when to use it, and a parameters block defining each argument.
The model never sees your actual function code — only the schema. The schema is the entire interface between the model's reasoning and your application.
A and a schema both use JSON Schema syntax. But they serve opposite directions.
Mixing them up is a common early mistake. Using a structured-output schema where a tool declaration is needed means the model produces formatted JSON instead of triggering an action.
# A dangerously under-specified schema get_weather_tool = { "name": "get_weather", "description": "Gets weather.", "parameters": { "type": "object", "properties": { "location": {"type": "string"} } } }
"type": "object""properties""required"This schema compiles and the model will use it — but it produces unpredictable, hard-to-debug behavior in practice.
Three things are wrong: the description gives the model no signal about when to call this vs. a search tool; location has no description so the model guesses the format (city name? lat/lon? zip code?); and required is absent, so the model may omit location entirely.
The model either omits location entirely (valid against the schema, crashes your function) or guesses a format like "New York, NY" vs. "New York" vs. "40.71,-74.00" — all valid strings, all potentially wrong for your API. You get a silent wrong call, not an error.
get_weather_tool = {
"name": "get_weather",
"description": "Return current weather for a city. Use this when the user asks about temperature, conditions, or forecasts.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Austin, TX'."},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit."}
},
"required": ["city"]
}
}"enum": ["celsius", "fahrenheit"]"description" on a property"required": ["city"]Every field now does real work: the tool description tells the model exactly when to reach for this tool, city has a format hint, unit is constrained to an enum so the model can't invent a value, and required enforces the one argument your function can't run without.
city: "Tokyo" (inferred from the question). unit: omitted or the model picks a default — it's not in required, so both are valid. Your function must handle a missing unit gracefully, e.g. defaulting to "celsius".
search_database_tool = {
"name": "search_database",
"description": "Search the product catalog by keyword. Use when the user asks about products, availability, or pricing.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keywords, e.g. 'red running shoes size 10'."
},
"max_results": {
"type": "integer",
# TODO: add a description AND a constraint so the model
# never requests more than 10 results.
# Hint: JSON Schema uses "minimum" and "maximum" for integers.
}
},
"required": ["query"]
}
}"minimum": 1, "maximum": 10"type": "integer"Stop — attempt the TODO before revealing. The missing piece is the crux: without a constraint on max_results, the model can request 1,000 rows and stall your chatbot.
Hints: use "minimum" and "maximum" as sibling keys to "type". Write a description that tells the model what a sensible value looks like.
"description": "Number of results to return (1–10). Defaults to 5 if not specified.",
"minimum": 1,
"maximum": 10
Changed lines: added description (tells the model the intent), minimum: 1 (prevents zero or negative), maximum: 10 (caps the load). Without these, the model treats max_results as an unbounded integer and may pass 100+.
Three failure patterns appear repeatedly when schemas are under-specified:
get_weather and search_database both say "gets information", the model picks arbitrarily. Observable symptom: the model calls the wrong tool consistently for certain phrasings, with no error raised."required": ["city"], the model omits the field. Your function raises KeyError: 'city' — or worse, silently queries with an empty string and returns garbage results."enum" on unit, the model may pass "Celsius" (capital C), "C", or "metric" — all syntactically valid strings, all rejected by your weather API.Note: a schema that passes JSON validation can still be semantically wrong. The model's output satisfies the schema's shape but carries a bad value — schema validation catches type errors, not logic errors.
If you ask an AI assistant to draft a function schema, run this checklist before wiring it into your chatbot:
description with a format hint or example value.enum or minimum/maximum — not just "type": "string".required array lists every field your function will crash without."data", "input") that the model can't infer its purpose.A quick smoke test: send a vague user message ("Tell me something about weather") and inspect the raw the model returns. If any argument is missing or in the wrong format, the schema needs tightening — not the prompt.
Drag to see how tightening a parameter schema shifts the tradeoff between model freedom and argument reliability.
Inspect the model's response object to detect a tool-call turn, extract the function name and arguments, and validate them against your schema before execution. You'll complete a parser for the `get_weather` call that catches missing fields, wrong types, and unknown function names.
How to detect a tool-call response, extract its arguments, and validate them against your schema before any execution happens.
Why this matters: Without this parsing and validation layer, your chatbot will either crash on bad model output or silently run tools with wrong arguments — this is the safety net between the model's proposal and your code.
Decision this forces: Should validation failures be returned to the model as an error message, or should the chatbot ask the user to rephrase?
When the model wants to invoke a tool, it returns a instead of plain text. This is a structured payload carrying a function name, a , and JSON-encoded arguments.
Your first job is to branch on response type. If finish_reason is "tool_calls", route to the parser. Otherwise treat it as a normal reply.
Missing this branch is the most common early bug. The chatbot prints raw JSON to the user instead of acting on it.
The model proposes arguments; it does not guarantee them. Even with a tight , the model can omit required fields, pass the wrong type, or hallucinate a function name that doesn't exist.
Running against your before execution means bad calls surface as structured errors — not runtime exceptions that crash the chatbot.
Three things to check: (1) the function name is on your allow-list, (2) all required fields are present, (3) each value matches its declared type.
import json KNOWN_TOOLS = {"get_weather"} # allow-list def parse_tool_call(response): if response.finish_reason != "tool_calls": return None # plain text turn — nothing to parse call = response.tool_calls[0] if call.function.name not in KNOWN_TOOLS: return {"error": f"Unknown tool: {call.function.name}"} args = json.loads(call.function.arguments) # string → dict return {"name": call.function.name, "args": args, "id": call.id}
response.finish_reasonKNOWN_TOOLSjson.loads(call.function.arguments)call.idThis stage handles the two guards that must run before any schema check: the response-type branch and the allow-list check.
Predict: what does parse_tool_call return when the model replies with plain text?
It returns None. The caller must check for None before attempting any validation — a None result means "route this as a normal chat reply, not a tool call".
# Requires: pip install jsonschema from jsonschema import validate, ValidationError GET_WEATHER_SCHEMA = { "type": "object", "properties": { "location": {"type": "string"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} }, "required": ["location"] } def validate_args(name, args): schema = {"get_weather": GET_WEATHER_SCHEMA}.get(name) try: validate(instance=args, schema=schema) return None # None = no error except ValidationError as e: return e.message # human-readable error string
validate(instance=args, schema=schema)ValidationError as e"enum": ["celsius", "fahrenheit"]Stage 2 wires the parsed args into a JSON Schema validator and returns either None (valid) or an error string — never a raw exception.
The schema here mirrors the one you authored in module 1 for get_weather — only "location" is required; "unit" is optional but constrained to an enum.
It returns a string like "'location' is a required property" — the ValidationError message from jsonschema. The location field is missing, which violates the "required" constraint.
def handle_response(response): parsed = parse_tool_call(response) # Stage 1 if parsed is None: return {"type": "text", "content": response.content} if "error" in parsed: return {"type": "error", "message": parsed["error"]} err = validate_args(parsed["name"], parsed["args"]) # Stage 2 if err: # TODO: return the right dict so the caller can feed # the error back to the model for a retry pass return {"type": "tool_call", "name": parsed["name"], "args": parsed["args"], "id": parsed["id"]}
parsed["id"]{"type": "validation_error", ...}Stop — attempt the TODO before revealing. The missing line is the crux of this module: what should the function return when validation fails, and what data does the caller need to feed the error back to the model?
Hints: (1) the caller needs to know it's a validation error, not a tool result; (2) the model needs the error text to correct its next attempt.
Replace the TODO with:
return {"type": "validation_error", "message": err, "id": parsed["id"]}
Changed lines vs. the worked example:
Returning the error to the model (rather than asking the user to rephrase) is the right default when the schema violation is recoverable — e.g. a missing field the model can infer from context.
Three failure patterns show up repeatedly in practice:
json.loads(), every downstream key lookup raises TypeError: string indices must be integers."location" when the user says "what's the weather?" without naming a city. Your tool runs with None and returns a confusing result — no exception, just wrong data."get_forecast" when only "get_weather" is registered. Without an allow-list check, your dispatcher raises KeyError and the chatbot crashes.Fix 1 and 3 with explicit guards before any schema check. Fix 2 by running full validation and returning the error message to the model so it can retry with the missing field.
Wrap each tool function in an execution layer that enforces an allow-list, catches exceptions, applies timeouts, and returns a structured result or error — never a raw exception. You'll add safe-execution wrappers to the `get_weather` and `search_database` tools, revisiting the schema constraints from Module 1 to see why tight types prevent most runtime errors.
Wraps validated tool calls in an allow-list dispatcher with try/except and timeout logic that always returns a structured result.
Why this matters: Prevents a single failing API call from crashing your chatbot loop and gives the model actionable error information to recover gracefully.
Decision this forces: Should execution errors be surfaced to the model as tool results, or should the loop retry automatically — and when does retry become dangerous?
Module 2's parser checks the function name (is it known?) and the argument types (do they match the ?). When either fails, it returns a structured error. That boundary is where this module picks up: validation passed, so now you must .
The driving question: what stops a network blip, slow API, or bad return value from crashing your chatbot loop?
A layer sits between your validated and the real function. It enforces three guarantees: only functions dispatch, every call has a , and every outcome returns as a .
The allow-list is a dict mapping function names to callables. If the name isn't in it, the dispatcher rejects the call before any I/O. This blocks prompt-injected tool names from reaching real code.
Returning a structured error instead of raising keeps the alive. The model receives the error as a and decides what to do next — retry, ask the user, or give up gracefully.
Three failure modes hit most tool-calling systems. Each has a distinct shape:
requests.exceptions.ConnectionError: HTTPSConnectionPool ... Max retries exceeded bubbles up uncaught and kills the loop. Cause: no try/except around the HTTP call. Fix: catch Exception at the wrapper boundary. Return {"error": "network_error", "message": "..."}.concurrent.futures and raise TimeoutError after your deadline.None or a raw Python object instead of a string. Cause: the tool returned something that can't be serialised. Fix: assert the result is a dict or str. Wrap surprises in an error shape.Your support bot has two tools: get_weather and search_database. The model sends a with name: "get_weather" and arguments: {"location": "Paris"}.
Without a safe layer, your code calls get_weather("Paris") directly. If the weather API is down, the exception propagates up. The loop crashes and the user sees a 500 error — or silence.
With the safe layer, the dispatcher looks up "get_weather" in the . It calls it inside a try/except with a 5-second . If anything fails, it returns {"error": "network_error", "message": "Weather API unreachable"}. The model reads that and tells the user it can't fetch weather. The loop never dies.
The same pattern covers search_database: a slow query exceeding the timeout returns {"error": "timeout", "message": "search_database exceeded 5s"} — a the model can reason about.
# No allow-list, no try/except, no timeout def dispatch(tool_call): name = tool_call["name"] args = tool_call["arguments"] # Calls whatever name the model sends — dangerous result = globals()[name](**args) return result # If get_weather() raises: ConnectionError crashes the loop
globals()[name](**args)This naive dispatcher has three fatal flaws: it trusts the model's function name blindly, it has no exception handling, and it has no timeout. Run it against a flaky API and the whole loop dies.
globals()["os.system"] resolves to the real os.system function and executes the shell command. The user sees whatever that command produces — or a crash. There is no allow-list to block it. This is why dispatching by name alone is dangerous.
import concurrent.futures ALLOW_LIST = { "get_weather": get_weather, "search_database": search_database, } def dispatch(tool_call, timeout_s=5): name = tool_call["name"] args = tool_call["arguments"] fn = ALLOW_LIST.get(name) if fn is None: return {"error": "unknown_tool", "message": f"{name!r} not allowed"} try: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: future = ex.submit(fn, **args) result = future.result(timeout=timeout_s) if not isinstance(result, (dict, str)): return {"error": "bad_return_type", "message": str(result)} return result except concurrent.futures.TimeoutError: return {"error": "timeout", "message": f"{name} exceeded {timeout_s}s"} except Exception as e: return {"error": "execution_error", "message": str(e)}
ALLOW_LIST.get(name)ThreadPoolExecutor(max_workers=1)future.result(timeout=timeout_s)isinstance(result, (dict, str))except Exception as eThis wrapper adds all three safety guarantees in one function: allow-list lookup rejects unknown names, ThreadPoolExecutor enforces the , and every failure path returns a instead of raising.
{"error": "timeout", "message": "search_database exceeded 5s"}. The ThreadPoolExecutor raises TimeoutError after 5 seconds; the except branch catches it and returns the structured error. The loop continues normally.
def dispatch(tool_call, timeout_s=5): name = tool_call["name"] args = tool_call["arguments"] fn = ALLOW_LIST.get(name) if fn is None: return {"error": "unknown_tool", "message": f"{name!r} not allowed"} try: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: future = ex.submit(fn, **args) result = future.result(timeout=timeout_s) # TODO: add the return-type guard and the success return here # Hint 1: check isinstance(result, (dict, str)) # Hint 2: on bad type, return {"error": "bad_return_type", ...} except concurrent.futures.TimeoutError: return {"error": "timeout", "message": f"{name} exceeded {timeout_s}s"} except Exception as e: return {"error": "execution_error", "message": str(e)}
isinstance(result, (dict, str))return resultThe allow-list check and timeout are already wired. Your job is to fill in the TODO: guard the return type and return the result. Stop and attempt it before revealing the answer.
The two lines that replace the TODO are:
if not isinstance(result, (dict, str)):
return {"error": "bad_return_type", "message": str(result)}
return result
When get_weather() returns 42, isinstance(42, (dict, str)) is False, so dispatch() returns {"error": "bad_return_type", "message": "42"}. The model receives a structured error instead of a raw integer. CHANGED LINES vs Stage 2: only the TODO block — the rest is identical, which is the point: the crux is the type guard, not the scaffolding.
Package tool output as a `tool` role message (or equivalent) with the correct `tool_call_id`, and append it to the conversation history so the model can incorporate it in its next response. You'll format both a success result and an error result for the `get_weather` tool and observe how each shapes the model's follow-up.
Package tool outputs as correctly structured tool-role messages — with the right tool_call_id and serialised content — and append them to conversation history so the model can use them.
Why this matters: Without this step your chatbot's tool calls are fire-and-forget: the model never sees the result and can't give the user a grounded answer.
— a dict with a status field and a message, never a raw traceback. That structured dict is exactly what you'll package in this module. The question now is: how do you hand it back to the model so the model can actually use it?
conversation history as a message with role "tool". Without this, the model cannot see what the tool returned.
Three fields are required: role (always "tool"), (the ID the model assigned), and content (a string carrying the result or error).
tool_call_id is the linchpin. In multi-tool turns, the model issues several calls at once. The ID matches each result to the right request. A mismatched or missing ID causes API rejection or hallucination.
The content field is always a string, even for structured data. Serialise your result dict with json.dumps() before inserting it.
Imagine your chatbot calls get_weather(location="Paris"). Two outcomes are possible, and each shapes the model's next reply differently.
The tool returns {"status": "ok", "temperature": 18, "condition": "cloudy"}. You serialise it and append a tool-role message. The model reads the temperature and condition, then replies: "It's 18 °C and cloudy in Paris right now."
The tool times out and your safe-execution wrapper returns {"status": "error", "error": "timeout", "message": "Weather service did not respond"}. You still append a tool-role message — with the same structure. The model reads the error and replies: "I couldn't fetch the weather right now; please try again in a moment."
The key insight: the model can only reason about failure if you tell it what failed. An empty content field or a missing message leaves the model guessing — and it will guess wrong.
import json # tool_call comes from the model's response (Module 2) tool_call_id = tool_call["id"] # e.g. "call_abc123" tool_result = {"status": "ok", "temperature": 18, "condition": "cloudy"} # from safe-exec (Module 3) result_msg = { "role": "tool", "tool_call_id": tool_call_id, "content": json.dumps(tool_result), } messages.append(result_msg)
"role": "tool""tool_call_id": tool_call_id"content": json.dumps(tool_result)messages.append(result_msg)This is the minimal correct structure for a tool-result message. Notice that tool_call_id is copied directly from the model's original request — never generated fresh.
A string: '{"status": "ok", "temperature": 18, "condition": "cloudy"}'. json.dumps() serialises the dict. The API requires content to be a string, not a Python object.
# safe_result comes from Module 3's wrapper — already structured safe_result = { "status": "error", "error": "timeout", "message": "Weather service did not respond in 5 s", } error_msg = { "role": "tool", "tool_call_id": tool_call_id, # same ID as the request "content": json.dumps(safe_result), } messages.append(error_msg)
"status": "error""error": "timeout""message": "..."The structure is identical to the success case — only the payload changes. Keeping the same shape means your downstream loop doesn't need to branch on success vs. error when building the message.
The model would say something like "I couldn't get the weather" with no detail. Without the human-readable message, it can't tell the user why or suggest a next step (e.g. "try again in a moment"). The error category alone is too sparse to be actionable.
# Two tool calls arrived in one model turn (parallel_tool_calls) # tool_calls = [{"id": "call_w1", "function": {"name": "get_weather", ...}}, # {"id": "call_w2", "function": {"name": "get_weather", ...}}] results = { "call_w1": {"status": "ok", "temperature": 18, "condition": "cloudy"}, "call_w2": {"status": "error", "error": "not_found", "message": "Location 'Atlantis' not recognised"}, } for tc in tool_calls: messages.append({ "role": "tool", "tool_call_id": # TODO: which value goes here? "content": json.dumps(results[tc["id"]]), })
for tc in tool_calls:results[tc["id"]]json.dumps(...)Stop — attempt the TODO before revealing. The loop packages both results; your job is to fill in the one field that links each result to its request.
Replace TODO with: tc["id"]
Changed line: "tool_call_id": tc["id"]
Why it matters: hardcoding "call_w1" for both messages means the second result is linked to the wrong request. The model sees two results for call_w1 and none for call_w2 — it will either error or hallucinate the missing result. The loop variable tc["id"] ensures each result is matched to its own request, which is the entire point of tool_call_id linkage in parallel-tool-call turns.
Slide to see how error detail level affects what the model can say — and what it might expose.
"tool_call_id did not match any pending tool call". You generated a new ID instead of copying the model's. Always read the ID from the model's response object."content must be a string". You forgot json.dumps(). Serialise before assigning to content.tool_call_id is read from the model's response, not hardcoded or re-generated.content is always a string — look for json.dumps() on every path, including error branches.— the cycle that sends, dispatches, and appends until the model returns plain text with no further tool calls.
Build the agent loop that sends messages, checks for tool calls, dispatches execution, appends results, and re-queries the model — repeating until a plain-text final answer arrives or a stop condition fires. You'll complete the loop skeleton for the full chatbot, adding an iteration cap and a cost-guard to prevent runaway calls.
Builds the while-loop that drives a tool-calling agent: send messages, check for tool calls, execute them, append results, and repeat until a final answer or a stop condition fires.
Why this matters: This is the control heart of your chatbot — without a correct loop and stop conditions, your tool-calling system either hangs forever or crashes instead of answering.
Decision this forces: What stop conditions does your chatbot need, and what should it do when it hits the cap — fail gracefully or return a partial answer?
Answer: it needs the tool_call_id (so the model knows which call this result belongs to) and the content string carrying the actual output. Without the ID, the model loses the thread and either hallucinates a result or errors out.
Module 4 left you with a correctly formatted appended to the . This module wires that step into a repeating loop — so the model can call more tools, read more results, and eventually produce a final answer.
The is a while loop with one decision inside: did the model ask for a tool, or did it give a final answer?
If the response contains a tool call, you execute it, append the to the , and call the model again. If the response is plain text, you exit and return it.
Without a , a buggy tool or a confused model can keep calling forever — burning tokens and money. You need at least an iteration cap before you ship.
def agent_loop(messages, tools, call_model, execute_tool): for iteration in range(10): # hard cap: 10 turns response = call_model(messages, tools) if not response.tool_calls: # plain-text final answer return response.content for tc in response.tool_calls: result = execute_tool(tc) # safe wrapper from module 3 messages.append(result) # tool-role message from module 4 return "[max iterations reached]" # graceful fallback
for iteration in range(10)response.tool_callsexecute_tool(tc)messages.append(result)return "[max iterations reached]"This skeleton captures the full loop in 9 lines. Each iteration either exits on a plain-text answer or appends a tool result and continues.
Notice the fallback string on the last line — that's the graceful cap, not a crash. The grows in place, so the model always sees every prior turn.
It returns the string "[max iterations reached]" after exactly 10 iterations. The loop exits cleanly — no exception, no infinite spin. The caller gets a partial signal rather than a crash.
You append the tool-role message but forget to append the preceding assistant message that contained tool_calls. The API returns: "messages[N] with role 'tool' must follow an assistant message with tool_calls". Fix: always append the assistant response object first, then the tool result.
After many tool turns the message list exceeds the model's context window (its working memory for the conversation). The API returns a context_length_exceeded error, or — worse — silently truncates early messages and the model loses the original user request. Guard with a token-count check or a sliding window before each call.
A tool that always returns an ambiguous result can cause the model to call it again and again. Without a cap you see no error — just a rising bill and a hanging process. The observable sign: your cost dashboard shows hundreds of calls for one user turn. The iteration cap is your only defence.
def agent_loop(messages, tools, call_model, execute_tool, max_iter=10, max_tool_calls=25): total_calls = 0 for iteration in range(max_iter): response = call_model(messages, tools) if not response.tool_calls: return response.content messages.append(response) # assistant msg first! for tc in response.tool_calls: total_calls += 1 if total_calls > max_tool_calls: return "[cost-guard: too many tool calls]" # TODO result = execute_tool(tc) messages.append(result) return "[max iterations reached]"
max_tool_calls=25total_calls += 1messages.append(response)This stage adds a cumulative tool-call counter alongside the iteration cap — two independent guards. The TODO line is where your cost-guard fires; your job is to decide what it returns.
Replace the TODO string with:
return response.content or "[cost-guard: too many tool calls]"
Changed lines (vs. the skeleton):
response.content first (the partial answer the model was building).Before trusting an AI-generated agent loop, check these four things specifically — generated code gets them wrong most often:
messages.append(response) — it must appear before any messages.append(result) in the same iteration.max_iter and doesn't silently continue.return None (callers rarely handle None gracefully).messages is the shared session history, not a fresh list each call.The next module — Full Implementation and Failure Modes — integrates every piece (schema, parser, executor, formatter, and this loop) into one runnable chatbot and audits the failure modes that only appear when all the parts run together.
Integrate schema definition, parsing, safe execution, result formatting, and the loop into one runnable chatbot; then audit the most common failure modes — hallucinated tool names, argument injection, infinite loops, and silent data loss — and apply a checklist to verify any AI-generated code before trusting it. You'll solo-complete the final chatbot by adding a second tool (`search_database`) and a parallel tool-call handler.
Integrates all five tool-calling building blocks into one runnable chatbot and teaches how to identify, reproduce, and guard against the top failure modes.
Why this matters: This is the assembly and hardening step — without it, the individual components you built earlier won't survive real user traffic or adversarial inputs.
Decision this forces: Which failure modes require hard guardrails in code versus soft mitigations in the system prompt — and how do you audit the boundary?
The loop ends when the model returns plain text (no ) or when a fires. Stop conditions include max turns, timeout, or error threshold. After each tool call, it appends the assistant's tool-call message and the message to history. The model sees full context on the next turn.
You now have all five building blocks: schema, parser, executor, formatter, and loop. This module wires them into one runnable chatbot. It stress-tests against failure modes that break real deployments.
Most tool-calling bugs fall into four categories. Knowing the symptom tells you where to look.
KeyError or silently no-ops. Fix: reject any name not in the registry before execution."../secrets" or SQL fragments) as an argument. Symptom: path traversal, unexpected DB writes, or data exfiltration. Fix: with strict type and pattern checks before the tool runs.TOOLS = [
{"name": "get_weather",
"description": "Return current weather for a city.",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}},
]
ALLOW_LIST = {t["name"] for t in TOOLS} # {'get_weather'}
def dispatch(name, args):
if name not in ALLOW_LIST:
return {"error": f"Unknown tool: {name}"}
return REGISTRY[name](**args){t["name"] for t in TOOLS}REGISTRY[name](**args)The TOOLS list is your — it's what you'll pass to the model. ALLOW_LIST is derived from it automatically, so the two can never drift apart.
Notice that dispatch rejects unknown names before touching REGISTRY — this is the hard guardrail that stops hallucinated tool names from reaching execution.
It returns {"error": "Unknown tool: delete_user"} because the allow-list check fires before REGISTRY is ever accessed — the KeyError never happens.
MAX_TURNS = 6 def run_chatbot(user_message): history = [{"role": "user", "content": user_message}] for turn in range(MAX_TURNS): response = model_call(messages=history, tools=TOOLS) if response.stop_reason != "tool_use": return response.content # plain-text answer for tc in response.tool_calls: # may be >1 (parallel) result = dispatch(tc.name, tc.arguments) history.append({"role": "assistant", "tool_calls": [tc]}) history.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result)}) return {"error": "max_turns exceeded"} # hard stop
response.stop_reason != "tool_use"for tc in response.tool_callsjson.dumps(result)This is the complete integrating every earlier module. The for tc in response.tool_calls loop handles both single and in one turn.
The hard on line 1 prevents infinite loops — it returns a structured error, never a silent hang.
Four messages: one {"role": "assistant"} carrying the first tool call, one {"role": "tool"} with its result, one {"role": "assistant"} for the second call, and one {"role": "tool"} with its result. Each tool result is matched to its call via tool_call_id.
# --- Extend TOOLS with the new schema --- TOOLS.append({ "name": "search_database", "description": "Search product records by keyword.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "maximum": 50}}, "required": ["query"]}, }) ALLOW_LIST = {t["name"] for t in TOOLS} # TODO: register search_database in REGISTRY # REGISTRY["search_database"] = ???
"maximum": 50REGISTRY["search_database"] = search_databaseThe schema for search_database is provided. Your job is to complete the REGISTRY entry and write the function so the chatbot can call both tools in a single turn.
Changed lines:
def search_database(query, limit=10):
try:
rows = db.search(query, limit=limit)
return {"results": rows}
except Exception as e:
return {"error": str(e)} # structured error, not a raise
REGISTRY["search_database"] = search_database
Why no raise: if the function raises, dispatch() propagates the exception and the loop crashes — the model never gets a tool result, so it hallucinates an answer. Returning a structured error lets the model handle the failure gracefully.
A customer support team deploys your chatbot. A user asks: "What's the weather in Austin, and do you have rain jackets in stock?" The model issues two tool calls in one turn. It calls get_weather and search_database. The loop handles both via the path you just completed.
The allow-list blocks a hallucinated delete_order call the model attempts mid-conversation. The max-turns catches a loop where the model keeps re-querying stock. It does this after receiving an empty result. The structured error from lets the model tell the user "I couldn't reach the database." It avoids fabricating inventory data.
You now have a complete, auditable tool-calling chatbot built from five composable blocks. The solo capstone challenge asks you to extend it. Add a third tool, a human-approval gate for high-impact calls, and a test suite. The suite exercises each failure mode deliberately.
Before trusting any AI-generated chatbot code in production, run through these five checks:
raise or silent except: pass inside tool functions.Before looking at the summary: reconstruct from memory the five stages a message travels through in your chatbot — from the moment the model decides to call a tool to the moment it produces a final answer. What does each stage receive, and what does it hand to the next?
Apply what you learned to a tool-calling chatbot system with function definitions, safe execution, and result feedback.
You define a function schema where the parameter description just says "the input" and the name is "process". What is the most likely consequence?
Schema quality directly shapes how the model decides when and how to call a function. Vague names and descriptions leave the model guessing, leading to wrong arguments or missed calls. The schema itself is syntactically valid, so no parse error occurs. The model does not refuse on name alone, and it absolutely uses descriptions — they are the primary signal for argument selection.
A model response arrives and you check it. It contains a tool_calls field with one entry whose function name is "send_email", but "send_email" is not in your allow-list dispatcher. What should your code do?
An unknown tool name is a hallucinated call — a known failure mode. The safe response is to return a structured error tied to the correct tool_call_id so the model can reason about the failure and recover. Crashing breaks the chatbot entirely. Executing an unlisted function bypasses the allow-list, which is the core safety guardrail. Asking the user to rephrase is appropriate for validation failures caused by ambiguous user input, not for a hallucinated function name the user never requested.
Read this short loop:
while True:
response = model.chat(messages)
if response.tool_calls:
messages.append(run_tools(response))
What is the most dangerous problem with this code?
The loop has no iteration cap, timeout, or exit branch for a plain-text (no-tool) response, so if the model never stops requesting tools the loop runs indefinitely — a loop-runaway failure mode. Appending tool results is correct behavior. A while-loop agent is exactly the standard pattern. The missing try/except is a real concern but not the most dangerous issue here; the infinite loop is the critical flaw.
Your tool executes successfully but returns a Python dict instead of the string your schema promised. When you format the tool-result message, which approach is correct?
Tool-result message content must be a string; the standard practice is to JSON-serialize structured data before placing it in the content field, always linked by the matching tool_call_id so the model can pair the result to the right call. Passing a raw dict will fail or be silently mishandled by most APIs. An empty content field looks like a failure to the model. Restarting the conversation is never the right response to a return-type mismatch — serialize and continue.
Name two failure modes from the full implementation module that require a hard guardrail enforced in code (not just a system prompt instruction), and briefly explain why a system prompt alone is insufficient for each.
System prompts are soft mitigations — the model may not follow them under adversarial inputs, prompt injection, or degenerate reasoning. Hard guardrails in code (allow-list checks, argument sanitization, iteration caps) enforce limits unconditionally regardless of model behavior. The audit boundary question is precisely about knowing which risks need code-level enforcement versus which are acceptable to handle in the prompt.