6.2.3 · D3AI Agents & Tool Use

Worked examples — Tool use and function calling

2,331 words11 min readBack to topic

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?

  1. Model reads intent → picks get_weather. Why this step? The description field ("Retrieves current weather for a location") matches the user's ask; no other tool fits. Tool selection happens purely from descriptions.
  2. Model fills required arg. location is required, so it must appear. The model normalises "Paris" → "Paris, France" to match the description hint "City name, e.g. 'San Francisco, CA'". Why this step? Required fields are non-negotiable; the schema is a contract.
  3. Model decides on optional arg. units is 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?

  1. 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 invent location: "".
  2. Model returns plain text, no function_call key 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?

  1. Model tries to fill location but the user gave nothing. Why this step? You cannot satisfy the contract without it.
  2. 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.

  3. 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-prompt

For 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?

  1. Model wants units="kelvin" but the enum forbids it. Why this step? An enum is a whitelist; anything outside is invalid by schema.
  2. Two acceptable resolutions:
    • Pick the nearest allowed value ("celsius") and convert in the final message ().
    • Ask the user to choose an allowed unit.
  3. Your validator enforces the enum regardless of what the model tries:
def enum_ok(value, allowed):
    return value in allowed

If 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?

  1. Call 1: get_capital(country="Japan"). Why this step? get_weather needs a location; 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"}.
  2. 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.
  3. Call 2: get_weather(location="Tokyo"). Why this step? Now the dependency is satisfied. → returns {"temp": 12}.
  4. 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?

  1. 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.
  2. Model emits a tool_calls array (parallel calling):
{"tool_calls": [
  {"id":"c1","function":{"name":"get_weather","arguments":"{\"location\":\"home\"}"}},
  {"id":"c2","function":{"name":"get_traffic","arguments":"{\"route\":\"home-office\"}"}}]}
  1. You execute both concurrently, matching each result to its id. Why this step? The id is 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?

  1. 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).
  2. Loop retries; attempt 1 succeeds. Why this step? Transient network errors often clear on retry. Total attempts here (indices 0 and 1).
  3. 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?

  1. 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).
  2. Fix: never expose raw execute_python. Replace with allowlisted calculate(expression). Why this step? An allowlist can only ever do the safe thing.
  3. 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%?

  1. 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.
  2. Slope: Why this step? Slope = rise over run; it tells us each extra tool costs ~ accuracy in this range.
  3. 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?

  1. Call 1: get_flight_info(...){"price": 850}. Why this step? Can't compare to budget without a real price; guessing prices is a hallucination.
  2. Call 2: check_user_budget(category="travel"){"remaining": 1200}. Why this step? Private data must be queried, never assumed.
  3. Model checks the guard → true. Why this step? A pure-reasoning step between tool calls; the decision to book is conditional.
  4. 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 description fields are pure 4.3.02-Prompt-engineering-techniques.
  • Feeding tool results back mirrors context injection in 7.1.01-RAG-fundamentals.