Chapter 9 · AI Engineer

Robust function-calling

When you give a model tools (functions it can call), it replies with a tool name and a bag of arguments. The catch: those arguments are untrusted input. The model might omit a required field, send a number as the string "19", or tack on a junk key. Dispatching that raw into your function crashes it.

A robust function-calling layer sits between the model and your tools and does three jobs:

  • Validate — every required argument is present, or reject the call with a clear error.
  • Coerce — fix what's fixable. "19" should become the int 19 if the spec wants an int.
  • Reject — what can't be fixed (a missing field, an unknown tool, junk that won't convert) is refused loudly, not passed through.

The tool's spec is a tiny dict — {'required': [...], 'types': {field: type}} — that says what a valid call looks like. You build a clean dict containing *only* the required, coerced arguments (dropping junk for free), then call the real function with **clean_args. This is the same defensive pattern you'd use for any external input; the model is just one more untrusted source.

Syntax

spec = {"required": ["celsius", "room"],
        "types": {"celsius": int, "room": str}}

# coerce: target_type(value) inside try/except
# validate: every required name present, else raise
# dispatch: look up spec, validate+coerce, then registry[name](**clean)

Worked examples

Coerce a value, or raise a clear error

def coerce_value(value, target_type):
    try:
        return target_type(value)
    except (ValueError, TypeError):
        raise ValueError(f"cannot coerce {value!r} to {target_type.__name__}")

print(coerce_value("19", int))   # 19   (string fixed to int)
print(coerce_value(21, int))     # 21   (already fine)
try:
    coerce_value("hot", int)
except ValueError as e:
    print(e)
# Output:
# 19
# 21
# cannot coerce 'hot' to int

Validate against a spec — drop junk, reject missing

def validate_and_coerce(args, spec):
    clean = {}
    for name in spec["required"]:
        if name not in args:
            raise ValueError(f"missing required arg: {name}")
        clean[name] = coerce_value(args[name], spec["types"][name])
    return clean

spec = {"required": ["celsius", "room"], "types": {"celsius": int, "room": str}}
print(validate_and_coerce({"celsius": "21", "room": "lab", "junk": 9}, spec))
# Output:
# {'celsius': 21, 'room': 'lab'}

Full safe dispatch (clean, recovered, and rejected calls)

def set_temp_tool(celsius, room):
    return {"ok": True, "celsius": celsius, "room": room}

SPECS = {"set_temp": {"required": ["celsius", "room"], "types": {"celsius": int, "room": str}}}
REGISTRY = {"set_temp": set_temp_tool}

def safe_dispatch(tool_call, specs, registry):
    name = tool_call["name"]
    if name not in specs:
        raise ValueError(f"unknown tool: {name}")
    clean = validate_and_coerce(tool_call["input"], specs[name])
    return registry[name](**clean)

print(safe_dispatch({"name": "set_temp", "input": {"celsius": "18", "room": "lab"}}, SPECS, REGISTRY))
try:
    safe_dispatch({"name": "launch_rocket", "input": {}}, SPECS, REGISTRY)
except ValueError as e:
    print(e)
# Output:
# {'ok': True, 'celsius': 18, 'room': 'lab'}
# unknown tool: launch_rocket

Common mistakes & gotchas

Never dispatch the model's args raw

Calling tool(**model_args) directly is the bug. A missing key raises TypeError, a string where an int is expected may silently do the wrong thing, and an extra key raises TypeError: unexpected keyword argument. The whole point of the validation layer is that the model's output reaches your function only after it's been checked.

Build a new clean dict — don't mutate the input

Copy only the required fields into a fresh dict rather than deleting junk keys out of the model's dict in place. Building a new dict drops unknown keys automatically and leaves the original untouched, which matters if you log or retry the raw call later.

int('19') works, int('19.5') does not

Coercion fixes some mismatches but not all. int("19") is fine, but int("19.5") raises ValueError — int() won't parse a decimal string. Decide deliberately what your coercion should accept; catching the error and rejecting cleanly is better than a surprise crash deep in a tool.

Why it matters

The moment you let a model call functions, it can trigger real side effects — change a setting, send an email, hit a database. A validation-and-coercion layer is the seatbelt: it turns the model's messy, occasionally-wrong arguments into something safe to execute, and rejects what it can't fix instead of letting a malformed call blow up (or worse, run) in production.