Agentic AI Patterns · Lesson 5 of 15
Tool Use Pattern: Dynamic Tool Selection
Tool Use in Agentic Systems
An agent without tools is just an LLM. Tools are what make agents capable of affecting the world: searching the web, querying databases, sending emails, running code, calling APIs. Understanding how to design and register tools is one of the most important skills in agentic AI engineering.
This lesson covers the tool registry pattern, dynamic vs static tool selection, tool composition, and a complete worked example with three different tool types.
What Is a Tool?
A tool is a function that:
- Has a clear name and description the LLM can understand
- Takes structured inputs (so the LLM can generate valid arguments)
- Returns a string result (so the LLM can read and reason over it)
- Has deterministic or reliable behavior (so the agent can trust the result)
The LLM does not "call" tools directly — it generates a structured JSON object specifying the tool name and arguments. Your code intercepts this, calls the actual function, and returns the result as an observation.
Tool Registry Pattern
A tool registry is a centralized dictionary mapping tool names to their implementations and schemas. This is the single source of truth for what tools are available.
from dataclasses import dataclass, field
from typing import Callable, Dict, Any, List, Optional
import json
import re
@dataclass
class Tool:
"""Represents a registered tool with its implementation and schema."""
name: str
description: str
function: Callable
parameters: Dict[str, Any] # JSON Schema for parameters
required: List[str] = field(default_factory=list)
class ToolRegistry:
"""
Central registry for all agent tools.
Maintains both the callable implementations and OpenAI schema definitions.
"""
def __init__(self):
self._tools: Dict[str, Tool] = {}
def register(
self,
name: str,
description: str,
parameters: Dict[str, Any],
required: Optional[List[str]] = None,
):
"""Decorator to register a function as a tool."""
def decorator(fn: Callable) -> Callable:
self._tools[name] = Tool(
name=name,
description=description,
function=fn,
parameters=parameters,
required=required or [],
)
return fn
return decorator
def call(self, name: str, **kwargs) -> str:
"""Execute a registered tool by name with given arguments."""
if name not in self._tools:
return f"Error: Tool '{name}' not found. Available tools: {list(self._tools.keys())}"
try:
result = self._tools[name].function(**kwargs)
return str(result)
except TypeError as e:
return f"Error: Invalid arguments for tool '{name}': {e}"
except Exception as e:
return f"Error executing tool '{name}': {type(e).__name__}: {e}"
def get_openai_schemas(self, tool_names: Optional[List[str]] = None) -> List[Dict]:
"""
Return OpenAI-compatible tool schemas.
Pass tool_names to get schemas for a subset of tools (dynamic selection).
"""
tools_to_include = tool_names or list(self._tools.keys())
schemas = []
for name in tools_to_include:
if name in self._tools:
tool = self._tools[name]
schemas.append({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": {
"type": "object",
"properties": tool.parameters,
"required": tool.required,
},
},
})
return schemas
@property
def available_tools(self) -> List[str]:
return list(self._tools.keys())
# Global registry instance
registry = ToolRegistry()Registering Tools
import sqlite3
import urllib.request
@registry.register(
name="search_web",
description=(
"Search the web for current information. Use for facts, news, prices, "
"or any information that might change over time. Returns a text summary."
),
parameters={
"query": {
"type": "string",
"description": "The search query. Be specific — include key terms.",
}
},
required=["query"],
)
def search_web(query: str) -> str:
"""
Simulated web search. In production, replace with Tavily, Serper, or Brave API.
"""
knowledge_base = {
"python version latest": "Python 3.13 is the latest stable release as of 2024.",
"openai gpt-4o pricing": "GPT-4o costs $5 per million input tokens and $15 per million output tokens.",
"rust ownership rules": (
"Rust ownership rules: (1) each value has one owner, "
"(2) there can only be one owner at a time, "
"(3) when the owner goes out of scope, the value is dropped."
),
"fastapi performance": (
"FastAPI is one of the fastest Python web frameworks, "
"comparable to NodeJS and Go. It uses async/await and Pydantic."
),
}
query_lower = query.lower()
for key, result in knowledge_base.items():
if any(word in query_lower for word in key.split()):
return result
return f"Web search result for '{query}': No specific result found in knowledge base."
@registry.register(
name="calculate",
description=(
"Evaluate a mathematical expression and return the numeric result. "
"Use for arithmetic, percentages, unit conversions, and any calculation. "
"Input must be a valid math expression using +, -, *, /, ** and parentheses."
),
parameters={
"expression": {
"type": "string",
"description": "Math expression like '(100 * 1.08) + 50' or '4000 / 500'",
}
},
required=["expression"],
)
def calculate(expression: str) -> str:
"""Safe expression evaluator for numeric math only."""
# Strict whitelist: only digits, operators, and whitespace
if not re.match(r'^[\d\s\+\-\*/\.\(\)\*\*]+$', expression):
return (
f"Error: Expression '{expression}' contains disallowed characters. "
"Only digits and +, -, *, /, ** and parentheses are allowed."
)
try:
result = eval(expression, {"__builtins__": {}}) # noqa: S307
return f"{result}"
except ZeroDivisionError:
return "Error: Division by zero"
except Exception as e:
return f"Calculation error: {e}"
@registry.register(
name="lookup_database",
description=(
"Query the internal product database. Use to look up product details, "
"inventory levels, pricing, or customer records. "
"Supports queries like 'product_id=123' or 'category=electronics'."
),
parameters={
"query_type": {
"type": "string",
"enum": ["product", "inventory", "customer"],
"description": "Type of record to look up",
},
"identifier": {
"type": "string",
"description": "The ID or search term for the lookup",
},
},
required=["query_type", "identifier"],
)
def lookup_database(query_type: str, identifier: str) -> str:
"""
Simulated database lookup. In production, replace with real DB connection.
"""
mock_data = {
"product": {
"P001": {"name": "Mechanical Keyboard", "price": 149.99, "category": "Electronics"},
"P002": {"name": "USB-C Hub", "price": 49.99, "category": "Electronics"},
"P003": {"name": "Ergonomic Mouse", "price": 89.99, "category": "Electronics"},
},
"inventory": {
"P001": {"stock": 42, "warehouse": "Seattle", "reorder_point": 10},
"P002": {"stock": 8, "warehouse": "Austin", "reorder_point": 15},
"P003": {"stock": 0, "warehouse": "N/A", "reorder_point": 5},
},
"customer": {
"C100": {"name": "Alice Johnson", "tier": "Gold", "orders": 23},
"C101": {"name": "Bob Smith", "tier": "Silver", "orders": 7},
},
}
if query_type not in mock_data:
return f"Error: Unknown query type '{query_type}'"
records = mock_data[query_type]
# Exact match first
if identifier in records:
return json.dumps(records[identifier])
# Partial match
matches = {k: v for k, v in records.items()
if identifier.lower() in str(v).lower()}
if matches:
return json.dumps(matches)
return f"No {query_type} records found for identifier '{identifier}'"Dynamic Tool Selection
For agents with many tools, passing all schemas in every call wastes tokens and can confuse the model. Dynamic tool selection uses the user query to select only the relevant subset:
import openai
client = openai.OpenAI()
def select_tools_for_query(query: str, all_tool_names: List[str]) -> List[str]:
"""
Use an LLM to select which tools are relevant for this query.
Returns a list of tool names to pass to the agent.
"""
tool_list = "\n".join(f"- {name}" for name in all_tool_names)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": (
f"Given this user query:\n{query}\n\n"
f"Which of these tools are potentially needed?\n{tool_list}\n\n"
"Return only a JSON array of tool names that are relevant. "
"Include a tool if there is any chance it will be needed. "
"Example: [\"search_web\", \"calculate\"]"
),
}
],
response_format={"type": "json_object"},
temperature=0,
)
data = json.loads(response.choices[0].message.content)
# Handle {"tools": [...]} or [...] formats
if isinstance(data, dict):
selected = data.get("tools", data.get("tool_names", list(data.values())[0]))
else:
selected = data
# Filter to only valid tool names
return [t for t in selected if t in all_tool_names]Tool Composition
Some tasks require chaining tools — the output of one feeds into the next. This happens naturally through the agent loop, but you can also compose tools explicitly:
def get_inventory_value(product_id: str) -> str:
"""
Composed tool: looks up inventory count and product price,
then calculates total inventory value.
This demonstrates how one tool's output feeds another.
"""
# Step 1: Get product details
product_info = lookup_database("product", product_id)
try:
product = json.loads(product_info)
except json.JSONDecodeError:
return f"Could not parse product data: {product_info}"
# Step 2: Get inventory count
inventory_info = lookup_database("inventory", product_id)
try:
inventory = json.loads(inventory_info)
except json.JSONDecodeError:
return f"Could not parse inventory data: {inventory_info}"
# Step 3: Calculate total value
price = product.get("price", 0)
stock = inventory.get("stock", 0)
total = price * stock
return (
f"Product: {product.get('name')}, "
f"Price: ${price}, "
f"Stock: {stock} units, "
f"Total Value: ${total:,.2f}"
)
# Register the composed tool
registry.register(
name="get_inventory_value",
description=(
"Calculate the total value of a product's inventory. "
"Looks up both price and stock count, then computes price * quantity."
),
parameters={
"product_id": {
"type": "string",
"description": "Product ID like 'P001', 'P002'",
}
},
required=["product_id"],
)(get_inventory_value)Agent with Tool Registry
def run_agent_with_registry(
user_query: str,
use_dynamic_selection: bool = False,
max_iterations: int = 10,
) -> str:
"""
Run an agent using the tool registry.
Optionally uses dynamic tool selection to reduce token usage.
"""
# Determine which tools to offer
if use_dynamic_selection:
print("Selecting tools dynamically...")
selected_tool_names = select_tools_for_query(
user_query, registry.available_tools
)
print(f"Selected tools: {selected_tool_names}")
else:
selected_tool_names = registry.available_tools
tool_schemas = registry.get_openai_schemas(selected_tool_names)
messages = [
{
"role": "system",
"content": (
"You are a helpful business assistant with access to web search, "
"a calculator, and a product database. Use tools to look up "
"accurate information before answering. Compose multiple tool "
"calls as needed to answer complex questions."
),
},
{"role": "user", "content": user_query},
]
for iteration in range(max_iterations):
print(f"\n--- Iteration {iteration + 1} ---")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tool_schemas,
tool_choice="auto",
)
msg = response.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
tool_name = tc.function.name
print(f"Tool: {tool_name}({args})")
result = registry.call(tool_name, **args)
print(f"Result: {result[:120]}")
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
return "Max iterations reached."
if __name__ == "__main__":
print("=== Query 1: Multi-tool composition ===")
answer = run_agent_with_registry(
"What is the total inventory value of product P001, "
"and what percentage of the reorder point is current stock?"
)
print(f"\nAnswer: {answer}")
print("\n=== Query 2: Dynamic tool selection ===")
answer = run_agent_with_registry(
"What is the latest Python version?",
use_dynamic_selection=True,
)
print(f"\nAnswer: {answer}")Tool Design Best Practices
Write descriptions for the LLM, not for humans — The description is how the model decides which tool to call. Mention what kinds of questions it answers, not just what it does technically.
Return strings, not objects — The LLM processes tool results as text. Serialize complex return values to JSON strings.
Make errors informative — If a tool fails, the error message should help the LLM recover. "Error: product P999 not found. Valid IDs are P001, P002, P003" is more useful than "Not found."
Validate inputs — Check that the LLM-generated arguments make sense before executing. Prevent injection attacks on tools that touch external systems.
Keep tools focused — Each tool should do one thing well. Avoid multi-purpose tools that do different things based on a mode flag — that makes tool selection harder for the LLM.
Summary
- A tool registry centralizes tool implementations and their OpenAI schemas
- Use a decorator pattern to register tools: clean, discoverable, co-located
- Dynamic tool selection reduces token usage by only including relevant tools per query
- Tool composition chains tool outputs — either explicitly in composed functions or implicitly through the agent loop
- Write tool descriptions for the LLM, not for humans — they drive tool selection
- Always handle errors gracefully and return informative error strings
Next: Agent Memory Types — how agents remember across turns, sessions, and tasks.