AI Engineer Portal
Your personal operating system for career transition.
Exercise
Build a tool registry with schema validation
Build a Tool Registry with Schema Validation
In any agent system, the model needs to know which tools are available, what arguments each tool expects, and how to call them safely. A tool registry is the foundational data structure that makes this possible. Without one, agent code devolves into a tangle of if-else branches and string matching.
What you are building
Create a ToolRegistry class that:
- Registers tools — each tool has a unique name, a callable, and a JSON Schema describing its parameters.
- Validates schemas on registration — reject tools whose parameter schemas are not valid JSON Schema objects (must have
type,properties, etc.). - Prevents duplicate names — raise a clear error if a tool with the same name is already registered.
- Looks up tools by name — return the tool definition or raise
KeyErrorwith a helpful message listing available tools. - Lists all tools — return a list of tool definitions suitable for passing to an LLM as the
toolsparameter.
Why this matters
Every major agent framework (LangChain, CrewAI, OpenAI function calling) relies on a registry pattern internally. Understanding how it works means you can debug tool-not-found errors, extend the registry with middleware (logging, auth, cost tracking), and build custom agent loops without depending on a heavy framework.
Constraints
- Use only the Python standard library plus
jsonschemafor validation. - Type-hint every method.
- Raise domain-specific exceptions (
DuplicateToolError,InvalidSchemaError) rather than generic ones.
Agents / intermediate / Step 1 of 8
Agent architecture patterns
Focus on explicit control surfaces — function schemas, state machines, and structured outputs. Agents are most useful when the task requires dynamic tool selection or multi-step reasoning. Start with the simplest pattern (single tool call) before reaching for ReAct loops.
- - Tools return structured, typed responses
- - Agent completes the task within a bounded number of steps
- - All tool calls include error handling and retries
- - Memory/state management prevents unbounded context growth
- - Tool schemas validate inputs and handle errors gracefully
- - Agent loop has explicit termination conditions
- - State is serializable and inspectable between steps
- - Cost and token usage are tracked per invocation
- - Fallback behavior exists for tool call failures
Practice
Generate a variation
Generate a new exercise variation to deepen understanding or practice a related concept.
Attempt history
Recent submissions
What part of this implementation felt least trustworthy, and what will you change on the next rep?
No notes
Reference
Correct answer and review notes
Use the reference solution to compare structure, not just syntax.
from __future__ import annotations
import json
from typing import Any, Callable
import jsonschema
class DuplicateToolError(Exception):
pass
class InvalidSchemaError(Exception):
pass
class ToolDefinition:
def __init__(self, name: str, description: str, fn: Callable, parameters_schema: dict):
self.name = name
self.description = description
self.fn = fn
self.parameters_schema = parameters_schema
def to_llm_format(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters_schema,
},
}
class ToolRegistry:
def __init__(self) -> None:
self._tools: dict[str, ToolDefinition] = {}
def register(
self,
name: str,
description: str,
fn: Callable,
parameters_schema: dict[str, Any],
) -> None:
if name in self._tools:
raise DuplicateToolError(f"Tool '{name}' is already registered")
# Validate the schema itself using the meta-schema
try:
jsonschema.Draft7Validator.check_schema(parameters_schema)
except jsonschema.SchemaError as exc:
raise InvalidSchemaError(
f"Invalid schema for tool '{name}': {exc.message}"
) from exc
if parameters_schema.get("type") != "object":
raise InvalidSchemaError(
f"Tool '{name}' schema must have type 'object' at the top level"
)
self._tools[name] = ToolDefinition(name, description, fn, parameters_schema)
def get(self, name: str) -> ToolDefinition:
if name not in self._tools:
available = ", ".join(sorted(self._tools.keys())) or "(none)"
raise KeyError(
f"Tool '{name}' not found. Available tools: {available}"
)
return self._tools[name]
def list_tools(self) -> list[dict]:
return [tool.to_llm_format() for tool in self._tools.values()]
Walkthrough: Tool Registry with Schema Validation
The core design decision
The registry stores ToolDefinition objects rather than raw dicts. This gives you a single place to add behavior later (middleware hooks, call counting, deprecation warnings) without changing every call site.
Schema validation strategy
We use jsonschema.Draft7Validator.check_schema() to validate the schema against the JSON Schema meta-schema. This catches structural problems like missing type fields or invalid $ref pointers at registration time rather than at call time.
We also enforce that the top-level type is "object" because LLM function-calling parameters are always objects with named properties:
if parameters_schema.get("type") != "object":
raise InvalidSchemaError(
f"Tool '{name}' schema must have type 'object' at the top level"
)
Why domain-specific exceptions matter
DuplicateToolError and InvalidSchemaError let callers distinguish between "I misconfigured a tool" and "I made a typo in a tool name." Generic ValueError would force callers to parse error messages.
The to_llm_format method
This returns the exact shape OpenAI and other providers expect. Keeping this on ToolDefinition rather than in the registry means you can serialize one tool at a time for selective exposure:
def to_llm_format(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters_schema,
},
}
The helpful KeyError pattern
When a tool is not found, the error message lists all available tools. This is a small quality-of-life detail that saves minutes of debugging when a model hallucinates a tool name:
available = ", ".join(sorted(self._tools.keys())) or "(none)"
raise KeyError(f"Tool '{name}' not found. Available tools: {available}")
Trade-offs and extensions
- Thread safety: This implementation is not thread-safe. In a production agent, you would use a
threading.Lockaround_tools. - Decorator registration: You could add a
@registry.tool(name, schema)decorator for convenience. - Runtime argument validation: You could validate incoming arguments against the schema before calling the function, adding another safety layer.