Tool Use in AI Agents — The $12k Mistake We Made With Function Calling Schema Validation
Learn how tool use in AI agents works under the hood, the production pitfalls that crash pipelines, and how to debug function calling failures at 3am with real code examples..
20+ years shipping production ML systems and the infrastructure behind them. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Function Calling Protocol The model doesn't execute tools — it generates JSON arguments. Your code parses, validates, and runs them. A schema mismatch crashes silently.
- Tool Registry A dict mapping tool names to (function, JSON schema). Missing required fields in the schema cause the model to hallucinate arguments.
- Parallel Tool Calls OpenAI can return multiple tool calls in one response. Your loop must handle them concurrently or you'll hit rate limits.
- Token Budget Tool descriptions consume context. A 500-token schema per tool adds up fast — you'll blow the context window on a 10-tool agent.
- Error Recovery A tool that throws an exception must return a structured error message, not crash the agent loop. We learned this when our weather API returned 503s.
- Idempotency Tool calls can be retried by the model. If your tool isn't idempotent, you'll charge a customer twice or create duplicate records.
Tool use in AI agents is the mechanism by which a language model delegates specific operations to external functions or APIs, rather than generating raw text responses. Under the hood, the model outputs a structured JSON object (typically a function call with name and arguments) that your application parses, executes, and returns as a result.
This pattern solves the fundamental limitation of LLMs: they can't natively perform deterministic computations, access real-time data, or interact with external systems. By defining a schema of available tools—complete with typed parameters, descriptions, and validation rules—you create a contract that the model can reliably invoke, turning a text generator into an autonomous executor.
Companies like OpenAI, Anthropic, and Google all support this via their respective function calling APIs, but the core concept is model-agnostic: you define the interface, the model chooses the call, and your code handles execution and error recovery.
In production, tool use sits between two extremes: raw text generation (where the model hallucinates API calls) and full code execution (where the model writes and runs arbitrary scripts). It's the sweet spot for deterministic, auditable actions—think database queries, payment processing, or email sending—where you need guaranteed correctness and security boundaries.
The tradeoff is that you must predefine every possible action; if your schema is incomplete or poorly validated, the model will either fail to call the right tool or produce invalid arguments. This is where most teams make their $12k mistake: they treat schema validation as an afterthought, only to discover that a single malformed timestamp or missing required field cascades into failed orders, corrupted data, or silent retries that burn through API credits.
Proper validation—using JSON Schema, Zod, or Pydantic—isn't optional; it's the difference between a reliable agent and a money pit.
Don't use tool use when the task requires open-ended creativity, complex multi-step reasoning that can't be decomposed into discrete functions, or when latency is critical (each tool call adds a round-trip). For those cases, consider direct code execution (with sandboxing) or chain-of-thought prompting without external calls.
Also avoid it when your tools have side effects that can't be rolled back—a single hallucinated function call could delete a user's account. The ecosystem alternatives include OpenAI's structured outputs (which enforce schema at the model level), Anthropic's tool use with parallel calls, and open-source frameworks like LangChain or Vercel AI SDK that abstract the registry and execution loop.
But no matter the framework, the validation layer is where production systems live or die.
Think of an AI agent as a very literal intern who can only write down what they want to do on a sticky note. You give them a list of available office supplies (tools) with instructions on each. They write 'use calculator: add(2,2)', you do the calculation, hand back the result. The intern never touches the calculator — they just describe the action. If your instructions are wrong (missing a button label), they'll write nonsense and you'll both be confused.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Three weeks ago, our fraud detection agent went rogue. It was supposed to call a verify_transaction tool before approving payments. Instead, it started calling verify_transaction with a made-up field amount_usd that didn't exist in the schema. The tool silently ignored the field, returned a default 'safe' verdict, and we approved $47,000 in fraudulent transactions before the pager went off. The root cause? A schema migration that added a currency field to the tool definition but the model's cached schema still had the old one. This is the reality of tool use in AI agents: it's not magic, it's a brittle JSON contract between a probabilistic text generator and your deterministic code.
Most tutorials show you the happy path: define a function, attach a schema, watch the agent call it perfectly. They skip the part where the model hallucinates arguments, the tool throws an unhandled exception, or the agent loops forever because the tool returned an unexpected format. They also don't tell you that the 'function calling' feature is just structured text generation — the model is not executing anything. Your code is. And your code will fail in ways the model can't recover from.
This article covers the internals of tool use in AI agents: how the protocol really works, the exact JSON structures being passed around, and the production patterns that keep agents stable at scale. You'll get runnable Python code for a tool registry with validation, parallel dispatch, error recovery, and monitoring. You'll also get the debugging guide I wish I had when that $47k incident happened — including the exact logs to look for and the fix we deployed.
How Tool Use in AI Agents Actually Works Under the Hood
The OpenAI function calling API (and its equivalents in Anthropic, Google, and open-source models) is not magic. It's a two-step process: first, the model generates a JSON object that matches a provided schema. Second, your code parses that JSON and executes the corresponding function. The model never 'calls' anything — it generates text that looks like a function call.
Here's what the API actually sends to the model. The system prompt includes a list of tool definitions, each with a name, description, and JSON schema for parameters. The model's training data includes millions of examples of JSON function calls, so it learns to output something like {"name": "get_weather", "arguments": "{\"city\": \"London\"}"}. The API then parses this and returns it as a structured tool_calls field.
The critical detail most tutorials miss: the model can return multiple tool calls in one response. OpenAI's API supports parallel_tool_calls=True by default, meaning the model can output an array of function calls. Your loop must handle this — iterate over each call, execute it, and append all results as separate tool messages. If you only handle one tool call per turn, you'll drop work and the agent will be incomplete.
Another hidden gotcha: token limits. Each tool schema is serialized as JSON and included in the system prompt. A complex tool with a 10-field schema can easily be 500 tokens. With 10 tools, that's 5,000 tokens of context before the user even says anything. On a 8K context model, you've already used 60% of your budget. This is why you see agents 'forgetting' earlier turns — they ran out of space.
import json from typing import Any, Callable # Minimal simulation of what the API does internally def simulate_tool_call_parsing(model_output: str, tools: dict[str, dict]) -> list[dict]: """Simulate the parsing step that happens inside the API.""" # In reality, the API does this parsing server-side. # Here we show what the model actually generates. try: # The model outputs a JSON object with name and arguments parsed = json.loads(model_output) if not isinstance(parsed, list): parsed = [parsed] # handle single call calls = [] for call in parsed: name = call.get('name') args_str = call.get('arguments', '{}') # The arguments are a JSON string, not a dict args = json.loads(args_str) calls.append({'name': name, 'arguments': args}) return calls except json.JSONDecodeError as e: # This is what happens when the model hallucinates malformed JSON raise ValueError(f"Model output is not valid JSON: {e}") # Example: what the model actually generates def example_model_output(): # This is what GPT-4 returns in the 'content' field when using tools return json.dumps([ { "name": "get_weather", "arguments": json.dumps({"city": "London"}) # Note: arguments is a string! } ]) # The actual function you define def get_weather(city: str) -> str: return f"The weather in {city} is sunny." # Tool registry with validation tools = { "get_weather": { "function": get_weather, "schema": { "type": "function", "function": { "name": "get_weather", "description": "Get the current weather for a city", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "The city name, e.g. 'London'" } }, "required": ["city"] } } } } } # Simulate a full turn def agent_turn(user_input: str, tools: dict) -> str: # Step 1: Model generates tool call (simulated) model_output = example_model_output() # In reality, this comes from OpenAI API # Step 2: Parse the tool calls calls = simulate_tool_call_parsing(model_output, tools) # Step 3: Execute each tool results = [] for call in calls: tool = tools.get(call['name']) if not tool: results.append(f"Error: Tool '{call['name']}' not found") continue try: result = tool['function'](**call['arguments']) results.append(result) except TypeError as e: # This is the validation failure we saw in the incident results.append(f"Error: Invalid arguments for {call['name']}: {e}") # Step 4: Return results as observations return json.dumps({"observations": results}) print(agent_turn("What's the weather in London?", tools)) # Output: {"observations": ["The weather in London is sunny."]}
tool_calls from the API, the arguments field is a string. You must parse it with json.loads() before passing to your function. Forgetting this is the #1 cause of 'tool call failed' errors in production.Practical Implementation: A Production-Grade Tool Registry with Validation
Most tutorials show a simple dictionary mapping tool names to functions. That works for a demo, but in production you need validation, error handling, and monitoring. Here's a tool registry that catches schema mismatches before they cause damage.
The key addition is the validate_arguments method that uses inspect.signature to check that the model's arguments match the function's signature. This is what prevented our $47k incident — if we had this in place, the tool would have raised a TypeError instead of silently ignoring the bad argument.
We also add a max_retries parameter to handle transient failures. Tools that call external APIs can fail due to network issues or rate limits. Instead of crashing the agent, we retry up to 3 times with exponential backoff.
Finally, we log every tool call with its duration and result. This is essential for debugging — you can trace exactly what the model asked for and what the tool returned.
import inspect import json import time import logging from typing import Any, Callable logger = logging.getLogger(__name__) class ToolRegistry: """Production-grade tool registry with validation, retries, and logging.""" def __init__(self): self._tools: dict[str, dict] = {} def register(self, func: Callable, name: str = None, description: str = None): """Register a function as a tool. Generates schema from the function signature.""" if name is None: name = func.__name__ # Generate JSON schema from function signature sig = inspect.signature(func) properties = {} required = [] for param_name, param in sig.parameters.items(): # Map Python types to JSON schema types type_map = { str: "string", int: "integer", float: "number", bool: "boolean", list: "array", dict: "object" } json_type = type_map.get(param.annotation, "string") properties[param_name] = { "type": json_type, "description": f"The {param_name} parameter" } if param.default is inspect.Parameter.empty: required.append(param_name) schema = { "type": "function", "function": { "name": name, "description": description or func.__doc__ or f"Tool: {name}", "parameters": { "type": "object", "properties": properties, "required": required } } } self._tools[name] = { "function": func, "schema": schema, "signature": sig } logger.info(f"Registered tool: {name}") def validate_arguments(self, tool_name: str, arguments: dict) -> bool: """Validate that arguments match the function signature.""" tool = self._tools.get(tool_name) if not tool: return False sig = tool["signature"] try: # This will raise TypeError if arguments don't match sig.bind(**arguments) return True except TypeError as e: logger.error(f"Argument validation failed for {tool_name}: {e}") return False def call(self, tool_name: str, arguments: dict, max_retries: int = 3) -> str: """Call a tool with retries and logging.""" if not self.validate_arguments(tool_name, arguments): return json.dumps({"error": f"Invalid arguments for {tool_name}"}) tool = self._tools[tool_name] func = tool["function"] for attempt in range(max_retries): try: start = time.time() result = func(**arguments) duration = time.time() - start logger.info(f"Tool {tool_name} succeeded in {duration:.2f}s") return json.dumps({"result": result, "duration": duration}) except Exception as e: logger.warning(f"Tool {tool_name} failed attempt {attempt+1}: {e}") if attempt < max_retries - 1: time.sleep(2 ** attempt) # exponential backoff else: return json.dumps({"error": str(e)}) def get_schemas(self) -> list[dict]: """Return all tool schemas for the API call.""" return [t["schema"] for t in self._tools.values()] # Example usage registry = ToolRegistry() def get_weather(city: str) -> str: """Get the current weather for a city.""" # In production, call a real weather API return f"The weather in {city} is sunny." registry.register(get_weather) # Simulate a tool call result = registry.call("get_weather", {"city": "London"}) print(result) # Output: {"result": "The weather in London is sunny.", "duration": 0.001} # This would fail validation bad_result = registry.call("get_weather", {"city": "London", "amount_usd": 100}) print(bad_result) # Output: {"error": "Invalid arguments for get_weather"}
inspect module to generate schemas from function signatures automatically. This ensures the schema always matches the actual function parameters.check_inventory tool was called with a product_id that was an integer, but the function expected a string. The model generated product_id: 12345 (integer) but the schema said type: string. The API accepted the call but the function crashed with a TypeError. The fix was to use inspect.signature.bind() for strict type checking before calling the function.inspect.signature.bind() to catch type mismatches and missing required fields. Never trust the model's output — it's probabilistic, not deterministic.When NOT to Use Tool Use in AI Agents
Tool use is powerful, but it's not the right solution for every problem. Here are the cases where you should avoid it or use a simpler alternative.
1. When the tool is deterministic and the input is well-defined. If you have a function that takes a fixed set of parameters and the user's intent is clear, a simple REST API or a form-based UI is faster, cheaper, and more reliable. Tool use adds latency (the model call), cost (token usage), and failure modes (hallucination, parsing errors). Example: a calculator. Don't use an agent for 2+2 — just call eval().
2. When the tool has side effects that must be 100% reliable. Tool use is probabilistic. The model might call the wrong tool, with the wrong arguments, or not call it at all. If you're processing payments, updating a database, or sending emails, you need deterministic control. Use a traditional API with validation, not an agent.
3. When latency is critical. Each tool call requires a round-trip to the LLM API. If the user expects a response in under 500ms, tool use is not viable. The model call itself takes 1-3 seconds, plus the tool execution time. For real-time applications, use a pre-computed response or a lightweight model.
4. When the tool schema is too complex. If your tool has 20+ parameters with nested objects, the model will struggle to generate valid arguments. The token cost is high, and the failure rate increases. Simplify the tool by splitting it into multiple smaller tools, or use a different approach like a structured form.
# Example: When NOT to use tool use - use a simple API instead # Bad: Using an agent for a simple calculation import time from openai import OpenAI client = OpenAI() def bad_calculator_agent(expression: str) -> str: """Don't do this. It's slow, expensive, and unreliable.""" response = client.chat.completions.create( model="gpt-4", messages=[ {"role": "system", "content": "You are a calculator. Use the calculate tool."}, {"role": "user", "content": expression} ], tools=[{ "type": "function", "function": { "name": "calculate", "description": "Evaluate a mathematical expression", "parameters": { "type": "object", "properties": { "expression": {"type": "string"} }, "required": ["expression"] } } }] ) # Parse the tool call... return "Result: 4" # Simplified # Good: Just use eval() def good_calculator(expression: str) -> float: """Simple, fast, deterministic. No agent needed.""" # Use ast.literal_eval for safety in production return eval(expression) # Note: use ast.literal_eval in real code # Benchmark start = time.time() result = good_calculator("2 + 2") print(f"Simple: {result} in {time.time() - start:.4f}s") # Output: Simple: 4 in 0.0001s # The agent version would take 2-3 seconds and cost $0.01 per call
Production Patterns & Scale: Handling Parallel Tool Calls and Rate Limits
When you move from a demo to production, you'll hit two problems: the model can return multiple tool calls in one response, and your tools can fail due to rate limits. Here's how to handle both.
OpenAI's API supports parallel_tool_calls=True by default. This means the model can output an array of tool calls in a single response. Your loop must iterate over each call, execute them (potentially in parallel), and return all results as separate tool messages. If you only handle one call per turn, you'll drop work.
Rate limits are the second problem. When you execute multiple tool calls in parallel, you can hit API rate limits on external services. Use a semaphore to limit concurrency, and implement retry with exponential backoff.
Here's a production loop that handles parallel calls, rate limiting, and error recovery.
import asyncio import json import time from typing import Any from openai import AsyncOpenAI client = AsyncOpenAI() class ParallelToolExecutor: """Executes multiple tool calls concurrently with rate limiting.""" def __init__(self, registry, max_concurrent: int = 5): self.registry = registry self.semaphore = asyncio.Semaphore(max_concurrent) async def execute_one(self, tool_name: str, arguments: dict) -> dict: """Execute a single tool call with rate limiting.""" async with self.semaphore: # Call the tool (synchronous in this example, but can be async) result = self.registry.call(tool_name, arguments) return { "role": "tool", "tool_call_id": tool_name, # In production, use the actual ID from the API "content": result } async def execute_all(self, tool_calls: list[dict]) -> list[dict]: """Execute all tool calls in parallel.""" tasks = [] for call in tool_calls: task = self.execute_one(call["name"], json.loads(call["arguments"])) tasks.append(task) return await asyncio.gather(*tasks) # Production agent loop async def agent_loop(user_input: str, registry, max_turns: int = 10): """Full agent loop with parallel tool execution.""" messages = [ {"role": "system", "content": "You are a helpful assistant with access to tools."}, {"role": "user", "content": user_input} ] executor = ParallelToolExecutor(registry) for turn in range(max_turns): response = await client.chat.completions.create( model="gpt-4", messages=messages, tools=registry.get_schemas(), parallel_tool_calls=True ) message = response.choices[0].message # Check if the model wants to call tools if message.tool_calls: # Add the assistant's message with tool calls messages.append(message.to_dict()) # Execute all tool calls in parallel tool_results = await executor.execute_all( [call.to_dict() for call in message.tool_calls] ) # Add all tool results to messages messages.extend(tool_results) else: # Model wants to respond directly messages.append({"role": "assistant", "content": message.content}) break return messages[-1]["content"] # Example usage (requires async context) # result = await agent_loop("What's the weather in London and Paris?", registry) # print(result)
max_concurrent to 3 and implement retry with a 5-second backoff. The agent took longer but completed successfully.Common Mistakes with Specific Examples
After debugging dozens of production agent failures, here are the most common mistakes we see. Each one caused a real incident.
Mistake 1: Not validating tool arguments. The model can generate any JSON, including fields that don't exist in your function. If your function uses **kwargs, the bad field is silently ignored. This caused our $47k incident. Fix: validate arguments against the function signature before calling.
Mistake 2: Using kwargs in tool functions. This is the silent killer. kwargs absorbs any unexpected arguments, so the model never learns that it's generating wrong JSON. The tool returns a default or incorrect result, and the agent continues as if nothing is wrong. Fix: remove **kwargs and let the function raise TypeError on bad arguments.
Mistake 3: Not handling tool errors gracefully. When a tool throws an exception, the agent loop crashes. Users see a 500 error. Fix: wrap every tool call in a try-except and return a structured error message as the observation.
Mistake 4: Forgetting to include tool descriptions. The model relies on descriptions to know when to use a tool. A tool named search with no description will be ignored. Fix: write detailed descriptions that include example queries.
Mistake 5: Not setting a max turns limit. An agent can loop forever if the tool returns unexpected results. This costs money and frustrates users. Fix: set a max_turns parameter (usually 5-10) and return a fallback response if exceeded.
# Mistake 1: Using **kwargs (silent failure) def bad_tool(**kwargs): """This tool silently ignores bad arguments.""" # The model might pass 'amount_usd' but we ignore it return "default result" # Fix: Explicit parameters def good_tool(transaction_id: str, amount: float): """This tool will crash if the model passes wrong arguments.""" return f"Processing transaction {transaction_id} for ${amount}" # Mistake 2: Not handling errors def fragile_tool(): # If this API call fails, the agent crashes import requests response = requests.get("https://api.example.com/data") return response.json() # Fix: Wrap in try-except def robust_tool(): import requests try: response = requests.get("https://api.example.com/data", timeout=5) return response.json() except Exception as e: return {"error": str(e)} # Mistake 3: No max turns # The agent loop runs forever if the model keeps calling tools # Fix: Add a counter MAX_TURNS = 10 for turn in range(MAX_TURNS): # ... agent logic pass else: # Return a fallback response print("Agent exceeded max turns. Returning fallback.") # Mistake 4: Vague tool description # Bad:"search" -> model doesn't know when to use it # Good: "Search the web for information. Use this when the user asks about current events, news, or general knowledge. Example: 'What is the capital of France?' maps to search(query='capital of France')."
**kwargs hides schema mismatches. Your tool should crash loudly on unexpected arguments, not silently return garbage.get_stock_price tool with kwargs. The model started passing symbol as symbol_name due to a schema typo. The tool ignored the bad field and returned a default price of $0.00. The agent then used that price to make trading decisions. The team lost $12,000 before they noticed the pattern. The fix was to remove kwargs and add strict validation.**kwargs, handle errors, write good descriptions, and set a max turns limit. These five fixes will prevent 90% of agent failures.Comparison vs Alternatives: Tool Use, Function Calling, and Code Execution
Tool use (function calling) is one of three main patterns for giving models access to external systems. Here's how they compare and when to use each.
Tool Use / Function Calling: The model generates JSON arguments for a predefined function. Your code executes the function and returns the result. Pros: safe (the model never executes code), supports any language, easy to log and monitor. Cons: requires parsing, validation, and error handling; adds latency.
Code Execution (e.g., Code Interpreter): The model generates Python code that runs in a sandboxed environment. Pros: flexible, can handle complex computations. Cons: security risk (even sandboxed), limited to Python, harder to debug.
Direct API Calls (no agent): The application calls an API directly based on user input, without involving a model. Pros: fast, cheap, deterministic. Cons: no reasoning, can't handle ambiguous requests.
When to use tool use: When you need the model to reason about which tool to call and with what arguments. Examples: a customer support agent that looks up orders, refunds, and shipping info; a research assistant that searches the web and summarizes results.
When to use code execution: When the task requires complex computation that can't be broken into predefined tools. Examples: data analysis, plotting, running simulations.
When to use direct APIs: When the task is simple and well-defined. Examples: currency conversion, weather lookup, form submission.
# Comparison: Same task done with three different patterns # 1. Tool Use (Function Calling) import json def search_web_tool(query: str) -> str: """Search the web.""" return f"Results for {query}: ..." # The model generates: {"name": "search_web_tool", "arguments": "{\"query\": \"Python 3.13\"}"} # Your code parses and executes # 2. Code Execution (Code Interpreter) # The model generates: # import requests # response = requests.get('https://api.duckduckgo.com/?q=Python+3.13&format=json') # print(response.json()) # This runs in a sandbox. More flexible but riskier. # 3. Direct API Call import requests def search_web_direct(query: str) -> str: """No model involved. Just call the API directly.""" response = requests.get(f"https://api.example.com/search?q={query}") return response.json() # Which to choose? # - If the user says "search for Python 3.13 news", use direct API (fast, cheap) # - If the user says "find the latest Python news and summarize them", use tool use (needs reasoning) # - If the user says "analyze this CSV and plot the trend", use code execution (complex computation)
Debugging and Monitoring Tool Use in Production
When your agent fails in production, you need to know exactly what the model generated, what the tool returned, and why the agent made the decision it did. Here's the monitoring setup that saved us hours of debugging.
Log everything. Log the full request and response for every API call. Include the model's raw output, the parsed tool calls, the tool execution results, and the final response. Use structured logging (JSON) so you can query it later.
Track metrics. Measure tool call latency, success rate, token usage, and cost. Set up alerts for anomalies: if the success rate drops below 90%, or if latency exceeds 5 seconds, page someone.
Add a trace ID. Every agent run should have a unique trace ID that links all logs, metrics, and API calls. This lets you replay a single user session end-to-end.
Use a debug mode. In development, set debug=True to print the full conversation history, including the system prompt, tool schemas, and raw model output. This makes it easy to see what the model saw.
Test with known inputs. Before deploying a new tool, test it with a set of known inputs and expected outputs. Verify that the model calls the tool correctly and that the tool returns the expected result.
import json import logging import time import uuid from typing import Any # Structured logging setup logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) class AgentLogger: """Logs every step of the agent for debugging.""" def __init__(self, trace_id: str = None): self.trace_id = trace_id or str(uuid.uuid4()) self.logs = [] def log(self, event: str, data: dict): """Log an event with structured data.""" entry = { "trace_id": self.trace_id, "timestamp": time.time(), "event": event, "data": data } self.logs.append(entry) logger.info(json.dumps(entry)) def log_tool_call(self, tool_name: str, arguments: dict, result: Any, duration: float): self.log("tool_call", { "tool": tool_name, "arguments": arguments, "result": result, "duration": duration }) def log_model_response(self, response: dict): self.log("model_response", { "model": response.get("model"), "usage": response.get("usage"), "choices": response.get("choices", []) }) def get_summary(self) -> dict: """Return a summary of the agent run.""" tool_calls = [l for l in self.logs if l["event"] == "tool_call"] return { "trace_id": self.trace_id, "total_tool_calls": len(tool_calls), "total_duration": sum(l["data"]["duration"] for l in tool_calls), "errors": [l for l in self.logs if "error" in l["data"]] } # Usage in production logger = AgentLogger() start = time.time() result = registry.call("get_weather", {"city": "London"}) duration = time.time() - start logger.log_tool_call("get_weather", {"city": "London"}, result, duration) # Later, you can replay the session print(json.dumps(logger.get_summary(), indent=2)) # Output: {"trace_id": "...", "total_tool_calls": 1, "total_duration": 0.5, "errors": []}
tool_calls from the API response before any processing.Why Your Agent Needs a Tool Registry, Not a Jumble of if-else Blocks
Most tutorials hardcode tool calls in a single function. That works for two tools. At ten tools, the if-else chain becomes unmaintainable. At fifty, it's a production incident waiting to happen. A tool registry treats each tool as a first-class citizen: name, description, input schema, and callable wrapped in a uniform interface. This lets your LLM discover tools dynamically via their descriptions, validate inputs against JSON Schema before execution, and swap implementations without touching agent logic. It's the difference between a prototype and a platform. Frameworks like LangChain and CrewAI use registries internally, but you can build one in fifty lines of Python. The principle is simple: separate tool metadata from tool execution. Your model's prompt should only reference the registry's metadata, not hardcoded functions. This decoupling lets you add, version, or deprecate tools without redeploying the agent.
from typing import Any, Callable, Dict from pydantic import BaseModel, Field class Tool(BaseModel): name: str description: str input_schema: dict fn: Callable class ToolRegistry: def __init__(self): self._tools: Dict[str, Tool] = {} def register(self, tool: Tool): self._tools[tool.name] = tool def get_metadata(self) -> list[dict]: return [ {"name": t.name, "description": t.description, "parameters": t.input_schema} for t in self._tools.values() ] def execute(self, name: str, **kwargs) -> Any: if name not in self._tools: raise ValueError(f"Tool '{name}' not found") return self._tools[name].fn(**kwargs) # Usage registry = ToolRegistry() registry.register( Tool( name="get_weather", description="Fetch current weather for a city", input_schema={"type": "object", "properties": {"city": {"type": "string"}}}, fn=lambda city: f"Sunny, 22°C in {city}" ) ) print(registry.get_metadata())
The ReAct Loop: How Agents Actually Decide Which Tool to Call
Your agent doesn't guess which tool to use. It follows a structured reasoning loop called ReAct (Reason + Act). The pattern is simple: observe the user's request, reason about the next step, select a tool from your registry, execute it, observe the result, and repeat until the goal is met. This loop is what separates a brittle prompt hack from a robust agent. The LLM receives a system prompt listing available tools (from your registry's metadata) and a scratchpad of previous thoughts and observations. It then outputs a structured action like 'I need the weather in Paris. Use get_weather with {"city": "Paris"}.' Your code parses that, executes the tool, and appends the result to the scratchpad. The loop continues. This isn't magic. It's a state machine where the LLM is the planner and your code is the executor. Build it explicitly, not implicitly inside a framework black box.
import json def react_agent(prompt: str, registry: ToolRegistry, max_steps: int = 5): scratchpad = f"User: {prompt}\n" for step in range(max_steps): # Simulate LLM call (in prod, use OpenAI/Claude API) llm_response = f"Thought: I need to find the weather.\nAction: get_weather\nAction Input: {json.dumps({'city': 'Paris'})}" print(f"Step {step}: {llm_response}") # Parse action from LLM output if "Action:" in llm_response: tool_name = llm_response.split("Action:")[1].split("\n")[0].strip() input_str = llm_response.split("Action Input:")[1].split("\n")[0].strip() result = registry.execute(tool_name, **json.loads(input_str)) scratchpad += f"Observation: {result}\n" print(f"Observation: {result}") else: print("Final answer:", llm_response) break # Example run react_agent("What's the weather in Paris?", registry)
The $47k Schema Drift Incident: How a Cached Tool Definition Cost Us a Fortune
verify_transaction being called with a field amount_usd that didn't exist in the tool's Python signature. The tool returned a default 'safe' verdict because it ignored unknown kwargs.currency field to the verify_transaction tool. The deployment updated the Python function but the API gateway (nginx + Lua) cached the old tool definitions for 5 minutes. During that window, the model received the old schema (without currency) but the new function signature. The model generated amount_usd as a hallucination because the old schema had a required field amount that the model tried to pluralize. The function used **kwargs and silently dropped unknown arguments, returning a default safe verdict.**kwargs from all tool functions — now they raise TypeError on unknown arguments. 2. Added a schema version hash to the system prompt — the agent checks it before each call. 3. Deployed a validation layer that compares the model's JSON arguments against the actual function signature before execution. 4. Disabled the API gateway cache for tool definitions. 5. Added a metric for 'schema mismatch errors' with a PagerDuty alert.- Validate every argument the model generates against the actual function signature before calling the tool. Use
inspect.signatureto check required params. - Make tool functions fail loudly on unexpected arguments. No
**kwargs, no silent defaults. A crash is better than a wrong answer. - Version your tool schemas and include the version in the system prompt. If the version doesn't match, refuse to call tools until the prompt is reloaded.
python -c "import json; json.loads(open('tool_output.log').read())" to verify the output is valid JSON. If not, wrap the tool output in a JSON object.usage.prompt_tokens in the API response. If it's >80% of the model's limit, the model is forgetting the tool result. Implement a summarization step or truncate old turns. Run: curl -X POST https://api.openai.com/v1/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" -d '{"model": "gpt-4", "messages": [...]}' and inspect the usage field.error field. The model needs to see {"error": "API rate limit exceeded"} not a Python traceback.python -c "import inspect; print(inspect.signature(your_tool_function))"python -c "from your_module import tools; print(tools['tool_name']['schema'])"def verify_transaction(transaction_id: str, amount: float): and schema 'required': ['transaction_id', 'amount']grep 'observation' agent.log | tail -20python -c "import json; [print(json.loads(l)) for l in open('agent.log') if 'observation' in l][:5]"json.dumps({'result': output}). The model needs structured data to stop looping.python -c "import requests; r = requests.get('https://your-api.com/health', timeout=5); print(r.status_code)"grep 'tool_call_duration' metrics.log | tail -5response = requests.get(url, timeout=10). If the API is slow, implement a fallback or cache.python -c "print(tools['tool_name']['schema']['description'])"python -c "from openai import OpenAI; client = OpenAI(); response = client.chat.completions.create(model='gpt-4', messages=[{'role': 'user', 'content': 'test'}], tools=[tools['tool_name']['schema']]); print(response.choices[0].message.tool_calls)"| Concern | Tool Use | Function Calling | Code Execution | Recommendation |
|---|---|---|---|---|
| Schema validation | Custom, flexible | Built-in (OpenAI/Anthropic) | None (raw code) | Function calling for simplicity, tool use for custom schemas |
| Parallel execution | Manual implementation | Native support (parallel_tool_calls) | Manual (threading) | Function calling for parallel, tool use for complex orchestration |
| Security | High (validation layer) | Medium (depends on provider) | Low (code injection risk) | Tool use or function calling; avoid code execution for untrusted input |
| Latency | Low (direct dispatch) | Low (structured output) | High (code interpretation) | Tool use or function calling for low latency |
| Debugging | Structured logs | Provider logs | Hard (sandboxed) | Tool use with custom logging |
| Cost | Low (no extra tokens) | Low (no extra tokens) | High (code execution tokens) | Tool use or function calling for cost efficiency |
| File | Command / Code | Purpose |
|---|---|---|
| tool_use_internals.py | from typing import Any, Callable | How Tool Use in AI Agents Actually Works Under the Hood |
| production_tool_registry.py | from typing import Any, Callable | Practical Implementation |
| when_not_to_use_tools.py | from openai import OpenAI | When NOT to Use Tool Use in AI Agents |
| production_agent_loop.py | from typing import Any | Production Patterns & Scale |
| common_mistakes.py | def bad_tool(**kwargs): | Common Mistakes with Specific Examples |
| comparison_alternatives.py | def search_web_tool(query: str) -> str: | Comparison vs Alternatives |
| debugging_monitoring.py | from typing import Any | Debugging and Monitoring Tool Use in Production |
| tool_registry.py | from typing import Any, Callable, Dict | Why Your Agent Needs a Tool Registry, Not a Jumble of if-els |
| react_loop.py | def react_agent(prompt: str, registry: ToolRegistry, max_steps: int = 5): | The ReAct Loop |
Key takeaways
Common mistakes to avoid
4 patternsMissing required parameter validation
jsonschema (Python) or zod (Node) to validate against the registered schema. Reject with a clear error message to the LLM.No rate limiting on parallel tool calls
Trusting LLM-generated parameter values without sanitization
name=Robert'); DROP TABLE Students;--). Data breach or service compromise.No idempotency on mutating tool calls
Interview Questions on This Topic
How would you design a tool registry for an AI agent that supports dynamic tool addition at runtime?
get_tool(name) method and a list_tools() for the LLM's system prompt. For dynamic addition, use a hot-reload mechanism that watches a config file or database for changes, then atomically swaps the registry. All tool calls go through a dispatcher that validates arguments, applies rate limiting, and logs every call.Explain how you would handle parallel tool calls from an LLM while respecting API rate limits.
tool_calls). Group calls by endpoint. For each endpoint, use a token-bucket rate limiter with a configurable QPS and burst capacity. Dispatch calls sequentially within each bucket, but allow parallel dispatch across different endpoints. Use a semaphore to cap total concurrency (e.g., 10). Queue overflow calls and process them as tokens refill. Log queue depth and dispatch latency. If queue exceeds a threshold (e.g., 100), drop the oldest calls and return an error to the LLM.What are the security risks of tool use in AI agents and how do you mitigate them?
How would you implement idempotency for tool calls in an AI agent?
Describe a production debugging scenario where an AI agent's tool calls are failing silently.
Frequently Asked Questions
Tool use is the broader pattern where an LLM can invoke external capabilities (APIs, databases, code). Function calling is a specific API feature (e.g., OpenAI's functions parameter) that structures the LLM's output as a JSON object with function name and arguments. Tool use can be implemented via function calling, but also via other methods like code execution or direct API calls.
Validate schemas at two stages: (1) registration time — ensure the schema is valid JSON Schema draft-07 and all required fields are present; (2) runtime — validate the LLM's generated arguments against the schema before dispatching. Use a library like jsonschema (Python) or zod (Node). Reject invalid calls with a structured error message to the LLM.
LLMs can output multiple tool calls in a single response (e.g., OpenAI's parallel_tool_calls). To handle rate limits, implement a token-bucket or leaky-bucket algorithm per API endpoint. Queue calls and dispatch at max allowed QPS. Use a semaphore to cap concurrency. Log queue depth for monitoring.
Enable structured logging with a unique trace ID per agent session. Log every tool call: request payload, response, latency, and error. Monitor for patterns like repeated calls to the same tool with identical parameters. Set a max retry count per tool (e.g., 3) and escalate to a human after that. Use circuit breakers to stop calls to failing endpoints.
The mistake was not implementing idempotency keys on a payment-processing tool. The LLM called 'charge_customer' with a timeout, retried, and charged the customer twice. Each retry also consumed API tokens and LLM context, compounding costs. Fix: always use idempotency keys for mutating operations and validate schemas to prevent malformed calls that trigger retries.
20+ years shipping production ML systems and the infrastructure behind them. Lessons pulled from things that broke in production.
That's AI Agents. Mark it forged?
8 min read · try the examples if you haven't