Free lesson · GenAI Agent Engineering

Build a Pydantic tool library

You can build a ToolCollection to manage many tools, use AliasPath for flexible field naming, and use Annotated types to attach metadata.

Course: GenAI Agent Engineering · Chapter 20 · The Pydantic Tool

Free to read — no subscription required.

Introduction

When you add multiple AI-callable tools to a system without a shared foundation, each tool ends up reinventing its own validation logic, generating its JSON schema differently, and handling errors in subtly incompatible ways — making the library brittle and hard to extend. After this lesson you will be able to design a structured tool library using a Python abstract base class backed by Pydantic models, so every tool you write automatically inherits consistent schema generation, input validation, and a standardized result envelope without duplicating a single line of boilerplate.

Key Terminology

  • Abstract Base Class (BaseTool) — a class inheriting from Python's ABC that declares name, description, params_model, and execute as abstract members, forcing every concrete tool subclass to supply all four or raise a TypeError at instantiation.
  • params_model — the abstract property each subclass sets to a Pydantic BaseModel subclass, making that model the single source of truth for both JSON schema generation (via get_schema()) and runtime input parsing (via invoke()).
  • invoke() — the concrete dispatch method on BaseTool that hydrates raw parameters through params_model, calls execute(), and wraps every outcome in a uniform result envelope; subclasses inherit it without overriding.
  • Result envelope — the standardized dict {"success": True, "result": ...} or {"success": False, "error": ...} that invoke() always returns, giving AI tool callers a predictable shape regardless of which tool ran or whether it succeeded.
  • model_json_schema() — the Pydantic BaseModel class method called by get_schema() to emit a JSON Schema object derived directly from the model's field declarations, so the schema the AI reads is never hand-authored separately from the validation rules.
  • ValidationError — Pydantic's exception raised when params_model(**raw) encounters fields that violate declared types or constraints (such as the pattern="^(add|subtract|multiply|divide)$" regex on CalculatorParams.operation); invoke() catches it and converts it into a structured error envelope.

Concepts

One Model, Two Jobs

The central design insight of this library is that the Pydantic model attached to each tool does double duty: it is simultaneously the validation contract and the schema descriptor. When you maintain these as two separate artifacts — a hand-authored JSON schema for the AI to read, and separate validation logic in the tool body — they drift. A field constrained in the validator but missing from the schema produces calls the AI thinks are valid but the tool rejects; a constraint loosened in the schema but forgotten in the validator lets bad data through silently.

BaseTool.get_schema() closes that gap by calling params_model.model_json_schema(), which introspects the Pydantic class you already wrote and emits the JSON Schema automatically. Tightening a constraint in one place — say, restricting operation to a regex — immediately propagates to both the schema the AI sees and the parse step that enforces it at runtime. There is no second file to keep in sync (see Code Walkthrough).

The Four-Member Contract and Free Inherited Behavior

BaseTool uses Python's ABC mechanism to enforce a four-member contract at the structural level: name, description, params_model, and execute. Attempting to instantiate a subclass that omits any of them raises a TypeError before a single tool call is made — not in a test, not at runtime, but at object construction. This turns a convention ("every tool should implement these") into a compiler-level guarantee.

The two concrete methods — get_schema() and invoke() — are intentionally not abstract. They are shared behavior the base provides for free: subclasses inherit full schema generation and validated dispatch without touching either method. A new tool is therefore exactly as heavy as its four required members and nothing more; the boilerplate lives once in the base.

The Result Envelope as a Firewall

AI tool callers need a predictable response shape. If one tool raises an unhandled exception, another returns a raw value, and a third returns None on error, the orchestration layer must handle all three cases differently — fragility compounds as the tool count grows.

invoke() acts as a firewall by catching every failure mode and normalizing it to the same envelope:

Loading diagram...

ValidationError is caught when raw input violates the Pydantic model — for example, passing "pow" as operation in CalculatorParams fails the pattern regex before execute is ever called. Any other exception thrown inside execute is caught by the bare except Exception branch and formatted with its type name. Either way the caller receives a dict with a boolean success key and either a result or error — never a Python exception propagating upward.

Code Walkthrough

Now that you've seen One Model, Two Jobs; the Four-Member Contract and Free Inherited Behavior; and the Result Envelope as a Firewall, this walkthrough turns them into working code.

The library is anchored by a BaseTool abstract class. Each subclass must implement four members — name, description, params_model, and execute — while two concrete methods on the base handle the shared concerns: get_schema() derives a JSON schema directly from the Pydantic model, and invoke() validates raw parameters, runs execute(), and wraps every outcome in a uniform {"success": ..., "result/error": ...} envelope.

