The Problem
You built a research agent with 10 tools. Now you need to extend it for a new domain. If each tool was defined by hand as a separate function, you need to edit and redeploy the agent. With tool composition and dynamic registration, you define new tools in a registry and the agent picks them up automatically — no redeployment needed.
The same principle applies to tool count: an agent shown 20 tools has lower selection accuracy than one shown 6 relevant tools. Dynamic context-based filtering shows the model only the tools relevant to the current task.
Tool composition: meta-tools
A meta-tool wraps a sequence of lower-level tool calls into a single interface presented to the model. Instead of the model calling search_web → summarize_results → save_to_memory as three separate decisions, it calls research_topic(topic) once, and your code orchestrates the three steps internally.
When to use a meta-tool:
- The same sequence of tool calls repeats across many agent tasks
- The sequence is deterministic (doesn't depend on intermediate results)
- The intermediate steps add decision noise without adding decision value
When NOT to use a meta-tool:
- The sequence needs to vary based on intermediate results (the model needs to see each step)
- Different steps require different error handling strategies
- The caller needs visibility into the intermediate results
An agent calls search_web, summarize_text, and save_to_notes every time it researches a topic. What's the best refactoring?
If the model reliably calls 3 tools in a fixed sequence, that sequence is deterministic and belongs in a meta-tool. This reduces the model's decision surface (from 3 choices to 1), reduces selection errors, speeds up execution (internal optimization possible), and simplifies the system prompt. The system prompt approach doesn't reduce the tool call count.
Dynamic tool registry
A ToolRegistry holds all available tools and exposes them selectively based on context. Tools register themselves with metadata — no code changes to the agent when tools are added or removed.
registry = ToolRegistry()
registry.register(search_product_catalog, namespace="ecommerce")
registry.register(create_calendar_event, namespace="calendar")
registry.register(send_email, namespace="email")
# For an ecommerce task, only show ecommerce tools
tools = registry.get_for_namespace("ecommerce")
# For a mixed task, use all tools
tools = registry.get_all()
The registry pattern separates tool definition (what tools exist) from tool presentation (which tools the model sees). This is the "separation of concerns" for tool systems.
Loading tools from config files extends this to runtime configuration: define tools as JSON or YAML, load them at startup without modifying Python code. Teams can add, modify, or disable tools without a redeploy.
Tool namespacing
When an agent serves multiple domains, name collisions cause selection errors. Both the e-commerce team and the HR team have a search tool. The model can't distinguish them.
Namespaced tool names solve this: ecommerce.search_products and hr.search_employees are unambiguous. The namespace is part of the tool's identity.
Your agent serves both e-commerce and HR domains. You have tools named "search" in both. What's the correct architectural fix?
Namespacing prevents collisions and makes tool selection unambiguous. Dynamic context-based filtering (showing only ecommerce tools for ecommerce tasks) further reduces the model's decision surface. A single merged "search" with a domain parameter is a worse design — it requires the model to know which domain it's in, creating new selection errors. Separate agents is architecturally heavier than namespacing.
Build
Build it
The code below implements the complete pattern: ToolRegistry, MetaTool, namespace-aware serialization, and context-based filtering.
"""
lesson_02_04_tool_composition.py
Tool registry, meta-tools, and namespace-based filtering.
Run:
pip install openai
export OPENAI_API_KEY=...
python lesson_02_04_tool_composition.py
"""
import json
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from openai import OpenAI
# ---------------------------------------------------------------------------
# Tool registry
# ---------------------------------------------------------------------------
@dataclass
class RegisteredTool:
name: str
namespace: str
description: str
parameters: dict
handler: Callable
tags: list[str] = field(default_factory=list)
@property
def full_name(self) -> str:
return f"{self.namespace}.{self.name}" if self.namespace else self.name
def to_openai_schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.full_name,
"description": self.description,
"parameters": self.parameters,
}
}
class ToolRegistry:
def __init__(self):
self._tools: dict[str, RegisteredTool] = {}
def register(
self,
handler: Callable,
name: str,
namespace: str,
description: str,
parameters: dict,
tags: list[str] = None,
) -> "ToolRegistry":
tool = RegisteredTool(
name=name,
namespace=namespace,
description=description,
parameters=parameters,
handler=handler,
tags=tags or [],
)
self._tools[tool.full_name] = tool
print(f" [registry] Registered: {tool.full_name}")
return self
def get_all(self) -> list[dict]:
return [t.to_openai_schema() for t in self._tools.values()]
def get_for_namespace(self, namespace: str) -> list[dict]:
return [
t.to_openai_schema()
for t in self._tools.values()
if t.namespace == namespace
]
def get_for_tags(self, tags: list[str]) -> list[dict]:
"""Return tools that match any of the given tags."""
return [
t.to_openai_schema()
for t in self._tools.values()
if any(tag in t.tags for tag in tags)
]
def deregister(self, full_name: str) -> None:
self._tools.pop(full_name, None)
print(f" [registry] Deregistered: {full_name}")
def execute(self, full_name: str, args: dict) -> Any:
tool = self._tools.get(full_name)
if not tool:
return {"error": f"Tool '{full_name}' not found in registry"}
return tool.handler(**args)
def list_tools(self) -> None:
for name, tool in self._tools.items():
print(f" {name} [{', '.join(tool.tags)}]")
# ---------------------------------------------------------------------------
# Tool handlers (simulated)
# ---------------------------------------------------------------------------
def search_products(query: str, category: str = None) -> dict:
return {"results": [{"name": f"Product matching '{query}'", "price": 49.99}]}
def get_order_status(order_id: str) -> dict:
return {"order_id": order_id, "status": "shipped", "eta": "2024-01-20"}
def search_employees(name: str, department: str = None) -> dict:
return {"employees": [{"name": name, "department": department or "Engineering", "id": "emp_001"}]}
def create_calendar_event(title: str, date: str, duration_minutes: int = 60) -> dict:
return {"event_id": "evt_001", "title": title, "date": date, "status": "created"}
def send_slack_message(channel: str, message: str) -> dict:
return {"status": "sent", "channel": channel, "ts": "1705123456.123"}
# ---------------------------------------------------------------------------
# Meta-tool: ResearchTopic
# ---------------------------------------------------------------------------
def web_search(query: str) -> dict:
"""Simulated web search."""
return {"results": [f"Result 1 for '{query}'", f"Result 2 for '{query}'"]}
def summarize_text(text: str, max_sentences: int = 3) -> dict:
"""Simulated summarizer."""
return {"summary": f"Summary of: {text[:100]}..."}
def save_to_notes(title: str, content: str) -> dict:
"""Simulated note saver."""
return {"saved": True, "note_id": "note_001", "title": title}
class ResearchMetaTool:
"""
A meta-tool that chains: web_search → summarize → save_to_notes.
The model calls research_topic() once; this class orchestrates the three steps.
"""
def __call__(self, topic: str, num_sources: int = 3) -> dict:
print(f" [meta-tool] research_topic('{topic}')")
# Step 1: Search
search_result = web_search(topic)
print(f" → search_web: {len(search_result['results'])} results")
# Step 2: Summarize
combined = " ".join(search_result["results"])
summary = summarize_text(combined)
print(f" → summarize_text: done")
# Step 3: Save
saved = save_to_notes(f"Research: {topic}", summary["summary"])
print(f" → save_to_notes: note_id={saved['note_id']}")
return {
"topic": topic,
"summary": summary["summary"],
"sources_searched": len(search_result["results"]),
"note_id": saved["note_id"],
}
if __name__ == "__main__":
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
print("TOOL REGISTRY DEMO")
print("=" * 50)
# Build the registry
registry = ToolRegistry()
registry.register(
search_products, "search_products", "ecommerce",
"Search the product catalog by name or description. For order status, use ecommerce.get_order_status.",
{"type": "object", "properties": {"query": {"type": "string"}, "category": {"type": "string"}}, "required": ["query"]},
tags=["search", "ecommerce"],
)
registry.register(
get_order_status, "get_order_status", "ecommerce",
"Get real-time status of a specific order. Requires order ID.",
{"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]},
tags=["orders", "ecommerce"],
)
registry.register(
search_employees, "search_employees", "hr",
"Search employees by name or department. Use for HR-related queries only.",
{"type": "object", "properties": {"name": {"type": "string"}, "department": {"type": "string"}}, "required": ["name"]},
tags=["search", "hr"],
)
registry.register(
create_calendar_event, "create_event", "calendar",
"Create a calendar event with a title and date.",
{"type": "object", "properties": {"title": {"type": "string"}, "date": {"type": "string"}, "duration_minutes": {"type": "integer"}}, "required": ["title", "date"]},
tags=["calendar", "scheduling"],
)
research = ResearchMetaTool()
registry.register(
research, "research_topic", "research",
"Research a topic by searching the web, summarizing results, and saving to notes. Use for any research task.",
{"type": "object", "properties": {"topic": {"type": "string"}, "num_sources": {"type": "integer"}}, "required": ["topic"]},
tags=["research", "search"],
)
print("\nAll registered tools:")
registry.list_tools()
# Context-based filtering demo
print("\n--- Namespace filtering ---")
ecomm_tools = registry.get_for_namespace("ecommerce")
print(f"Ecommerce tools: {[t['function']['name'] for t in ecomm_tools]}")
hr_tools = registry.get_for_namespace("hr")
print(f"HR tools: {[t['function']['name'] for t in hr_tools]}")
# Agent demo with namespaced tools
print("\n--- Agent demo: ecommerce task ---")
messages = [
{"role": "system", "content": "You are a helpful e-commerce assistant."},
{"role": "user", "content": "What's the status of order ORD-12345?"},
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=registry.get_for_namespace("ecommerce"),
tool_choice="auto",
)
msg = response.choices[0].message
if msg.tool_calls:
tc = msg.tool_calls[0]
print(f"Model called: {tc.function.name}")
result = registry.execute(tc.function.name, json.loads(tc.function.arguments))
print(f"Result: {result}")
else:
print(f"Model responded directly: {msg.content}")
# Meta-tool demo
print("\n--- Meta-tool demo: research_topic ---")
result = research("asyncio in Python")
print(f"Meta-tool result: {json.dumps(result, indent=2)}")
# Dynamic deregistration
print("\n--- Dynamic deregistration ---")
registry.deregister("hr.search_employees")
print(f"Tools after HR deregistration: {len(registry.get_all())} tools")
Here's a compact standalone version of the registry + namespace filtering pattern, without the OpenAI client, so you can see the core mechanism run end to end.
class ToolRegistry:
def __init__(self):
self._tools = {}
def register(self, name, namespace, description):
full_name = f"{namespace}.{name}"
self._tools[full_name] = {"namespace": namespace, "description": description}
return full_name
def get_for_namespace(self, namespace):
return [name for name, t in self._tools.items() if t["namespace"] == namespace]
def get_all(self):
return list(self._tools.keys())
registry = ToolRegistry()
registry.register("search_products", "ecommerce", "Search the product catalog")
registry.register("get_order_status", "ecommerce", "Get order status by ID")
registry.register("search_employees", "hr", "Search employees by name")
registry.register("create_event", "calendar", "Create a calendar event")
print("All tools:", registry.get_all())
print("Ecommerce task sees:", registry.get_for_namespace("ecommerce"))
print("HR task sees:", registry.get_for_namespace("hr"))
print(f"Tool count shown for ecommerce task: {len(registry.get_for_namespace('ecommerce'))} of {len(registry.get_all())} total")