Skip to content

ADR-0007: Functional Tool Construction via tool_from_function

  • Date: 2026-05-06
  • Authors: Matteo Rizzo
  • Status: Accepted
  • Approval State: Approved (Approved by: Matteo Rizzo on 2026-05-06)
  • Implementation State: Completed

1. Context and Problem Statement

Pydantic AI's Tool class hierarchy supports two construction styles: subclassing (class MyTool(Tool): ...) and helper-based (tool = Tool.from_callable(my_func) or, in mirai-agent-core, tool_from_function(my_func)). Each shared skill exposes 1–5 tools to the agent, and we need a consistent way to author them.

Subclassing pulls every tool into the Pydantic AI Tool inheritance tree — that means every tool author needs to know the Tool class's hooks (__init_subclass__, model_config, etc.), every test mock has to satisfy the parent's invariants, and every refactor of the parent class breaks every tool. The helper-based style flips this: a tool is a plain async function with type hints and a docstring, and tool_from_function wraps it into a Tool at registration time.

The choice affects how every shared skill is authored, tested, and reviewed. We want to lock it in.

2. Decision Drivers (Forces)

  • Author ergonomics: A skill author writes ~3 tools per skill; that's the modal authoring activity in this repo. It should be as close to "just write a Python function" as possible.
  • Decoupling from Pydantic AI internals: The tool-author should not need to know Tool's subclass contract.
  • Static checkability: Type hints on the function signature must drive the JSON schema the LLM sees.
  • Testability: Tools should be unit-testable as plain async functions, without instantiating a Tool wrapper.
  • Symmetric across skills: Every skill's get_tools() should return tools constructed the same way, so reviewers can pattern-match.

3. Considered Options

  1. Option 1: Tool subclassing. Each tool is class MyTool(Tool): async def __call__(...): ....
  2. Option 2: tool_from_function (chosen). Plain async function; wrapper does the rest.
  3. Option 3: Decorator-based (@tool on the function).
  4. Option 4: Mixed style — subclass for complex tools, helper for simple ones.

4. Decision Outcome

Chosen option: Option 2 (tool_from_function), used uniformly across every skill in mirai-shared-skills.

The pattern, captured in every skill.py:

from mirai_core.capabilities.tools import Tool, tool_from_function
from mirai_core.core.types import BaseSkill


class WeatherSkill(BaseSkill):
    instructions = "Look up current weather conditions for a location..."

    def get_tools(self) -> list[Tool]:
        return [
            tool_from_function(self._lookup_current),
            tool_from_function(self._lookup_forecast),
        ]

    async def _lookup_current(self, location: str) -> dict[str, Any]:
        """Return current weather for `location` (city name or ISO 3166 code)."""
        ...

Three properties of the pattern:

  1. The function signature drives the schema. tool_from_function introspects the function's type annotations and docstring; the LLM sees the schema derived from them.
  2. The function is the unit of test. await skill._lookup_current("London") works in tests — no Tool instantiation, no Pydantic AI runtime needed.
  3. Skill state is captured by closure. Methods on self capture configuration (API keys, provider instances, etc.) without explicit dependency injection — tool_from_function is happy to wrap a bound method.

4.1. Validation / Compliance

  • Lint rule (manual review for now): no class in mirai_shared_skills/ subclasses mirai_core.capabilities.tools.Tool directly.
  • Each skill's tests exercise tools as direct method calls, not via a Tool wrapper.
  • mypy --strict enforces that every wrapped method has full type annotations.

5. Pros and Cons of the Options

Option 1: Subclassing

  • Pros: Familiar OO pattern.
  • Cons:
  • Tool authors learn the parent class's hooks.
  • Test mocks must satisfy the parent's invariants.
  • Refactoring Tool breaks every tool.

Option 2 (chosen): tool_from_function

  • Pros:
  • Tool author writes a plain async method.
  • Test directly: await skill._method(...).
  • Type hints + docstring → LLM schema.
  • Symmetric across the repo.
  • Cons:
  • Tools needing tool-state (e.g. progress callbacks) have to use the function's parameter list rather than instance attributes.

Option 3: Decorator

  • Pros: Co-locates registration with definition.
  • Cons: Decorators on self methods are awkward (self not bound at decoration time); module-level decoration has the same coupling as Option 1.

Option 4: Mixed

  • Pros: Most flexible.
  • Cons: Reviewer overhead — every skill review starts with "which style is this?" Re-introduces the cost we paid Option 2 to avoid.

6. Consequences

  • Positive Consequences:
  • Adding a tool is "write a method, return it from get_tools()". Two-line change per tool.
  • Skill files stay small and Python-idiomatic.
  • Tests don't need a Pydantic AI runtime fixture.
  • Negative Consequences / Trade-offs:
  • Tool authors who want non-trivial pre/post hooks (e.g. logging the input arguments) have to wrap the method themselves rather than override a parent hook. We accept this; the wrap is a one-liner with functools.wraps.
  • Risks & Mitigations:
  • Risk: A future refactor of tool_from_function (in mirai-agent-core) breaks every shared skill at once. Mitigation: mirai-agent-core keeps tool_from_function stable as part of its public API — agent-core ADR-0005 (Dynamic External Tooling via MCP) treats this helper as part of the tool-construction contract.

7. Implementation Plan & Status Updates

  • Target Milestone/Release: v0.1.0 (current).
  • Implementation Notes:
  • 2026-05-06: ADR formalizes the pattern already used in every skill.py. No code changes.