Compare commits

...
3 Commits
Author SHA1 Message Date
MervinPraison 8e1fe1d860 Release v0.2.36 2026-08-04 11:41:31 +00:00
praisonai-triage-agent[bot] 0629d18a4e Merge pull request #75 from MervinPraison/claude/issue-74-20260804-0919
feat: add tool discovery/catalogue contract
2026-08-04 11:40:29 +00:00
MervinPraisonandMervin Praison 1950368530 feat: add tool discovery/catalogue contract (fixes #74)
Expose a programmatic catalogue of shipped tools plus a `praisonai.tools`
entry-point discovery contract so validators/CLI/docs/third-party packages
resolve against the live catalogue instead of a hand-maintained allowlist
that drifts.

- praisonai_tools.catalogue: ToolCatalogueEntry, list_tools(), get_tool_names()
- Derive catalogue from the existing _TOOL_MAP source of truth (no optional deps)
- Discover third-party tools via `praisonai.tools` entry points
- Add `praisonai-tools list [--extras]` CLI subcommand
- Document the entry-point group in pyproject.toml
- Tests in tests/test_catalogue.py

Co-authored-by: Mervin Praison <[email protected]>
2026-08-04 09:24:51 +00:00
6 changed files with 294 additions and 9 deletions

No files matched your search

+9
View File
@@ -37,6 +37,11 @@ from importlib.metadata import version, PackageNotFoundError
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
from praisonai_tools.catalogue import (
ToolCatalogueEntry,
list_tools,
get_tool_names,
)
try:
__version__ = version("praisonai-tools")
@@ -64,6 +69,10 @@ __all__ = [
"FunctionTool",
"is_tool",
"get_tool_schema",
# Tool discovery / catalogue
"ToolCatalogueEntry",
"list_tools",
"get_tool_names",
# Email Tool
"EmailTool",
"send_email",
+166
View File
@@ -0,0 +1,166 @@
"""Tool discovery / catalogue contract for PraisonAI Tools.
This module provides a single, programmatic way to enumerate every tool that
``praisonai-tools`` ships, plus any third-party tool registered via the
``praisonai.tools`` entry-point group. It exists so that the wider SDK (CLI,
docs, YAML validators, third-party packages) can answer *"what tools can I
actually use here?"* against a live catalogue derived from the real registry
rather than a hand-maintained allowlist that drifts.
Usage:
from praisonai_tools import list_tools
for entry in list_tools():
print(entry.name, entry.module, entry.extras, entry.summary)
Third-party packages can register their own tools by declaring an entry point:
# pyproject.toml (third-party tool package)
[project.entry-points."praisonai.tools"]
my_tool = "my_pkg:MyTool"
Nothing here imports optional/heavy dependencies: the catalogue is built from
the module map alone, so ``list_tools()`` is safe to call without any extras
installed.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Tuple
ENTRY_POINT_GROUP = "praisonai.tools"
# Map from the module (as recorded in ``_TOOL_MAP``) to the optional-dependency
# extras required to use tools defined in it. Modules absent from this mapping
# have no extra requirements (they rely only on the standard library or on the
# base dependencies). Keep this aligned with ``[project.optional-dependencies]``
# in ``pyproject.toml``.
_MODULE_EXTRAS = {
"wordpress_tool": ("wordpress",),
"pinchwork_tool": ("pinchwork", "marketplace"),
"agentid_tool": ("agentid", "marketplace"),
"joy_trust_tool": ("joy-trust", "marketplace"),
"agentfolio_tool": ("agentfolio", "marketplace"),
"praisonai_tools.n8n.n8n_workflow": ("n8n",),
"langextract_tool": ("langextract",),
"swarmscore_tool": ("swarmscore",),
"composio_tool": ("composio",),
}
@dataclass(frozen=True)
class ToolCatalogueEntry:
"""A single discoverable tool.
Attributes:
name: Public name used to import/reference the tool, e.g. ``"QdrantTool"``.
module: Dotted or relative module path that defines the tool.
extras: Optional-dependency extras required to use the tool, e.g.
``("swarmscore",)``. Empty when no extra install is needed.
summary: A one-line human-readable description of the tool.
"""
name: str
module: str
extras: Tuple[str, ...] = field(default_factory=tuple)
summary: str = ""
def _summary_for(name: str) -> str:
"""Derive a concise one-line summary for a tool class name.
We avoid importing the tool (which could pull heavy/optional deps) and
instead produce a stable, human-friendly summary from the name itself.
"""
if name.endswith("Tool"):
base = name[:-4]
else:
base = name
return f"{base} integration tool".strip()
def _first_party_entries() -> "list[ToolCatalogueEntry]":
"""Build catalogue entries for every first-party tool class.
The source of truth is ``praisonai_tools.tools._TOOL_MAP`` which maps every
public name (both classes and helper functions) to its defining module. We
expose only the tool *classes* (names ending in ``Tool``) in the catalogue,
since those are what ``agents.yaml`` and the SDK reference.
"""
from praisonai_tools.tools import _TOOL_MAP
entries = []
for name, module in sorted(_TOOL_MAP.items()):
if not name.endswith("Tool"):
continue
extras = _MODULE_EXTRAS.get(module, ())
entries.append(
ToolCatalogueEntry(
name=name,
module=module,
extras=extras,
summary=_summary_for(name),
)
)
return entries
def _entry_point_entries() -> "list[ToolCatalogueEntry]":
"""Build catalogue entries for third-party tools registered via entry points.
Third-party packages register under the ``praisonai.tools`` entry-point
group. We do not import the target object (to keep discovery cheap and
dependency-free); we record the declared name and module value instead.
"""
try:
from importlib.metadata import entry_points
except ImportError: # pragma: no cover - Python < 3.8
return []
entries = []
try:
eps = entry_points()
# importlib.metadata API differs across versions.
if hasattr(eps, "select"):
selected = eps.select(group=ENTRY_POINT_GROUP)
else: # pragma: no cover - Python < 3.10 dict interface
selected = eps.get(ENTRY_POINT_GROUP, [])
except Exception: # pragma: no cover - defensive
return []
for ep in selected:
entries.append(
ToolCatalogueEntry(
name=ep.name,
module=getattr(ep, "value", "") or "",
extras=(),
summary=f"Third-party tool registered via {ENTRY_POINT_GROUP}",
)
)
return entries
def list_tools() -> "list[ToolCatalogueEntry]":
"""Return the full catalogue of available tools.
Includes every first-party tool shipped with ``praisonai-tools`` plus any
third-party tool registered via the ``praisonai.tools`` entry-point group.
Entries are de-duplicated by name (first-party wins) and returned sorted by
name.
"""
seen = {}
for entry in _first_party_entries():
seen[entry.name] = entry
for entry in _entry_point_entries():
seen.setdefault(entry.name, entry)
return sorted(seen.values(), key=lambda e: e.name)
def get_tool_names() -> "set[str]":
"""Return the set of every available tool name.
Convenience helper for validators that only need membership checks (e.g. to
replace a hand-maintained allowlist).
"""
return {entry.name for entry in list_tools()}
+18 -1
View File
@@ -11,6 +11,7 @@ def main():
print("PraisonAI Developer Tools CLI")
print("\nUsage: praisonai-tools [tool] [args]")
print("\nAvailable tools:")
print(" list List the available tool catalogue")
print(" docs-generate Generate SDK reference documentation")
print(" video AI-powered video editing tools")
print(" fcp Final Cut Pro automation tools")
@@ -19,7 +20,23 @@ def main():
tool = sys.argv[1]
tool_args = sys.argv[2:]
if tool == "docs-generate":
if tool == "list":
parser = argparse.ArgumentParser(prog="praisonai-tools list")
parser.add_argument("--extras", action="store_true",
help="Only show tools that require optional-dependency extras")
args = parser.parse_args(tool_args)
from .catalogue import list_tools
entries = list_tools()
if args.extras:
entries = [e for e in entries if e.extras]
width = max((len(e.name) for e in entries), default=0)
for entry in entries:
extras = f" [extras: {', '.join(entry.extras)}]" if entry.extras else ""
print(f"{entry.name.ljust(width)} {entry.summary}{extras}")
print(f"\n{len(entries)} tools")
return 0
elif tool == "docs-generate":
# Handle docs-generate with its own parser for better help
parser = argparse.ArgumentParser(prog="praisonai-tools docs-generate")
parser.add_argument("--package", choices=["praisonaiagents", "praisonai", "typescript", "all"],
+13 -7
View File
@@ -19,10 +19,12 @@ Ready-to-use tools:
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
# Lazy imports for tools to avoid loading dependencies until needed
def __getattr__(name):
"""Lazy load tool classes."""
tool_map = {
# Mapping of every public tool name (classes and functions) to the module that
# defines it. This is the single source of truth for lazy loading AND for the
# catalogue/discovery contract (see ``praisonai_tools.catalogue``). Keeping it a
# module-level constant lets other modules enumerate the shipped tools without
# importing any optional dependency.
_TOOL_MAP = {
# Email
"EmailTool": "email_tool",
"send_email": "email_tool",
@@ -559,10 +561,14 @@ def __getattr__(name):
"scan_agent_code": "inkog_tool",
"scan_skill_package": "inkog_tool",
"scan_mcp_server": "inkog_tool",
}
}
if name in tool_map:
module_name = tool_map[name]
# Lazy imports for tools to avoid loading dependencies until needed
def __getattr__(name):
"""Lazy load tool classes."""
if name in _TOOL_MAP:
module_name = _TOOL_MAP[name]
from importlib import import_module
# Handle absolute module paths (e.g., praisonai_tools.n8n.n8n_workflow)
+9 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-tools"
version = "0.2.35"
version = "0.2.36"
description = "Extended tools for PraisonAI Agents"
authors = [
{name = "Mervin Praison"}
@@ -27,6 +27,14 @@ dependencies = [
[project.scripts]
praisonai-tools = "praisonai_tools.cli:main"
# Discovery contract for third-party tool packages. They register agent-callable
# tools under the ``praisonai.tools`` entry-point group so that
# ``pip install some-praisonai-tool`` makes them discoverable via
# ``praisonai_tools.list_tools()`` without editing core, e.g.:
#
# [project.entry-points."praisonai.tools"]
# my_tool = "my_pkg:MyTool"
[project.optional-dependencies]
dev = [
+79
View File
@@ -0,0 +1,79 @@
"""Tests for the tool discovery / catalogue contract."""
def test_list_tools_importable_from_top_level():
from praisonai_tools import list_tools, ToolCatalogueEntry, get_tool_names
assert callable(list_tools)
assert callable(get_tool_names)
assert ToolCatalogueEntry is not None
def test_list_tools_returns_entries():
from praisonai_tools import list_tools
from praisonai_tools.catalogue import ToolCatalogueEntry
entries = list_tools()
assert isinstance(entries, list)
assert len(entries) > 100 # ships a large catalogue
assert all(isinstance(e, ToolCatalogueEntry) for e in entries)
def test_entries_only_expose_tool_classes():
from praisonai_tools import list_tools
for entry in list_tools():
assert entry.name.endswith("Tool"), entry.name
def test_entries_are_sorted_and_unique():
from praisonai_tools import list_tools
names = [e.name for e in list_tools()]
assert names == sorted(names)
assert len(names) == len(set(names))
def test_catalogue_derived_from_tool_map_source_of_truth():
from praisonai_tools import list_tools
from praisonai_tools.tools import _TOOL_MAP
expected = {name for name in _TOOL_MAP if name.endswith("Tool")}
actual = {e.name for e in list_tools()}
# Every class in the map must be catalogued (third-party EPs may add more).
assert expected <= actual
def test_previously_missing_optional_tool_is_now_discoverable():
# QdrantTool was a valid tool absent from the wrapper's hand-maintained
# allowlist; it must now be discoverable via the catalogue.
from praisonai_tools import get_tool_names
names = get_tool_names()
assert "QdrantTool" in names
def test_extras_are_populated_for_optional_tools():
from praisonai_tools import list_tools
by_name = {e.name for e in list_tools()}
entries = {e.name: e for e in list_tools()}
assert "SwarmScoreTool" in by_name
assert "swarmscore" in entries["SwarmScoreTool"].extras
def test_entries_have_summary_and_module():
from praisonai_tools import list_tools
for entry in list_tools():
assert entry.module
assert entry.summary
def test_list_tools_does_not_require_optional_deps():
# Building the catalogue must not import any tool module / optional dep.
from praisonai_tools import list_tools
entries = list_tools()
assert entries # smoke: no ImportError raised