From ad6eb57c85b13f71cdb3afd0993bb57e8ccb4bc2 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Fri, 7 Aug 2026 02:47:51 +0530 Subject: [PATCH] feat(tools): pluggable prebuilt tools framework + Exa web search (5.8.0) Add smallestai.tools: a small registry of prebuilt tools that plug into a crew's ToolRegistry. Each tool wraps a third-party capability with a @function_tool-decorated run(), so registering is one line and the LLM can call it; run() is also directly callable for standalone use. - smallestai.tools.base.Tool: base with a register(registry) helper. - smallestai.tools.exa.ExaSearchTool: first integration (web search); lazy-imports exa-py and raises a clear "install smallestai[exa]" error otherwise; runs the sync client off the event loop. - list_tools()/get_tool(name) for discovery; third-party libs are optional extras only. Core deps unchanged. Version 5.8.0. --- .fernignore | 3 ++ changelog.md | 10 +++++ pyproject.toml | 7 ++- src/smallestai/tools/__init__.py | 60 +++++++++++++++++++++++++ src/smallestai/tools/base.py | 33 ++++++++++++++ src/smallestai/tools/exa.py | 65 ++++++++++++++++++++++++++++ tests/custom/test_tools_framework.py | 60 +++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/smallestai/tools/__init__.py create mode 100644 src/smallestai/tools/base.py create mode 100644 src/smallestai/tools/exa.py create mode 100644 tests/custom/test_tools_framework.py diff --git a/.fernignore b/.fernignore index 8718b2d8..9c5c1b56 100644 --- a/.fernignore +++ b/.fernignore @@ -62,3 +62,6 @@ src/smallestai/waves/helpers/** # Hand-written README (do not regenerate) README.md + +# Prebuilt tools framework (hand-written) +src/smallestai/tools/** diff --git a/changelog.md b/changelog.md index 0feb8ae3..f0806c2d 100644 --- a/changelog.md +++ b/changelog.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index ae784bdb..2792a25d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ dynamic = ["version"] [tool.poetry] name = "smallestai" -version = "5.5.0" +version = "5.8.0" description = "" readme = "README.md" authors = [] @@ -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" diff --git a/src/smallestai/tools/__init__.py b/src/smallestai/tools/__init__.py new file mode 100644 index 00000000..a4215d5d --- /dev/null +++ b/src/smallestai/tools/__init__.py @@ -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"] diff --git a/src/smallestai/tools/base.py b/src/smallestai/tools/base.py new file mode 100644 index 00000000..82d6edd0 --- /dev/null +++ b/src/smallestai/tools/base.py @@ -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) diff --git a/src/smallestai/tools/exa.py b/src/smallestai/tools/exa.py new file mode 100644 index 00000000..e70d21f8 --- /dev/null +++ b/src/smallestai/tools/exa.py @@ -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) diff --git a/tests/custom/test_tools_framework.py b/tests/custom/test_tools_framework.py new file mode 100644 index 00000000..bb0f8f3d --- /dev/null +++ b/tests/custom/test_tools_framework.py @@ -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"])