A tool exposes a narrow capability to the model. The application decides whether a requested call is valid and authorized. A generated tool name or argument is a request, not permission to execute arbitrary code.
Use an allowlist
def dispatch(name, args, tools):
if name not in tools:
raise ValueError("Unknown tool")
if not isinstance(args, dict):
raise ValueError("Expected named arguments")
return tools[name](**args)
This dispatcher is only the outer boundary. Each tool still needs argument validation, authorization, a deadline, and output limits. Prefer get_ticket(ticket_id) to a general-purpose SQL or shell tool. The smaller interface is easier to reason about and test.
Bound the loop
At each step, request a model decision, validate it, execute an allowed tool, and append a bounded observation. Stop on a final answer, a step limit, a deadline, or an unrecoverable error. Do not treat “try again” as a plan with unlimited budget.
For side effects, separate proposal from execution. Use an idempotency key when retrying a previously authorized action so a transient network failure does not duplicate the mutation.
Exercise
Build a sandbox with a document search tool and a ticket lookup tool. Send unknown names, missing arguments, oversized outputs, and timeouts through the dispatcher.
Check: invalid requests produce controlled failures, never arbitrary execution. Record the stop reason and number of tool calls for every run.