Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ src/smallestai/waves/helpers/**

# Hand-written README (do not regenerate)
README.md

# Prebuilt tools framework (hand-written)
src/smallestai/tools/**
10 changes: 10 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 5.8.0 - 2026-08-07

* **tools**: new `smallestai.tools` framework for prebuilt, pluggable crew tools. Each tool
wraps a third-party capability with a `@function_tool`-decorated `run`, so it drops
straight into a crew's `ToolRegistry` (`tool.register(self.tool_registry)`) and is also
callable directly. Third-party libraries are optional extras, lazy-imported with a clear
"install the extra" error. Discover with `list_tools()` / `get_tool(name)`.
* **tools**: first integration `ExaSearchTool` (web search). Reads `EXA_API_KEY`; install
with `pip install "smallestai[exa]"`.

## 5.5.0 - 2026-08-05

DevX pass (backward-compatible).
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ dynamic = ["version"]

[tool.poetry]
name = "smallestai"
version = "5.5.0"
version = "5.8.0"
description = ""
readme = "README.md"
authors = []
Expand Down Expand Up @@ -56,6 +56,11 @@ rich = ">=14.2.0"
questionary = ">=2.1.1"
tomli = ">=2.3.0"
tomli-w = ">=1.2.0"
# Optional prebuilt tools (smallestai.tools.*). Installed only via extras.
exa-py = { version = "*", optional = true }

[tool.poetry.extras]
exa = ["exa-py"]

[tool.poetry.scripts]
smallestai = "smallestai.cli.main:main"
Expand Down
60 changes: 60 additions & 0 deletions src/smallestai/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Prebuilt, pluggable tools for crew agents.

Each tool is a small class wrapping a third-party capability (web search, etc.) with a
``@function_tool``-decorated ``run`` method, so it drops straight into a crew's
``ToolRegistry`` and can also be called directly. Third-party libraries are optional
extras, lazy-imported on first use with a clear "install the extra" error.

from smallestai.tools import ExaSearchTool

search = ExaSearchTool() # reads EXA_API_KEY
# inside a crew node:
search.register(self.tool_registry)
# or standalone:
results = await search.run(query="latest news on voice AI")

Discover what's available:

from smallestai.tools import list_tools, get_tool
list_tools() # {"exa_search": ExaSearchTool, ...}
"""
from __future__ import annotations

import importlib
from typing import Dict, Type

from smallestai.tools.base import Tool

# name -> "module:ClassName". Lazy so importing this package never pulls a third-party lib.
_REGISTRY: Dict[str, str] = {
"exa_search": "smallestai.tools.exa:ExaSearchTool",
}


def list_tools() -> Dict[str, Type[Tool]]:
"""Return every available tool as ``{name: class}`` (imports each tool module)."""
out: Dict[str, Type[Tool]] = {}
for name in _REGISTRY:
out[name] = get_tool(name)
return out


def get_tool(name: str) -> Type[Tool]:
"""Return a tool class by registry name (e.g. ``"exa_search"``)."""
try:
path = _REGISTRY[name]
except KeyError as exc:
raise KeyError(f"Unknown tool {name!r}. Available: {sorted(_REGISTRY)}") from exc
module_path, _, class_name = path.partition(":")
module = importlib.import_module(module_path)
return getattr(module, class_name)


def __getattr__(name: str): # PEP 562: expose tool classes lazily at package level
for reg_name, path in _REGISTRY.items():
if path.endswith(":" + name):
return get_tool(reg_name)
raise AttributeError(f"module 'smallestai.tools' has no attribute {name!r}")


__all__ = ["Tool", "list_tools", "get_tool", "ExaSearchTool"]
33 changes: 33 additions & 0 deletions src/smallestai/tools/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Base class for prebuilt crew tools."""
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from smallestai.atoms.crew.tools import ToolRegistry


class Tool:
"""A prebuilt tool wrapping a third-party capability.

Subclasses implement an async ``run`` method decorated with ``@function_tool`` (so the
crew can auto-extract its schema and the LLM can call it), and set ``name`` /
``description``. ``run`` stays directly callable for standalone use.
"""

name: str = ""
description: str = ""

def register(self, registry: "ToolRegistry") -> None:
"""Add this tool's ``run`` to a crew ``ToolRegistry`` so the agent's LLM can call it.

search = ExaSearchTool()
search.register(self.tool_registry)
"""
run = getattr(self, "run", None)
if run is None or not hasattr(run, "__tool_info__"):
raise TypeError(
f"{type(self).__name__}.run must be decorated with @function_tool to be "
"registered with a crew ToolRegistry."
)
registry.register(run)
65 changes: 65 additions & 0 deletions src/smallestai/tools/exa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Exa web-search tool for crew agents.

from smallestai.tools import ExaSearchTool
search = ExaSearchTool() # reads EXA_API_KEY
search.register(self.tool_registry) # inside a crew node

Requires the exa extra: pip install "smallestai[exa]"
"""
from __future__ import annotations

import asyncio
import os
import typing

from smallestai.atoms.crew.tools import function_tool
from smallestai.tools.base import Tool

_INSTALL_HINT = 'pip install "smallestai[exa]"'


class ExaSearchTool(Tool):
name = "exa_search"
description = "Search the web for current information using Exa."

def __init__(self, api_key: typing.Optional[str] = None) -> None:
self._api_key = api_key or os.getenv("EXA_API_KEY")
self._client: typing.Any = None

def _get_client(self) -> typing.Any:
if self._client is not None:
return self._client
try:
from exa_py import Exa # type: ignore[import-not-found]
except ImportError as exc:
raise ImportError(
"ExaSearchTool requires the exa-py package. Install it with:\n " + _INSTALL_HINT
) from exc
if not self._api_key:
raise ValueError("No Exa API key. Pass api_key=... or set the EXA_API_KEY env var.")
self._client = Exa(self._api_key)
return self._client

@function_tool(name="web_search")
async def run(self, query: str, num_results: int = 3) -> str:
"""Search the web for up-to-date information and return the top results.

Args:
query: What to search the web for.
num_results: How many results to return (default 3).
"""
client = self._get_client()
# exa-py is synchronous; run it off the event loop so we don't block the call.
response = await asyncio.to_thread(
client.search_and_contents, query, num_results=num_results, text=True
)
results = getattr(response, "results", None) or []
if not results:
return f"No results found for {query!r}."
lines = []
for r in results:
title = getattr(r, "title", "") or ""
url = getattr(r, "url", "") or ""
text = (getattr(r, "text", "") or "")[:500].strip()
lines.append("\n".join(part for part in (f"- {title}", f" {url}", f" {text}") if part.strip()))
return "\n".join(lines)
60 changes: 60 additions & 0 deletions tests/custom/test_tools_framework.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""smallestai.tools: registry + Exa tool (lazy third-party dep, crew-pluggable)."""
import asyncio

import pytest


def test_registry_lists_and_resolves_exa():
from smallestai.tools import ExaSearchTool, get_tool, list_tools

tools = list_tools()
assert "exa_search" in tools
assert get_tool("exa_search") is ExaSearchTool
assert tools["exa_search"] is ExaSearchTool


def test_unknown_tool_raises_keyerror():
from smallestai.tools import get_tool

with pytest.raises(KeyError):
get_tool("does-not-exist")


def test_tool_plugs_into_crew_registry():
from smallestai.atoms.crew.tools import ToolRegistry
from smallestai.tools import ExaSearchTool

registry = ToolRegistry()
ExaSearchTool(api_key="x").register(registry)
names = {s["function"]["name"] for s in registry.get_schemas()}
assert "web_search" in names


def test_importing_tools_does_not_require_exa_py():
# importing the package + constructing the tool must not need exa-py
import importlib

importlib.import_module("smallestai.tools")
importlib.import_module("smallestai.tools.exa")


def test_exa_run_without_exa_py_raises_clear_error():
from smallestai.tools import ExaSearchTool

try:
import exa_py # noqa: F401

installed = True
except ImportError:
installed = False

if installed:
pytest.skip("exa-py is installed; the missing-dep path can't be exercised here")

with pytest.raises(ImportError) as ei:
asyncio.run(ExaSearchTool(api_key="x").run(query="hello"))
assert 'smallestai[exa]' in str(ei.value)


if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading