方寸 Portal

AI Engineer Portal

Your personal operating system for career transition.

Private mode

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:

  1. Registers tools — each tool has a unique name, a callable, and a JSON Schema describing its parameters.
  2. Validates schemas on registration — reject tools whose parameter schemas are not valid JSON Schema objects (must have type, properties, etc.).
  3. Prevents duplicate names — raise a clear error if a tool with the same name is already registered.
  4. Looks up tools by name — return the tool definition or raise KeyError with a helpful message listing available tools.
  5. Lists all tools — return a list of tool definitions suitable for passing to an LLM as the tools parameter.

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 jsonschema for validation.
  • Type-hint every method.
  • Raise domain-specific exceptions (DuplicateToolError, InvalidSchemaError) rather than generic ones.

Agents / intermediate / Step 1 of 8

Practice stage

Agent architecture patterns

Hint

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.

Success criteria
  • - 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
Review checklist
  • - 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?

retry

No notes

15/100

Reference

Correct answer and review notes

Use the reference solution to compare structure, not just syntax.

Reference solution
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()]
Why this answer works

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.Lock around _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.