Generative AI

LLM tool calling with validated arguments and a bounded dispatcher

Treat tool proposals as untrusted input, validate a read-only unit conversion and reject unknown tools, fields and nonfinite values.

An LLM tool call is a proposal for an application action. The application must decide whether the named tool exists, whether its arguments are valid and whether the action is permitted. A structured-looking JSON object does not establish any of those conditions.

We will build a read-only length-conversion dispatcher. The input proposals are manually authored fixtures representing possible model outputs; no model is called in this example. This isolates the boundary we can test deterministically before connecting a live model.

Define the capability before accepting proposals

The only supported tool is convert_length. It accepts exactly value, from_unit and to_unit. Units are limited to millimeters, centimeters and meters. The value must be a finite number within a declared teaching range of zero to one million input units.

The range is a demonstrative application policy, not a universal physical limit. Python Boolean values must be rejected explicitly because bool is a subclass of int. Unknown fields are rejected so that a payload cannot quietly smuggle extra instructions or parameters through an apparently valid object.

This tool has no network, filesystem, messaging or purchasing capability. Adding any capability later requires its own authorization and input contract; a model's choice of tool name does not grant permission.

Run visible proposal traces

import math
FACTORS = {'mm': .001, 'cm': .01, 'm': 1.}

def dispatch(proposal):
    if type(proposal) is not dict or set(proposal) != {'name', 'arguments'}:
        raise ValueError('invalid envelope')
    if proposal['name'] != 'convert_length':
        raise ValueError('unknown tool')
    args = proposal['arguments']
    if type(args) is not dict or set(args) != {'value', 'from_unit', 'to_unit'}:
        raise ValueError('invalid fields')
    value = args['value']
    if type(value) not in (int, float) or not 0 <= value <= 1_000_000 or not math.isfinite(value):
        raise ValueError('invalid value')
    if any(type(args[key]) is not str or args[key] not in FACTORS
           for key in ['from_unit', 'to_unit']):
        raise ValueError('unsupported unit')
    return value * FACTORS[args['from_unit']] / FACTORS[args['to_unit']]

valid = {'name': 'convert_length', 'arguments': {'value': 1250, 'from_unit': 'mm', 'to_unit': 'm'}}
fixtures = [valid, {'name': 'send_message', 'arguments': {}},
            {'name': 'convert_length', 'arguments': {**valid['arguments'], 'execute': True}},
            {'name': 'convert_length', 'arguments': {**valid['arguments'], 'value': True}},
            {'name': 'convert_length', 'arguments': {**valid['arguments'], 'value': float('nan')}}]
for index, proposal in enumerate(fixtures):
    try:
        print(index, 'accepted', f'result={dispatch(proposal):.3f} m')
    except ValueError as error:
        print(index, 'rejected', str(error))
assert dispatch(valid) == 1.25

Checked output:

0 accepted result=1.250 m
1 rejected unknown tool
2 rejected invalid fields
3 rejected invalid value
4 rejected invalid value

Separate schema validation from permission

A valid argument object is necessary but not sufficient for a state-changing tool. If a future tool reads private data, the application still needs an authenticated account and an ownership filter. If it changes external state, it needs the authorization appropriate to that operation. Neither belongs in model-generated arguments as a trustworthy self-declaration.

The dispatcher here is deliberately narrow enough that its capability is easy to inspect. It does not execute arbitrary Python, dynamically import a model-supplied function name or evaluate a string. Tool implementations should be selected from an application-owned allowlist.

When a provider supplies a JSON Schema or constrained generation mode, use it to improve proposal structure. Keep server-side validation anyway: transport failures, partial output, version mismatches and application policy still need handling. Provider support for a schema is not the same as enforcement of your business rules.

Handle failures without an unbounded repair loop

Return a structured validation error that identifies the allowed shape without exposing private state. A bounded retry can let a model repair a unit or field name. Track the attempt count and stop when the budget is exhausted; repeating a bad proposal forever is not recovery.

Log a safe tool name, validation outcome and request identifier. Avoid storing full prompts or sensitive arguments by default. For this fixture, the printed trace is sufficient because all values are invented and public.

Extend the contract

Test a list in place of the argument object, an unsupported unit and a value beyond the range. Then change the return type to an explicit {value, unit} object so downstream code never has to infer the unit from context.

The Generative AI syllabus covers tools and validation; lessons are being published. Compare structured-output validation, which checks a different data contract, and prompt injection at a retrieval boundary, which explains why retrieved instructions must not expand tool permissions.

References

Transformers tool-use guide — Tool schemas and model proposals in chat-template workflows.

Python math.isfinite — Finite-number checks used by the bounded dispatcher.