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
Toolwrapper. - Symmetric across skills: Every skill's
get_tools()should return tools constructed the same way, so reviewers can pattern-match.
3. Considered Options¶
- Option 1: Tool subclassing. Each tool is
class MyTool(Tool): async def __call__(...): .... - Option 2:
tool_from_function(chosen). Plain async function; wrapper does the rest. - Option 3: Decorator-based (
@toolon the function). - 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:
- The function signature drives the schema.
tool_from_functionintrospects the function's type annotations and docstring; the LLM sees the schema derived from them. - The function is the unit of test.
await skill._lookup_current("London")works in tests — noToolinstantiation, no Pydantic AI runtime needed. - Skill state is captured by closure. Methods on
selfcapture configuration (API keys, provider instances, etc.) without explicit dependency injection —tool_from_functionis happy to wrap a bound method.
4.1. Validation / Compliance¶
- Lint rule (manual review for now): no class in
mirai_shared_skills/subclassesmirai_core.capabilities.tools.Tooldirectly. - Each skill's tests exercise tools as direct method calls, not via a
Toolwrapper. mypy --strictenforces 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
Toolbreaks 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
selfmethods are awkward (selfnot 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(inmirai-agent-core) breaks every shared skill at once. Mitigation:mirai-agent-corekeepstool_from_functionstable 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.
8. References / Related Documents¶
mirai_core.capabilities.tools.tool_from_function— the wrapper.- Every
skill.pyinmirai_shared_skills/(e.g.weather.py,agentic_rag/skill.py) — usage examples. - agent-core ADR-0005: Dynamic External Tooling via MCP — the contract
tool_from_functionis part of.