Skip to main content

Command Palette

Search for a command to run...

Building a Tool Registry the Way Agent Frameworks Do: __init_subclass__, Descriptors, and MRO

Updated
5 min read

I'm working through a 16-week Agentic AI syllabus, and rather than just consuming Week 1 (Python fundamentals for agent systems), I built a small framework that mirrors the internal architecture of tools like LangGraph and CrewAI. This is the first post in that series — code is real and pulled directly from the repo, not simplified for the post.

The shape of the problem

Every agent framework needs a discoverable catalog of callable capabilities. A planner (human or LLM) has to be able to ask "what tools exist, and what do they do" without you hand-maintaining a registry dict that inevitably drifts out of sync with your actual classes.

I solved this the way several real frameworks do internally: self-registering subclasses via __init_subclass__.

Registry

class ToolRegistry:
    __slots__ = ()
    _tools: dict[str, type[Any]] = {}

    @classmethod
    def register(cls, name: str, tool_cls: type[Any]) -> None:
        existing = cls._tools.get(name)
        if existing is not None and existing is not tool_cls:
            raise DuplicateToolError(
                f"tool name {name!r} is already registered by {existing.__name__}"
            )
        cls._tools[name] = tool_cls

    @classmethod
    def get(cls, name: str) -> type[Any]:
        try:
            return cls._tools[name]
        except KeyError as error:
            available = ", ".join(cls.names()) or "none"
            raise ToolNotFoundError(
                f"unknown tool {name!r}; available tools: {available}"
            ) from error

A flat dict, intentionally global — agent runtimes need exactly one source of truth that any part of the system can query.

The hook that wires it together

class BaseTool(LoggingMixin, RetryMixin, MetricsMixin, ABC):
    __slots__ = ("_metrics", "config")

    def __init_subclass__(
        cls,
        *,
        tool_name: str | None = None,
        description: str = "",
        streamable: bool = False,
        abstract: bool = False,
        **kwargs: Any,
    ) -> None:
        super().__init_subclass__(**kwargs)
        if abstract:
            return
        if tool_name is None:
            raise TypeError(f"{cls.__name__} must define tool_name='...'")

        cls._tool_name = tool_name.strip().lower()
        cls.description = description.strip()
        cls._streamable = streamable
        ToolRegistry.register(cls._tool_name, cls)

__init_subclass__ runs at class-definition time, not instantiation. The moment class SearchTool(BaseTool, tool_name="search", ...) is parsed, it's already in ToolRegistry. There's no import-order trick or manual call required — just defining the class is the registration event.

Notice the abstract: bool = False escape hatch too — it lets you define intermediate abstract base classes in the hierarchy without forcing them to register as real tools. Small detail, but it's the kind of thing that bites you later if you don't plan for it.

Validated config via descriptors

The other piece worth showing is how ToolConfig validates itself using data descriptors instead of manual if checks in __init__:

class ValidatedField(Generic[T]):
    __slots__ = ("default", "private_name", "public_name")

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        if hasattr(instance, self.private_name):
            return getattr(instance, self.private_name)
        if self.default is not _MISSING:
            return self.default
        raise AttributeError(f"{self.public_name} has not been configured")

    def __set__(self, instance, value):
        setattr(instance, self.private_name, self.validate(value))

    def validate(self, value):
        return value

And a concrete subclass:

class IntegerRange(ValidatedField[int]):
    def __init__(self, min_value: int, max_value: int, default: int) -> None:
        super().__init__(default)
        self.min_value = min_value
        self.max_value = max_value

    def validate(self, value: Any) -> int:
        if isinstance(value, bool) or not isinstance(value, int):
            raise TypeError(f"{self.public_name} must be an integer")
        if not self.min_value <= value <= self.max_value:
            raise ValueError(
                f"{self.public_name} must be between {self.min_value} and {self.max_value}"
            )
        return value

Used like this in ToolConfig:

class ToolConfig:
    __slots__ = ("_retries", "_streaming_enabled", "_timeout", "_tool_name")

    tool_name: str = IdentifierField()
    retries: int = IntegerRange(0, 5, default=1)
    timeout: float = FloatRange(0.1, 120.0, default=10.0)
    streaming_enabled: bool = BooleanField(default=True)

Setting config.retries = 99 raises immediately, at the point of assignment — not three calls later when the retry loop does something nonsensical. This matters more than it sounds like in agent systems, where bad config silently propagating through a long planning loop is a genuinely painful class of bug to trace.

Mixins and MRO

class BaseTool(LoggingMixin, RetryMixin, MetricsMixin, ABC):

Three single-purpose mixins compose into the base class:

  • LoggingMixin — scoped logging via self.name

  • RetryMixinwith_retries() wraps any callable with retry semantics from config.max_attempts

  • MetricsMixinrecord_metric() / self.metrics

Each mixin is __slots__ = () — no state of its own, just behavior — which keeps the MRO clean and avoids the diamond-inheritance footguns that show up when mixins start carrying their own instance state. You can inspect the actual chain at runtime:

@property
def mro_path(self) -> tuple[str, ...]:
    return tuple(cls.__name__ for cls in self.__class__.mro())
$ python main.py describe search
MRO: SearchTool -> BaseTool -> LoggingMixin -> RetryMixin -> MetricsMixin -> ABC -> object

What's next in this series

This is Week 1 of a 16-week syllabus I'm working through (Python → NLP → embeddings → LLM internals → RAG → multi-agent systems → MCP → deployment). Each week gets a build and a writeup. Next up is Week 2 — NLP prep for LLMs — and I'll be extending this same toolkit rather than starting fresh each time.

Repo: https://github.com/Sajid0875/agentic-systems-bootcamp

If you've built something similar inside LangGraph, CrewAI, or PydanticAI internals and want to compare how the registration patterns differ, I'd genuinely like to hear it in the comments.