Compare commits

...
7 Commits
Author SHA1 Message Date
Mervin Praison bc23d3a104 Release v2.3.9 2025-12-16 08:18:09 +00:00
Mervin Praison bb4e1d8b9e Release v2.3.8 2025-12-16 07:55:55 +00:00
Mervin Praison 69740baa7c Release v2.3.7 2025-12-16 07:46:40 +00:00
Mervin Praison 5035978a29 fix: resolve Mintlify parsing errors
- Fix corrupted/duplicated database service in docker-compose.dev.yml
- Fix MDX parsing error in tests/README.md (<1s -> under 1s)
2025-12-15 23:11:04 +00:00
Mervin Praison 4ddb043324 Release v2.3.7 2025-12-15 22:49:12 +00:00
Mervin Praison 337014f4db planning example 2025-12-15 22:25:09 +00:00
Mervin Praison 35c371b25e update 2025-12-15 22:22:42 +00:00
34 changed files with 5241 additions and 41 deletions

No files matched your search

+97 -1
View File
@@ -60,6 +60,7 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
- 🔧 **Planning Tools** (Research with tools during planning)
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
@@ -414,6 +415,101 @@ praisonai research --query-rewrite --tools tools.py --save "Your research query"
praisonai research -v "Your research query"
```
### Planning Mode CLI:
```bash
# Enable planning mode - agent creates a plan before execution
praisonai "Research AI trends and write a summary" --planning
# Planning with tools for research
praisonai "Analyze market trends" --planning --planning-tools tools.py
# Planning with chain-of-thought reasoning
praisonai "Complex analysis task" --planning --planning-reasoning
# Auto-approve plans without confirmation
praisonai "Task" --planning --auto-approve-plan
```
### Memory CLI:
```bash
# Enable memory for agent (persists across sessions)
praisonai "My name is John" --memory
# Memory with user isolation
praisonai "Remember my preferences" --memory --user-id user123
# Memory management commands
praisonai memory show # Show memory statistics
praisonai memory add "User prefers Python" # Add to long-term memory
praisonai memory search "Python" # Search memories
praisonai memory clear # Clear short-term memory
praisonai memory clear all # Clear all memory
praisonai memory save my_session # Save session
praisonai memory resume my_session # Resume session
praisonai memory sessions # List saved sessions
praisonai memory checkpoint # Create checkpoint
praisonai memory restore <checkpoint_id> # Restore checkpoint
praisonai memory checkpoints # List checkpoints
praisonai memory help # Show all commands
```
### Rules CLI:
```bash
# List all loaded rules (from PRAISON.md, CLAUDE.md, etc.)
praisonai rules list
# Show specific rule details
praisonai rules show <rule_name>
# Create a new rule
praisonai rules create my_rule "Always use type hints"
# Delete a rule
praisonai rules delete my_rule
# Show rules statistics
praisonai rules stats
# Include manual rules with prompts
praisonai "Task" --include-rules security,testing
```
### Workflow CLI:
```bash
# List available workflows
praisonai workflow list
# Execute a workflow
praisonai workflow run deploy
# Execute with variables
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
# Show workflow details
praisonai workflow show deploy
# Create a new workflow template
praisonai workflow create my_workflow
```
### Hooks CLI:
```bash
# List configured hooks
praisonai hooks list
# Show hooks statistics
praisonai hooks stats
# Create hooks.json template
praisonai hooks init
```
### Claude Memory Tool CLI:
```bash
# Enable Claude Memory Tool (Anthropic models only)
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
```
## Using JavaScript Code
```bash
@@ -1045,7 +1141,7 @@ cd src/praisonai && uv publish
- 🎨 Interactive UIs
- 📄 YAML-based Configuration
- 🛠️ Custom Tool Integration
- 🔍 Internet Search Capability (using Crawl4AI and Tavily)
- 🔍 Internet Search Capability (Tavily, You.com, Exa, DuckDuckGo, Crawl4AI)
- 🖼️ Vision Language Model (VLM) Support
- 🎙️ Real-time Voice Interaction
+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.3.6" \
"praisonai>=2.3.9" \
"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.3.6" \
"praisonai>=2.3.9" \
"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.3.6" \
"praisonai>=2.3.9" \
"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.3.6" \
"praisonai>=2.3.9" \
"praisonai[ui]" \
"praisonai[crewai]"
+4 -15
View File
@@ -137,22 +137,11 @@ services:
POSTGRES_USER: praisonai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
volumes:
database:
image: postgres:15-alpine
environment:
POSTGRES_DB: praisonai_dev
POSTGRES_USER: praisonai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
volumes:
- postgres_data:/var/lib/postgresql/data
read_only: true
tmpfs:
- /tmp
- /var/tmp
ports:
- "5432:5432"
networks:
- postgres_data:/var/lib/postgresql/data
read_only: true
tmpfs:
- /tmp
- /var/tmp
ports:
- "5432:5432"
networks:
+1
View File
@@ -86,3 +86,4 @@ build
graph.py
chroma_db/
.qodo
*_COMPARISON_ANALYSIS.md
+27
View File
@@ -657,6 +657,8 @@ agent = Agent(tools=[my_tool])
Connect to MCP servers for extended capabilities.
### MCP Client (Consume MCP Servers)
```python
from praisonaiagents import Agent, MCP
@@ -683,6 +685,31 @@ agent = Agent(
)
```
### MCP Server (Expose Tools as MCP Server)
Expose your Python functions as MCP tools for external clients (Claude Desktop, Cursor, etc.).
```python
from praisonaiagents.mcp import ToolsMCPServer
# Define your tools as regular Python functions
def search_web(query: str, max_results: int = 5) -> dict:
"""Search the web for information."""
return {"results": [f"Result for {query}"]}
def calculate(expression: str) -> dict:
"""Evaluate a mathematical expression."""
return {"result": eval(expression)}
# Create and run MCP server
server = ToolsMCPServer(name="my-tools")
server.register_tools([search_web, calculate])
server.run() # Starts stdio server (for Claude Desktop)
# Or run as SSE server for web clients
# server.run_sse(host="0.0.0.0", port=8080)
```
---
## Native Web Search
@@ -0,0 +1,144 @@
"""Example: Expose custom tools as MCP server.
This example demonstrates how to create an MCP server that exposes
custom Python functions as MCP tools. These tools can then be used
by any MCP client (Claude Desktop, Cursor, etc.).
Usage:
# Run as stdio server (for Claude Desktop, etc.)
python mcp_server_example.py
# Run as SSE server (for web clients)
python mcp_server_example.py --sse --port 8080
"""
import argparse
from typing import List, Dict, Any
# Define your custom tools as regular Python functions
# Type hints and docstrings are used to generate MCP schemas
def search_web(query: str, max_results: int = 5) -> Dict[str, Any]:
"""Search the web for information.
Args:
query: The search query string
max_results: Maximum number of results to return (default: 5)
Returns:
Search results with titles and URLs
"""
# This is a mock implementation - replace with actual search logic
return {
"query": query,
"results": [
{"title": f"Result {i+1} for '{query}'", "url": f"https://example.com/{i+1}"}
for i in range(max_results)
]
}
def calculate(expression: str) -> Dict[str, Any]:
"""Evaluate a mathematical expression.
Args:
expression: A mathematical expression to evaluate (e.g., "2 + 2 * 3")
Returns:
The result of the calculation
"""
try:
# Safe evaluation of mathematical expressions
# In production, use a proper math parser
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Note: Use safer alternative in production
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
def get_weather(city: str, units: str = "celsius") -> Dict[str, Any]:
"""Get current weather for a city.
Args:
city: Name of the city
units: Temperature units - "celsius" or "fahrenheit" (default: celsius)
Returns:
Weather information for the city
"""
# Mock implementation - replace with actual weather API
return {
"city": city,
"temperature": 22 if units == "celsius" else 72,
"units": units,
"condition": "Sunny",
"humidity": 45
}
def list_files(directory: str = ".", pattern: str = "*") -> List[str]:
"""List files in a directory.
Args:
directory: Directory path to list (default: current directory)
pattern: Glob pattern to filter files (default: *)
Returns:
List of file names matching the pattern
"""
import glob
import os
try:
full_pattern = os.path.join(directory, pattern)
files = glob.glob(full_pattern)
return [os.path.basename(f) for f in files]
except Exception as e:
return [f"Error: {str(e)}"]
def main():
parser = argparse.ArgumentParser(description="Run PraisonAI Tools as MCP Server")
parser.add_argument("--sse", action="store_true", help="Use SSE transport instead of stdio")
parser.add_argument("--port", type=int, default=8080, help="Port for SSE server (default: 8080)")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host for SSE server")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
# Import MCP server components
from praisonaiagents.mcp import ToolsMCPServer
# Create server with custom name
server = ToolsMCPServer(
name="praisonai-custom-tools",
debug=args.debug
)
# Register all tools
server.register_tools([
search_web,
calculate,
get_weather,
list_files
])
# Print registered tools
print(f"📦 Registered {len(server.tools)} tools:")
for name in server.get_tool_names():
print(f" - {name}")
print()
# Run the server
if args.sse:
server.run_sse(host=args.host, port=args.port)
else:
server.run_stdio()
if __name__ == "__main__":
main()
@@ -1,9 +1,40 @@
"""
Model Context Protocol (MCP) integration for PraisonAI Agents.
This package provides classes and utilities for connecting to MCP servers
using different transport methods (stdio, SSE, etc.).
This package provides classes and utilities for:
- Connecting to MCP servers (as client) using different transports (stdio, SSE, etc.)
- Exposing Python functions as MCP servers for external clients
Client Usage:
from praisonaiagents.mcp import MCP
agent = Agent(
tools=MCP("npx -y @modelcontextprotocol/server-weather")
)
Server Usage:
from praisonaiagents.mcp import ToolsMCPServer
def my_tool(query: str) -> str:
'''Search for information.'''
return f"Results for {query}"
server = ToolsMCPServer(name="my-tools")
server.register_tool(my_tool)
server.run() # Starts MCP server
"""
from .mcp import MCP
from .mcp_server import ToolsMCPServer, launch_tools_mcp_server
from .mcp_utils import function_to_mcp_schema, get_tool_metadata, python_type_to_json_schema
__all__ = ["MCP"]
__all__ = [
# Client
"MCP",
# Server
"ToolsMCPServer",
"launch_tools_mcp_server",
# Utilities
"function_to_mcp_schema",
"get_tool_metadata",
"python_type_to_json_schema",
]
@@ -0,0 +1,356 @@
"""MCP Server wrapper for exposing PraisonAI tools as MCP servers.
This module provides a simple way to expose Python functions as MCP tools
that can be consumed by any MCP client (Claude Desktop, Cursor, etc.).
Usage:
from praisonaiagents.mcp import ToolsMCPServer
def search(query: str) -> str:
'''Search the web.'''
return f"Results for {query}"
server = ToolsMCPServer(name="my-tools")
server.register_tool(search)
server.run() # Starts MCP server (stdio by default)
"""
import asyncio
import inspect
import logging
from typing import Any, Callable, Dict, List, Optional
from .mcp_utils import function_to_mcp_schema
logger = logging.getLogger(__name__)
class ToolsMCPServer:
"""MCP Server that exposes Python functions as MCP tools.
This class wraps Python functions and exposes them as MCP tools
using the FastMCP library. It supports both sync and async functions.
Example:
server = ToolsMCPServer(name="my-tools")
def search(query: str) -> str:
'''Search the web.'''
return f"Results for {query}"
server.register_tool(search)
server.run() # Starts stdio server
"""
def __init__(
self,
name: str = "praisonai-tools",
tools: Optional[List[Callable]] = None,
debug: bool = False
):
"""Initialize the MCP server.
Args:
name: Name of the MCP server
tools: Optional list of tools to register on init
debug: Enable debug logging
"""
self.name = name
self.tools: List[Callable] = []
self._tool_map: Dict[str, Callable] = {}
self._fastmcp = None
self._debug = debug
if debug:
logger.setLevel(logging.DEBUG)
# Register initial tools if provided
if tools:
self.register_tools(tools)
def register_tool(self, func: Callable) -> None:
"""Register a single tool function.
Args:
func: The function to register as an MCP tool
"""
if not callable(func):
raise TypeError(f"Expected callable, got {type(func)}")
self.tools.append(func)
tool_name = getattr(func, '__mcp_name__', None) or func.__name__
self._tool_map[tool_name] = func
logger.debug(f"Registered tool: {tool_name}")
def register_tools(self, funcs: List[Callable]) -> None:
"""Register multiple tool functions.
Args:
funcs: List of functions to register as MCP tools
"""
for func in funcs:
self.register_tool(func)
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Get MCP schemas for all registered tools.
Returns:
List of MCP tool schema dictionaries
"""
return [function_to_mcp_schema(tool) for tool in self.tools]
def get_tool_names(self) -> List[str]:
"""Get names of all registered tools.
Returns:
List of tool names
"""
return list(self._tool_map.keys())
def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Execute a registered tool synchronously.
Args:
tool_name: Name of the tool to execute
arguments: Arguments to pass to the tool
Returns:
Result from the tool execution
Raises:
ValueError: If tool is not found
"""
if tool_name not in self._tool_map:
raise ValueError(f"Tool '{tool_name}' not found")
func = self._tool_map[tool_name]
# Handle async functions
if asyncio.iscoroutinefunction(func):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(func(**arguments))
finally:
loop.close()
return func(**arguments)
async def execute_tool_async(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Execute a registered tool asynchronously.
Args:
tool_name: Name of the tool to execute
arguments: Arguments to pass to the tool
Returns:
Result from the tool execution
Raises:
ValueError: If tool is not found
"""
if tool_name not in self._tool_map:
raise ValueError(f"Tool '{tool_name}' not found")
func = self._tool_map[tool_name]
if asyncio.iscoroutinefunction(func):
return await func(**arguments)
# Run sync function in executor
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: func(**arguments))
def get_fastmcp(self):
"""Get or create FastMCP instance with registered tools.
Returns:
FastMCP instance with all tools registered
"""
if self._fastmcp is None:
self._fastmcp = self._create_fastmcp()
return self._fastmcp
def _create_fastmcp(self):
"""Create FastMCP instance and register all tools.
Returns:
Configured FastMCP instance
"""
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
raise ImportError(
"FastMCP not available. Install with: pip install mcp"
)
mcp = FastMCP(self.name)
# Register each tool with FastMCP
for func in self.tools:
self._register_tool_with_fastmcp(mcp, func)
return mcp
def _register_tool_with_fastmcp(self, mcp, func: Callable) -> None:
"""Register a single function with FastMCP.
Args:
mcp: FastMCP instance
func: Function to register
"""
# Get tool metadata
tool_name = getattr(func, '__mcp_name__', None) or func.__name__
description = getattr(func, '__mcp_description__', None)
if description is None:
docstring = inspect.getdoc(func)
description = docstring.split('\n')[0] if docstring else tool_name
# Create wrapper that preserves signature
if asyncio.iscoroutinefunction(func):
@mcp.tool(name=tool_name, description=description)
async def tool_wrapper(**kwargs):
return await func(**kwargs)
else:
@mcp.tool(name=tool_name, description=description)
def tool_wrapper(**kwargs):
return func(**kwargs)
# Copy signature for proper schema generation
tool_wrapper.__signature__ = inspect.signature(func)
tool_wrapper.__annotations__ = getattr(func, '__annotations__', {})
def run(self, transport: str = "stdio") -> None:
"""Run the MCP server.
Args:
transport: Transport type - "stdio" or "sse"
"""
if transport == "stdio":
self.run_stdio()
elif transport == "sse":
self.run_sse()
else:
raise ValueError(f"Unknown transport: {transport}. Use 'stdio' or 'sse'")
def run_stdio(self) -> None:
"""Run the MCP server using stdio transport."""
mcp = self.get_fastmcp()
print(f"🚀 Starting MCP server '{self.name}' with stdio transport")
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
mcp.run()
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
"""Run the MCP server using SSE transport.
Args:
host: Host to bind to
port: Port to listen on
"""
try:
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.routing import Mount, Route
except ImportError as e:
raise ImportError(
f"SSE dependencies not available: {e}. "
"Install with: pip install uvicorn starlette"
)
mcp = self.get_fastmcp()
# Set up SSE transport
sse_path = "/sse"
messages_path = "/messages/"
sse_transport = SseServerTransport(messages_path)
async def handle_sse(request: Request):
async with sse_transport.connect_sse(
request.scope, request.receive, request._send
) as (read_stream, write_stream):
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options()
)
app = Starlette(
debug=self._debug,
routes=[
Route(sse_path, endpoint=handle_sse),
Mount(messages_path, app=sse_transport.handle_post_message),
]
)
print(f"🚀 Starting MCP server '{self.name}' with SSE transport")
print(f"📡 SSE endpoint: http://{host}:{port}{sse_path}")
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
uvicorn.run(app, host=host, port=port)
def launch_tools_mcp_server(
tools: Optional[List[Callable]] = None,
tool_names: Optional[List[str]] = None,
name: str = "praisonai-tools",
transport: str = "stdio",
host: str = "0.0.0.0",
port: int = 8080,
debug: bool = False
) -> None:
"""Convenience function to launch an MCP server with tools.
Args:
tools: List of tool functions to expose
tool_names: List of tool names to load from praisonaiagents.tools
name: Name of the MCP server
transport: Transport type - "stdio" or "sse"
host: Host for SSE transport
port: Port for SSE transport
debug: Enable debug logging
Example:
# With custom tools
def my_search(query: str) -> str:
return f"Results for {query}"
launch_tools_mcp_server(tools=[my_search])
# With built-in tools
launch_tools_mcp_server(tool_names=["tavily_search", "exa_search"])
"""
server = ToolsMCPServer(name=name, debug=debug)
# Register custom tools
if tools:
server.register_tools(tools)
# Load and register tools by name
if tool_names:
from praisonaiagents import tools as praison_tools
for tool_name in tool_names:
if hasattr(praison_tools, tool_name):
tool_func = getattr(praison_tools, tool_name)
if callable(tool_func):
server.register_tool(tool_func)
else:
logger.warning(f"Tool '{tool_name}' is not callable")
else:
logger.warning(f"Tool '{tool_name}' not found in praisonaiagents.tools")
if not server.tools:
raise ValueError("No tools registered. Provide 'tools' or 'tool_names'.")
# Run the server
if transport == "stdio":
server.run_stdio()
elif transport == "sse":
server.run_sse(host=host, port=port)
else:
raise ValueError(f"Unknown transport: {transport}")
@@ -0,0 +1,238 @@
"""MCP utility functions for converting Python functions to MCP tool schemas.
This module provides utilities to convert Python functions with type hints
into MCP-compatible tool schemas for exposing as MCP server tools.
Usage:
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def my_tool(query: str, max_results: int = 10) -> str:
'''Search for information.'''
return "results"
schema = function_to_mcp_schema(my_tool)
# Returns MCP-compatible tool schema
"""
import inspect
from typing import Any, Callable, Dict, List, Union, get_type_hints, get_origin, get_args
def python_type_to_json_schema(python_type: Any) -> Dict[str, Any]:
"""Convert a Python type annotation to JSON Schema.
Args:
python_type: A Python type annotation (e.g., str, int, List[str], Optional[int])
Returns:
JSON Schema dictionary representing the type
"""
# Handle None type
if python_type is None or python_type is type(None):
return {"type": "null"}
# Handle Any type
if python_type is Any:
return {}
# Handle basic types
type_mapping = {
str: {"type": "string"},
int: {"type": "integer"},
float: {"type": "number"},
bool: {"type": "boolean"},
bytes: {"type": "string"},
}
if python_type in type_mapping:
return type_mapping[python_type]
# Handle generic types (List, Dict, Optional, Union)
origin = get_origin(python_type)
args = get_args(python_type)
# Handle List[T]
if origin is list:
if args:
return {
"type": "array",
"items": python_type_to_json_schema(args[0])
}
return {"type": "array", "items": {}}
# Handle Dict[K, V]
if origin is dict:
return {"type": "object"}
# Handle Optional[T] (Union[T, None])
if origin is Union:
# Filter out None type
non_none_args = [arg for arg in args if arg is not type(None)]
if len(non_none_args) == 1:
# Optional[T] case
return python_type_to_json_schema(non_none_args[0])
elif len(non_none_args) > 1:
# Union of multiple types
return {"oneOf": [python_type_to_json_schema(arg) for arg in non_none_args]}
return {}
# Handle tuple
if origin is tuple:
if args:
return {
"type": "array",
"items": [python_type_to_json_schema(arg) for arg in args]
}
return {"type": "array"}
# Handle set
if origin is set:
if args:
return {
"type": "array",
"items": python_type_to_json_schema(args[0]),
"uniqueItems": True
}
return {"type": "array", "uniqueItems": True}
# Default to object for unknown types
return {"type": "object"}
def get_tool_metadata(func: Callable) -> Dict[str, str]:
"""Extract metadata from a function for MCP tool registration.
Checks for custom attributes (__mcp_name__, __mcp_description__) first,
then falls back to function name and docstring.
Args:
func: The function to extract metadata from
Returns:
Dictionary with 'name' and 'description' keys
"""
# Get name - check for custom attribute first
name = getattr(func, '__mcp_name__', None) or func.__name__
# Get description - check for custom attribute first
description = getattr(func, '__mcp_description__', None)
if description is None:
# Extract from docstring
docstring = inspect.getdoc(func)
if docstring:
# Use first line of docstring as description
description = docstring.split('\n')[0].strip()
else:
# Fall back to function name
description = name
return {
"name": name,
"description": description
}
def function_to_mcp_schema(func: Callable) -> Dict[str, Any]:
"""Convert a Python function to an MCP tool schema.
This function inspects the function's signature, type hints, and docstring
to generate a complete MCP-compatible tool schema.
Args:
func: The function to convert
Returns:
MCP tool schema dictionary with:
- name: Tool name
- description: Tool description
- inputSchema: JSON Schema for parameters
Example:
def search(query: str, max_results: int = 10) -> str:
'''Search the web for information.'''
return "results"
schema = function_to_mcp_schema(search)
# {
# "name": "search",
# "description": "Search the web for information.",
# "inputSchema": {
# "type": "object",
# "properties": {
# "query": {"type": "string"},
# "max_results": {"type": "integer"}
# },
# "required": ["query"]
# }
# }
"""
# Get metadata
metadata = get_tool_metadata(func)
# Get function signature
sig = inspect.signature(func)
# Try to get type hints
try:
type_hints = get_type_hints(func)
except Exception:
type_hints = {}
# Build properties and required list
properties = {}
required = []
for param_name, param in sig.parameters.items():
# Skip self, cls, and special parameters
if param_name in ('self', 'cls'):
continue
# Skip *args and **kwargs
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
# Get type annotation
param_type = type_hints.get(param_name, param.annotation)
if param_type is inspect.Parameter.empty:
# No type annotation - default to string
properties[param_name] = {"type": "string"}
else:
properties[param_name] = python_type_to_json_schema(param_type)
# Check if parameter is required (no default value)
if param.default is inspect.Parameter.empty:
# Check if it's Optional type
origin = get_origin(param_type) if param_type is not inspect.Parameter.empty else None
args = get_args(param_type) if param_type is not inspect.Parameter.empty else ()
is_optional = origin is Union and type(None) in args
if not is_optional:
required.append(param_name)
# Build the schema
schema = {
"name": metadata["name"],
"description": metadata["description"],
"inputSchema": {
"type": "object",
"properties": properties,
"required": required
}
}
return schema
def functions_to_mcp_tools(functions: List[Callable]) -> List[Dict[str, Any]]:
"""Convert multiple functions to MCP tool schemas.
Args:
functions: List of functions to convert
Returns:
List of MCP tool schemas
"""
return [function_to_mcp_schema(func) for func in functions]
@@ -176,6 +176,48 @@ TOOL_MAPPINGS = {
# Claude Memory Tool (Anthropic Beta)
'ClaudeMemoryTool': ('.claude_memory_tool', 'ClaudeMemoryTool'),
# Tavily Tools (requires TAVILY_API_KEY)
'tavily': ('.tavily_tools', None),
'tavily_search': ('.tavily_tools', None),
'tavily_extract': ('.tavily_tools', None),
'tavily_crawl': ('.tavily_tools', None),
'tavily_map': ('.tavily_tools', None),
'tavily_search_async': ('.tavily_tools', None),
'tavily_extract_async': ('.tavily_tools', None),
'TavilyTools': ('.tavily_tools', 'TavilyTools'),
'tavily_tools': ('.tavily_tools', None),
# You.com Tools (requires YDC_API_KEY)
'ydc': ('.youdotcom_tools', None),
'ydc_search': ('.youdotcom_tools', None),
'ydc_contents': ('.youdotcom_tools', None),
'ydc_news': ('.youdotcom_tools', None),
'ydc_images': ('.youdotcom_tools', None),
'YouTools': ('.youdotcom_tools', 'YouTools'),
'youdotcom_tools': ('.youdotcom_tools', None),
# Exa Tools (requires EXA_API_KEY)
'exa': ('.exa_tools', None),
'exa_search': ('.exa_tools', None),
'exa_search_contents': ('.exa_tools', None),
'exa_find_similar': ('.exa_tools', None),
'exa_answer': ('.exa_tools', None),
'exa_search_async': ('.exa_tools', None),
'exa_search_contents_async': ('.exa_tools', None),
'exa_answer_async': ('.exa_tools', None),
'ExaTools': ('.exa_tools', 'ExaTools'),
'exa_tools': ('.exa_tools', None),
# Crawl4AI Tools (async web crawling)
'crawl4ai': ('.crawl4ai_tools', None),
'crawl4ai_many': ('.crawl4ai_tools', None),
'crawl4ai_extract': ('.crawl4ai_tools', None),
'crawl4ai_llm_extract': ('.crawl4ai_tools', None),
'crawl4ai_sync': ('.crawl4ai_tools', None),
'crawl4ai_extract_sync': ('.crawl4ai_tools', None),
'Crawl4AITools': ('.crawl4ai_tools', 'Crawl4AITools'),
'crawl4ai_tools': ('.crawl4ai_tools', None),
}
_instances = {} # Cache for class instances
@@ -187,8 +229,8 @@ def __getattr__(name: str) -> Any:
module_path, class_name = TOOL_MAPPINGS[name]
# Return class itself (not instance) for ClaudeMemoryTool
if name == 'ClaudeMemoryTool':
# Return class itself (not instance) for ClaudeMemoryTool, TavilyTools, YouTools, ExaTools, Crawl4AITools
if name in ('ClaudeMemoryTool', 'TavilyTools', 'YouTools', 'ExaTools', 'Crawl4AITools'):
module = import_module(module_path, __package__)
return getattr(module, class_name)
@@ -205,11 +247,18 @@ def __getattr__(name: str) -> Any:
'insert_document', 'insert_documents', 'find_documents', 'update_document', 'delete_document',
'create_vector_index', 'vector_search', 'store_with_embedding', 'text_search', 'get_stats', 'connect_mongodb',
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics', 'calculate_financial'
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics', 'calculate_financial',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
'exa', 'exa_search', 'exa_search_contents', 'exa_find_similar', 'exa_answer',
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async',
'crawl4ai', 'crawl4ai_many', 'crawl4ai_extract', 'crawl4ai_llm_extract',
'crawl4ai_sync', 'crawl4ai_extract_sync'
]:
return getattr(module, name)
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools']:
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -0,0 +1,630 @@
"""Crawl4AI web crawling and extraction tools.
Crawl4AI provides async web crawling with JavaScript rendering, content extraction,
and LLM-based data extraction capabilities.
Usage:
from praisonaiagents.tools import crawl4ai, crawl4ai_extract
# Simple crawl
result = await crawl4ai("https://example.com")
print(result["markdown"])
# Extract structured data
result = await crawl4ai_extract(
"https://example.com/products",
schema={"name": "Products", "baseSelector": ".product", ...}
)
# Or use the class directly
from praisonaiagents.tools import Crawl4AITools
tools = Crawl4AITools()
result = await tools.crawl("https://example.com")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import asyncio
from importlib import util
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
"""Check if Crawl4AI is available.
Returns:
Tuple of (is_available, error_message)
"""
if util.find_spec("crawl4ai") is None:
return False, "crawl4ai package is not installed. Install it with: pip install crawl4ai && crawl4ai-setup"
return True, None
class Crawl4AITools:
"""Crawl4AI web crawling and extraction tools.
Provides methods for:
- crawl: Basic web crawling with markdown output
- crawl_many: Crawl multiple URLs concurrently
- extract_css: Extract data using CSS selectors
- extract_llm: Extract data using LLM
Example:
from praisonaiagents.tools import Crawl4AITools
tools = Crawl4AITools()
result = await tools.crawl("https://example.com")
print(result["markdown"])
"""
def __init__(
self,
headless: bool = True,
verbose: bool = False,
browser_type: str = "chromium"
):
"""Initialize Crawl4AITools.
Args:
headless: Run browser in headless mode (default True)
verbose: Enable verbose logging (default False)
browser_type: Browser type - "chromium", "firefox", or "webkit"
"""
self.headless = headless
self.verbose = verbose
self.browser_type = browser_type
self.logger = logging.getLogger(__name__)
self._crawler = None
async def _get_crawler(self):
"""Get or create AsyncWebCrawler instance."""
is_available, error = _check_crawl4ai_available()
if not is_available:
raise ImportError(error)
from crawl4ai import AsyncWebCrawler, BrowserConfig
if self._crawler is None:
browser_config = BrowserConfig(
headless=self.headless,
verbose=self.verbose,
browser_type=self.browser_type
)
self._crawler = AsyncWebCrawler(config=browser_config)
await self._crawler.start()
return self._crawler
async def close(self):
"""Close the crawler and release resources."""
if self._crawler is not None:
await self._crawler.close()
self._crawler = None
async def crawl(
self,
url: str,
word_count_threshold: int = 10,
css_selector: Optional[str] = None,
excluded_tags: Optional[List[str]] = None,
exclude_external_links: bool = False,
js_code: Optional[Union[str, List[str]]] = None,
wait_for: Optional[str] = None,
screenshot: bool = False,
pdf: bool = False,
bypass_cache: bool = True
) -> Dict[str, Any]:
"""Crawl a single URL and return content.
Args:
url: The URL to crawl
word_count_threshold: Minimum words per text block (default 10)
css_selector: CSS selector to focus on specific content
excluded_tags: HTML tags to exclude (e.g., ["nav", "footer"])
exclude_external_links: Remove external links from content
js_code: JavaScript code to execute before crawling
wait_for: Wait condition - "css:selector" or "js:() => boolean"
screenshot: Capture screenshot (base64)
pdf: Generate PDF
bypass_cache: Skip cache and fetch fresh content (default True)
Returns:
Dict with 'markdown', 'html', 'cleaned_html', 'links', 'media', etc.
"""
try:
crawler = await self._get_crawler()
from crawl4ai import CrawlerRunConfig, CacheMode
config = CrawlerRunConfig(
word_count_threshold=word_count_threshold,
css_selector=css_selector,
excluded_tags=excluded_tags or [],
exclude_external_links=exclude_external_links,
js_code=js_code,
wait_for=wait_for,
screenshot=screenshot,
pdf=pdf,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
)
result = await crawler.arun(url=url, config=config)
if not result.success:
return {"error": f"Crawl failed: {result.error_message}", "success": False}
response = {
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html,
"cleaned_html": result.cleaned_html,
"links": result.links,
"media": result.media,
"metadata": result.metadata
}
if hasattr(result.markdown, 'fit_markdown') and result.markdown.fit_markdown:
response["fit_markdown"] = result.markdown.fit_markdown
if screenshot and result.screenshot:
response["screenshot"] = result.screenshot
if pdf and result.pdf:
response["pdf"] = result.pdf
return response
except Exception as e:
self.logger.error(f"Crawl4AI crawl error: {e}")
return {"error": f"Crawl4AI crawl error: {str(e)}", "success": False}
async def crawl_many(
self,
urls: List[str],
word_count_threshold: int = 10,
css_selector: Optional[str] = None,
bypass_cache: bool = True,
stream: bool = False
) -> List[Dict[str, Any]]:
"""Crawl multiple URLs concurrently.
Args:
urls: List of URLs to crawl
word_count_threshold: Minimum words per text block
css_selector: CSS selector to focus on specific content
bypass_cache: Skip cache and fetch fresh content
stream: If True, returns async generator
Returns:
List of crawl results
"""
try:
crawler = await self._get_crawler()
from crawl4ai import CrawlerRunConfig, CacheMode
config = CrawlerRunConfig(
word_count_threshold=word_count_threshold,
css_selector=css_selector,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED,
stream=stream
)
results = []
if stream:
async for result in await crawler.arun_many(urls, config=config):
if result.success:
results.append({
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
})
else:
results.append({
"success": False,
"url": result.url,
"error": result.error_message
})
else:
raw_results = await crawler.arun_many(urls, config=config)
for result in raw_results:
if result.success:
results.append({
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
})
else:
results.append({
"success": False,
"url": result.url,
"error": result.error_message
})
return results
except Exception as e:
self.logger.error(f"Crawl4AI crawl_many error: {e}")
return [{"error": f"Crawl4AI crawl_many error: {str(e)}", "success": False}]
async def extract_css(
self,
url: str,
schema: Dict[str, Any],
js_code: Optional[Union[str, List[str]]] = None,
wait_for: Optional[str] = None,
bypass_cache: bool = True
) -> Dict[str, Any]:
"""Extract structured data using CSS selectors.
Args:
url: The URL to crawl (or "raw://<html>" for raw HTML)
schema: Extraction schema with baseSelector and fields
js_code: JavaScript to execute before extraction
wait_for: Wait condition before extraction
bypass_cache: Skip cache
Returns:
Dict with 'data' containing extracted items
Example schema:
{
"name": "Products",
"baseSelector": "div.product",
"fields": [
{"name": "title", "selector": "h2", "type": "text"},
{"name": "price", "selector": ".price", "type": "text"},
{"name": "link", "selector": "a", "type": "attribute", "attribute": "href"}
]
}
"""
try:
crawler = await self._get_crawler()
from crawl4ai import CrawlerRunConfig, CacheMode
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
extraction_strategy = JsonCssExtractionStrategy(schema, verbose=self.verbose)
config = CrawlerRunConfig(
extraction_strategy=extraction_strategy,
js_code=js_code,
wait_for=wait_for,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
)
result = await crawler.arun(url=url, config=config)
if not result.success:
return {"error": f"Extraction failed: {result.error_message}", "success": False}
import json
try:
data = json.loads(result.extracted_content) if result.extracted_content else []
except json.JSONDecodeError:
data = result.extracted_content
return {
"success": True,
"url": result.url,
"data": data,
"count": len(data) if isinstance(data, list) else 1
}
except Exception as e:
self.logger.error(f"Crawl4AI extract_css error: {e}")
return {"error": f"Crawl4AI extract_css error: {str(e)}", "success": False}
async def extract_llm(
self,
url: str,
instruction: str,
schema: Optional[Dict[str, Any]] = None,
provider: str = "openai/gpt-4o-mini",
api_token: Optional[str] = None,
js_code: Optional[Union[str, List[str]]] = None,
wait_for: Optional[str] = None,
bypass_cache: bool = True
) -> Dict[str, Any]:
"""Extract structured data using LLM.
Args:
url: The URL to crawl
instruction: Instructions for the LLM on what to extract
schema: Optional Pydantic-style JSON schema for structured output
provider: LLM provider string (e.g., "openai/gpt-4o-mini", "ollama/llama3.3")
api_token: API token for the provider (uses env var if not provided)
js_code: JavaScript to execute before extraction
wait_for: Wait condition before extraction
bypass_cache: Skip cache
Returns:
Dict with 'data' containing extracted content
"""
try:
crawler = await self._get_crawler()
import os
from crawl4ai import CrawlerRunConfig, CacheMode, LLMConfig
from crawl4ai.extraction_strategy import LLMExtractionStrategy
# Get API token from env if not provided
if api_token is None and "openai" in provider.lower():
api_token = os.environ.get("OPENAI_API_KEY")
llm_config = LLMConfig(provider=provider, api_token=api_token)
extraction_strategy = LLMExtractionStrategy(
llm_config=llm_config,
schema=schema,
extraction_type="schema" if schema else "block",
instruction=instruction,
verbose=self.verbose
)
config = CrawlerRunConfig(
extraction_strategy=extraction_strategy,
js_code=js_code,
wait_for=wait_for,
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
)
result = await crawler.arun(url=url, config=config)
if not result.success:
return {"error": f"LLM extraction failed: {result.error_message}", "success": False}
import json
try:
data = json.loads(result.extracted_content) if result.extracted_content else None
except json.JSONDecodeError:
data = result.extracted_content
return {
"success": True,
"url": result.url,
"data": data
}
except Exception as e:
self.logger.error(f"Crawl4AI extract_llm error: {e}")
return {"error": f"Crawl4AI extract_llm error: {str(e)}", "success": False}
# Standalone async functions for easy access
async def crawl4ai(
url: str,
css_selector: Optional[str] = None,
js_code: Optional[str] = None,
wait_for: Optional[str] = None,
screenshot: bool = False
) -> Dict[str, Any]:
"""Crawl a URL and return markdown content.
Args:
url: The URL to crawl
css_selector: CSS selector to focus on specific content
js_code: JavaScript to execute before crawling
wait_for: Wait condition - "css:selector" or "js:() => boolean"
screenshot: Capture screenshot
Returns:
Dict with 'markdown', 'html', 'links', 'media', etc.
Example:
result = await crawl4ai("https://example.com")
print(result["markdown"])
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return {"error": error, "success": False}
tools = Crawl4AITools()
try:
result = await tools.crawl(
url=url,
css_selector=css_selector,
js_code=js_code,
wait_for=wait_for,
screenshot=screenshot
)
return result
finally:
await tools.close()
async def crawl4ai_many(
urls: List[str],
css_selector: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Crawl multiple URLs concurrently.
Args:
urls: List of URLs to crawl
css_selector: CSS selector to focus on specific content
Returns:
List of crawl results
Example:
results = await crawl4ai_many(["https://example.com", "https://example.org"])
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return [{"error": error, "success": False}]
tools = Crawl4AITools()
try:
return await tools.crawl_many(urls=urls, css_selector=css_selector)
finally:
await tools.close()
async def crawl4ai_extract(
url: str,
schema: Dict[str, Any],
js_code: Optional[str] = None,
wait_for: Optional[str] = None
) -> Dict[str, Any]:
"""Extract structured data using CSS selectors.
Args:
url: The URL to crawl
schema: Extraction schema with baseSelector and fields
js_code: JavaScript to execute before extraction
wait_for: Wait condition before extraction
Returns:
Dict with 'data' containing extracted items
Example:
schema = {
"name": "Products",
"baseSelector": "div.product",
"fields": [
{"name": "title", "selector": "h2", "type": "text"},
{"name": "price", "selector": ".price", "type": "text"}
]
}
result = await crawl4ai_extract("https://example.com/products", schema)
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return {"error": error, "success": False}
tools = Crawl4AITools()
try:
return await tools.extract_css(
url=url,
schema=schema,
js_code=js_code,
wait_for=wait_for
)
finally:
await tools.close()
async def crawl4ai_llm_extract(
url: str,
instruction: str,
schema: Optional[Dict[str, Any]] = None,
provider: str = "openai/gpt-4o-mini"
) -> Dict[str, Any]:
"""Extract data using LLM.
Args:
url: The URL to crawl
instruction: Instructions for the LLM
schema: Optional JSON schema for structured output
provider: LLM provider (default "openai/gpt-4o-mini")
Returns:
Dict with 'data' containing extracted content
Example:
result = await crawl4ai_llm_extract(
"https://example.com/about",
instruction="Extract company name, founding year, and description"
)
"""
is_available, error = _check_crawl4ai_available()
if not is_available:
logging.error(error)
return {"error": error, "success": False}
tools = Crawl4AITools()
try:
return await tools.extract_llm(
url=url,
instruction=instruction,
schema=schema,
provider=provider
)
finally:
await tools.close()
# Synchronous wrappers for convenience
def crawl4ai_sync(
url: str,
css_selector: Optional[str] = None,
js_code: Optional[str] = None,
wait_for: Optional[str] = None
) -> Dict[str, Any]:
"""Synchronous version of crawl4ai.
Args:
url: The URL to crawl
css_selector: CSS selector to focus on specific content
js_code: JavaScript to execute
wait_for: Wait condition
Returns:
Dict with crawl results
"""
return asyncio.get_event_loop().run_until_complete(
crawl4ai(url, css_selector, js_code, wait_for)
)
def crawl4ai_extract_sync(
url: str,
schema: Dict[str, Any],
js_code: Optional[str] = None
) -> Dict[str, Any]:
"""Synchronous version of crawl4ai_extract.
Args:
url: The URL to crawl
schema: Extraction schema
js_code: JavaScript to execute
Returns:
Dict with extracted data
"""
return asyncio.get_event_loop().run_until_complete(
crawl4ai_extract(url, schema, js_code)
)
if __name__ == "__main__":
# Example usage
async def main():
print("=" * 60)
print("Crawl4AI Tools - Example Usage")
print("=" * 60)
is_available, error = _check_crawl4ai_available()
if not is_available:
print(f"\nCrawl4AI is not available: {error}")
print("\nTo use Crawl4AI tools:")
print("1. Install the package: pip install crawl4ai")
print("2. Run setup: crawl4ai-setup")
else:
print("Crawl4AI is available!")
# Example crawl
print("\n1. Basic Crawl")
print("-" * 30)
result = await crawl4ai("https://example.com")
if result.get("success"):
print(f"URL: {result['url']}")
print(f"Markdown length: {len(result.get('markdown', ''))}")
print(f"Links found: {len(result.get('links', {}).get('internal', []))}")
else:
print(f"Error: {result.get('error')}")
print("\n" + "=" * 60)
print("Demonstration Complete")
print("=" * 60)
asyncio.run(main())
@@ -0,0 +1,741 @@
"""Exa search and research tools.
Exa provides AI-powered web search, content retrieval, and research capabilities.
This module requires the EXA_API_KEY environment variable to be set.
Usage:
from praisonaiagents.tools import exa_search, exa_search_contents
# Search the web
results = exa_search("AI startups")
# Search with contents
results = exa_search_contents("AI in healthcare", text=True)
# Or use the class directly
from praisonaiagents.tools import ExaTools
exa = ExaTools()
results = exa.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
def _check_exa_available() -> tuple[bool, Optional[str]]:
"""Check if Exa is available and API key is set.
Returns:
Tuple of (is_available, error_message)
"""
# Check if exa_py package is installed
if util.find_spec("exa_py") is None:
return False, "exa_py package is not installed. Install it with: pip install exa_py"
# Check if API key is set
if not os.environ.get("EXA_API_KEY"):
return False, "EXA_API_KEY environment variable is not set. Please set it to use Exa tools."
return True, None
class ExaTools:
"""Exa search and research tools.
Provides methods for:
- search: Basic web search
- search_and_contents: Search with full text/highlights
- find_similar: Find similar pages to a URL
- find_similar_and_contents: Find similar with content
- answer: Get AI-generated answers with citations
Example:
from praisonaiagents.tools import ExaTools
exa = ExaTools()
results = exa.search("AI startups", num_results=5)
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize ExaTools.
Args:
api_key: Exa API key. If not provided, uses EXA_API_KEY env var.
"""
self.api_key = api_key or os.environ.get("EXA_API_KEY")
self._client = None
self.logger = logging.getLogger(__name__)
def _get_client(self):
"""Get or create Exa client."""
if self._client is None:
is_available, error = _check_exa_available()
if not is_available:
raise ImportError(error)
from exa_py import Exa
self._client = Exa(self.api_key)
return self._client
def search(
self,
query: str,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
include_text: Optional[List[str]] = None,
exclude_text: Optional[List[str]] = None,
additional_queries: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Perform an Exa search.
Args:
query: The search query
num_results: Number of results to return (default 10, max 100)
type: Search type - "auto", "neural", "fast", or "deep"
category: Data category - company, research paper, news, linkedin profile,
github, tweet, movie, song, personal site, pdf, financial report
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
include_text: Strings that must be present in results
exclude_text: Strings that must not be present in results
additional_queries: Additional query variations for deep search
Returns:
Dict with 'results' list and optional 'autopromptString'
"""
try:
client = self._get_client()
kwargs = {
"query": query,
"num_results": num_results
}
if type:
kwargs["type"] = type
if category:
kwargs["category"] = category
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if include_text:
kwargs["include_text"] = include_text
if exclude_text:
kwargs["exclude_text"] = exclude_text
if additional_queries:
kwargs["additional_queries"] = additional_queries
response = client.search(**kwargs)
return {
"results": [
{
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
for r in response.results
],
"autopromptString": getattr(response, "autoprompt_string", None),
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa search error: {e}")
return {"error": f"Exa search error: {str(e)}"}
def search_and_contents(
self,
query: str,
text: Union[bool, Dict] = True,
highlights: Union[bool, Dict] = False,
summary: Optional[Dict] = None,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
include_text: Optional[List[str]] = None,
exclude_text: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Search and retrieve content from results.
Args:
query: The search query
text: Include full text content (True or TextContentsOptions dict)
highlights: Include highlights (True or HighlightsContentsOptions dict)
summary: Summary options with optional schema for structured output
num_results: Number of results to return
type: Search type - "auto", "neural", "fast", or "deep"
category: Data category filter
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
include_text: Strings that must be present in results
exclude_text: Strings that must not be present in results
Returns:
Dict with 'results' list containing text/highlights
"""
try:
client = self._get_client()
kwargs = {
"query": query,
"num_results": num_results
}
if text:
kwargs["text"] = text
if highlights:
kwargs["highlights"] = highlights
if summary:
kwargs["summary"] = summary
if type:
kwargs["type"] = type
if category:
kwargs["category"] = category
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if include_text:
kwargs["include_text"] = include_text
if exclude_text:
kwargs["exclude_text"] = exclude_text
response = client.search_and_contents(**kwargs)
results = []
for r in response.results:
result = {
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
if hasattr(r, "text") and r.text:
result["text"] = r.text
if hasattr(r, "highlights") and r.highlights:
result["highlights"] = r.highlights
if hasattr(r, "highlight_scores") and r.highlight_scores:
result["highlight_scores"] = r.highlight_scores
if hasattr(r, "summary") and r.summary:
result["summary"] = r.summary
results.append(result)
return {
"results": results,
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa search_and_contents error: {e}")
return {"error": f"Exa search_and_contents error: {str(e)}"}
def find_similar(
self,
url: str,
num_results: int = 10,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
exclude_source_domain: bool = False,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Find similar pages to a given URL.
Args:
url: The URL to find similar pages for
num_results: Number of results to return
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
exclude_source_domain: Exclude results from the same domain
category: Data category filter
Returns:
Dict with 'results' list of similar pages
"""
try:
client = self._get_client()
kwargs = {
"url": url,
"num_results": num_results
}
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if exclude_source_domain:
kwargs["exclude_source_domain"] = exclude_source_domain
if category:
kwargs["category"] = category
response = client.find_similar(**kwargs)
return {
"results": [
{
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
for r in response.results
],
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa find_similar error: {e}")
return {"error": f"Exa find_similar error: {str(e)}"}
def find_similar_and_contents(
self,
url: str,
text: Union[bool, Dict] = True,
highlights: Union[bool, Dict] = False,
num_results: int = 10,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_crawl_date: Optional[str] = None,
end_crawl_date: Optional[str] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None,
exclude_source_domain: bool = False,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Find similar pages with content.
Args:
url: The URL to find similar pages for
text: Include full text content
highlights: Include highlights
num_results: Number of results to return
include_domains: List of domains to include
exclude_domains: List of domains to exclude
start_crawl_date: Only include links crawled after this date
end_crawl_date: Only include links crawled before this date
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
exclude_source_domain: Exclude results from the same domain
category: Data category filter
Returns:
Dict with 'results' list containing text/highlights
"""
try:
client = self._get_client()
kwargs = {
"url": url,
"num_results": num_results
}
if text:
kwargs["text"] = text
if highlights:
kwargs["highlights"] = highlights
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if start_crawl_date:
kwargs["start_crawl_date"] = start_crawl_date
if end_crawl_date:
kwargs["end_crawl_date"] = end_crawl_date
if start_published_date:
kwargs["start_published_date"] = start_published_date
if end_published_date:
kwargs["end_published_date"] = end_published_date
if exclude_source_domain:
kwargs["exclude_source_domain"] = exclude_source_domain
if category:
kwargs["category"] = category
response = client.find_similar_and_contents(**kwargs)
results = []
for r in response.results:
result = {
"url": r.url,
"id": r.id,
"title": getattr(r, "title", None),
"published_date": getattr(r, "published_date", None),
"author": getattr(r, "author", None)
}
if hasattr(r, "text") and r.text:
result["text"] = r.text
if hasattr(r, "highlights") and r.highlights:
result["highlights"] = r.highlights
if hasattr(r, "highlight_scores") and r.highlight_scores:
result["highlight_scores"] = r.highlight_scores
results.append(result)
return {
"results": results,
"requestId": getattr(response, "request_id", None)
}
except Exception as e:
self.logger.error(f"Exa find_similar_and_contents error: {e}")
return {"error": f"Exa find_similar_and_contents error: {str(e)}"}
def answer(
self,
query: str,
text: bool = False
) -> Dict[str, Any]:
"""Generate an answer using Exa's search and LLM capabilities.
Args:
query: The question to answer
text: If True, include full text of citations
Returns:
Dict with 'answer' and 'citations'
"""
try:
client = self._get_client()
response = client.answer(query=query, text=text)
citations = []
for c in response.citations:
citation = {
"id": c.id,
"url": c.url,
"title": getattr(c, "title", None),
"published_date": getattr(c, "published_date", None),
"author": getattr(c, "author", None)
}
if text and hasattr(c, "text") and c.text:
citation["text"] = c.text
citations.append(citation)
return {
"answer": response.answer,
"citations": citations
}
except Exception as e:
self.logger.error(f"Exa answer error: {e}")
return {"error": f"Exa answer error: {str(e)}"}
# Standalone functions for easy access
def exa_search(
query: str,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
start_published_date: Optional[str] = None,
end_published_date: Optional[str] = None
) -> Dict[str, Any]:
"""Search the web using Exa.
Args:
query: The search query
num_results: Number of results (default 10)
type: Search type - "auto", "neural", "fast", or "deep"
category: Data category filter
include_domains: Domains to include
exclude_domains: Domains to exclude
start_published_date: Only include links published after this date
end_published_date: Only include links published before this date
Returns:
Dict with search results or error
Example:
results = exa_search("AI startups", num_results=5)
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.search(
query=query,
num_results=num_results,
type=type,
category=category,
include_domains=include_domains,
exclude_domains=exclude_domains,
start_published_date=start_published_date,
end_published_date=end_published_date
)
def exa_search_contents(
query: str,
text: bool = True,
highlights: bool = False,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Search and get content from results.
Args:
query: The search query
text: Include full text content
highlights: Include highlights
num_results: Number of results
type: Search type
category: Data category filter
include_domains: Domains to include
exclude_domains: Domains to exclude
Returns:
Dict with results containing text/highlights
Example:
results = exa_search_contents("AI in healthcare", text=True, highlights=True)
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.search_and_contents(
query=query,
text=text,
highlights=highlights,
num_results=num_results,
type=type,
category=category,
include_domains=include_domains,
exclude_domains=exclude_domains
)
def exa_find_similar(
url: str,
num_results: int = 10,
exclude_source_domain: bool = True,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Find pages similar to a given URL.
Args:
url: The URL to find similar pages for
num_results: Number of results
exclude_source_domain: Exclude same domain results
category: Data category filter
Returns:
Dict with similar results
Example:
results = exa_find_similar("https://openai.com", num_results=5)
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.find_similar(
url=url,
num_results=num_results,
exclude_source_domain=exclude_source_domain,
category=category
)
def exa_answer(
query: str,
text: bool = False
) -> Dict[str, Any]:
"""Get an AI-generated answer with citations.
Args:
query: The question to answer
text: Include full text of citations
Returns:
Dict with 'answer' and 'citations'
Example:
result = exa_answer("What is the capital of France?")
print(result["answer"])
"""
is_available, error = _check_exa_available()
if not is_available:
logging.error(error)
return {"error": error}
tools = ExaTools()
return tools.answer(query=query, text=text)
# Async versions
async def exa_search_async(
query: str,
num_results: int = 10,
type: Optional[str] = None,
category: Optional[str] = None
) -> Dict[str, Any]:
"""Async version of exa_search.
Args:
query: The search query
num_results: Number of results
type: Search type
category: Data category filter
Returns:
Dict with search results
"""
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None,
lambda: exa_search(query, num_results, type, category)
)
async def exa_search_contents_async(
query: str,
text: bool = True,
highlights: bool = False,
num_results: int = 10
) -> Dict[str, Any]:
"""Async version of exa_search_contents.
Args:
query: The search query
text: Include full text
highlights: Include highlights
num_results: Number of results
Returns:
Dict with results containing content
"""
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None,
lambda: exa_search_contents(query, text, highlights, num_results)
)
async def exa_answer_async(
query: str,
text: bool = False
) -> Dict[str, Any]:
"""Async version of exa_answer.
Args:
query: The question to answer
text: Include citation text
Returns:
Dict with answer and citations
"""
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None,
lambda: exa_answer(query, text)
)
# Alias for simple usage
exa = exa_search
if __name__ == "__main__":
# Example usage
print("=" * 60)
print("Exa Tools - Example Usage")
print("=" * 60)
is_available, error = _check_exa_available()
if not is_available:
print(f"\nExa is not available: {error}")
print("\nTo use Exa tools:")
print("1. Install the package: pip install exa_py")
print("2. Set environment variable: export EXA_API_KEY=your_api_key")
else:
print("Exa SDK is available!")
# Example search
print("\n1. Basic Search")
print("-" * 30)
results = exa_search("Latest AI developments 2024", num_results=3)
if "error" not in results:
print(f"Found {len(results.get('results', []))} results")
for r in results.get("results", [])[:3]:
print(f" - {r.get('title', 'No title')[:50]}...")
print(f" URL: {r.get('url', 'No URL')}")
else:
print(f"Error: {results['error']}")
print("\n" + "=" * 60)
print("Demonstration Complete")
print("=" * 60)
@@ -0,0 +1,591 @@
"""Tavily search and extraction tools.
Tavily provides AI-powered web search, content extraction, and crawling capabilities.
This module requires the TAVILY_API_KEY environment variable to be set.
Usage:
from praisonaiagents.tools import tavily_search, tavily_extract
# Search the web
results = tavily_search("AI news 2024")
# Extract content from URLs
content = tavily_extract("https://example.com")
# Or use the class directly
from praisonaiagents.tools import TavilyTools
tavily = TavilyTools()
results = tavily.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
def _check_tavily_available() -> tuple[bool, Optional[str]]:
"""Check if Tavily is available and API key is set.
Returns:
Tuple of (is_available, error_message)
"""
# Check if tavily package is installed
if util.find_spec("tavily") is None:
return False, "Tavily package is not installed. Please install it using: pip install tavily-python"
# Check if API key is set
api_key = os.environ.get("TAVILY_API_KEY")
if not api_key:
return False, "TAVILY_API_KEY environment variable is not set. Please set it to use Tavily tools."
return True, None
class TavilyTools:
"""Comprehensive tools for web search and content extraction using Tavily API.
Tavily provides AI-powered search capabilities optimized for LLM applications.
Features:
- Web search with advanced filtering and ranking
- Content extraction from URLs
- Website crawling with intelligent navigation
- Site mapping
Requires:
- tavily-python package: pip install tavily-python
- TAVILY_API_KEY environment variable
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize TavilyTools.
Args:
api_key: Optional API key. If not provided, uses TAVILY_API_KEY env var.
"""
self._api_key = api_key
self._client = None
self._async_client = None
def _get_api_key(self) -> Optional[str]:
"""Get API key from instance or environment."""
return self._api_key or os.environ.get("TAVILY_API_KEY")
def _get_client(self):
"""Get or create synchronous Tavily client."""
if self._client is None:
is_available, error = _check_tavily_available()
if not is_available:
return None, error
from tavily import TavilyClient
api_key = self._get_api_key()
if not api_key:
return None, "TAVILY_API_KEY not set"
self._client = TavilyClient(api_key=api_key)
return self._client, None
def _get_async_client(self):
"""Get or create asynchronous Tavily client."""
if self._async_client is None:
is_available, error = _check_tavily_available()
if not is_available:
return None, error
from tavily import AsyncTavilyClient
api_key = self._get_api_key()
if not api_key:
return None, "TAVILY_API_KEY not set"
self._async_client = AsyncTavilyClient(api_key=api_key)
return self._async_client, None
def search(
self,
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using Tavily's AI-powered search.
Args:
query: The search query
search_depth: "basic" or "advanced" (advanced uses more credits but better results)
topic: "general", "news", or "finance"
max_results: Maximum number of results (1-20)
include_answer: Include LLM-generated answer based on results
include_raw_content: Include cleaned HTML content of each result
include_images: Include query-related images
include_domains: List of domains to include (max 300)
exclude_domains: List of domains to exclude (max 150)
time_range: Filter by time - "day", "week", "month", "year" or "d", "w", "m", "y"
Returns:
Dict containing search results with keys:
- results: List of search results with title, url, content, score
- query: The search query
- response_time: Time taken for search
- answer: (optional) LLM-generated answer if include_answer=True
- images: (optional) List of image URLs if include_images=True
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"query": query,
"search_depth": search_depth,
"topic": topic,
"max_results": max_results,
"include_answer": include_answer,
"include_raw_content": include_raw_content,
"include_images": include_images,
}
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if time_range:
kwargs["time_range"] = time_range
response = client.search(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def extract(
self,
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Extract content from one or more URLs.
Args:
urls: Single URL or list of URLs (max 20)
include_images: Include extracted images
extract_depth: "basic" or "advanced" (advanced retrieves more data)
Returns:
Dict containing:
- results: List of successful extractions with url, raw_content
- failed_results: List of URLs that failed with error messages
- response_time: Time taken for extraction
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
response = client.extract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
return response
except Exception as e:
error_msg = f"Tavily extract error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def crawl(
self,
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Crawl a website starting from a base URL.
Args:
url: The root URL to begin crawling
max_depth: Maximum depth from base URL (default: 1)
max_breadth: Max links to follow per page (default: 20)
limit: Total links to process before stopping (default: 50)
instructions: Natural language instructions for the crawler
include_images: Extract image URLs from pages
extract_depth: "basic" or "advanced"
Returns:
Dict containing:
- base_url: The starting URL
- results: List of crawled pages with url, raw_content, images
- response_time: Time taken for crawl
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"url": url,
"max_depth": max_depth,
"max_breadth": max_breadth,
"limit": limit,
"include_images": include_images,
"extract_depth": extract_depth,
}
if instructions:
kwargs["instructions"] = instructions
response = client.crawl(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily crawl error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def map(
self,
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a sitemap starting from a base URL.
Args:
url: The root URL to begin mapping
max_depth: Maximum depth from base URL (default: 1)
max_breadth: Max links to follow per page (default: 20)
limit: Total links to process before stopping (default: 50)
instructions: Natural language instructions for the mapper
Returns:
Dict containing:
- base_url: The starting URL
- results: List of discovered URLs
- response_time: Time taken for mapping
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"url": url,
"max_depth": max_depth,
"max_breadth": max_breadth,
"limit": limit,
}
if instructions:
kwargs["instructions"] = instructions
response = client.map(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily map error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
# Async methods
async def asearch(
self,
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Async version of search. See search() for documentation."""
client, error = self._get_async_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {
"query": query,
"search_depth": search_depth,
"topic": topic,
"max_results": max_results,
"include_answer": include_answer,
"include_raw_content": include_raw_content,
"include_images": include_images,
}
if include_domains:
kwargs["include_domains"] = include_domains
if exclude_domains:
kwargs["exclude_domains"] = exclude_domains
if time_range:
kwargs["time_range"] = time_range
response = await client.search(**kwargs)
return response
except Exception as e:
error_msg = f"Tavily async search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
async def aextract(
self,
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Async version of extract. See extract() for documentation."""
client, error = self._get_async_client()
if error:
logging.error(error)
return {"error": error}
try:
response = await client.extract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
return response
except Exception as e:
error_msg = f"Tavily async extract error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
# Standalone functions for direct import
def tavily_search(
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using Tavily's AI-powered search.
This is a convenience function that creates a TavilyTools instance.
For repeated calls, consider using TavilyTools class directly.
Args:
query: The search query
search_depth: "basic" or "advanced"
topic: "general", "news", or "finance"
max_results: Maximum number of results (1-20)
include_answer: Include LLM-generated answer
include_raw_content: Include cleaned HTML content
include_images: Include query-related images
include_domains: List of domains to include
exclude_domains: List of domains to exclude
time_range: Filter by time - "day", "week", "month", "year"
Returns:
Dict containing search results
"""
tools = TavilyTools()
return tools.search(
query=query,
search_depth=search_depth,
topic=topic,
max_results=max_results,
include_answer=include_answer,
include_raw_content=include_raw_content,
include_images=include_images,
include_domains=include_domains,
exclude_domains=exclude_domains,
time_range=time_range,
)
def tavily_extract(
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Extract content from one or more URLs using Tavily.
Args:
urls: Single URL or list of URLs (max 20)
include_images: Include extracted images
extract_depth: "basic" or "advanced"
Returns:
Dict containing extraction results
"""
tools = TavilyTools()
return tools.extract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
def tavily_crawl(
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Crawl a website starting from a base URL using Tavily.
Args:
url: The root URL to begin crawling
max_depth: Maximum depth from base URL
max_breadth: Max links to follow per page
limit: Total links to process before stopping
instructions: Natural language instructions for the crawler
include_images: Extract image URLs from pages
extract_depth: "basic" or "advanced"
Returns:
Dict containing crawl results
"""
tools = TavilyTools()
return tools.crawl(
url=url,
max_depth=max_depth,
max_breadth=max_breadth,
limit=limit,
instructions=instructions,
include_images=include_images,
extract_depth=extract_depth,
)
def tavily_map(
url: str,
max_depth: int = 1,
max_breadth: int = 20,
limit: int = 50,
instructions: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a sitemap starting from a base URL using Tavily.
Args:
url: The root URL to begin mapping
max_depth: Maximum depth from base URL
max_breadth: Max links to follow per page
limit: Total links to process before stopping
instructions: Natural language instructions for the mapper
Returns:
Dict containing map results with discovered URLs
"""
tools = TavilyTools()
return tools.map(
url=url,
max_depth=max_depth,
max_breadth=max_breadth,
limit=limit,
instructions=instructions,
)
# Async standalone functions
async def tavily_search_async(
query: str,
search_depth: str = "basic",
topic: str = "general",
max_results: int = 5,
include_answer: bool = False,
include_raw_content: bool = False,
include_images: bool = False,
include_domains: Optional[List[str]] = None,
exclude_domains: Optional[List[str]] = None,
time_range: Optional[str] = None,
) -> Dict[str, Any]:
"""Async version of tavily_search. See tavily_search() for documentation."""
tools = TavilyTools()
return await tools.asearch(
query=query,
search_depth=search_depth,
topic=topic,
max_results=max_results,
include_answer=include_answer,
include_raw_content=include_raw_content,
include_images=include_images,
include_domains=include_domains,
exclude_domains=exclude_domains,
time_range=time_range,
)
async def tavily_extract_async(
urls: Union[str, List[str]],
include_images: bool = False,
extract_depth: str = "basic",
) -> Dict[str, Any]:
"""Async version of tavily_extract. See tavily_extract() for documentation."""
tools = TavilyTools()
return await tools.aextract(
urls=urls,
include_images=include_images,
extract_depth=extract_depth,
)
if __name__ == "__main__":
# Example usage
print("\n==================================================")
print("Tavily Tools Demonstration")
print("==================================================\n")
# Check if API key is available
is_available, error = _check_tavily_available()
if not is_available:
print(f"Error: {error}")
print("\nTo use Tavily tools:")
print("1. Install: pip install tavily-python")
print("2. Set environment variable: export TAVILY_API_KEY=your_api_key")
else:
print("Tavily is available!")
# Example search
print("\n1. Basic Search")
print("-" * 30)
results = tavily_search("Latest AI developments 2024", max_results=3)
if "error" not in results:
print(f"Found {len(results.get('results', []))} results")
for r in results.get("results", [])[:3]:
print(f" - {r.get('title', 'No title')}")
print(f" URL: {r.get('url', 'No URL')}")
else:
print(f"Error: {results['error']}")
print("\n==================================================")
print("Demonstration Complete")
print("==================================================")
# Alias for simple usage: from praisonaiagents.tools import tavily
tavily = tavily_search
@@ -0,0 +1,533 @@
"""You.com search and content tools.
You.com provides AI-powered web search, news search, content extraction, and image search.
This module requires the YDC_API_KEY environment variable to be set.
Usage:
from praisonaiagents.tools import ydc_search, ydc_news, ydc_contents
# Search the web (unified web + news)
results = ydc_search("AI news 2024")
# Get live news
news = ydc_news("technology trends")
# Extract content from URLs
content = ydc_contents(["https://example.com"])
# Or use the class directly
from praisonaiagents.tools import YouTools
you = YouTools()
results = you.search("AI news")
"""
from typing import List, Dict, Any, Optional, Union
import logging
import os
from importlib import util
def _check_youdotcom_available() -> tuple[bool, Optional[str]]:
"""Check if You.com SDK is available and API key is set.
Returns:
Tuple of (is_available, error_message)
"""
# Check if youdotcom package is installed
if util.find_spec("youdotcom") is None:
return False, "youdotcom package is not installed. Please install it using: pip install youdotcom"
# Check if API key is set
api_key = os.environ.get("YDC_API_KEY")
if not api_key:
return False, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
return True, None
class YouTools:
"""Comprehensive tools for web search and content using You.com API.
You.com provides AI-powered search capabilities optimized for LLM applications.
Features:
- Unified web and news search with LLM-ready snippets
- Live news search from authoritative sources
- Content extraction from URLs (HTML/Markdown)
- Image search
- Advanced search operators support
- Geographic and language targeting
- Freshness controls
Requires:
- youdotcom package: pip install youdotcom
- YDC_API_KEY environment variable
"""
def __init__(self, api_key: Optional[str] = None):
"""Initialize YouTools.
Args:
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
"""
self._api_key = api_key
self._client = None
def _get_api_key(self) -> Optional[str]:
"""Get API key from instance or environment."""
return self._api_key or os.environ.get("YDC_API_KEY")
def _get_client(self):
"""Get or create You.com client."""
if self._client is None:
is_available, error = _check_youdotcom_available()
if not is_available:
return None, error
from youdotcom import You
api_key = self._get_api_key()
if not api_key:
return None, "YDC_API_KEY not set"
self._client = You(api_key_auth=api_key)
return self._client, None
def search(
self,
query: str,
count: int = 10,
freshness: Optional[str] = None,
country: Optional[str] = None,
language: Optional[str] = None,
offset: int = 0,
safesearch: str = "moderate",
livecrawl: Optional[str] = None,
livecrawl_formats: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using You.com's unified search API.
Returns both web and news results in a single call with LLM-ready snippets.
Args:
query: Search query (supports operators like site:, filetype:, +term, -term)
count: Max results per section (default 10, max 100)
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
country: ISO country code (e.g., "US", "GB", "FR")
language: BCP 47 language code (e.g., "EN", "JP", "DE")
offset: Pagination offset (0-9), results = offset * count
safesearch: Content filter - "off", "moderate", "strict"
livecrawl: Fetch full content - "web", "news", or "all"
livecrawl_formats: Content format - "html" or "markdown"
Returns:
Dict containing:
- results: {web: [...], news: [...]}
- metadata: {query, search_uuid, latency}
Search Operators:
- site:domain.com - Search within specific domain
- filetype:pdf - Filter by file type
- +term - Must include term
- -term - Exclude term
- AND, OR, NOT - Boolean operators
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {"query": query, "count": count, "offset": offset}
if freshness:
kwargs["freshness"] = freshness
if country:
kwargs["country"] = country
if language:
kwargs["language"] = language
if safesearch:
kwargs["safesearch"] = safesearch
if livecrawl:
kwargs["livecrawl"] = livecrawl
if livecrawl_formats:
kwargs["livecrawl_formats"] = livecrawl_formats
response = client.search.unified(**kwargs)
# Convert response to dict if it's a model object
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'to_dict'):
return response.to_dict()
elif hasattr(response, '__dict__'):
return self._convert_response(response)
return response
except Exception as e:
error_msg = f"You.com search error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def _convert_response(self, response) -> Dict[str, Any]:
"""Convert SDK response object to dictionary."""
result = {}
if hasattr(response, 'results'):
results = response.results
result['results'] = {}
if hasattr(results, 'web') and results.web:
result['results']['web'] = [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'snippets': getattr(r, 'snippets', []),
'thumbnail_url': getattr(r, 'thumbnail_url', None),
'page_age': getattr(r, 'page_age', None),
'favicon_url': getattr(r, 'favicon_url', None),
'authors': getattr(r, 'authors', []),
}
for r in results.web
]
if hasattr(results, 'news') and results.news:
result['results']['news'] = [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'thumbnail_url': getattr(r, 'thumbnail_url', None),
'page_age': getattr(r, 'page_age', None),
}
for r in results.news
]
if hasattr(response, 'metadata'):
meta = response.metadata
result['metadata'] = {
'query': getattr(meta, 'query', ''),
'search_uuid': getattr(meta, 'search_uuid', ''),
'latency': getattr(meta, 'latency', 0),
}
return result
def get_contents(
self,
urls: Union[str, List[str]],
format: str = "markdown",
) -> Dict[str, Any]:
"""Fetch content from URLs using You.com's content extraction API.
Args:
urls: Single URL or list of URLs to fetch content from
format: Content format - "html" or "markdown" (default: markdown)
Returns:
Dict containing array of results with:
- url: The fetched URL
- title: Page title
- html: HTML content (if format="html")
- markdown: Markdown content (if format="markdown")
- metadata: {site_name, favicon_url}
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
if isinstance(urls, str):
urls = [urls]
# Use the contents endpoint
response = client.search.contents(urls=urls, format=format)
# Convert response
if hasattr(response, 'model_dump'):
return {"results": response.model_dump()}
elif isinstance(response, list):
return {"results": [
{
'url': getattr(r, 'url', ''),
'title': getattr(r, 'title', ''),
'html': getattr(r, 'html', None),
'markdown': getattr(r, 'markdown', None),
'metadata': {
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
}
}
for r in response
]}
return {"results": response}
except Exception as e:
error_msg = f"You.com contents error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def live_news(
self,
query: str,
count: Optional[int] = None,
) -> Dict[str, Any]:
"""Search for live news using You.com's news API.
Note: This API may require early access. Contact [email protected] for access.
Args:
query: News search query
count: Maximum number of news results to return
Returns:
Dict containing:
- news: {query, results: [...], metadata}
- Each result has: title, description, url, page_age, source_name, thumbnail
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
kwargs = {"q": query}
if count:
kwargs["count"] = count
# Try to access live news endpoint
if hasattr(client, 'livenews'):
response = client.livenews.get(**kwargs)
else:
# Fallback: use unified search with news focus
return self.search(query=query, count=count or 10)
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'news'):
news = response.news
return {
'news': {
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
'results': [
{
'title': getattr(r, 'title', ''),
'description': getattr(r, 'description', ''),
'url': getattr(r, 'url', ''),
'page_age': getattr(r, 'page_age', None),
'age': getattr(r, 'age', None),
'source_name': getattr(r, 'source_name', None),
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
}
for r in getattr(news, 'results', [])
],
'metadata': {
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
}
}
}
return response
except Exception as e:
error_msg = f"You.com live news error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def images(
self,
query: str,
) -> Dict[str, Any]:
"""Search for images using You.com's image search API.
Note: This API may require early access. Contact [email protected] for access.
Args:
query: Image search query
Returns:
Dict containing:
- images: {results: [...]}
- metadata: {query, search_uuid}
- Each result has: title, page_url, image_url
"""
client, error = self._get_client()
if error:
logging.error(error)
return {"error": error}
try:
if hasattr(client, 'images'):
response = client.images.get(q=query)
else:
return {"error": "Image search not available in this SDK version"}
if hasattr(response, 'model_dump'):
return response.model_dump()
elif hasattr(response, 'images'):
images = response.images
return {
'images': {
'results': [
{
'title': getattr(r, 'title', ''),
'page_url': getattr(r, 'page_url', ''),
'image_url': getattr(r, 'image_url', ''),
}
for r in getattr(images, 'results', [])
]
},
'metadata': {
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
}
}
return response
except Exception as e:
error_msg = f"You.com images error: {str(e)}"
logging.error(error_msg)
return {"error": error_msg}
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
if self._client is not None:
# Close client if it has a close method
if hasattr(self._client, 'close'):
self._client.close()
elif hasattr(self._client, '__exit__'):
self._client.__exit__(exc_type, exc_val, exc_tb)
return False
# Standalone functions for direct import
def ydc_search(
query: str,
count: int = 10,
freshness: Optional[str] = None,
country: Optional[str] = None,
language: Optional[str] = None,
offset: int = 0,
safesearch: str = "moderate",
livecrawl: Optional[str] = None,
livecrawl_formats: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the web using You.com's unified search API.
This is a convenience function that creates a YouTools instance.
For repeated calls, consider using YouTools class directly.
Args:
query: Search query (supports operators like site:, filetype:, +term, -term)
count: Max results per section (default 10, max 100)
freshness: Filter by recency - "day", "week", "month", "year"
country: ISO country code (e.g., "US", "GB")
language: BCP 47 language code (e.g., "EN", "JP")
offset: Pagination offset (0-9)
safesearch: Content filter - "off", "moderate", "strict"
livecrawl: Fetch full content - "web", "news", or "all"
livecrawl_formats: Content format - "html" or "markdown"
Returns:
Dict containing search results with web and news sections
"""
tools = YouTools()
return tools.search(
query=query,
count=count,
freshness=freshness,
country=country,
language=language,
offset=offset,
safesearch=safesearch,
livecrawl=livecrawl,
livecrawl_formats=livecrawl_formats,
)
def ydc_contents(
urls: Union[str, List[str]],
format: str = "markdown",
) -> Dict[str, Any]:
"""Fetch content from URLs using You.com's content extraction API.
Args:
urls: Single URL or list of URLs to fetch content from
format: Content format - "html" or "markdown"
Returns:
Dict containing extracted content for each URL
"""
tools = YouTools()
return tools.get_contents(urls=urls, format=format)
def ydc_news(
query: str,
count: Optional[int] = None,
) -> Dict[str, Any]:
"""Search for live news using You.com's news API.
Args:
query: News search query
count: Maximum number of results
Returns:
Dict containing news results
"""
tools = YouTools()
return tools.live_news(query=query, count=count)
def ydc_images(
query: str,
) -> Dict[str, Any]:
"""Search for images using You.com's image search API.
Args:
query: Image search query
Returns:
Dict containing image results
"""
tools = YouTools()
return tools.images(query=query)
if __name__ == "__main__":
# Example usage
print("\n" + "=" * 60)
print("You.com Tools Demonstration")
print("=" * 60 + "\n")
# Check if API key is available
is_available, error = _check_youdotcom_available()
if not is_available:
print(f"Error: {error}")
print("\nTo use You.com tools:")
print("1. Install: pip install youdotcom")
print("2. Set environment variable: export YDC_API_KEY=your_api_key")
else:
print("You.com SDK is available!")
# Example search
print("\n1. Basic Search")
print("-" * 30)
results = ydc_search("Latest AI developments 2024", count=3)
if "error" not in results:
web_results = results.get('results', {}).get('web', [])
print(f"Found {len(web_results)} web results")
for r in web_results[:3]:
print(f" - {r.get('title', 'No title')[:50]}...")
print(f" URL: {r.get('url', 'No URL')}")
else:
print(f"Error: {results['error']}")
print("\n" + "=" * 60)
print("Demonstration Complete")
print("=" * 60)
# Alias for simple usage: from praisonaiagents.tools import ydc
ydc = ydc_search
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.0.184"
version = "0.0.188"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
requires-python = ">=3.10"
authors = [
@@ -0,0 +1,18 @@
from praisonaiagents import Agent
def search_web(query: str) -> str:
return "AI trends: LLMs, multimodal AI, autonomous agents"
agent = Agent(
name="AI Assistant",
role="Research and Writing Specialist",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
planning_reasoning=True,
verbose=False # Quiet mode
)
result = agent.start("Research AI trends in 2025 and write a 2 sentence summary")
print("\n=== FINAL RESULT ===")
print(result)
@@ -0,0 +1,62 @@
from praisonaiagents import Agent
def search_web(query: str) -> str:
return "AI trends: LLMs, multimodal AI, autonomous agents, edge AI"
print("=" * 60)
print("TEST 1: Planning ONLY (no tools, no reasoning)")
print("=" * 60)
agent1 = Agent(
name="Assistant",
llm="gpt-4o-mini",
planning=True,
verbose=False
)
result1 = agent1.start("Write 2 sentences about AI in healthcare")
print(f"\nResult: {result1[:200]}...")
print("\n" + "=" * 60)
print("TEST 2: Planning + Tools (no reasoning)")
print("=" * 60)
agent2 = Agent(
name="Assistant",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
verbose=False
)
result2 = agent2.start("Write 2 sentences about AI trends")
print(f"\nResult: {result2[:200]}...")
print("\n" + "=" * 60)
print("TEST 3: Planning + Tools + Reasoning")
print("=" * 60)
agent3 = Agent(
name="Assistant",
llm="gpt-4o-mini",
planning=True,
planning_tools=[search_web],
planning_reasoning=True,
verbose=False
)
result3 = agent3.start("Write 2 sentences about AI trends")
print(f"\nResult: {result3[:200]}...")
print("\n" + "=" * 60)
print("TEST 4: No Planning (direct execution)")
print("=" * 60)
agent4 = Agent(
name="Assistant",
llm="gpt-4o-mini",
verbose=False
)
result4 = agent4.start("Write 2 sentences about AI trends")
print(f"\nResult: {result4[:200]}...")
print("\n" + "=" * 60)
print("COMPARISON COMPLETE")
print("=" * 60)
@@ -0,0 +1,299 @@
"""Tests for MCP server wrapper - TDD approach.
These tests define the expected behavior of ToolsMCPServer class.
"""
import pytest
from unittest.mock import patch
class TestToolsMCPServerInit:
"""Test ToolsMCPServer initialization."""
def test_init_with_name(self):
"""Test server initializes with custom name."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer(name="my-tools")
assert server.name == "my-tools"
def test_init_with_default_name(self):
"""Test server initializes with default name."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
assert server.name == "praisonai-tools"
def test_init_with_tools_list(self):
"""Test server initializes with list of tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
def my_tool(x: str) -> str:
"""A test tool."""
return x
server = ToolsMCPServer(tools=[my_tool])
assert len(server.tools) == 1
def test_init_empty_tools(self):
"""Test server initializes with empty tools list."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
assert server.tools == []
class TestToolsMCPServerRegister:
"""Test tool registration."""
def test_register_single_tool(self):
"""Test registering a single tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def search(query: str) -> str:
"""Search for information."""
return f"Results for {query}"
server.register_tool(search)
assert len(server.tools) == 1
assert server.tools[0].__name__ == "search"
def test_register_multiple_tools(self):
"""Test registering multiple tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def tool1(x: str) -> str:
"""Tool 1."""
return x
def tool2(y: int) -> int:
"""Tool 2."""
return y
server.register_tool(tool1)
server.register_tool(tool2)
assert len(server.tools) == 2
def test_register_tools_batch(self):
"""Test registering multiple tools at once."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def tool1(x: str) -> str:
"""Tool 1."""
return x
def tool2(y: int) -> int:
"""Tool 2."""
return y
server.register_tools([tool1, tool2])
assert len(server.tools) == 2
def test_register_async_tool(self):
"""Test registering an async tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
async def async_search(query: str) -> str:
"""Async search."""
return f"Results for {query}"
server.register_tool(async_search)
assert len(server.tools) == 1
class TestToolsMCPServerSchemas:
"""Test schema generation for registered tools."""
def test_get_tool_schemas(self):
"""Test getting schemas for all registered tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def search(query: str, max_results: int = 10) -> str:
"""Search the web."""
return ""
server.register_tool(search)
schemas = server.get_tool_schemas()
assert len(schemas) == 1
assert schemas[0]["name"] == "search"
assert schemas[0]["description"] == "Search the web."
assert "query" in schemas[0]["inputSchema"]["properties"]
def test_get_tool_names(self):
"""Test getting list of tool names."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def tool1(x: str) -> str:
"""Tool 1."""
return x
def tool2(y: int) -> int:
"""Tool 2."""
return y
server.register_tools([tool1, tool2])
names = server.get_tool_names()
assert set(names) == {"tool1", "tool2"}
class TestToolsMCPServerExecution:
"""Test tool execution through MCP server."""
def test_execute_sync_tool(self):
"""Test executing a synchronous tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
server.register_tool(add)
result = server.execute_tool("add", {"a": 5, "b": 3})
assert result == 8
@pytest.mark.asyncio
async def test_execute_async_tool(self):
"""Test executing an async tool."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
async def async_add(a: int, b: int) -> int:
"""Add two numbers asynchronously."""
return a + b
server.register_tool(async_add)
result = await server.execute_tool_async("async_add", {"a": 5, "b": 3})
assert result == 8
def test_execute_nonexistent_tool(self):
"""Test executing a tool that doesn't exist."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
with pytest.raises(ValueError, match="Tool 'nonexistent' not found"):
server.execute_tool("nonexistent", {})
class TestToolsMCPServerFastMCP:
"""Test FastMCP integration."""
def test_get_fastmcp_instance(self):
"""Test getting FastMCP instance with registered tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer(name="test-server")
def my_tool(x: str) -> str:
"""A test tool."""
return x
server.register_tool(my_tool)
# Get FastMCP instance
mcp = server.get_fastmcp()
assert mcp is not None
assert mcp.name == "test-server"
def test_fastmcp_has_registered_tools(self):
"""Test that FastMCP instance has all registered tools."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
def search(query: str) -> str:
"""Search tool."""
return query
def calculate(x: int, y: int) -> int:
"""Calculate tool."""
return x + y
server.register_tools([search, calculate])
mcp = server.get_fastmcp()
# FastMCP should have both tools registered
# We can check by looking at the internal tool registry
assert mcp is not None
class TestLaunchToolsMCPServer:
"""Test the convenience function for launching MCP server."""
def test_launch_function_exists(self):
"""Test that launch_tools_mcp_server function exists."""
from praisonaiagents.mcp.mcp_server import launch_tools_mcp_server
assert callable(launch_tools_mcp_server)
def test_launch_with_tool_names(self):
"""Test launching server with tool names from registry."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
# This should create a server with specified tools
# We'll mock the actual launch to avoid blocking
with patch.object(ToolsMCPServer, 'run', return_value=None):
# Just verify it doesn't raise
pass # Actual launch test will be in integration tests
def test_launch_with_custom_tools(self):
"""Test launching server with custom tool functions."""
from praisonaiagents.mcp.mcp_server import launch_tools_mcp_server
def my_custom_tool(x: str) -> str:
"""Custom tool."""
return x
# Verify function accepts tools parameter
# Actual launch test will be in integration tests
assert callable(launch_tools_mcp_server)
class TestToolsMCPServerTransports:
"""Test different transport options."""
def test_stdio_transport_config(self):
"""Test stdio transport configuration."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
# Server should support stdio transport
assert hasattr(server, 'run_stdio') or hasattr(server, 'run')
def test_sse_transport_config(self):
"""Test SSE transport configuration."""
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
server = ToolsMCPServer()
# Server should support SSE transport
assert hasattr(server, 'run_sse') or hasattr(server, 'run')
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,225 @@
"""Tests for MCP utility functions - TDD approach.
These tests define the expected behavior of mcp_utils module.
"""
import pytest
from typing import Optional, List, Dict, Any
class TestFunctionToMCPSchema:
"""Test function_to_mcp_schema() converts Python functions to MCP tool schemas."""
def test_simple_function_with_string_param(self):
"""Test conversion of simple function with string parameter."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str) -> str:
"""Search the web for information."""
return f"Results for {query}"
schema = function_to_mcp_schema(search)
assert schema["name"] == "search"
assert schema["description"] == "Search the web for information."
assert schema["inputSchema"]["type"] == "object"
assert "query" in schema["inputSchema"]["properties"]
assert schema["inputSchema"]["properties"]["query"]["type"] == "string"
assert "query" in schema["inputSchema"]["required"]
def test_function_with_multiple_params(self):
"""Test function with multiple parameters of different types."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str, max_results: int, include_images: bool) -> Dict[str, Any]:
"""Search with options."""
return {}
schema = function_to_mcp_schema(search)
assert schema["name"] == "search"
props = schema["inputSchema"]["properties"]
assert props["query"]["type"] == "string"
assert props["max_results"]["type"] == "integer"
assert props["include_images"]["type"] == "boolean"
assert set(schema["inputSchema"]["required"]) == {"query", "max_results", "include_images"}
def test_function_with_optional_params(self):
"""Test function with optional parameters (defaults)."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str, max_results: int = 10, include_images: bool = False) -> str:
"""Search with optional params."""
return ""
schema = function_to_mcp_schema(search)
# Only required params should be in required list
assert schema["inputSchema"]["required"] == ["query"]
# All params should still be in properties
assert "max_results" in schema["inputSchema"]["properties"]
assert "include_images" in schema["inputSchema"]["properties"]
def test_function_with_list_param(self):
"""Test function with list parameter."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def process(items: List[str]) -> str:
"""Process a list of items."""
return ""
schema = function_to_mcp_schema(process)
assert schema["inputSchema"]["properties"]["items"]["type"] == "array"
assert schema["inputSchema"]["properties"]["items"]["items"]["type"] == "string"
def test_function_with_dict_param(self):
"""Test function with dict parameter."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def process(data: Dict[str, Any]) -> str:
"""Process data dictionary."""
return ""
schema = function_to_mcp_schema(process)
assert schema["inputSchema"]["properties"]["data"]["type"] == "object"
def test_function_with_optional_type(self):
"""Test function with Optional type hint."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def search(query: str, category: Optional[str] = None) -> str:
"""Search with optional category."""
return ""
schema = function_to_mcp_schema(search)
assert "category" in schema["inputSchema"]["properties"]
assert "category" not in schema["inputSchema"]["required"]
def test_function_without_docstring(self):
"""Test function without docstring uses function name as description."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
def my_tool(x: str) -> str:
return x
schema = function_to_mcp_schema(my_tool)
assert schema["name"] == "my_tool"
assert schema["description"] == "my_tool" # Falls back to name
def test_async_function(self):
"""Test async function is handled correctly."""
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
async def async_search(query: str) -> str:
"""Async search function."""
return ""
schema = function_to_mcp_schema(async_search)
assert schema["name"] == "async_search"
assert schema["description"] == "Async search function."
class TestGetToolMetadata:
"""Test get_tool_metadata() extracts metadata from functions."""
def test_extracts_name(self):
"""Test name extraction."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""Tool description."""
return x
metadata = get_tool_metadata(my_tool)
assert metadata["name"] == "my_tool"
def test_extracts_description_from_docstring(self):
"""Test description extraction from docstring."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""This is the tool description.
More details here.
"""
return x
metadata = get_tool_metadata(my_tool)
assert metadata["description"] == "This is the tool description."
def test_custom_name_attribute(self):
"""Test custom __mcp_name__ attribute is used."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""Description."""
return x
my_tool.__mcp_name__ = "custom_name"
metadata = get_tool_metadata(my_tool)
assert metadata["name"] == "custom_name"
def test_custom_description_attribute(self):
"""Test custom __mcp_description__ attribute is used."""
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
def my_tool(x: str) -> str:
"""Original docstring."""
return x
my_tool.__mcp_description__ = "Custom description"
metadata = get_tool_metadata(my_tool)
assert metadata["description"] == "Custom description"
class TestPythonTypeToJsonSchema:
"""Test Python type to JSON Schema conversion."""
def test_basic_types(self):
"""Test basic Python types convert correctly."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
assert python_type_to_json_schema(str) == {"type": "string"}
assert python_type_to_json_schema(int) == {"type": "integer"}
assert python_type_to_json_schema(float) == {"type": "number"}
assert python_type_to_json_schema(bool) == {"type": "boolean"}
def test_list_type(self):
"""Test List type converts to array."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(List[str])
assert result["type"] == "array"
assert result["items"]["type"] == "string"
def test_dict_type(self):
"""Test Dict type converts to object."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(Dict[str, Any])
assert result["type"] == "object"
def test_optional_type(self):
"""Test Optional type is handled."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(Optional[str])
assert result["type"] == "string" # Optional just means nullable
def test_any_type(self):
"""Test Any type converts to empty schema."""
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
result = python_type_to_json_schema(Any)
assert result == {} # Any type = no constraints
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,142 @@
"""Test Tavily tools integration."""
import os
import sys
# Test 1: Import test - verify lazy loading works
print("=" * 60)
print("Test 1: Import Test (Lazy Loading)")
print("=" * 60)
try:
from praisonaiagents.tools import tavily_search, tavily_extract, tavily_crawl, tavily_map
print("✓ Successfully imported tavily_search, tavily_extract, tavily_crawl, tavily_map")
except ImportError as e:
print(f"✗ Import failed: {e}")
sys.exit(1)
try:
from praisonaiagents.tools import TavilyTools
print("✓ Successfully imported TavilyTools class")
except ImportError as e:
print(f"✗ TavilyTools import failed: {e}")
sys.exit(1)
try:
from praisonaiagents.tools import tavily_search_async, tavily_extract_async
print("✓ Successfully imported async functions")
except ImportError as e:
print(f"✗ Async import failed: {e}")
sys.exit(1)
# Test 2: Check API key detection
print("\n" + "=" * 60)
print("Test 2: API Key Detection")
print("=" * 60)
api_key = os.environ.get("TAVILY_API_KEY")
if api_key:
print(f"✓ TAVILY_API_KEY is set (length: {len(api_key)})")
else:
print("✗ TAVILY_API_KEY is not set")
print(" To run full tests, set: export TAVILY_API_KEY=your_api_key")
# Test 3: Error handling without API key
print("\n" + "=" * 60)
print("Test 3: Error Handling (No API Key)")
print("=" * 60)
# Temporarily unset API key to test error handling
original_key = os.environ.pop("TAVILY_API_KEY", None)
try:
result = tavily_search("test query")
if "error" in result:
print(f"✓ Proper error returned when no API key: {result['error'][:50]}...")
else:
print("✗ Expected error but got results")
except Exception as e:
print(f"✗ Unexpected exception: {e}")
# Restore API key
if original_key:
os.environ["TAVILY_API_KEY"] = original_key
# Test 4: TavilyTools class instantiation
print("\n" + "=" * 60)
print("Test 4: TavilyTools Class")
print("=" * 60)
try:
tools = TavilyTools()
print("✓ TavilyTools instantiated successfully")
# Check methods exist
methods = ['search', 'extract', 'crawl', 'map', 'asearch', 'aextract']
for method in methods:
if hasattr(tools, method):
print(f" ✓ Method '{method}' exists")
else:
print(f" ✗ Method '{method}' missing")
except Exception as e:
print(f"✗ TavilyTools instantiation failed: {e}")
# Test 5: Full integration test (only if API key is available)
print("\n" + "=" * 60)
print("Test 5: Full Integration Test")
print("=" * 60)
if os.environ.get("TAVILY_API_KEY"):
print("Running search test...")
try:
result = tavily_search("Python programming", max_results=2)
if "error" not in result:
print(f"✓ Search successful!")
print(f" Query: {result.get('query', 'N/A')}")
print(f" Results: {len(result.get('results', []))} items")
if result.get('results'):
print(f" First result: {result['results'][0].get('title', 'N/A')[:50]}...")
else:
print(f"✗ Search returned error: {result['error']}")
except Exception as e:
print(f"✗ Search failed with exception: {e}")
else:
print("⊘ Skipped (TAVILY_API_KEY not set)")
# Test 6: Agent integration test
print("\n" + "=" * 60)
print("Test 6: Agent Integration Test")
print("=" * 60)
if os.environ.get("TAVILY_API_KEY") and os.environ.get("OPENAI_API_KEY"):
try:
from praisonaiagents import Agent
agent = Agent(
name="SearchAgent",
role="Web Researcher",
goal="Search the web for information",
tools=[tavily_search]
)
print("✓ Agent created with tavily_search tool")
# Check tool is properly registered
if agent.tools and tavily_search in agent.tools:
print("✓ tavily_search is in agent's tools list")
else:
print("⊘ Tool registration check inconclusive")
except Exception as e:
print(f"✗ Agent integration failed: {e}")
else:
missing = []
if not os.environ.get("TAVILY_API_KEY"):
missing.append("TAVILY_API_KEY")
if not os.environ.get("OPENAI_API_KEY"):
missing.append("OPENAI_API_KEY")
print(f"⊘ Skipped (missing: {', '.join(missing)})")
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
print("All import and basic tests passed!")
print("For full functionality, ensure TAVILY_API_KEY is set.")
@@ -0,0 +1,135 @@
"""Test You.com tools integration."""
import os
import sys
# Test 1: Import test - verify lazy loading works
print("=" * 60)
print("Test 1: Import Test (Lazy Loading)")
print("=" * 60)
try:
from praisonaiagents.tools import ydc_search, ydc_contents, ydc_news, ydc_images
print("✓ Successfully imported ydc_search, ydc_contents, ydc_news, ydc_images")
except ImportError as e:
print(f"✗ Import failed: {e}")
sys.exit(1)
try:
from praisonaiagents.tools import YouTools
print("✓ Successfully imported YouTools class")
except ImportError as e:
print(f"✗ YouTools import failed: {e}")
sys.exit(1)
# Test 2: Check API key detection
print("\n" + "=" * 60)
print("Test 2: API Key Detection")
print("=" * 60)
api_key = os.environ.get("YDC_API_KEY")
if api_key:
print(f"✓ YDC_API_KEY is set (length: {len(api_key)})")
else:
print("✗ YDC_API_KEY is not set")
print(" To run full tests, set: export YDC_API_KEY=your_api_key")
# Test 3: Error handling without API key
print("\n" + "=" * 60)
print("Test 3: Error Handling (No API Key)")
print("=" * 60)
# Temporarily unset API key to test error handling
original_key = os.environ.pop("YDC_API_KEY", None)
try:
result = ydc_search("test query")
if "error" in result:
print(f"✓ Proper error returned when no API key: {result['error'][:50]}...")
else:
print("✗ Expected error but got results")
except Exception as e:
print(f"✗ Unexpected exception: {e}")
# Restore API key
if original_key:
os.environ["YDC_API_KEY"] = original_key
# Test 4: YouTools class instantiation
print("\n" + "=" * 60)
print("Test 4: YouTools Class")
print("=" * 60)
try:
tools = YouTools()
print("✓ YouTools instantiated successfully")
# Check methods exist
methods = ['search', 'get_contents', 'live_news', 'images']
for method in methods:
if hasattr(tools, method):
print(f" ✓ Method '{method}' exists")
else:
print(f" ✗ Method '{method}' missing")
except Exception as e:
print(f"✗ YouTools instantiation failed: {e}")
# Test 5: Full integration test (only if API key is available)
print("\n" + "=" * 60)
print("Test 5: Full Integration Test")
print("=" * 60)
if os.environ.get("YDC_API_KEY"):
print("Running search test...")
try:
result = ydc_search("Python programming", count=2)
if "error" not in result:
print(f"✓ Search successful!")
web_results = result.get('results', {}).get('web', [])
print(f" Web results: {len(web_results)} items")
if web_results:
print(f" First result: {web_results[0].get('title', 'N/A')[:50]}...")
else:
print(f"✗ Search returned error: {result['error']}")
except Exception as e:
print(f"✗ Search failed with exception: {e}")
else:
print("⊘ Skipped (YDC_API_KEY not set)")
# Test 6: Agent integration test
print("\n" + "=" * 60)
print("Test 6: Agent Integration Test")
print("=" * 60)
if os.environ.get("YDC_API_KEY") and os.environ.get("OPENAI_API_KEY"):
try:
from praisonaiagents import Agent
agent = Agent(
name="SearchAgent",
role="Web Researcher",
goal="Search the web for information",
tools=[ydc_search]
)
print("✓ Agent created with ydc_search tool")
# Check tool is properly registered
if agent.tools and ydc_search in agent.tools:
print("✓ ydc_search is in agent's tools list")
else:
print("⊘ Tool registration check inconclusive")
except Exception as e:
print(f"✗ Agent integration failed: {e}")
else:
missing = []
if not os.environ.get("YDC_API_KEY"):
missing.append("YDC_API_KEY")
if not os.environ.get("OPENAI_API_KEY"):
missing.append("OPENAI_API_KEY")
print(f"⊘ Skipped (missing: {', '.join(missing)})")
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
print("All import and basic tests passed!")
print("For full functionality, ensure YDC_API_KEY is set.")
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.184"
version = "0.0.188"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+97 -1
View File
@@ -60,6 +60,7 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
- 🔧 **Planning Tools** (Research with tools during planning)
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
@@ -414,6 +415,101 @@ praisonai research --query-rewrite --tools tools.py --save "Your research query"
praisonai research -v "Your research query"
```
### Planning Mode CLI:
```bash
# Enable planning mode - agent creates a plan before execution
praisonai "Research AI trends and write a summary" --planning
# Planning with tools for research
praisonai "Analyze market trends" --planning --planning-tools tools.py
# Planning with chain-of-thought reasoning
praisonai "Complex analysis task" --planning --planning-reasoning
# Auto-approve plans without confirmation
praisonai "Task" --planning --auto-approve-plan
```
### Memory CLI:
```bash
# Enable memory for agent (persists across sessions)
praisonai "My name is John" --memory
# Memory with user isolation
praisonai "Remember my preferences" --memory --user-id user123
# Memory management commands
praisonai memory show # Show memory statistics
praisonai memory add "User prefers Python" # Add to long-term memory
praisonai memory search "Python" # Search memories
praisonai memory clear # Clear short-term memory
praisonai memory clear all # Clear all memory
praisonai memory save my_session # Save session
praisonai memory resume my_session # Resume session
praisonai memory sessions # List saved sessions
praisonai memory checkpoint # Create checkpoint
praisonai memory restore <checkpoint_id> # Restore checkpoint
praisonai memory checkpoints # List checkpoints
praisonai memory help # Show all commands
```
### Rules CLI:
```bash
# List all loaded rules (from PRAISON.md, CLAUDE.md, etc.)
praisonai rules list
# Show specific rule details
praisonai rules show <rule_name>
# Create a new rule
praisonai rules create my_rule "Always use type hints"
# Delete a rule
praisonai rules delete my_rule
# Show rules statistics
praisonai rules stats
# Include manual rules with prompts
praisonai "Task" --include-rules security,testing
```
### Workflow CLI:
```bash
# List available workflows
praisonai workflow list
# Execute a workflow
praisonai workflow run deploy
# Execute with variables
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
# Show workflow details
praisonai workflow show deploy
# Create a new workflow template
praisonai workflow create my_workflow
```
### Hooks CLI:
```bash
# List configured hooks
praisonai hooks list
# Show hooks statistics
praisonai hooks stats
# Create hooks.json template
praisonai hooks init
```
### Claude Memory Tool CLI:
```bash
# Enable Claude Memory Tool (Anthropic models only)
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
```
## Using JavaScript Code
```bash
@@ -1045,7 +1141,7 @@ cd src/praisonai && uv publish
- 🎨 Interactive UIs
- 📄 YAML-based Configuration
- 🛠️ Custom Tool Integration
- 🔍 Internet Search Capability (using Crawl4AI and Tavily)
- 🔍 Internet Search Capability (Tavily, You.com, Exa, DuckDuckGo, Crawl4AI)
- 🖼️ Vision Language Model (VLM) Support
- 🎙️ Real-time Voice Interaction
+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.3.6.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.6.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.9.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.9.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+619 -1
View File
@@ -549,7 +549,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -588,6 +588,25 @@ class PraisonAI:
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
# Planning Mode arguments
parser.add_argument("--planning", action="store_true", help="Enable planning mode - create plan before execution")
parser.add_argument("--planning-tools", type=str, help="Tools for planning research (path to tools.py or comma-separated tool names)")
parser.add_argument("--planning-reasoning", action="store_true", help="Enable chain-of-thought reasoning in planning")
parser.add_argument("--auto-approve-plan", action="store_true", help="Auto-approve generated plans without user confirmation")
# Memory arguments
parser.add_argument("--memory", action="store_true", help="Enable file-based memory for agent")
parser.add_argument("--user-id", type=str, help="User ID for memory isolation")
# Rules arguments
parser.add_argument("--include-rules", type=str, help="Include manual rules by name (comma-separated)")
# Workflow arguments
parser.add_argument("--workflow-var", action="append", help="Workflow variable in key=value format (can be used multiple times)")
# Claude Memory Tool arguments
parser.add_argument("--claude-memory", action="store_true", help="Enable Claude Memory Tool (Anthropic models only)")
# If we're in a test environment, parse with empty args to avoid pytest interference
if in_test_env:
args, unknown_args = parser.parse_known_args([])
@@ -734,6 +753,60 @@ class PraisonAI:
self.handle_research_command(research_query, research_model, verbose, save, query_rewrite, tools_path, rewrite_tools)
sys.exit(0)
elif args.command == 'memory':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action and arguments from remaining args
action = unknown_args[0] if unknown_args else 'help'
action_args = unknown_args[1:] if len(unknown_args) > 1 else []
user_id = getattr(args, 'user_id', None)
self.handle_memory_command(action, action_args, user_id)
sys.exit(0)
elif args.command == 'rules':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action and arguments from remaining args
action = unknown_args[0] if unknown_args else 'list'
action_args = unknown_args[1:] if len(unknown_args) > 1 else []
self.handle_rules_command(action, action_args)
sys.exit(0)
elif args.command == 'workflow':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action and arguments from remaining args
action = unknown_args[0] if unknown_args else 'list'
action_args = unknown_args[1:] if len(unknown_args) > 1 else []
workflow_vars = {}
if getattr(args, 'workflow_var', None):
for var in args.workflow_var:
if '=' in var:
key, value = var.split('=', 1)
workflow_vars[key] = value
self.handle_workflow_command(action, action_args, workflow_vars)
sys.exit(0)
elif args.command == 'hooks':
if not PRAISONAI_AVAILABLE:
print("[red]ERROR: PraisonAI Agents is not installed. Install with:[/red]")
print("\npip install praisonaiagents\n")
sys.exit(1)
# Get action from remaining args
action = unknown_args[0] if unknown_args else 'list'
self.handle_hooks_command(action)
sys.exit(0)
# Only check framework availability for agent-related operations
if not args.command and (args.init or args.auto or args.framework):
if not CREWAI_AVAILABLE and not AUTOGEN_AVAILABLE and not PRAISONAI_AVAILABLE:
@@ -936,6 +1009,520 @@ class PraisonAI:
verbose = getattr(self.args, 'verbose', False)
return self._expand_prompt(prompt, expand_tools, verbose)
def _load_tools(self, tools_path: str) -> list:
"""
Load tools from a file path or comma-separated tool names.
Args:
tools_path: Path to tools.py file or comma-separated tool names
Returns:
List of tool functions
"""
tools_list = []
if not tools_path:
return tools_list
if os.path.isfile(tools_path):
# Load from file
try:
import inspect
spec = importlib.util.spec_from_file_location("tools_module", tools_path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for name, obj in inspect.getmembers(module):
if inspect.isfunction(obj) and not name.startswith('_'):
tools_list.append(obj)
if tools_list:
print(f"[cyan]Loaded {len(tools_list)} tools from {tools_path}[/cyan]")
except Exception as e:
print(f"[yellow]Warning: Failed to load tools from {tools_path}: {e}[/yellow]")
else:
# Treat as comma-separated tool names
try:
from praisonaiagents.tools import TOOL_MAPPINGS
import praisonaiagents.tools as tools_module
tool_names = [t.strip() for t in tools_path.split(',')]
for tool_name in tool_names:
if tool_name in TOOL_MAPPINGS:
try:
tool = getattr(tools_module, tool_name)
tools_list.append(tool)
except Exception as e:
print(f"[yellow]Warning: Failed to load tool '{tool_name}': {e}[/yellow]")
else:
print(f"[yellow]Warning: Unknown tool '{tool_name}'[/yellow]")
if tools_list:
print(f"[cyan]Loaded {len(tools_list)} built-in tools[/cyan]")
except ImportError:
print("[yellow]Warning: Could not import tools module[/yellow]")
return tools_list
def handle_memory_command(self, action: str, action_args: list, user_id: str = None):
"""
Handle memory subcommand actions.
Args:
action: The memory action (show, add, search, clear, save, resume, etc.)
action_args: Additional arguments for the action
user_id: User ID for memory isolation
"""
try:
from praisonaiagents.memory import FileMemory
from rich import print
from rich.table import Table
from rich.console import Console
console = Console()
memory = FileMemory(user_id=user_id or "default")
if action == 'show':
stats = memory.get_stats()
table = Table(title="Memory Statistics")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
for key, value in stats.items():
table.add_row(str(key), str(value))
console.print(table)
# Show recent memories
print("\n[bold]Recent Short-term Memories:[/bold]")
short_term = memory.get_short_term(limit=5)
for i, item in enumerate(short_term, 1):
content = item.get('content', str(item))[:100]
print(f" {i}. {content}")
elif action == 'add':
if not action_args:
print("[red]ERROR: Content required. Usage: praisonai memory add \"Your memory content\"[/red]")
return
content = ' '.join(action_args)
memory.add_long_term(content, importance=0.8)
print(f"[green]✅ Added to long-term memory: {content[:50]}...[/green]")
elif action == 'search':
if not action_args:
print("[red]ERROR: Query required. Usage: praisonai memory search \"query\"[/red]")
return
query = ' '.join(action_args)
results = memory.search(query, limit=10)
print(f"[bold]Search results for '{query}':[/bold]")
for i, result in enumerate(results, 1):
content = result.get('content', str(result))[:100]
print(f" {i}. {content}")
elif action == 'clear':
target = action_args[0] if action_args else 'short'
if target == 'all':
memory.clear_all()
print("[green]✅ All memory cleared[/green]")
else:
memory.clear_short_term()
print("[green]✅ Short-term memory cleared[/green]")
elif action == 'save':
if not action_args:
print("[red]ERROR: Session name required. Usage: praisonai memory save <session_name>[/red]")
return
session_name = action_args[0]
memory.save_session(session_name)
print(f"[green]✅ Session saved: {session_name}[/green]")
elif action == 'resume':
if not action_args:
print("[red]ERROR: Session name required. Usage: praisonai memory resume <session_name>[/red]")
return
session_name = action_args[0]
memory.resume_session(session_name)
print(f"[green]✅ Session resumed: {session_name}[/green]")
elif action == 'sessions':
sessions = memory.list_sessions()
if sessions:
print("[bold]Saved Sessions:[/bold]")
for s in sessions:
print(f" - {s.get('name', 'Unknown')} (saved: {s.get('saved_at', 'Unknown')})")
else:
print("[yellow]No saved sessions found[/yellow]")
elif action == 'compress':
print("[cyan]Compressing short-term memory...[/cyan]")
# Note: compress requires an LLM function, so we'll just show stats
stats = memory.get_stats()
print(f"[green]Short-term items: {stats.get('short_term_count', 0)}[/green]")
print("[yellow]Note: Full compression requires an LLM. Use programmatically with memory.compress(llm_func=...)[/yellow]")
elif action == 'checkpoint':
name = action_args[0] if action_args else None
checkpoint_id = memory.create_checkpoint(name)
print(f"[green]✅ Checkpoint created: {checkpoint_id}[/green]")
elif action == 'restore':
if not action_args:
print("[red]ERROR: Checkpoint ID required. Usage: praisonai memory restore <checkpoint_id>[/red]")
return
checkpoint_id = action_args[0]
memory.restore_checkpoint(checkpoint_id)
print(f"[green]✅ Checkpoint restored: {checkpoint_id}[/green]")
elif action == 'checkpoints':
checkpoints = memory.list_checkpoints()
if checkpoints:
print("[bold]Checkpoints:[/bold]")
for cp in checkpoints:
print(f" - {cp.get('id', 'Unknown')} ({cp.get('name', 'Unnamed')})")
else:
print("[yellow]No checkpoints found[/yellow]")
elif action == 'help' or action == '--help':
print("[bold]Memory Commands:[/bold]")
print(" praisonai memory show - Show memory statistics")
print(" praisonai memory add <content> - Add to long-term memory")
print(" praisonai memory search <query> - Search memories")
print(" praisonai memory clear [short|all] - Clear memory")
print(" praisonai memory save <session_name> - Save session")
print(" praisonai memory resume <session_name> - Resume session")
print(" praisonai memory sessions - List saved sessions")
print(" praisonai memory compress - Compress short-term memory")
print(" praisonai memory checkpoint [name] - Create checkpoint")
print(" praisonai memory restore <checkpoint_id> - Restore checkpoint")
print(" praisonai memory checkpoints - List checkpoints")
print("\n[bold]Options:[/bold]")
print(" --user-id <id> - User ID for memory isolation")
else:
print(f"[red]Unknown memory action: {action}[/red]")
print("Use 'praisonai memory help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import memory module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Memory command failed: {e}[/red]")
def handle_rules_command(self, action: str, action_args: list):
"""
Handle rules subcommand actions.
Args:
action: The rules action (list, show, create, delete, stats)
action_args: Additional arguments for the action
"""
try:
from praisonaiagents.memory import RulesManager
from rich import print
from rich.table import Table
from rich.console import Console
console = Console()
rules = RulesManager(workspace_path=os.getcwd())
if action == 'list':
all_rules = rules.get_all_rules()
if all_rules:
table = Table(title="Loaded Rules")
table.add_column("Name", style="cyan")
table.add_column("Description", style="white")
table.add_column("Activation", style="green")
table.add_column("Priority", style="yellow")
for rule in all_rules:
table.add_row(
rule.name,
rule.description[:50] + "..." if len(rule.description) > 50 else rule.description,
rule.activation,
str(rule.priority)
)
console.print(table)
else:
print("[yellow]No rules found. Create PRAISON.md, CLAUDE.md, or files in .praison/rules/[/yellow]")
elif action == 'show':
if not action_args:
print("[red]ERROR: Rule name required. Usage: praisonai rules show <name>[/red]")
return
rule_name = action_args[0]
rule = rules.get_rule_by_name(rule_name)
if rule:
print(f"[bold cyan]Rule: {rule.name}[/bold cyan]")
print(f"[bold]Description:[/bold] {rule.description}")
print(f"[bold]Activation:[/bold] {rule.activation}")
print(f"[bold]Priority:[/bold] {rule.priority}")
if rule.globs:
print(f"[bold]Globs:[/bold] {', '.join(rule.globs)}")
print(f"\n[bold]Content:[/bold]\n{rule.content}")
else:
print(f"[red]Rule not found: {rule_name}[/red]")
elif action == 'create':
if len(action_args) < 2:
print("[red]ERROR: Name and content required. Usage: praisonai rules create <name> <content>[/red]")
return
rule_name = action_args[0]
content = ' '.join(action_args[1:])
rules.create_rule(
name=rule_name,
content=content,
description=f"Rule created via CLI: {rule_name}",
activation="always",
scope="workspace"
)
print(f"[green]✅ Rule created: {rule_name}[/green]")
elif action == 'delete':
if not action_args:
print("[red]ERROR: Rule name required. Usage: praisonai rules delete <name>[/red]")
return
rule_name = action_args[0]
rules.delete_rule(rule_name)
print(f"[green]✅ Rule deleted: {rule_name}[/green]")
elif action == 'stats':
stats = rules.get_stats()
table = Table(title="Rules Statistics")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
for key, value in stats.items():
if isinstance(value, dict):
table.add_row(str(key), str(value))
else:
table.add_row(str(key), str(value))
console.print(table)
elif action == 'help' or action == '--help':
print("[bold]Rules Commands:[/bold]")
print(" praisonai rules list - List all loaded rules")
print(" praisonai rules show <name> - Show specific rule details")
print(" praisonai rules create <name> <content> - Create a new rule")
print(" praisonai rules delete <name> - Delete a rule")
print(" praisonai rules stats - Show rules statistics")
print("\n[bold]Supported Rule Files:[/bold]")
print(" PRAISON.md, CLAUDE.md, AGENTS.md, GEMINI.md")
print(" .cursorrules, .windsurfrules")
print(" .praison/rules/*.md, ~/.praison/rules/*.md")
else:
print(f"[red]Unknown rules action: {action}[/red]")
print("Use 'praisonai rules help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import rules module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Rules command failed: {e}[/red]")
def handle_workflow_command(self, action: str, action_args: list, variables: dict = None):
"""
Handle workflow subcommand actions.
Args:
action: The workflow action (list, run, create, show)
action_args: Additional arguments for the action
variables: Workflow variables for substitution
"""
try:
from praisonaiagents.memory import WorkflowManager
from rich import print
from rich.table import Table
from rich.console import Console
console = Console()
manager = WorkflowManager(workspace_path=os.getcwd())
if action == 'list':
workflows = manager.list_workflows()
if workflows:
table = Table(title="Available Workflows")
table.add_column("Name", style="cyan")
table.add_column("Description", style="white")
table.add_column("Steps", style="green")
for workflow in workflows:
table.add_row(
workflow.name,
workflow.description[:50] + "..." if len(workflow.description) > 50 else workflow.description,
str(len(workflow.steps))
)
console.print(table)
else:
print("[yellow]No workflows found. Create files in .praison/workflows/[/yellow]")
elif action == 'run':
if not action_args:
print("[red]ERROR: Workflow name required. Usage: praisonai workflow run <name>[/red]")
return
workflow_name = action_args[0]
# Create a simple executor using PraisonAgent
def executor(prompt):
agent = PraisonAgent(
name="WorkflowExecutor",
role="Task Executor",
goal="Execute workflow steps"
)
return agent.chat(prompt)
print(f"[bold cyan]Running workflow: {workflow_name}[/bold cyan]")
result = manager.execute(
workflow_name,
executor=executor,
variables=variables or {}
)
if result.get("success"):
print("[green]✅ Workflow completed successfully![/green]")
for step_result in result.get("results", []):
status = "✅" if step_result.get("status") == "completed" else "❌"
print(f" {status} {step_result.get('step', 'Unknown step')}")
else:
print(f"[red]❌ Workflow failed: {result.get('error', 'Unknown error')}[/red]")
elif action == 'show':
if not action_args:
print("[red]ERROR: Workflow name required. Usage: praisonai workflow show <name>[/red]")
return
workflow_name = action_args[0]
workflow = manager.get_workflow(workflow_name)
if workflow:
print(f"[bold cyan]Workflow: {workflow.name}[/bold cyan]")
print(f"[bold]Description:[/bold] {workflow.description}")
print(f"\n[bold]Steps ({len(workflow.steps)}):[/bold]")
for i, step in enumerate(workflow.steps, 1):
print(f" {i}. {step.name}: {step.action[:80]}...")
if workflow.variables:
print(f"\n[bold]Variables:[/bold] {workflow.variables}")
else:
print(f"[red]Workflow not found: {workflow_name}[/red]")
elif action == 'create':
if not action_args:
print("[red]ERROR: Workflow name required. Usage: praisonai workflow create <name>[/red]")
return
workflow_name = action_args[0]
# Create a simple template workflow
manager.create_workflow(
name=workflow_name,
description=f"Workflow created via CLI: {workflow_name}",
steps=[
{"name": "Step 1", "action": "First step - edit this in .praison/workflows/"},
{"name": "Step 2", "action": "Second step - edit this in .praison/workflows/"}
]
)
print(f"[green]✅ Workflow created: {workflow_name}[/green]")
print(f"[cyan]Edit the workflow in .praison/workflows/{workflow_name}.md[/cyan]")
elif action == 'help' or action == '--help':
print("[bold]Workflow Commands:[/bold]")
print(" praisonai workflow list - List available workflows")
print(" praisonai workflow run <name> - Execute a workflow")
print(" praisonai workflow show <name> - Show workflow details")
print(" praisonai workflow create <name> - Create a new workflow")
print("\n[bold]Options:[/bold]")
print(" --workflow-var key=value - Set workflow variable (can be repeated)")
print("\n[bold]Example:[/bold]")
print(" praisonai workflow run deploy --workflow-var environment=staging")
else:
print(f"[red]Unknown workflow action: {action}[/red]")
print("Use 'praisonai workflow help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import workflow module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Workflow command failed: {e}[/red]")
def handle_hooks_command(self, action: str):
"""
Handle hooks subcommand actions.
Args:
action: The hooks action (list, stats, init)
"""
try:
from praisonaiagents.memory import HooksManager
from rich import print
from rich.table import Table
from rich.console import Console
import json
console = Console()
hooks = HooksManager(workspace_path=os.getcwd())
if action == 'list':
stats = hooks.get_stats()
if stats.get('total_hooks', 0) > 0:
print("[bold]Configured Hooks:[/bold]")
for event in stats.get('events', []):
print(f" - {event}")
else:
print("[yellow]No hooks configured. Create .praison/hooks.json[/yellow]")
elif action == 'stats':
stats = hooks.get_stats()
table = Table(title="Hooks Statistics")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
for key, value in stats.items():
table.add_row(str(key), str(value))
console.print(table)
elif action == 'init':
hooks_dir = os.path.join(os.getcwd(), ".praison")
os.makedirs(hooks_dir, exist_ok=True)
hooks_file = os.path.join(hooks_dir, "hooks.json")
if os.path.exists(hooks_file):
print(f"[yellow]hooks.json already exists at {hooks_file}[/yellow]")
else:
template = {
"enabled": True,
"timeout": 30,
"hooks": {
"pre_write_code": "./scripts/lint.sh",
"post_write_code": "./scripts/format.sh",
"pre_run_command": {
"command": "./scripts/validate.sh",
"timeout": 60,
"block_on_failure": True
}
}
}
with open(hooks_file, 'w') as f:
json.dump(template, f, indent=2)
print(f"[green]✅ Created hooks.json at {hooks_file}[/green]")
print("[cyan]Edit the file to configure your hooks[/cyan]")
elif action == 'help' or action == '--help':
print("[bold]Hooks Commands:[/bold]")
print(" praisonai hooks list - List configured hooks")
print(" praisonai hooks stats - Show hooks statistics")
print(" praisonai hooks init - Create hooks.json template")
print("\n[bold]Hook Events:[/bold]")
print(" pre_read_code, post_read_code")
print(" pre_write_code, post_write_code")
print(" pre_run_command, post_run_command")
print(" pre_user_prompt, post_user_prompt")
print(" pre_mcp_tool_use, post_mcp_tool_use")
else:
print(f"[red]Unknown hooks action: {action}[/red]")
print("Use 'praisonai hooks help' for available commands")
except ImportError as e:
print(f"[red]ERROR: Failed to import hooks module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
except Exception as e:
print(f"[red]ERROR: Hooks command failed: {e}[/red]")
def _save_output(self, prompt: str, result: str):
"""
Save output to output/prompts/ folder.
@@ -994,6 +1581,37 @@ class PraisonAI:
agent_config["web_fetch"] = True
if getattr(self.args, 'prompt_caching', False):
agent_config["prompt_caching"] = True
# Planning Mode
if getattr(self.args, 'planning', False):
agent_config["planning"] = True
print("[bold cyan]Planning mode enabled - agent will create a plan before execution[/bold cyan]")
# Load planning tools if specified
if getattr(self.args, 'planning_tools', None):
planning_tools_list = self._load_tools(self.args.planning_tools)
if planning_tools_list:
agent_config["planning_tools"] = planning_tools_list
if getattr(self.args, 'planning_reasoning', False):
agent_config["planning_reasoning"] = True
# Memory
if getattr(self.args, 'memory', False):
agent_config["memory"] = True
print("[bold cyan]Memory enabled - agent will remember context across sessions[/bold cyan]")
if getattr(self.args, 'user_id', None):
agent_config["user_id"] = self.args.user_id
# Claude Memory Tool (Anthropic only)
if getattr(self.args, 'claude_memory', False):
llm = getattr(self.args, 'llm', '')
if llm and 'anthropic' in llm.lower():
agent_config["claude_memory"] = True
print("[bold cyan]Claude Memory Tool enabled - Claude will autonomously manage memories[/bold cyan]")
else:
print("[yellow]Warning: --claude-memory requires an Anthropic model (--llm anthropic/...)[/yellow]")
agent = PraisonAgent(**agent_config)
result = agent.start(prompt)
+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.3.6 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.3.9 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.3.6"
__version__ = "2.3.9"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.0.184",
"praisonaiagents>=0.0.188",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+1 -1
View File
@@ -365,7 +365,7 @@ This is **normal and expected** during development. The key metrics are:
2. **Mocking**: Mock external dependencies and APIs
3. **Naming**: Clear, descriptive test names
4. **Documentation**: Document complex test scenarios
5. **Performance**: Keep unit tests fast (<1s each)
5. **Performance**: Keep unit tests fast (under 1s each)
6. **Coverage**: Aim for high coverage of critical paths
7. **Maintainability**: Regular test maintenance and updates
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Tests for new CLI features (fast, direct import, no subprocess):
- Planning Mode
- Memory CLI
- Rules CLI
- Workflow CLI
- Hooks CLI
- Claude Memory Tool
"""
import argparse
class TestCLIArgumentParsing:
"""Test CLI argument parsing directly (fast, no subprocess)."""
def get_parser(self):
"""Create argument parser matching CLI."""
parser = argparse.ArgumentParser(description="praisonAI CLI")
# Planning args
parser.add_argument("--planning", action="store_true")
parser.add_argument("--planning-tools")
parser.add_argument("--planning-reasoning", action="store_true")
parser.add_argument("--auto-approve-plan", action="store_true")
# Memory args
parser.add_argument("--memory", action="store_true")
parser.add_argument("--user-id")
# Rules args
parser.add_argument("--include-rules")
# Workflow args
parser.add_argument("--workflow-var", action="append")
# Claude memory
parser.add_argument("--claude-memory", action="store_true")
parser.add_argument("command", nargs="?")
return parser
def test_planning_args_parsed(self):
"""Test planning arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--planning", "--planning-reasoning", "test"])
assert args.planning is True
assert args.planning_reasoning is True
assert args.command == "test"
def test_planning_tools_parsed(self):
"""Test planning-tools argument is parsed."""
parser = self.get_parser()
args = parser.parse_args(["--planning", "--planning-tools", "tools.py", "test"])
assert args.planning is True
assert args.planning_tools == "tools.py"
def test_memory_args_parsed(self):
"""Test memory arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--memory", "--user-id", "user123", "test"])
assert args.memory is True
assert args.user_id == "user123"
def test_rules_args_parsed(self):
"""Test rules arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--include-rules", "security,testing", "test"])
assert args.include_rules == "security,testing"
def test_workflow_vars_parsed(self):
"""Test workflow-var arguments are parsed correctly."""
parser = self.get_parser()
args = parser.parse_args([
"--workflow-var", "env=prod",
"--workflow-var", "branch=main",
"test"
])
assert args.workflow_var == ["env=prod", "branch=main"]
def test_claude_memory_parsed(self):
"""Test claude-memory argument is parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--claude-memory", "test"])
assert args.claude_memory is True
def test_auto_approve_plan_parsed(self):
"""Test auto-approve-plan argument is parsed correctly."""
parser = self.get_parser()
args = parser.parse_args(["--planning", "--auto-approve-plan", "test"])
assert args.auto_approve_plan is True
class TestMemoryModule:
"""Test FileMemory module directly."""
def test_file_memory_import(self):
"""Test FileMemory can be imported."""
try:
from praisonaiagents.memory import FileMemory
assert FileMemory is not None
except ImportError:
pass # Skip if not installed
def test_file_memory_basic(self, tmp_path):
"""Test FileMemory basic operations."""
try:
from praisonaiagents.memory import FileMemory
memory = FileMemory(user_id="test_user")
# Test add
memory.add_long_term("Test fact")
# Test stats
stats = memory.get_stats()
assert stats["user_id"] == "test_user"
assert stats["long_term_count"] >= 1
except (ImportError, TypeError):
pass # Skip if not installed or API changed
class TestRulesModule:
"""Test RulesManager module directly."""
def test_rules_manager_import(self):
"""Test RulesManager can be imported."""
try:
from praisonaiagents.memory import RulesManager
assert RulesManager is not None
except ImportError:
pass # Skip if not installed
def test_rules_manager_basic(self):
"""Test RulesManager basic operations."""
try:
from praisonaiagents.memory import RulesManager
manager = RulesManager()
# Test stats
stats = manager.get_stats()
assert "total_rules" in stats
except ImportError:
pass # Skip if not installed
class TestWorkflowModule:
"""Test WorkflowManager module directly."""
def test_workflow_manager_import(self):
"""Test WorkflowManager can be imported."""
try:
from praisonaiagents.memory import WorkflowManager
assert WorkflowManager is not None
except ImportError:
pass # Skip if not installed
class TestHooksModule:
"""Test HooksManager module directly."""
def test_hooks_manager_import(self):
"""Test HooksManager can be imported."""
try:
from praisonaiagents.memory import HooksManager
assert HooksManager is not None
except ImportError:
pass # Skip if not installed
def test_hooks_manager_basic(self):
"""Test HooksManager basic operations."""
try:
from praisonaiagents.memory import HooksManager
hooks = HooksManager()
# Test stats
stats = hooks.get_stats()
assert "total_hooks" in stats
except ImportError:
pass # Skip if not installed
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])
+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.184" },
{ name = "praisonaiagents", specifier = ">=0.0.188" },
{ 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.184"
version = "0.0.188"
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/98/56/219e1f1eca6f0d521822f83fda970b44decba97dd49346c75cba00684bce/praisonaiagents-0.0.184.tar.gz", hash = "sha256:c09d036a35c5e34a3a40ffee01b94fa50ca6f6cc07f4a67a52ea1cbba42c139e", size = 412203, upload-time = "2025-12-15T22:13:47.78Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/b1/4ea337476bdb07ef504af6023e378c513a9c35414ff080a777221e70add5/praisonaiagents-0.0.188.tar.gz", hash = "sha256:ba498e6328e3a6184e3d99d2ae7a73473bd0aef747abe71e9a2c99d201e791f5", size = 438151, upload-time = "2025-12-16T08:17:22.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/a5/ffa998f4b99b763f9ff1d2ecc0a96b9669747b36563d9e453be3d4f9df3a/praisonaiagents-0.0.184-py3-none-any.whl", hash = "sha256:6aaa099cf181a9b7ac2ff446a6658ae8e2b687d55dc68a89a8eaa3f8ff1a0705", size = 398575, upload-time = "2025-12-15T22:13:46.253Z" },
{ url = "https://files.pythonhosted.org/packages/0d/3c/122ca9485b531a7307a44c1149f7b6e72b5a962b2244f8a8756f459f50e8/praisonaiagents-0.0.188-py3-none-any.whl", hash = "sha256:3ba651ca4e52de2b2f63101f265411a676f918ac308d4dbb95f2124cdc42ca6e", size = 420694, upload-time = "2025-12-16T08:17:20.78Z" },
]
[[package]]