Worked examples — Tool use and function calling
This page is the "throw every case at it" companion to the parent topic. We already know the four-step loop (user + schemas → model emits JSON call → your system runs it → result goes back → final answer). Here we stress-test that loop against every situation it can meet: clean calls, missing arguments, wrong types, zero-tool cases, timeouts, parallel calls, injection attacks, and the limiting cases where the model should call nothing at all.
If a word here feels new, it was defined in the parent — this page assumes only that loop.
The scenario matrix
Think of every function-calling situation as a cell in a grid. The two big axes are "how many tools should fire?" (zero, one, several) and "is the input clean or broken?" (valid, missing field, wrong type, malicious). Around the edges sit the limiting and real-world cases.
| Cell | What makes it special | Covered by |
|---|---|---|
| A. Clean single call | one tool, all args valid | Example 1 |
| B. Zero-call (degenerate) | model should answer directly, call nothing | Example 2 |
| C. Missing required arg | schema says required, user didn't give it |
Example 3 |
| D. Wrong type / enum | user gives text where a number/enum is needed | Example 4 |
| E. Multi-step chain | output of tool 1 feeds tool 2 | Example 5 |
| F. Parallel independent | two tools, neither needs the other | Example 6 |
| G. Failure + retry (limiting) | tool times out; graceful degradation | Example 7 |
| H. Injection (adversarial) | malicious argument, exam-style twist | Example 8 |
| I. Tool-count limit (scaling) | accuracy vs number of tools, a numeric estimate | Example 9 |
| J. Word problem (real world) | full budget/travel loop end-to-end | Example 10 |
A. Clean single call
Forecast: Guess the JSON before reading on. How many keys? Is units present even though it's optional?
- Model reads intent → picks
get_weather. Why this step? Thedescriptionfield ("Retrieves current weather for a location") matches the user's ask; no other tool fits. Tool selection happens purely from descriptions. - Model fills required arg.
locationis required, so it must appear. The model normalises "Paris" →"Paris, France"to match thedescriptionhint"City name, e.g. 'San Francisco, CA'". Why this step? Required fields are non-negotiable; the schema is a contract. - Model decides on optional arg.
unitsis optional. A well-tuned model still emits a sensible default ("celsius") because Paris is metric. Why this step? Optional means "you may omit," not "you must omit." Including it removes ambiguity downstream.
Emitted call:
{"function_call": {"name": "get_weather",
"arguments": "{\"location\": \"Paris, France\", \"units\": \"celsius\"}"}}Your system runs it, returns {"temp": 18, "condition": "cloudy"}, and the model says "18°C and cloudy in Paris."
Verify. Parse the arguments string as JSON: it must (a) be valid JSON, (b) contain location. Both hold → the call is schema-valid. ✓ (checked in VERIFY as valid_A.)
B. Zero-call — the degenerate case
Forecast: Will it call get_weather with a blank location? Or nothing?
- Model checks whether ANY tool advances the goal. "Thanks" needs no external data.
Why this step?
tool_choice="auto"means the model is allowed to answer directly. Forcing a call here (tool_choice="required") is a classic bug — it would inventlocation: "". - Model returns plain text, no
function_callkey at all.
{"role": "assistant", "content": "You're welcome!"}Verify. The degenerate output has zero tool calls. num_calls == 0. ✓ (zero_B.)
C. Missing required argument
Forecast: Does the model guess a city, error out, or ask back?
- Model tries to fill
locationbut the user gave nothing. Why this step? You cannot satisfy the contract without it. - Correct behaviour: model asks a clarifying question instead of emitting a call:
"Sure — which city?" Why this step? Emitting
location: "New York"(a guess) would be a silent hallucination. Asking keeps the loop honest. - If a call is forced by your framework, your validator must reject it. A guard:
def validate(call, schema):
missing = [k for k in schema["required"] if k not in call["args"]]
return missing # non-empty => reject, re-promptFor our case call["args"] = {}, so missing == ["location"].
Verify. missing == ["location"], i.e. exactly one required field absent. ✓ (missing_C.)
D. Wrong type / bad enum
Forecast: Will the model force "kelvin" into units?
- Model wants
units="kelvin"but the enum forbids it. Why this step? Anenumis a whitelist; anything outside is invalid by schema. - Two acceptable resolutions:
- Pick the nearest allowed value (
"celsius") and convert in the final message (). - Ask the user to choose an allowed unit.
- Pick the nearest allowed value (
- Your validator enforces the enum regardless of what the model tries:
def enum_ok(value, allowed):
return value in allowedIf the model emitted "kelvin", enum_ok("kelvin", ["celsius","fahrenheit"]) is False → reject.
Numerically, if the tool returns 18 °C and the model chose to report Kelvin anyway:
Verify. enum_ok("kelvin", allowed) == False and 18 + 273.15 == 291.15. ✓ (enum_D.)
E. Multi-step chain (output feeds next input)
Forecast: How many calls? Which runs first?
- Call 1:
get_capital(country="Japan"). Why this step?get_weatherneeds alocation; we don't have one yet. Tool 1 produces the input tool 2 consumes. This dependency forces order — see the arrow in the figure. → returns{"capital": "Tokyo"}. - Inject result, model reads "Tokyo". Why this step? The loop's step 4: result goes back into context so the next reasoning turn can use it.
- Call 2:
get_weather(location="Tokyo"). Why this step? Now the dependency is satisfied. → returns{"temp": 12}. - Final answer: "It's 12°C in Tokyo (Japan's capital)."
The number of calls is 2, and they are serial (cannot parallelise) because call 2's argument is call 1's output.
Verify. num_calls == 2 and the ordering constraint holds (capital before weather). ✓ (chain_E.)
F. Parallel independent calls
Forecast: Serial like E, or something faster?
- Model detects independence. Weather doesn't depend on traffic or vice-versa. Why this step? When outputs are independent, waiting for one before starting the other wastes wall-clock time.
- Model emits a
tool_callsarray (parallel calling):
{"tool_calls": [
{"id":"c1","function":{"name":"get_weather","arguments":"{\"location\":\"home\"}"}},
{"id":"c2","function":{"name":"get_traffic","arguments":"{\"route\":\"home-office\"}"}}]}- You execute both concurrently, matching each result to its
id. Why this step? Theidis the join key — results can return out of order.
Timing intuition. If each call takes seconds, serial cost is ; parallel cost is . For : serial s, parallel s — a speedup here, and in general for independent calls.
Verify. For : serial , parallel , speedup . ✓ (parallel_F.)
G. Failure + retry — the limiting case
Forecast: Does the agent hang forever, or recover?
- Attempt 0 raises
TimeoutError. Why this step? We wrap the call so a hang becomes a catchable exception, not an infinite freeze (parent Mistake 2). - Loop retries; attempt 1 succeeds. Why this step? Transient network errors often clear on retry. Total attempts here (indices 0 and 1).
- Limiting behaviour: if all retries fail, we inject
{"error": "Tool timeout"}as the function result and let the model decide to apologise or try another tool. Why this step? Degrade gracefully — the model, not your process, chooses the fallback.
def execute_tool_safe(call, timeout=10, retries=2):
for attempt in range(retries):
try:
return run(call, timeout=timeout)
except TimeoutError:
if attempt == retries - 1:
return {"error": "Tool timeout"}Verify. With retries=2, the loop index runs over {0,1} → max 2 attempts; the "give up" branch triggers exactly when attempt == retries-1 == 1. ✓ (retry_G.)
H. Injection — the adversarial exam twist
Forecast: Is the model's obedience the vulnerability, or your executor?
- The vulnerability is the tool surface, not the model.
Why this step? The model has no concept of "safe." If
execute_python(code)exists, a prompt injection can weaponise it (parent Mistake 1). - Fix: never expose raw
execute_python. Replace with allowlistedcalculate(expression). Why this step? An allowlist can only ever do the safe thing. - Guard the argument even then: reject any expression containing forbidden tokens.
BANNED = ["os", "system", "import", "__"]
def calc_ok(expr):
return not any(b in expr for b in BANNED)For the injected code, calc_ok("os.system('rm -rf /')") is False → blocked. For a legit "2+2", it's True.
Verify. calc_ok("os.system('rm -rf /')") == False and calc_ok("2+2") == True. ✓ (inject_H.)
I. Tool-count scaling — a numeric estimate
Forecast: Above or below 60%?
- Model accuracy vs tool count as a line through and . Why this step? We only need a rough interpolation between the measured points, so the simplest model (a line) is the right tool — no need for anything fancier.
- Slope: Why this step? Slope = rise over run; it tells us each extra tool costs ~ accuracy in this range.
- Predict at 35: Why this step? Point-slope form from the anchor.
Interpretation. 62.5% is a coin-flip-plus; the parent's fix (hierarchical / dynamically filtered tools) is warranted well before 35.
Verify. and . ✓ (scaling_I.)
J. Real-world word problem — the full loop
Forecast: How many calls, and does it book?
- Call 1:
get_flight_info(...)→{"price": 850}. Why this step? Can't compare to budget without a real price; guessing prices is a hallucination. - Call 2:
check_user_budget(category="travel")→{"remaining": 1200}. Why this step? Private data must be queried, never assumed. - Model checks the guard → true. Why this step? A pure-reasoning step between tool calls; the decision to book is conditional.
- Call 3:
book_flight(...), then reports remaining . Why this step? Only now is the action safe. If , the model would skip call 3 and explain instead.
Total: 3 calls. Leftover budget:
Verify. Guard is True, remaining , and num_calls == 3. ✓ (booking_J.)
Recall
Recall
When should the model emit zero tool calls? ::: When no tool advances the goal (chit-chat, or it can answer from context) — and you must NOT use tool_choice="required" there.
In a chain (Example E), why can't the two calls run in parallel? ::: Call 2's argument (location=Tokyo) is call 1's output, so there's a data dependency forcing serial order.
Where does the injection defence live — in the model or your executor? ::: In your executor: allowlist tools and validate arguments; the model itself has no notion of "safe."
Predicted accuracy at 35 tools from the (20,75)-(50,50) line? ::: 62.5%.
Connections
- Chains and loops here formalise the ReAct idea in 6.2.02-Agent-architectures-and-planning.
- The schema discipline is the flip side of 5.2.03-JSON-mode-and-structured-outputs.
- Good
descriptionfields are pure 4.3.02-Prompt-engineering-techniques. - Feeding tool results back mirrors context injection in 7.1.01-RAG-fundamentals.