Code snippetpython
1from abc import ABC, abstractmethod 2from pydantic import BaseModel, ValidationError 3from typing import Type, Any, Dict 4 5class BaseTool(ABC): 6 @property 7 @abstractmethod 8 def name(self) -> str: ... 9 10 @property 11 @abstractmethod 12 def description(self) -> str: ... 13 14 @property 15 @abstractmethod 16 def params_model(self) -> Type[BaseModel]: ... 17 18 @abstractmethod 19 def execute(self, params: BaseModel) -> Any: ... 20 21 def get_schema(self) -> Dict[str, Any]: 22 return { 23 "name": self.name, 24 "description": self.description, 25 "parameters": self.params_model.model_json_schema(), 26 } 27 28 def invoke(self, raw: Dict[str, Any]) -> Dict[str, Any]: 29 try: 30 result = self.execute(self.params_model(**raw)) 31 return {"success": True, "result": result} 32 except ValidationError as e: 33 return {"success": False, "error": str(e)} 34 except Exception as e: 35 return {"success": False, "error": f"{type(e).__name__}: {e}"}

A concrete tool fills in only those four members. CalculatorTool demonstrates the pattern: a Pydantic model constrains operation to the four allowed strings at parse time, so execute can focus entirely on the arithmetic without any defensive checks of its own.

Code snippetpython
1from pydantic import BaseModel, Field 2from typing import Type 3 4class CalculatorParams(BaseModel): 5 operation: str = Field(..., pattern="^(add|subtract|multiply|divide)$", 6 description="Arithmetic operation to perform") 7 a: float = Field(..., description="First operand") 8 b: float = Field(..., description="Second operand") 9 10class CalculatorTool(BaseTool): 11 @property 12 def name(self) -> str: return "calculator" 13 14 @property 15 def description(self) -> str: 16 return "Perform arithmetic operations on two numbers" 17 18 @property 19 def params_model(self) -> Type[BaseModel]: return CalculatorParams 20 21 def execute(self, params: CalculatorParams) -> float: 22 ops = { 23 "add": lambda a, b: a + b, 24 "subtract": lambda a, b: a - b, 25 "multiply": lambda a, b: a * b, 26 "divide": lambda a, b: a / b if b != 0 else float("inf"), 27 } 28 return ops[params.operation](params.a, params.b)

To verify the library is wired correctly, run CalculatorTool().invoke({"operation": "add", "a": 3, "b": 4}) and confirm the output is {"success": True, "result": 7.0}; then pass {"operation": "pow", "a": 2, "b": 3} and confirm success is False with a Pydantic validation message, proving both the schema enforcement and the error envelope function end-to-end.

Do's and Don'ts

Having walked through building a tool library above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do declare name, description, params_model, and execute as abstract members on every BaseTool subclass — the ABC contract guarantees that get_schema() and invoke() on the base always have a valid Pydantic model and a callable execute to delegate to, so no concrete tool can accidentally skip one of those four load-bearing pieces.
  2. Do encode input constraints directly in the Pydantic Field definition (e.g., pattern="^(add|subtract|multiply|divide)$" on CalculatorParams.operation) — this pushes invalid-operation rejection into params_model(**raw) inside invoke(), keeping execute() free of defensive checks and ensuring the same constraint also appears in the auto-generated JSON schema.
  3. Do call tools exclusively through invoke() rather than calling execute() directlyinvoke() is the only path that runs params_model(**raw) validation and wraps both ValidationError and arbitrary runtime exceptions in the uniform {"success": ..., "result/error": ...} envelope; bypassing it strips both the type-safety gate and the standardized error surface.

Don'ts

  1. Don't implement ad-hoc schema generation or per-tool validation logic in individual subclasses — duplicating what get_schema() (via params_model.model_json_schema()) and invoke() already provide creates drift between the schema the AI receives and the constraints actually enforced at runtime, which is exactly the brittleness BaseTool exists to eliminate.
  2. Don't let execute() accept raw Dict input instead of the typed params model — if execute receives an unvalidated dictionary, the Field constraints on the Pydantic model are never exercised, silent type coercion errors surface at arithmetic time rather than at parse time, and the failure is no longer captured by the ValidationError branch in invoke().
  3. Don't catch and swallow exceptions inside execute() before they reach invoke()invoke() relies on uncaught exceptions propagating outward so it can normalize them into {"success": False, "error": "ExceptionType: message"}; handling errors silently inside a concrete tool breaks the uniform result envelope and hides failures from the caller.

This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.

From · cancel anytime

More free lessons in GenAI Agent Engineering

All free lessons in GenAI Agent Engineering