Compare commits

...
3 Commits
Author SHA1 Message Date
Mervin Praison 657fd93743 Release v3.9.2 2026-01-16 04:37:53 +00:00
github-actions[bot] f0a2936b7f docs: auto-update api.md [skip ci] 2026-01-15 20:08:49 +00:00
Mervin Praison 58778492ca Release v3.9.1 2026-01-15 20:08:35 +00:00
40 changed files with 3688 additions and 198 deletions

No files matched your search

+3
View File
@@ -199,6 +199,7 @@ Methods:
* <code title="cli">praisonai docs run-all <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs serve <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs stats <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai doctor cleanup <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor config <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor db <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor doctor-callback <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
@@ -208,6 +209,7 @@ Methods:
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
* <code title="cli">praisonai endpoints test <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
* <code title="cli">praisonai environment check <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
@@ -238,6 +240,7 @@ Methods:
* <code title="cli">praisonai mcp list <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp mcp-callback <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp remove <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp run <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp status <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp sync <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp test <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
+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>=3.9.0" \
"praisonai>=3.9.2" \
"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>=3.9.0" \
"praisonai>=3.9.2" \
"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>=3.9.0" \
"praisonai>=3.9.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+148
View File
@@ -0,0 +1,148 @@
"""
Multi-Server MCP Example.
This example demonstrates how to use multiple MCP servers with PraisonAI Agents,
including mixing MCP tools with regular Python function tools.
Requirements:
- Node.js and npx installed (or uvx for Python-based servers)
- pip install praisonaiagents[mcp]
- OPENAI_API_KEY environment variable set
Usage:
python multi_server.py
"""
from praisonaiagents import Agent, MCP
import os
def calculate(expression: str) -> str:
"""
A simple calculator tool that evaluates mathematical expressions.
Args:
expression: A mathematical expression to evaluate (e.g., "2 + 2")
Returns:
The result of the calculation
"""
try:
# Safe evaluation of mathematical expressions
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression)
return f"Result: {result}"
except Exception as e:
return f"Error: {str(e)}"
def main():
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
return
print("=" * 60)
print("Multi-Server MCP Example")
print("=" * 60)
# Create MCP instance with Time server
print("\nInitializing MCP servers...")
try:
time_mcp = MCP("uvx mcp-server-time", timeout=30)
print("✓ Time MCP server initialized")
except Exception as e:
print(f"✗ Time MCP server failed: {e}")
return
# Create an agent with multiple tool sources
agent = Agent(
name="MultiToolAgent",
instructions="""You are a helpful assistant with access to multiple tools:
1. Time tools - for getting current time in different timezones
2. Calculator - for mathematical calculations
Use the appropriate tool based on the user's request.""",
llm="openai/gpt-4o-mini",
tools=[time_mcp, calculate] # Mix MCP and regular tools
)
# Show available tools
print("\nAvailable tools:")
formatted_tools = agent._format_tools_for_completion(agent.tools)
for tool in formatted_tools:
print(f" - {tool['function']['name']}: {tool['function']['description'][:50]}...")
# Example queries
queries = [
"What time is it in Tokyo?",
"Calculate 15 * 7 + 23",
"What's the current time in New York and London?"
]
print("\n" + "=" * 60)
print("Running queries...")
print("=" * 60)
for i, query in enumerate(queries, 1):
print(f"\n--- Query {i} ---")
print(f"Question: {query}\n")
try:
response = agent.chat(query)
print(f"Response:\n{response}")
except Exception as e:
print(f"Error: {e}")
print("-" * 40)
# Clean up
time_mcp.shutdown()
print("\nDone!")
def demo_multiple_mcp_servers():
"""
Demonstrate using multiple MCP servers simultaneously.
This function shows how to create agents with multiple MCP servers,
each providing different capabilities.
"""
print("\n" + "=" * 60)
print("Multiple MCP Servers Demo")
print("=" * 60)
# Initialize multiple MCP servers
servers = {}
try:
servers['time'] = MCP("uvx mcp-server-time", timeout=30)
print("✓ Time server initialized")
except Exception as e:
print(f"✗ Time server failed: {e}")
# Create agent with all available servers
available_tools = list(servers.values())
if not available_tools:
print("No MCP servers available")
return
agent = Agent(
name="MultiServerAgent",
instructions="You have access to multiple MCP servers. Use the appropriate tools.",
llm="openai/gpt-4o-mini",
tools=available_tools
)
print(f"\nAgent created with {len(available_tools)} MCP server(s)")
# Clean up
for server in servers.values():
server.shutdown()
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
"""
Sequential Thinking MCP Example.
This example demonstrates how to use the Sequential Thinking MCP server
with PraisonAI Agents to break down complex problems step by step.
Requirements:
- Node.js and npx installed
- pip install praisonaiagents[mcp]
- OPENAI_API_KEY environment variable set
Usage:
python sequential_thinking.py
"""
from praisonaiagents import Agent, MCP
import os
def main():
# Check for API key
if not os.environ.get("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
return
# Create MCP instance with Sequential Thinking server
# The server provides tools for step-by-step problem analysis
sequential_mcp = MCP(
"npx -y @modelcontextprotocol/server-sequential-thinking",
timeout=60
)
# Create an agent with the Sequential Thinking tools
agent = Agent(
name="ProblemSolver",
instructions="""You are an expert problem solver who breaks down complex problems
into manageable steps. When given a problem:
1. Use the sequential thinking tool to analyze it step by step
2. Provide clear, actionable steps
3. Explain your reasoning at each step""",
llm="openai/gpt-4o-mini",
tools=sequential_mcp
)
# Example problems to solve
problems = [
"Break down the process of making a cup of tea",
"How would you plan a weekend trip to a new city?",
"What steps would you take to learn a new programming language?"
]
print("=" * 60)
print("Sequential Thinking MCP Example")
print("=" * 60)
for i, problem in enumerate(problems, 1):
print(f"\n--- Problem {i} ---")
print(f"Question: {problem}\n")
response = agent.chat(problem)
print(f"Response:\n{response}")
print("-" * 40)
# Clean up
sequential_mcp.shutdown()
print("\nDone!")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,7 +7,7 @@ agent = Agent(
instructions="You are a helpful assistant",
llm="gpt-4o-mini",
tools=[get_weather],
output="debug"
output="status"
)
agent.start("What is the weather in Tokyo?")
@@ -5,6 +5,29 @@ This module uses lazy loading to minimize import time and memory usage.
Heavy dependencies like litellm are only loaded when actually needed.
"""
# =============================================================================
# NAMESPACE PACKAGE GUARD
# =============================================================================
# Detect if we're loaded as a namespace package (which indicates stale artifacts
# in site-packages). This happens when there's a praisonaiagents/ directory in
# site-packages without an __init__.py file.
#
# Root cause: Partial uninstall or version mismatch leaves directories that
# Python's PathFinder treats as namespace packages, shadowing the real package.
# =============================================================================
if __file__ is None:
import warnings as _ns_warnings
_ns_warnings.warn(
"praisonaiagents is loaded as a namespace package, which indicates "
"stale artifacts in site-packages. This will cause import errors. "
"Fix: Remove the stale directory with:\n"
" rm -rf $(python -c \"import site; print(site.getsitepackages()[0])\")/praisonaiagents/\n"
"Then reinstall: pip install praisonaiagents",
ImportWarning,
stacklevel=1
)
del _ns_warnings
# Apply warning patch BEFORE any imports to intercept warnings at the source
from . import _warning_patch # noqa: F401
@@ -4762,9 +4762,11 @@ Write the complete compiled report:"""
)
console.print(response_panel)
# Show tool activity summary if tools were called
# Show tool activity summary if tools were called (deduplicated)
if tools_called:
tools_summary = ", ".join(tools_called)
# Use dict.fromkeys to preserve order while removing duplicates
unique_tools = list(dict.fromkeys(tools_called))
tools_summary = ", ".join(unique_tools)
console.print(f"[dim]🔧 Tools used: {tools_summary}[/dim]")
else:
result = self.chat(prompt, **kwargs)
+23 -13
View File
@@ -3756,9 +3756,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
output_json = override_params.get('output_json')
output_pydantic = override_params.get('output_pydantic')
# Always remove these from params as they're not native litellm parameters
params.pop('output_json', None)
params.pop('output_pydantic', None)
# Always remove internal PraisonAI parameters that are not native litellm parameters
# These are used internally by PraisonAI but should not be passed to the LLM API
internal_params = [
'output_json', 'output_pydantic', # Structured output handling
'reflection', 'self_reflect', 'max_reflect', 'min_reflect', 'reflect_llm', # Self-reflection
'verbose', 'markdown', # Display options
'agent_name', 'agent_role', 'agent_tools', # Agent metadata
'task_name', 'task_description', 'task_id', # Task metadata
'execute_tool_fn', 'stream_callback', 'emit_events', # Callbacks
'console', # Rich console
]
for param in internal_params:
params.pop(param, None)
if output_json or output_pydantic:
@@ -3777,18 +3787,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Using Gemini native structured output with schema: {json.dumps(schema, indent=2)}")
# Add tool_choice="auto" when tools are provided (unless already specified)
# This encourages the model to use tools when appropriate
if 'tools' in params and params['tools'] and 'tool_choice' not in params:
# For Gemini models, use tool_choice to encourage tool usage
if self._is_gemini_model():
try:
import litellm
# Check if model supports function calling before setting tool_choice
if litellm.supports_function_calling(model=self.model):
params['tool_choice'] = 'auto'
except Exception as e:
# If check fails, still set tool_choice for known Gemini models
logging.debug(f"Could not verify function calling support: {e}. Setting tool_choice anyway.")
try:
import litellm
# Check if model supports function calling before setting tool_choice
if litellm.supports_function_calling(model=self.model):
params['tool_choice'] = 'auto'
logging.debug(f"Set tool_choice='auto' for model {self.model}")
except Exception as e:
# If check fails, still set tool_choice to encourage tool usage
logging.debug(f"Could not verify function calling support: {e}. Setting tool_choice='auto' anyway.")
params['tool_choice'] = 'auto'
# Add web_search_options if web_search is enabled and model supports it
if self.web_search and self._supports_web_search():
@@ -45,6 +45,15 @@ def __getattr__(name):
elif name == "python_type_to_json_schema":
from .mcp_utils import python_type_to_json_schema
return python_type_to_json_schema
elif name == "fix_array_schemas":
from .mcp_schema_utils import fix_array_schemas
return fix_array_schemas
elif name == "ThreadLocalEventLoop":
from .mcp_schema_utils import ThreadLocalEventLoop
return ThreadLocalEventLoop
elif name == "get_thread_local_event_loop":
from .mcp_schema_utils import get_thread_local_event_loop
return get_thread_local_event_loop
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
@@ -57,6 +66,10 @@ __all__ = [
"function_to_mcp_schema",
"get_tool_metadata",
"python_type_to_json_schema",
# Schema utilities (lazy loaded)
"fix_array_schemas",
"ThreadLocalEventLoop",
"get_thread_local_event_loop",
# Transport components (lazy loaded)
"WebSocketMCPClient",
"SessionManager",
@@ -507,6 +507,26 @@ class MCP:
"""
return iter(self._tools)
def get_tools(self) -> List[Callable]:
"""
Get the list of tool functions from this MCP instance.
This method provides explicit access to the tools list, which is useful
when you need to inspect or manipulate the tools programmatically.
Returns:
List[Callable]: List of tool functions that can be called
Example:
```python
mcp = MCP("npx -y @modelcontextprotocol/server-time")
tools = mcp.get_tools()
for tool in tools:
print(f"Tool: {tool.__name__}")
```
"""
return self._tools
def _fix_array_schemas(self, schema):
"""
Fix array schemas by adding missing 'items' attribute required by OpenAI.
@@ -30,20 +30,19 @@ except ImportError:
logger = logging.getLogger("mcp-http-stream")
# Global event loop for async operations
_event_loop = None
# Import shared utilities for thread-safe event loop and schema fixing
from .mcp_schema_utils import ThreadLocalEventLoop, fix_array_schemas
# Thread-local event loop for async operations (thread-safe)
_event_loop_manager = ThreadLocalEventLoop()
# Global registry of active clients for cleanup
_active_clients = weakref.WeakSet()
_cleanup_registered = False
def get_event_loop():
"""Get or create a global event loop."""
global _event_loop
if _event_loop is None or _event_loop.is_closed():
_event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_event_loop)
return _event_loop
"""Get or create a thread-local event loop."""
return _event_loop_manager.get_loop()
def _cleanup_all_clients():
@@ -148,47 +147,10 @@ class HTTPStreamMCPTool:
logger.error(f"Error in _async_call for {self.name}: {e}")
raise
def _fix_array_schemas(self, schema):
"""
Fix array schemas by adding missing 'items' attribute required by OpenAI.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas
if "properties" in fixed_schema:
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema:
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
def to_openai_tool(self):
"""Convert the tool to OpenAI format."""
# Fix array schemas to include 'items' attribute
fixed_schema = self._fix_array_schemas(self.input_schema)
# Fix array schemas to include 'items' attribute (using shared utility)
fixed_schema = fix_array_schemas(self.input_schema)
return {
"type": "function",
@@ -0,0 +1,189 @@
"""
MCP Schema Utilities.
This module provides shared schema manipulation utilities for MCP transports.
These utilities ensure compatibility with OpenAI's function calling format
and other LLM providers.
DRY: This module centralizes schema fixing logic that was previously
duplicated across mcp.py, mcp_sse.py, mcp_http_stream.py, and mcp_websocket.py.
"""
import threading
from typing import Any, Dict, Optional
def fix_array_schemas(schema: Any) -> Any:
"""
Fix array schemas by adding missing 'items' attribute required by OpenAI.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema with 'items' added to array types
Example:
>>> schema = {"type": "array"}
>>> fixed = fix_array_schemas(schema)
>>> fixed
{'type': 'array', 'items': {'type': 'string'}}
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas
if "properties" in fixed_schema:
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
fixed_properties[prop_name] = fix_array_schemas(prop_schema)
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema:
fixed_schema["items"] = fix_array_schemas(fixed_schema["items"])
# Fix additionalProperties if it's a schema
if isinstance(fixed_schema.get("additionalProperties"), dict):
fixed_schema["additionalProperties"] = fix_array_schemas(
fixed_schema["additionalProperties"]
)
# Fix anyOf/oneOf/allOf schemas
for key in ("anyOf", "oneOf", "allOf"):
if key in fixed_schema and isinstance(fixed_schema[key], list):
fixed_schema[key] = [fix_array_schemas(s) for s in fixed_schema[key]]
return fixed_schema
class ThreadLocalEventLoop:
"""
Thread-local event loop storage for MCP transports.
This class provides thread-safe event loop management, ensuring that
multiple MCP instances don't share the same event loop, which could
cause race conditions.
Usage:
event_loop_manager = ThreadLocalEventLoop()
loop = event_loop_manager.get_loop()
# Use loop for async operations
event_loop_manager.cleanup()
"""
def __init__(self):
"""Initialize thread-local storage."""
self._local = threading.local()
self._lock = threading.Lock()
def get_loop(self):
"""
Get or create an event loop for the current thread.
Returns:
asyncio.AbstractEventLoop: Event loop for current thread
"""
import asyncio
# Check if we have a loop for this thread
loop = getattr(self._local, 'loop', None)
if loop is None or loop.is_closed():
with self._lock:
# Double-check after acquiring lock
loop = getattr(self._local, 'loop', None)
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
self._local.loop = loop
return loop
def set_loop(self, loop):
"""
Set the event loop for the current thread.
Args:
loop: The event loop to set
"""
self._local.loop = loop
def cleanup(self):
"""Clean up the event loop for the current thread."""
loop = getattr(self._local, 'loop', None)
if loop is not None and not loop.is_closed():
try:
loop.close()
except Exception:
pass
self._local.loop = None
# Singleton instance for backward compatibility with existing code
# that uses get_event_loop() function pattern
_default_event_loop_manager = ThreadLocalEventLoop()
def get_thread_local_event_loop():
"""
Get or create a thread-local event loop.
This is a drop-in replacement for the global get_event_loop() functions
in the transport modules, providing thread safety.
Returns:
asyncio.AbstractEventLoop: Event loop for current thread
"""
return _default_event_loop_manager.get_loop()
def json_type_to_python_type(json_type: str) -> type:
"""
Convert JSON Schema type to Python type annotation.
Args:
json_type: JSON Schema type string
Returns:
Python type corresponding to the JSON type
"""
type_map = {
'string': str,
'integer': int,
'number': float,
'boolean': bool,
'array': list,
'object': dict,
'null': type(None),
}
return type_map.get(json_type, str)
def extract_schema_properties(input_schema: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""
Extract properties and required fields from an input schema.
Args:
input_schema: JSON Schema for tool input
Returns:
Dictionary with 'properties' and 'required' keys
"""
if not input_schema:
return {'properties': {}, 'required': []}
return {
'properties': input_schema.get('properties', {}),
'required': input_schema.get('required', [])
}
@@ -22,16 +22,15 @@ except ImportError:
logger = logging.getLogger("mcp-sse")
# Global event loop for async operations
_event_loop = None
# Import shared utilities for thread-safe event loop and schema fixing
from .mcp_schema_utils import ThreadLocalEventLoop, fix_array_schemas
# Thread-local event loop for async operations (thread-safe)
_event_loop_manager = ThreadLocalEventLoop()
def get_event_loop():
"""Get or create a global event loop."""
global _event_loop
if _event_loop is None or _event_loop.is_closed():
_event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_event_loop)
return _event_loop
"""Get or create a thread-local event loop."""
return _event_loop_manager.get_loop()
class SSEMCPTool:
@@ -111,47 +110,10 @@ class SSEMCPTool:
logger.error(f"Error in _async_call for {self.name}: {e}")
raise
def _fix_array_schemas(self, schema):
"""
Fix array schemas by adding missing 'items' attribute required by OpenAI.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas
if "properties" in fixed_schema:
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema:
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
def to_openai_tool(self):
"""Convert the tool to OpenAI format."""
# Fix array schemas to include 'items' attribute
fixed_schema = self._fix_array_schemas(self.input_schema)
# Fix array schemas to include 'items' attribute (using shared utility)
fixed_schema = fix_array_schemas(self.input_schema)
return {
"type": "function",
@@ -26,6 +26,9 @@ from typing import Any, Dict, Optional, List
logger = logging.getLogger("mcp-websocket")
# Import shared utilities for thread-safe event loop and schema fixing
from .mcp_schema_utils import ThreadLocalEventLoop, fix_array_schemas
def is_websocket_url(url: str) -> bool:
"""
@@ -58,18 +61,13 @@ def calculate_backoff(attempt: int, base_delay: float = 1.0, max_delay: float =
return min(delay, max_delay)
# Global event loop for WebSocket operations (similar to other MCP transports)
_event_loop = None
_loop_lock = threading.Lock()
# Thread-local event loop for WebSocket operations (thread-safe)
_event_loop_manager = ThreadLocalEventLoop()
def get_event_loop():
"""Get or create a global event loop for WebSocket operations."""
global _event_loop
with _loop_lock:
if _event_loop is None or _event_loop.is_closed():
_event_loop = asyncio.new_event_loop()
return _event_loop
"""Get or create a thread-local event loop for WebSocket operations."""
return _event_loop_manager.get_loop()
class WebSocketTransport:
@@ -359,36 +357,10 @@ class WebSocketMCPTool:
logger.error(f"Error in _async_call for {self.name}: {e}")
raise
def _fix_array_schemas(self, schema: Dict[str, Any]) -> Dict[str, Any]:
"""
Fix array schemas by adding missing 'items' attribute.
OpenAI requires array types to specify item types.
"""
if not isinstance(schema, dict):
return schema
fixed_schema = schema.copy()
# Fix array types at current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas
if "properties" in fixed_schema:
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
fixed_schema["properties"] = fixed_properties
if "items" in fixed_schema:
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
def to_openai_tool(self) -> Dict[str, Any]:
"""Convert the tool to OpenAI function calling format."""
fixed_schema = self._fix_array_schemas(self.input_schema)
# Fix array schemas to include 'items' attribute (using shared utility)
fixed_schema = fix_array_schemas(self.input_schema)
return {
"type": "function",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.12.0"
version = "0.12.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -4,7 +4,7 @@ import os
sequential_agent = Agent(
instructions="""You are a helpful assistant that can break down complex problems.
Use the available tools when relevant to perform step-by-step analysis.""",
llm="openai/gpt-5-nano",
llm="openai/gpt-4o-mini",
tools=MCP("npx -y @modelcontextprotocol/server-sequential-thinking")
)
@@ -0,0 +1,246 @@
"""
Integration tests for Multi-Agent MCP scenarios.
These tests verify that multiple agents can use different MCP servers
concurrently without interference.
Note: These tests require MCP servers to be available via npx/uvx.
They are marked as integration tests and can be skipped in CI.
"""
import pytest
import os
import threading
import time
# Skip entire module if MCP package is not installed
try:
from praisonaiagents.mcp import MCP
from praisonaiagents import Agent
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestMultiAgentMCP:
"""Integration tests for multiple agents with MCP."""
def test_multiple_mcp_instances_isolated(self):
"""Test that multiple MCP instances are isolated."""
try:
mcp1 = MCP("uvx mcp-server-time", timeout=30)
mcp2 = MCP("uvx mcp-server-time", timeout=30)
# Each should have its own tools
tools1 = mcp1.get_tools()
tools2 = mcp2.get_tools()
assert len(tools1) > 0
assert len(tools2) > 0
# Tools should be separate instances
assert tools1 is not tools2
mcp1.shutdown()
mcp2.shutdown()
except Exception as e:
pytest.skip(f"MCP server not available: {e}")
def test_multiple_agents_different_mcp(self):
"""Test multiple agents with different MCP servers."""
try:
mcp_time = MCP("uvx mcp-server-time", timeout=30)
agent1 = Agent(
name="TimeAgent",
instructions="You help with time-related queries.",
tools=mcp_time
)
agent2 = Agent(
name="GeneralAgent",
instructions="You are a general assistant."
)
# Both agents should be functional
assert agent1 is not None
assert agent2 is not None
# Agent1 should have MCP tools
assert agent1.tools is not None
mcp_time.shutdown()
except Exception as e:
pytest.skip(f"MCP server not available: {e}")
def test_concurrent_mcp_initialization(self):
"""Test concurrent MCP initialization from multiple threads."""
results = []
errors = []
def init_mcp():
try:
mcp = MCP("uvx mcp-server-time", timeout=30)
tools = mcp.get_tools()
results.append(len(tools))
mcp.shutdown()
except Exception as e:
errors.append(str(e))
# Start multiple threads
threads = []
for _ in range(3):
t = threading.Thread(target=init_mcp)
threads.append(t)
t.start()
# Wait for all threads
for t in threads:
t.join(timeout=60)
# Check results
if errors:
# If all failed with same error, skip
if all("not available" in e.lower() or "timeout" in e.lower() for e in errors):
pytest.skip(f"MCP server not available: {errors[0]}")
else:
pytest.fail(f"Errors during concurrent init: {errors}")
assert len(results) == 3, f"Expected 3 results, got {len(results)}"
assert all(r > 0 for r in results), "All MCP instances should have tools"
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestMCPToolExecution:
"""Integration tests for MCP tool execution."""
def test_tool_execution_returns_result(self):
"""Test that tool execution returns a result."""
try:
mcp = MCP("uvx mcp-server-time", timeout=30)
tools = mcp.get_tools()
# Find a tool that we can call
for tool in tools:
if "time" in tool.__name__.lower() or "current" in tool.__name__.lower():
# Try to call the tool
try:
result = tool(timezone="UTC")
assert result is not None
break
except TypeError:
# Tool might have different signature
continue
mcp.shutdown()
except Exception as e:
pytest.skip(f"MCP server not available: {e}")
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestMCPWithMixedTools:
"""Integration tests for MCP with mixed tool types."""
def test_agent_with_mcp_and_regular_tools(self):
"""Test agent with both MCP and regular function tools."""
def regular_tool(query: str) -> str:
"""A regular Python function tool."""
return f"Regular tool result: {query}"
try:
mcp = MCP("uvx mcp-server-time", timeout=30)
agent = Agent(
name="MixedToolsAgent",
instructions="You have both MCP and regular tools.",
tools=[mcp, regular_tool]
)
assert agent is not None
assert len(agent.tools) == 2
# Format tools for LLM
formatted = agent._format_tools_for_completion(agent.tools)
assert len(formatted) > 1 # Should have MCP tools + regular tool
mcp.shutdown()
except Exception as e:
pytest.skip(f"MCP server not available: {e}")
def test_multiple_mcp_in_tools_list(self):
"""Test agent with multiple MCP instances in tools list."""
try:
mcp1 = MCP("uvx mcp-server-time", timeout=30)
agent = Agent(
name="MultiMCPAgent",
instructions="You have multiple MCP servers.",
tools=[mcp1]
)
assert agent is not None
# Format tools should include all MCP tools
formatted = agent._format_tools_for_completion(agent.tools)
assert len(formatted) > 0
mcp1.shutdown()
except Exception as e:
pytest.skip(f"MCP server not available: {e}")
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestMCPThreadSafety:
"""Integration tests for MCP thread safety."""
def test_concurrent_tool_calls(self):
"""Test concurrent tool calls from multiple threads."""
try:
mcp = MCP("uvx mcp-server-time", timeout=30)
tools = mcp.get_tools()
if not tools:
pytest.skip("No tools available")
results = []
errors = []
def call_tool():
try:
tool = tools[0]
# Try calling with common parameter patterns
try:
result = tool(timezone="UTC")
except TypeError:
result = tool()
results.append(result)
except Exception as e:
errors.append(str(e))
# Start multiple threads
threads = []
for _ in range(5):
t = threading.Thread(target=call_tool)
threads.append(t)
t.start()
# Wait for all threads
for t in threads:
t.join(timeout=30)
# All calls should succeed
assert len(errors) == 0, f"Errors: {errors}"
assert len(results) == 5
mcp.shutdown()
except Exception as e:
pytest.skip(f"MCP server not available: {e}")
if __name__ == "__main__":
pytest.main([__file__, "-v", "-m", "integration"])
@@ -0,0 +1,155 @@
"""
Integration tests for Sequential Thinking MCP server.
These tests verify end-to-end functionality with the Sequential Thinking
MCP server (@modelcontextprotocol/server-sequential-thinking).
Note: These tests require the MCP server to be available via npx.
They are marked as integration tests and can be skipped in CI.
"""
import pytest
import os
import sys
# Skip entire module if MCP package is not installed
try:
from praisonaiagents.mcp import MCP
from praisonaiagents import Agent
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
@pytest.fixture
def sequential_thinking_mcp():
"""Create a Sequential Thinking MCP server instance."""
if not MCP_AVAILABLE:
pytest.skip("MCP package not installed")
try:
mcp = MCP("npx -y @modelcontextprotocol/server-sequential-thinking", timeout=60)
yield mcp
mcp.shutdown()
except Exception as e:
pytest.skip(f"Sequential Thinking MCP server not available: {e}")
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestSequentialThinkingMCP:
"""Integration tests for Sequential Thinking MCP server."""
def test_mcp_initializes(self, sequential_thinking_mcp):
"""Test that MCP initializes successfully."""
assert sequential_thinking_mcp is not None
def test_mcp_has_tools(self, sequential_thinking_mcp):
"""Test that MCP has tools available."""
tools = sequential_thinking_mcp.get_tools()
assert isinstance(tools, list)
assert len(tools) > 0
def test_mcp_tools_are_callable(self, sequential_thinking_mcp):
"""Test that MCP tools are callable."""
tools = sequential_thinking_mcp.get_tools()
for tool in tools:
assert callable(tool)
def test_mcp_to_openai_tool(self, sequential_thinking_mcp):
"""Test conversion to OpenAI tool format."""
openai_tools = sequential_thinking_mcp.to_openai_tool()
assert isinstance(openai_tools, list)
assert len(openai_tools) > 0
for tool in openai_tools:
assert "type" in tool
assert tool["type"] == "function"
assert "function" in tool
assert "name" in tool["function"]
assert "description" in tool["function"]
def test_mcp_iterable(self, sequential_thinking_mcp):
"""Test that MCP is iterable."""
tools = list(sequential_thinking_mcp)
assert len(tools) > 0
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestSequentialThinkingWithAgent:
"""Integration tests for Sequential Thinking MCP with Agent."""
@pytest.fixture
def agent_with_sequential_thinking(self):
"""Create an Agent with Sequential Thinking MCP tools."""
try:
mcp = MCP("npx -y @modelcontextprotocol/server-sequential-thinking", timeout=60)
agent = Agent(
name="SequentialThinkingAgent",
instructions="You are a helpful assistant that breaks down complex problems step by step.",
tools=mcp
)
yield agent, mcp
mcp.shutdown()
except Exception as e:
pytest.skip(f"Could not create agent with MCP: {e}")
def test_agent_accepts_mcp_tools(self, agent_with_sequential_thinking):
"""Test that Agent accepts MCP tools."""
agent, mcp = agent_with_sequential_thinking
assert agent is not None
assert agent.tools is not None
def test_agent_formats_mcp_tools(self, agent_with_sequential_thinking):
"""Test that Agent can format MCP tools for LLM."""
agent, mcp = agent_with_sequential_thinking
# Get formatted tools
formatted = agent._format_tools_for_completion(agent.tools)
assert isinstance(formatted, list)
assert len(formatted) > 0
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
@pytest.mark.skipif(
os.environ.get("SKIP_LLM_TESTS") == "1",
reason="LLM tests skipped"
)
class TestSequentialThinkingEndToEnd:
"""End-to-end tests with actual LLM calls."""
@pytest.fixture
def agent_with_llm(self):
"""Create an Agent with LLM and Sequential Thinking MCP."""
try:
mcp = MCP("npx -y @modelcontextprotocol/server-sequential-thinking", timeout=60)
agent = Agent(
name="SequentialThinkingAgent",
instructions="You are a helpful assistant that breaks down complex problems step by step. Use the sequential thinking tool when asked to break down a problem.",
llm="openai/gpt-4o-mini",
tools=mcp
)
yield agent, mcp
mcp.shutdown()
except Exception as e:
pytest.skip(f"Could not create agent: {e}")
def test_agent_can_use_sequential_thinking(self, agent_with_llm):
"""Test that agent can use sequential thinking tool."""
agent, mcp = agent_with_llm
# This test requires an API key and will make actual LLM calls
if not os.environ.get("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set")
response = agent.chat("Break down the process of making a cup of tea into steps")
assert response is not None
assert len(response) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v", "-m", "integration"])
@@ -0,0 +1,95 @@
"""
TDD Tests for MCP.get_tools() method.
These tests verify that the MCP class has a get_tools() method
that returns the list of tool functions.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
class TestMCPGetTools:
"""Test MCP.get_tools() method."""
def test_get_tools_method_exists(self):
"""Test that MCP class has get_tools method."""
from praisonaiagents.mcp.mcp import MCP
assert hasattr(MCP, 'get_tools'), "MCP class should have get_tools method"
def test_get_tools_returns_list(self):
"""Test that get_tools returns a list."""
from praisonaiagents.mcp.mcp import MCP
# Create a mock MCP instance without actually connecting
with patch.object(MCP, '__init__', lambda self: None):
mcp = MCP.__new__(MCP)
mcp._tools = [lambda x: x, lambda y: y]
result = mcp.get_tools()
assert isinstance(result, list), "get_tools should return a list"
assert len(result) == 2, "get_tools should return all tools"
def test_get_tools_returns_same_as_iter(self):
"""Test that get_tools returns same tools as __iter__."""
from praisonaiagents.mcp.mcp import MCP
with patch.object(MCP, '__init__', lambda self: None):
mcp = MCP.__new__(MCP)
tool1 = lambda x: x
tool2 = lambda y: y
mcp._tools = [tool1, tool2]
get_tools_result = mcp.get_tools()
iter_result = list(mcp)
assert get_tools_result == iter_result, "get_tools should return same as __iter__"
def test_get_tools_returns_empty_list_when_no_tools(self):
"""Test that get_tools returns empty list when no tools."""
from praisonaiagents.mcp.mcp import MCP
with patch.object(MCP, '__init__', lambda self: None):
mcp = MCP.__new__(MCP)
mcp._tools = []
result = mcp.get_tools()
assert result == [], "get_tools should return empty list when no tools"
def test_get_tools_returns_callable_items(self):
"""Test that get_tools returns callable items."""
from praisonaiagents.mcp.mcp import MCP
with patch.object(MCP, '__init__', lambda self: None):
mcp = MCP.__new__(MCP)
mcp._tools = [lambda x: x, lambda y: y]
result = mcp.get_tools()
for tool in result:
assert callable(tool), "Each tool should be callable"
class TestMCPGetToolsIntegration:
"""Integration tests for get_tools with real MCP initialization."""
@pytest.mark.skipif(
True, # Skip by default - requires MCP server
reason="Requires MCP server to be running"
)
def test_get_tools_with_real_mcp(self):
"""Test get_tools with real MCP server."""
from praisonaiagents.mcp import MCP
# This would require a real MCP server
mcp = MCP("uvx mcp-server-time", timeout=30)
tools = mcp.get_tools()
assert isinstance(tools, list)
assert len(tools) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,216 @@
"""
TDD Tests for MCP thread safety.
These tests verify that MCP transports use thread-local event loops
instead of global event loops, ensuring thread safety when multiple
MCP instances are used concurrently.
"""
import pytest
import threading
import time
from unittest.mock import patch
class TestThreadLocalEventLoop:
"""Test ThreadLocalEventLoop class."""
def test_thread_local_event_loop_exists(self):
"""Test that ThreadLocalEventLoop class exists."""
from praisonaiagents.mcp.mcp_schema_utils import ThreadLocalEventLoop
assert ThreadLocalEventLoop is not None
def test_get_loop_returns_event_loop(self):
"""Test that get_loop returns an event loop."""
import asyncio
from praisonaiagents.mcp.mcp_schema_utils import ThreadLocalEventLoop
manager = ThreadLocalEventLoop()
loop = manager.get_loop()
assert loop is not None
assert isinstance(loop, asyncio.AbstractEventLoop)
def test_same_thread_gets_same_loop(self):
"""Test that same thread gets same event loop."""
from praisonaiagents.mcp.mcp_schema_utils import ThreadLocalEventLoop
manager = ThreadLocalEventLoop()
loop1 = manager.get_loop()
loop2 = manager.get_loop()
assert loop1 is loop2, "Same thread should get same loop"
def test_different_threads_get_different_loops(self):
"""Test that different threads get different event loops."""
from praisonaiagents.mcp.mcp_schema_utils import ThreadLocalEventLoop
manager = ThreadLocalEventLoop()
loops = []
errors = []
def get_loop_in_thread():
try:
loop = manager.get_loop()
loops.append(loop)
except Exception as e:
errors.append(e)
# Create multiple threads
threads = []
for _ in range(3):
t = threading.Thread(target=get_loop_in_thread)
threads.append(t)
t.start()
# Wait for all threads
for t in threads:
t.join()
assert len(errors) == 0, f"Errors occurred: {errors}"
assert len(loops) == 3, "Should have 3 loops"
# All loops should be different
unique_loops = set(id(loop) for loop in loops)
assert len(unique_loops) == 3, "Each thread should have its own loop"
def test_cleanup_closes_loop(self):
"""Test that cleanup closes the event loop."""
from praisonaiagents.mcp.mcp_schema_utils import ThreadLocalEventLoop
manager = ThreadLocalEventLoop()
loop = manager.get_loop()
assert not loop.is_closed(), "Loop should not be closed initially"
manager.cleanup()
assert loop.is_closed(), "Loop should be closed after cleanup"
def test_get_loop_after_cleanup_creates_new_loop(self):
"""Test that get_loop after cleanup creates a new loop."""
from praisonaiagents.mcp.mcp_schema_utils import ThreadLocalEventLoop
manager = ThreadLocalEventLoop()
loop1 = manager.get_loop()
manager.cleanup()
loop2 = manager.get_loop()
assert loop1 is not loop2, "Should create new loop after cleanup"
assert not loop2.is_closed(), "New loop should not be closed"
class TestGetThreadLocalEventLoop:
"""Test get_thread_local_event_loop function."""
def test_function_exists(self):
"""Test that get_thread_local_event_loop function exists."""
from praisonaiagents.mcp.mcp_schema_utils import get_thread_local_event_loop
assert callable(get_thread_local_event_loop)
def test_returns_event_loop(self):
"""Test that function returns an event loop."""
import asyncio
from praisonaiagents.mcp.mcp_schema_utils import get_thread_local_event_loop
loop = get_thread_local_event_loop()
assert loop is not None
assert isinstance(loop, asyncio.AbstractEventLoop)
class TestFixArraySchemas:
"""Test fix_array_schemas function."""
def test_function_exists(self):
"""Test that fix_array_schemas function exists."""
from praisonaiagents.mcp.mcp_schema_utils import fix_array_schemas
assert callable(fix_array_schemas)
def test_adds_items_to_array_without_items(self):
"""Test that items is added to array schemas without it."""
from praisonaiagents.mcp.mcp_schema_utils import fix_array_schemas
schema = {"type": "array"}
fixed = fix_array_schemas(schema)
assert "items" in fixed
assert fixed["items"] == {"type": "string"}
def test_preserves_existing_items(self):
"""Test that existing items are preserved."""
from praisonaiagents.mcp.mcp_schema_utils import fix_array_schemas
schema = {"type": "array", "items": {"type": "integer"}}
fixed = fix_array_schemas(schema)
assert fixed["items"] == {"type": "integer"}
def test_fixes_nested_arrays(self):
"""Test that nested arrays are fixed."""
from praisonaiagents.mcp.mcp_schema_utils import fix_array_schemas
schema = {
"type": "object",
"properties": {
"tags": {"type": "array"}
}
}
fixed = fix_array_schemas(schema)
assert "items" in fixed["properties"]["tags"]
def test_handles_non_dict_input(self):
"""Test that non-dict input is returned as-is."""
from praisonaiagents.mcp.mcp_schema_utils import fix_array_schemas
assert fix_array_schemas("string") == "string"
assert fix_array_schemas(123) == 123
assert fix_array_schemas(None) is None
def test_does_not_modify_original(self):
"""Test that original schema is not modified."""
from praisonaiagents.mcp.mcp_schema_utils import fix_array_schemas
original = {"type": "array"}
fixed = fix_array_schemas(original)
assert "items" not in original
assert "items" in fixed
class TestMCPTransportThreadSafety:
"""Test that MCP transports use thread-local event loops."""
def test_sse_uses_thread_local(self):
"""Test that SSE transport can use thread-local event loop."""
# This test verifies the import works - actual thread safety
# is tested via ThreadLocalEventLoop tests
try:
from praisonaiagents.mcp.mcp_schema_utils import get_thread_local_event_loop
loop = get_thread_local_event_loop()
assert loop is not None
except ImportError:
pytest.skip("MCP not available")
def test_http_stream_uses_thread_local(self):
"""Test that HTTP Stream transport can use thread-local event loop."""
try:
from praisonaiagents.mcp.mcp_schema_utils import get_thread_local_event_loop
loop = get_thread_local_event_loop()
assert loop is not None
except ImportError:
pytest.skip("MCP not available")
def test_websocket_uses_thread_local(self):
"""Test that WebSocket transport can use thread-local event loop."""
try:
from praisonaiagents.mcp.mcp_schema_utils import get_thread_local_event_loop
loop = get_thread_local_event_loop()
assert loop is not None
except ImportError:
pytest.skip("MCP not available")
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,147 @@
"""
Tests for namespace package guard in praisonaiagents.
These tests verify that praisonaiagents properly detects when it's loaded
as a namespace package (which indicates stale artifacts in site-packages)
and provides helpful error messages.
"""
import pytest
class TestNamespaceGuard:
"""Tests for namespace package detection."""
def test_praisonaiagents_has_file_attribute(self):
"""Verify praisonaiagents.__file__ is not None (not a namespace package)."""
import praisonaiagents
assert praisonaiagents.__file__ is not None, (
"praisonaiagents.__file__ is None - this indicates it's loaded as a "
"namespace package. Remove stale praisonaiagents directory from site-packages."
)
def test_praisonaiagents_has_init_py(self):
"""Verify praisonaiagents has a proper __init__.py."""
import praisonaiagents
import os
# __file__ should point to __init__.py
if praisonaiagents.__file__:
assert praisonaiagents.__file__.endswith("__init__.py"), (
f"Expected __init__.py, got {praisonaiagents.__file__}"
)
assert os.path.exists(praisonaiagents.__file__), (
f"__init__.py does not exist at {praisonaiagents.__file__}"
)
def test_agent_import_succeeds(self):
"""Verify Agent can be imported from praisonaiagents."""
from praisonaiagents import Agent
assert Agent is not None
assert hasattr(Agent, '__init__')
def test_lazy_loading_works(self):
"""Verify lazy loading via __getattr__ works correctly."""
import praisonaiagents
# These should be lazy-loaded via __getattr__
Agent = praisonaiagents.Agent
assert Agent is not None
# Verify it's cached
Agent2 = praisonaiagents.Agent
assert Agent is Agent2
def test_package_spec_has_origin(self):
"""Verify the package spec has an origin (not a namespace package)."""
import praisonaiagents
spec = praisonaiagents.__spec__
assert spec is not None, "Package has no __spec__"
# Namespace packages have origin=None
# Regular packages have origin pointing to __init__.py
assert spec.origin is not None, (
"Package spec.origin is None - this indicates a namespace package. "
"Remove stale praisonaiagents directory from site-packages."
)
def test_submodule_search_locations_correct(self):
"""Verify submodule_search_locations points to source, not site-packages stale dir."""
import praisonaiagents
spec = praisonaiagents.__spec__
locations = spec.submodule_search_locations
assert locations is not None, "No submodule_search_locations"
assert len(locations) > 0, "Empty submodule_search_locations"
# Should NOT contain site-packages path without __init__.py
for loc in locations:
if "site-packages" in loc:
import os
init_path = os.path.join(loc, "__init__.py")
# If it's in site-packages, it should have __init__.py
# (this would be a proper install, not a stale namespace)
if os.path.isdir(loc):
assert os.path.exists(init_path), (
f"Found stale namespace package at {loc} - "
"remove this directory to fix import issues"
)
class TestAgentImportIntegrity:
"""Tests for Agent class import integrity."""
def test_agent_class_attributes(self):
"""Verify Agent class has expected attributes."""
from praisonaiagents import Agent
# Core attributes that should exist
assert hasattr(Agent, '__init__')
assert hasattr(Agent, 'start')
assert hasattr(Agent, 'chat')
def test_agents_class_import(self):
"""Verify Agents class can be imported."""
from praisonaiagents import Agents
assert Agents is not None
def test_task_class_import(self):
"""Verify Task class can be imported."""
from praisonaiagents import Task
assert Task is not None
def test_tool_decorator_import(self):
"""Verify tool decorator can be imported."""
from praisonaiagents import tool
assert tool is not None
assert callable(tool)
def test_tools_class_import(self):
"""Verify Tools class can be imported."""
from praisonaiagents import Tools
assert Tools is not None
class TestImportErrorMessages:
"""Tests for helpful error messages on import failures."""
def test_import_error_is_informative(self):
"""Verify that import errors provide helpful information."""
# This test documents expected behavior - if Agent import fails,
# the error message should be informative
try:
from praisonaiagents import Agent
# If we get here, import succeeded - that's good
assert Agent is not None
except ImportError as e:
# If import fails, error should mention namespace package
error_msg = str(e).lower()
assert any(word in error_msg for word in [
"namespace", "stale", "site-packages", "unknown location"
]), f"Import error should be informative, got: {e}"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.12.0"
version = "0.12.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.9.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.9.0.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.9.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.9.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+2
View File
@@ -215,6 +215,7 @@ def register_commands():
from .commands.endpoints import app as endpoints_app
from .commands.test import app as test_app
from .commands.examples import app as examples_app
from .commands.batch import app as batch_app
# Import TUI and queue commands
from .features.tui.debug import create_debug_app as create_tui_debug_app
@@ -272,6 +273,7 @@ def register_commands():
app.add_typer(endpoints_app, name="endpoints", help="API endpoint management")
app.add_typer(test_app, name="test", help="Run test suite with tier and provider options")
app.add_typer(examples_app, name="examples", help="Run and manage example files")
app.add_typer(batch_app, name="batch", help="Run all PraisonAI scripts in current folder")
# Register standardise command
try:
@@ -0,0 +1,848 @@
"""
PraisonAI Batch CLI Command.
Run all Python files with PraisonAI imports in the current directory.
Designed for quick debugging - running multiple local scripts at once.
Usage:
praisonai batch # Run all PraisonAI scripts in current dir
praisonai batch --sub # Include subdirectories
praisonai batch --sub --depth 2 # Limit recursion depth
praisonai batch --parallel # Run in parallel with async reporting
praisonai batch list # List discovered files
praisonai batch stats # Show statistics
praisonai batch report # View latest report
"""
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(
help="Run all PraisonAI scripts in current folder for quick debugging",
no_args_is_help=False,
)
def _get_default_report_dir() -> Path:
"""Get default report directory with timestamp."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return Path.home() / "Downloads" / "reports" / "batch" / timestamp
def _get_latest_report_dir() -> Optional[Path]:
"""Get the latest report directory."""
base_dir = Path.home() / "Downloads" / "reports" / "batch"
if not base_dir.exists():
return None
# Find most recent timestamp directory
dirs = [d for d in base_dir.iterdir() if d.is_dir()]
if not dirs:
return None
return max(dirs, key=lambda d: d.name)
@app.callback(invoke_without_command=True)
def batch_run(
ctx: typer.Context,
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search (default: current directory)",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth (only with --sub)",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files (test_*.py, *_test.py)",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-script timeout in seconds",
),
max_items: Optional[int] = typer.Option(
None,
"--max-items",
help="Maximum scripts to run",
),
fail_fast: bool = typer.Option(
False,
"--fail-fast", "-x",
help="Stop on first failure",
),
parallel: bool = typer.Option(
False,
"--parallel", "--async",
help="Run in parallel with async reporting (background)",
),
max_workers: int = typer.Option(
4,
"--workers", "-w",
help="Max parallel workers (only with --parallel)",
),
no_stream: bool = typer.Option(
False,
"--no-stream",
help="Don't stream output to terminal",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir",
help="Directory for reports (default: ~/Downloads/reports/batch/<timestamp>)",
),
no_report: bool = typer.Option(
False,
"--no-report",
help="Skip report generation",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
server: bool = typer.Option(
False,
"--server",
help="Run only server scripts (uvicorn, Flask, streamlit, etc.) with 10s timeout",
),
filter_type: Optional[str] = typer.Option(
None,
"--filter", "-f",
help="Filter by type: 'agent', 'agents', or 'workflow'",
),
ci: bool = typer.Option(
False,
"--ci",
help="CI-friendly output (no colors, proper exit codes)",
),
):
"""
Run all PraisonAI scripts in the current folder.
Quick debugging tool for running multiple Python files that use
praisonaiagents or praisonai. Unlike 'praisonai run' (which runs
agent configs) or 'praisonai examples' (which runs repo examples),
this command finds and executes local scripts for rapid testing.
Only files containing 'from praisonaiagents' or 'from praisonai'
are executed. Test files (test_*.py) are excluded by default.
Examples:
praisonai batch # Run all in current dir
praisonai batch --sub # Include subdirectories
praisonai batch --sub --depth 2 # Limit depth
praisonai batch --parallel # Run in parallel
praisonai batch --timeout 120 # Custom timeout
"""
# If a subcommand was invoked, skip the default run
if ctx.invoked_subcommand is not None:
return
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner import BatchSource, SuiteExecutor, RunResult
# Resolve paths
search_path = Path(path).resolve() if path else Path.cwd().resolve()
output_dir = report_dir or _get_default_report_dir()
if not search_path.exists():
if ci:
print(f"ERROR: Path not found: {search_path}")
else:
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
# Validate filter_type
if filter_type and filter_type not in ("agent", "agents", "workflow"):
if ci:
print(f"ERROR: Invalid filter type: {filter_type}. Use 'agent', 'agents', or 'workflow'")
else:
typer.echo(f"❌ Invalid filter type: {filter_type}. Use 'agent', 'agents', or 'workflow'")
raise typer.Exit(2)
# Server mode uses 10s timeout by default
effective_timeout = 10 if server else timeout
# Create source
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
exclude_servers=not server, # Exclude servers by default, include when --server
server_only=server, # Only servers when --server flag
filter_type=filter_type,
)
# Discover items
items = source.discover()
if not items:
if ci:
print(f"INFO: No PraisonAI scripts found in {search_path}")
else:
typer.echo(f"ℹ️ No PraisonAI scripts found in {search_path}")
if not recursive:
typer.echo(" Tip: Use --sub to include subdirectories")
raise typer.Exit(0)
# Parallel execution
if parallel:
_run_parallel(
items=items,
source=source,
search_path=search_path,
output_dir=output_dir,
timeout=effective_timeout,
max_workers=max_workers,
quiet=quiet,
ci=ci,
)
return
# Sequential execution (default)
executor = SuiteExecutor(
suite="batch",
source_path=search_path,
timeout=effective_timeout,
fail_fast=fail_fast,
stream_output=not no_stream,
max_items=max_items,
report_dir=output_dir if not no_report else None,
generate_json=not no_report,
generate_md=not no_report,
generate_csv=not no_report,
pythonpath_additions=source.get_pythonpath(),
)
# Status icons (CI mode uses text)
if ci:
icons = {
"passed": "PASS",
"failed": "FAIL",
"skipped": "SKIP",
"timeout": "TIMEOUT",
"not_run": "NOT_RUN",
"xfail": "XFAIL",
}
else:
icons = {
"passed": "✅",
"failed": "❌",
"skipped": "⏭️",
"timeout": "⏱️",
"not_run": "📝",
"xfail": "⚠️",
}
def on_item_start(item, idx: int, total: int):
if not quiet:
if ci:
print(f"[{idx}/{total}] Running: {item.display_name}")
else:
typer.echo(f"\n[{idx}/{total}] Running: {item.display_name}")
def on_item_end(result: RunResult, idx: int, total: int):
icon = icons.get(result.status, "?")
duration = f"{result.duration_seconds:.2f}s" if result.duration_seconds else ""
if quiet:
if ci:
print(f"{icon} {result.display_name}")
else:
typer.echo(f"{icon} {result.display_name}")
else:
msg = f" {icon} {result.status.upper()}"
if duration:
msg += f" ({duration})"
if result.skip_reason:
msg += f" - {result.skip_reason}"
if result.error_message and not no_stream:
msg += f"\n Error: {result.error_message[:100]}"
if ci:
print(msg)
else:
typer.echo(msg)
def on_output(line: str, stream: str):
if not quiet and not no_stream:
prefix = " | " if ci else " │ "
if stream != "stdout":
prefix += "[err] "
if ci:
print(f"{prefix}{line.rstrip()}")
else:
typer.echo(f"{prefix}{line.rstrip()}")
# Print header
if not quiet:
if ci:
print("=" * 60)
print("PraisonAI Batch Runner")
print("=" * 60)
print(f"Path: {search_path}")
print(f"Recursive: {recursive}")
if depth:
print(f"Depth: {depth}")
print(f"Timeout: {effective_timeout}s")
print(f"Scripts: {len(items)}")
if server:
print("Mode: Server scripts only")
if filter_type:
print(f"Filter: {filter_type}")
if not no_report:
print(f"Reports: {output_dir}")
else:
typer.echo("=" * 60)
typer.echo("PraisonAI Batch Runner")
typer.echo("=" * 60)
typer.echo(f"Path: {search_path}")
typer.echo(f"Recursive: {recursive}")
if depth:
typer.echo(f"Depth: {depth}")
typer.echo(f"Timeout: {effective_timeout}s")
typer.echo(f"Scripts: {len(items)}")
if server:
typer.echo("Mode: Server scripts only")
if filter_type:
typer.echo(f"Filter: {filter_type}")
if not no_report:
typer.echo(f"Reports: {output_dir}")
# Run
report = executor.run(
items=items,
on_item_start=on_item_start,
on_item_end=on_item_end,
on_output=on_output if not no_stream else None,
)
# Print summary
totals = report.totals
total_count = sum(totals.values())
if not quiet:
if ci:
print("")
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f" PASSED: {totals['passed']}")
print(f" FAILED: {totals['failed']}")
print(f" SKIPPED: {totals['skipped']}")
print(f" TIMEOUT: {totals['timeout']}")
print(" -----------------")
print(f" TOTAL: {total_count}")
print("=" * 60)
if not no_report and output_dir.exists():
print(f"Reports: {output_dir}")
else:
typer.echo("\n" + "=" * 60)
typer.echo("SUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
if not no_report and output_dir.exists():
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
def _run_parallel(
items,
source,
search_path: Path,
output_dir: Path,
timeout: int,
max_workers: int,
quiet: bool,
ci: bool = False,
):
"""Run items in parallel with async reporting."""
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from praisonai.suite_runner import RunResult, RunReport, SuiteReporter
from datetime import timezone
if ci:
print("=" * 60)
print("PraisonAI Batch Runner (Parallel Mode)")
print("=" * 60)
print(f"Path: {search_path}")
print(f"Workers: {max_workers}")
print(f"Timeout: {timeout}s")
print(f"Scripts: {len(items)}")
print(f"Reports: {output_dir}")
print("")
print("Running... Progress:")
print("-" * 60)
else:
typer.echo("=" * 60)
typer.echo("PraisonAI Batch Runner (Parallel Mode)")
typer.echo("=" * 60)
typer.echo(f"Path: {search_path}")
typer.echo(f"Workers: {max_workers}")
typer.echo(f"Timeout: {timeout}s")
typer.echo(f"Scripts: {len(items)}")
typer.echo(f"Reports: {output_dir}")
typer.echo("")
typer.echo("Running in background... Progress:")
typer.echo("-" * 60)
results = []
completed = 0
total = len(items)
# Build environment
import os
env = os.environ.copy()
pythonpath = source.get_pythonpath()
if pythonpath:
existing = env.get('PYTHONPATH', '')
env['PYTHONPATH'] = os.pathsep.join(pythonpath + ([existing] if existing else []))
def run_item(item):
"""Run a single item and return result."""
nonlocal completed
if not item.runnable:
completed += 1
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="skipped" if item.skip else "not_run",
skip_reason=item.skip_reason,
code_hash=item.code_hash,
)
start_time = datetime.now(timezone.utc)
try:
result = subprocess.run(
[sys.executable, '-u', str(item.script_path)],
capture_output=True,
text=True,
timeout=timeout,
env=env,
cwd=item.script_path.parent,
)
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
status = "passed" if result.returncode == 0 else "failed"
if item.xfail and result.returncode != 0:
status = "xfail"
error_message = None
if result.returncode != 0 and result.stderr:
lines = result.stderr.strip().split('\n')
error_message = lines[-1][:200] if lines else None
completed += 1
# Progress indicator
if ci:
icon = "PASS" if status == "passed" else "FAIL" if status == "failed" else "XFAIL"
else:
icon = "✅" if status == "passed" else "❌" if status == "failed" else "⚠️"
if not quiet:
if ci:
print(f" [{completed}/{total}] {icon} {item.display_name} ({duration:.2f}s)")
else:
typer.echo(f" [{completed}/{total}] {icon} {item.display_name} ({duration:.2f}s)")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status=status,
exit_code=result.returncode,
duration_seconds=duration,
start_time=start_time.isoformat(),
end_time=end_time.isoformat(),
error_message=error_message,
stdout=result.stdout,
stderr=result.stderr,
code_hash=item.code_hash,
)
except subprocess.TimeoutExpired:
completed += 1
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
if not quiet:
if ci:
print(f" [{completed}/{total}] TIMEOUT {item.display_name} (timeout)")
else:
typer.echo(f" [{completed}/{total}] ⏱️ {item.display_name} (timeout)")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="timeout",
duration_seconds=duration,
error_message=f"Exceeded {timeout}s timeout",
code_hash=item.code_hash,
)
except Exception as e:
completed += 1
if not quiet:
if ci:
print(f" [{completed}/{total}] FAIL {item.display_name} (error: {e})")
else:
typer.echo(f" [{completed}/{total}] ❌ {item.display_name} (error: {e})")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="failed",
error_message=str(e),
code_hash=item.code_hash,
)
# Run in parallel
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(run_item, item): item for item in items}
for future in as_completed(futures):
result = future.result()
results.append(result)
# Create report
report = RunReport(
results=results,
suite="batch",
source_path=search_path,
groups_run=[],
)
# Generate reports
output_dir.mkdir(parents=True, exist_ok=True)
reporter = SuiteReporter(output_dir)
reporter.save_logs(results)
reporter.generate_json(report)
reporter.generate_markdown(report)
reporter.generate_csv(report)
# Print summary
totals = report.totals
total_count = sum(totals.values())
if ci:
print("-" * 60)
print("")
print("SUMMARY")
print("=" * 60)
print(f" PASSED: {totals['passed']}")
print(f" FAILED: {totals['failed']}")
print(f" SKIPPED: {totals['skipped']}")
print(f" TIMEOUT: {totals['timeout']}")
print(" -----------------")
print(f" TOTAL: {total_count}")
print("=" * 60)
print(f"Reports: {output_dir}")
else:
typer.echo("-" * 60)
typer.echo("\nSUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("list")
def batch_list(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files",
),
show_groups: bool = typer.Option(
False,
"--groups",
help="Show available groups only",
),
):
"""
List discovered PraisonAI scripts without running them.
Examples:
praisonai batch list
praisonai batch list --sub
praisonai batch list --groups
"""
from praisonai.suite_runner import BatchSource
search_path = Path(path).resolve() if path else Path.cwd().resolve()
if not search_path.exists():
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
)
# Show groups only
if show_groups:
groups = source.get_groups()
if not groups:
typer.echo(f"No PraisonAI scripts found in {search_path}")
return
typer.echo(f"Available groups in {search_path}:\n")
for g in groups:
typer.echo(f" - {g}")
return
items = source.discover()
if not items:
typer.echo(f"No PraisonAI scripts found in {search_path}")
if not recursive:
typer.echo("Tip: Use --sub to include subdirectories")
return
typer.echo(f"Found {len(items)} PraisonAI scripts in {search_path}\n")
for idx, item in enumerate(items, 1):
rel_path = item.source_path.relative_to(search_path)
flags = []
if item.skip:
flags.append("skip")
if item.uses_agent:
flags.append("Agent")
if item.uses_agents:
flags.append("Agents")
if item.uses_workflow:
flags.append("Workflow")
flag_str = f" [{', '.join(flags)}]" if flags else ""
typer.echo(f" {idx:3}. [{item.group}] {rel_path}{flag_str}")
@app.command("stats")
def batch_stats(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files",
),
):
"""
Show statistics for PraisonAI scripts.
Displays counts by group, runnable status, and agent-centric usage.
Examples:
praisonai batch stats
praisonai batch stats --sub
"""
from collections import Counter
from praisonai.suite_runner import BatchSource
search_path = Path(path).resolve() if path else Path.cwd().resolve()
if not search_path.exists():
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
)
items = source.discover()
if not items:
typer.echo(f"No PraisonAI scripts found in {search_path}")
return
# Calculate stats
group_counts = Counter(item.group for item in items)
runnable_by_group = Counter(item.group for item in items if item.runnable)
# Agent-centric stats
agent_count = sum(1 for item in items if item.uses_agent)
agents_count = sum(1 for item in items if item.uses_agents)
workflow_count = sum(1 for item in items if item.uses_workflow)
agent_by_group = Counter(item.group for item in items if item.uses_agent)
agents_by_group = Counter(item.group for item in items if item.uses_agents)
workflow_by_group = Counter(item.group for item in items if item.uses_workflow)
typer.echo("\n📊 Batch Execution Statistics")
typer.echo(f"Path: {search_path}")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Runnable':>10} {'Agent':>8} {'Agents':>8} {'Workflow':>10}")
typer.echo("-" * 80)
for g in sorted(group_counts.keys()):
typer.echo(
f"{g:<20} {group_counts[g]:>8} {runnable_by_group[g]:>10} "
f"{agent_by_group[g]:>8} {agents_by_group[g]:>8} {workflow_by_group[g]:>10}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {len(items):>8} {sum(1 for i in items if i.runnable):>10} "
f"{agent_count:>8} {agents_count:>8} {workflow_count:>10}"
)
typer.echo()
@app.command("report")
def batch_report(
report_dir: Optional[Path] = typer.Option(
None,
"--dir", "-d",
help="Specific report directory (default: latest)",
),
format: str = typer.Option(
"summary",
"--format", "-f",
help="Output format: summary, failures, full",
),
):
"""
View the latest batch execution report.
Examples:
praisonai batch report
praisonai batch report --format failures
praisonai batch report --dir ~/Downloads/reports/batch/20240115_120000
"""
import json
# Find report directory
if report_dir:
target_dir = Path(report_dir)
else:
target_dir = _get_latest_report_dir()
if not target_dir or not target_dir.exists():
typer.echo("❌ No batch reports found.")
typer.echo(" Run 'praisonai batch' first to generate a report.")
raise typer.Exit(1)
# Load JSON report
json_path = target_dir / "report.json"
if not json_path.exists():
typer.echo(f"❌ Report not found: {json_path}")
raise typer.Exit(1)
report_data = json.loads(json_path.read_text())
typer.echo(f"\n📊 Batch Report: {target_dir.name}")
typer.echo("=" * 60)
# Summary
totals = report_data.get("totals", {})
typer.echo(f" ✅ Passed: {totals.get('passed', 0)}")
typer.echo(f" ❌ Failed: {totals.get('failed', 0)}")
typer.echo(f" ⏭️ Skipped: {totals.get('skipped', 0)}")
typer.echo(f" ⏱️ Timeout: {totals.get('timeout', 0)}")
typer.echo("=" * 60)
if format in ("failures", "full"):
results = report_data.get("results", [])
failures = [r for r in results if r.get("status") == "failed"]
if failures:
typer.echo("\n❌ FAILURES:")
typer.echo("-" * 60)
for r in failures:
typer.echo(f"\n {r.get('source_path', 'unknown')}")
if r.get("error_message"):
typer.echo(f" Error: {r['error_message'][:200]}")
if format == "full":
results = report_data.get("results", [])
typer.echo("\n📋 ALL RESULTS:")
typer.echo("-" * 60)
for r in results:
status = r.get("status", "unknown")
icon = {"passed": "✅", "failed": "❌", "skipped": "⏭️", "timeout": "⏱️"}.get(status, "?")
duration = r.get("duration_seconds", 0)
typer.echo(f" {icon} {r.get('source_path', 'unknown')} ({duration:.2f}s)")
typer.echo(f"\n📁 Full report: {target_dir}")
@@ -144,6 +144,174 @@ def doctor_selftest(
raise typer.Exit(_run_doctor(args))
@app.command("cleanup")
def doctor_cleanup(
dry_run: bool = typer.Option(True, "--dry-run/--execute", help="Show what would be removed without removing"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
):
"""
Clean up stale package artifacts that can cause import errors.
This removes stale praisonaiagents directories from site-packages that
lack an __init__.py file (namespace package artifacts).
Examples:
praisonai doctor cleanup # Show what would be removed
praisonai doctor cleanup --execute # Actually remove stale artifacts
"""
import os
import site
import shutil
output = get_output_controller()
stale_dirs = []
# Check all site-packages directories
try:
site_packages = site.getsitepackages()
except AttributeError:
site_packages = []
# Also check user site-packages
user_site = site.getusersitepackages() if hasattr(site, 'getusersitepackages') else None
if user_site:
site_packages = list(site_packages) + [user_site]
for sp in site_packages:
praisonai_dir = os.path.join(sp, 'praisonaiagents')
if os.path.isdir(praisonai_dir):
init_path = os.path.join(praisonai_dir, '__init__.py')
if not os.path.exists(init_path):
stale_dirs.append(praisonai_dir)
if not stale_dirs:
output.print_success("No stale package artifacts found. Environment is clean.")
raise typer.Exit(0)
output.print_warning(f"Found {len(stale_dirs)} stale praisonaiagents directory(ies):")
for d in stale_dirs:
output.console.print(f" • {d}")
if dry_run:
output.console.print("\n[dim]Run with --execute to remove these directories.[/dim]")
raise typer.Exit(0)
# Confirm before removing
if not force:
confirm = typer.confirm("\nRemove these directories?")
if not confirm:
output.print_info("Aborted.")
raise typer.Exit(0)
# Remove stale directories
removed = 0
for d in stale_dirs:
try:
shutil.rmtree(d)
output.print_success(f"Removed: {d}")
removed += 1
except Exception as e:
output.print_error(f"Failed to remove {d}: {e}")
if removed == len(stale_dirs):
output.print_success(f"\nSuccessfully cleaned up {removed} stale directory(ies).")
output.console.print("[dim]Run 'pip install praisonaiagents' to reinstall if needed.[/dim]")
else:
output.print_warning(f"\nCleaned up {removed}/{len(stale_dirs)} directories.")
raise typer.Exit(0 if removed == len(stale_dirs) else 1)
@app.command("troubleshoot")
def doctor_troubleshoot():
"""
Show troubleshooting information for common import errors.
This command displays diagnostic information and solutions for:
- ImportError: cannot import name 'Agent' from 'praisonaiagents'
- Namespace package shadowing issues
- Stale package artifacts
"""
output = get_output_controller()
output.console.print("\n[bold cyan]PraisonAI Import Troubleshooting[/bold cyan]\n")
# Check current state
output.console.print("[bold]1. Checking praisonaiagents package state...[/bold]")
try:
import praisonaiagents
file_attr = praisonaiagents.__file__
spec_origin = praisonaiagents.__spec__.origin if praisonaiagents.__spec__ else None
if file_attr is None:
output.print_error(" ✗ praisonaiagents.__file__ is None (namespace package detected)")
output.console.print(" [yellow]This indicates stale artifacts in site-packages.[/yellow]")
else:
output.print_success(f" ✓ praisonaiagents.__file__: {file_attr}")
if spec_origin is None:
output.print_error(" ✗ praisonaiagents.__spec__.origin is None")
else:
output.print_success(f" ✓ praisonaiagents.__spec__.origin: {spec_origin}")
# Try importing Agent
output.console.print("\n[bold]2. Testing Agent import...[/bold]")
try:
from praisonaiagents import Agent
output.print_success(f" ✓ Agent imported successfully: {Agent}")
except ImportError as e:
output.print_error(f" ✗ Agent import failed: {e}")
except ImportError as e:
output.print_error(f" ✗ Cannot import praisonaiagents: {e}")
# Check for stale directories
output.console.print("\n[bold]3. Checking for stale artifacts...[/bold]")
import site
import os
stale_found = False
try:
site_packages = site.getsitepackages()
except AttributeError:
site_packages = []
for sp in site_packages:
praisonai_dir = os.path.join(sp, 'praisonaiagents')
if os.path.isdir(praisonai_dir):
init_path = os.path.join(praisonai_dir, '__init__.py')
if not os.path.exists(init_path):
output.print_error(f" ✗ Stale directory found: {praisonai_dir}")
stale_found = True
else:
output.print_success(f" ✓ Valid package at: {praisonai_dir}")
if not stale_found:
output.print_success(" ✓ No stale artifacts found")
# Show solutions
output.console.print("\n[bold]Solutions:[/bold]")
output.console.print("""
[cyan]If you see import errors:[/cyan]
1. Run cleanup to remove stale artifacts:
[green]praisonai doctor cleanup --execute[/green]
2. Reinstall praisonaiagents:
[green]pip install --force-reinstall praisonaiagents[/green]
3. Or manually remove stale directory:
[green]rm -rf $(python -c "import site; print(site.getsitepackages()[0])")/praisonaiagents/[/green]
[cyan]For editable installs:[/cyan]
[green]pip install -e /path/to/praisonai-agents[/green]
""")
raise typer.Exit(0)
@app.callback(invoke_without_command=True)
def doctor_callback(
ctx: typer.Context,
+171 -35
View File
@@ -206,47 +206,82 @@ def mcp_test(
@app.command("sync")
def mcp_sync(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
timeout: float = typer.Option(30.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Sync tool schemas from MCP servers to local index."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
servers_to_sync = []
if server:
if server not in config.mcp.servers:
output.print_error(f"Server not found: {server}")
raise typer.Exit(1)
servers_to_sync = [server]
else:
servers_to_sync = list(config.mcp.servers.keys())
if not servers_to_sync:
output.print_info("No MCP servers configured")
return
total_tools = 0
synced_servers = []
failed_servers = []
for srv in servers_to_sync:
output.print_info(f"Syncing {srv}...")
srv_config = config.mcp.servers[srv]
index = MCPToolIndex()
loader = get_config_loader()
config = loader.load()
servers_to_sync = []
if server:
if server not in config.mcp.servers:
output.print_error(f"Server not found: {server}")
raise typer.Exit(1)
servers_to_sync = [server]
else:
servers_to_sync = list(config.mcp.servers.keys())
if not servers_to_sync:
output.print_info("No MCP servers configured")
return
total_tools = 0
for srv in servers_to_sync:
output.print_info(f"Syncing {srv}...")
# Note: This would need actual MCP connection to fetch tools
# For now, we just create the index structure
count = index.sync(srv, tools=[])
total_tools += count
output.print(f" Synced {count} tools")
if output.is_json_mode:
output.print_json({"synced_servers": servers_to_sync, "total_tools": total_tools})
else:
output.print_success(f"Synced {len(servers_to_sync)} servers, {total_tools} tools total")
try:
# Actually connect to MCP server and fetch tools
from praisonaiagents.mcp import MCP
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
# Build command string
cmd_parts = [srv_config.command] + srv_config.args
cmd_string = " ".join(cmd_parts)
# Create MCP instance with env vars
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
# Get tools
tools = mcp.get_tools()
openai_tools = mcp.to_openai_tool() or []
tool_count = len(tools)
total_tools += tool_count
synced_servers.append(srv)
output.print(f" Found {tool_count} tools:")
for tool_def in openai_tools:
if isinstance(tool_def, dict) and "function" in tool_def:
name = tool_def["function"].get("name", "unknown")
desc = tool_def["function"].get("description", "")[:40]
output.print(f" - {name}: {desc}...")
# Clean up
mcp.shutdown()
except ImportError:
output.print_error(" MCP package not installed. Install with: pip install praisonaiagents[mcp]")
failed_servers.append(srv)
except Exception as e:
output.print_error(f" Failed to sync: {e}")
failed_servers.append(srv)
# Summary
if output.is_json_mode:
output.print_json({
"synced_servers": synced_servers,
"failed_servers": failed_servers,
"total_tools": total_tools
})
else:
if synced_servers:
output.print_success(f"Synced {len(synced_servers)} server(s), {total_tools} tools total")
if failed_servers:
output.print_error(f"Failed to sync {len(failed_servers)} server(s): {', '.join(failed_servers)}")
@app.command("tools")
@@ -361,6 +396,107 @@ def mcp_status(
raise typer.Exit(1)
@app.command("run")
def mcp_run(
name: str = typer.Argument(..., help="Server name to run"),
port: int = typer.Option(8080, "--port", "-p", help="Port for SSE transport"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
transport: str = typer.Option("stdio", "--transport", "-T", help="Transport type: stdio or sse"),
):
"""Run an MCP server from configuration.
This command starts an MCP server using the configuration stored
for the given server name. It can run in stdio mode (default) or
as an SSE server.
Examples:
praisonai mcp run my-server
praisonai mcp run my-server --transport sse --port 8080
"""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
if transport not in ("stdio", "sse"):
output.print_error(f"Invalid transport: {transport}. Use 'stdio' or 'sse'")
raise typer.Exit(1)
output.print_info(f"Starting MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
output.print(f" Transport: {transport}")
if transport == "sse":
output.print(f" Endpoint: http://{host}:{port}/sse")
import subprocess
import os
import sys
try:
cmd = [server.command] + server.args
# Build environment
env = os.environ.copy()
if server.env:
env.update(server.env)
if transport == "stdio":
# Run in stdio mode - pass through stdin/stdout
output.print_info("Running in stdio mode. Press Ctrl+C to stop.")
proc = subprocess.Popen(
cmd,
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
env=env,
)
proc.wait()
else:
# SSE mode - would need to wrap the server
# For now, just run the server and let it handle SSE
output.print_info(f"Running in SSE mode on http://{host}:{port}")
output.print_info("Press Ctrl+C to stop.")
# Try to use praisonaiagents MCP server wrapper
try:
from praisonaiagents.mcp import MCP
# Build command string
cmd_string = " ".join(cmd)
mcp = MCP(cmd_string, timeout=60, env=server.env or {})
# Get tools and expose via SSE
tools = mcp.get_tools()
output.print(f" Exposing {len(tools)} tools via SSE")
# Use ToolsMCPServer to expose tools
from praisonaiagents.mcp import ToolsMCPServer
mcp_server = ToolsMCPServer(name=name, tools=tools)
mcp_server.run_sse(host=host, port=port)
except ImportError:
output.print_error("MCP package not installed. Install with: pip install praisonaiagents[mcp]")
raise typer.Exit(1)
except KeyboardInterrupt:
output.print_info("\nServer stopped.")
except FileNotFoundError:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except KeyboardInterrupt:
output.print_info("\nServer stopped.")
except Exception as e:
output.print_error(f"Failed to start server: {e}")
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def mcp_callback(ctx: typer.Context):
"""Show MCP help or list servers."""
@@ -504,6 +504,91 @@ def check_optional_deps(config: DoctorConfig) -> CheckResult:
)
@register_check(
id="stale_packages",
title="Stale Package Artifacts",
description="Check for stale praisonaiagents artifacts in site-packages",
category=CheckCategory.ENVIRONMENT,
severity=CheckSeverity.CRITICAL,
)
def check_stale_packages(config: DoctorConfig) -> CheckResult:
"""
Check for stale praisonaiagents artifacts in site-packages.
This detects namespace package shadowing issues where a stale directory
in site-packages (without __init__.py) shadows the real package.
"""
import site
stale_dirs = []
# Check all site-packages directories
try:
site_packages = site.getsitepackages()
except AttributeError:
# Some environments don't have getsitepackages
site_packages = []
# Also check user site-packages
user_site = site.getusersitepackages() if hasattr(site, 'getusersitepackages') else None
if user_site:
site_packages = list(site_packages) + [user_site]
for sp in site_packages:
praisonai_dir = os.path.join(sp, 'praisonaiagents')
if os.path.isdir(praisonai_dir):
init_path = os.path.join(praisonai_dir, '__init__.py')
if not os.path.exists(init_path):
# This is a stale namespace package directory
stale_dirs.append(praisonai_dir)
if stale_dirs:
return CheckResult(
id="stale_packages",
title="Stale Package Artifacts",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message=f"Found {len(stale_dirs)} stale praisonaiagents directory(ies)",
details=f"Stale directories: {', '.join(stale_dirs)}",
remediation=(
"Remove stale directories to fix import errors:\n"
f" rm -rf {stale_dirs[0]}\n"
"Then reinstall: pip install praisonaiagents"
),
severity=CheckSeverity.CRITICAL,
metadata={"stale_dirs": stale_dirs},
)
# Also verify the package loads correctly (not as namespace)
try:
import praisonaiagents
if praisonaiagents.__file__ is None:
return CheckResult(
id="stale_packages",
title="Stale Package Artifacts",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.FAIL,
message="praisonaiagents loaded as namespace package",
details="__file__ is None, indicating namespace package shadowing",
remediation=(
"Remove stale praisonaiagents directory from site-packages:\n"
" rm -rf $(python -c \"import site; print(site.getsitepackages()[0])\")/praisonaiagents/\n"
"Then reinstall: pip install praisonaiagents"
),
severity=CheckSeverity.CRITICAL,
)
except ImportError:
pass # Package not installed - handled by praisonaiagents_package check
return CheckResult(
id="stale_packages",
title="Stale Package Artifacts",
category=CheckCategory.ENVIRONMENT,
status=CheckStatus.PASS,
message="No stale package artifacts found",
)
@register_check(
id="model_env_vars",
title="Model Configuration",
+1 -1
View File
@@ -33,7 +33,7 @@ TYPER_COMMANDS = {
'chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research',
'memory', 'workflow', 'tools', 'knowledge', 'deploy', 'agents', 'skills',
'eval', 'templates', 'recipe', 'todo', 'docs', 'commit', 'hooks', 'rules',
'registry', 'package', 'endpoints',
'registry', 'package', 'endpoints', 'test', 'examples', 'batch',
# Standardisation commands
'standardise', 'standardize',
}
+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==3.9.0 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.9.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -25,6 +25,7 @@ __all__ = [
# Sources
"ExamplesSource",
"DocsSource",
"BatchSource",
# Executor
"SuiteExecutor",
]
@@ -56,6 +57,10 @@ def __getattr__(name: str):
from .docs_source import DocsSource
return DocsSource
if name == "BatchSource":
from .batch_source import BatchSource
return BatchSource
if name == "SuiteExecutor":
from .executor import SuiteExecutor
return SuiteExecutor
@@ -0,0 +1,351 @@
"""
Batch Source - Adapter for running local Python files with PraisonAI imports.
Discovers and prepares Python files in the current directory (or specified path)
that contain imports from praisonaiagents or praisonai packages.
This is designed for quick debugging - running multiple local scripts at once.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List, Optional
from .models import RunItem
from .discovery import FileDiscovery, get_pythonpath_for_dev
# Import detection patterns - matches 'from praisonai...' or 'import praisonai...'
PRAISONAI_IMPORT_PATTERN = re.compile(
r'^(?:from|import)\s+praisonai(?:agents)?\b',
re.MULTILINE
)
# Agent-centric detection patterns (reused from examples_source)
AGENT_PATTERN = re.compile(r'\bAgent\s*\(')
AGENTS_PATTERN = re.compile(r'\b(?:Agents|PraisonAIAgents)\s*\(')
WORKFLOW_PATTERN = re.compile(r'\bWorkflow\s*\(')
# Directive parsing regex (reused from examples_source)
DIRECTIVE_PATTERN = re.compile(r'^#\s*praisonai:\s*(\w+)=(.+)$', re.MULTILINE)
INPUT_PATTERN = re.compile(r'\binput\s*\(')
# Test file patterns to exclude by default
TEST_FILE_PATTERNS = {'test_*.py', '*_test.py', 'conftest.py'}
# Server detection patterns - scripts that start long-running servers
SERVER_PATTERNS = [
re.compile(r'uvicorn\.run\s*\('), # uvicorn.run()
re.compile(r'\.launch\s*\('), # agent.launch(), demo.launch()
re.compile(r'app\.run\s*\('), # Flask app.run()
re.compile(r'^\s*import\s+streamlit\b', re.MULTILINE), # import streamlit
re.compile(r'^\s*from\s+streamlit\b', re.MULTILINE), # from streamlit import
re.compile(r'^\s*import\s+gradio\b', re.MULTILINE), # import gradio
re.compile(r'^\s*from\s+gradio\b', re.MULTILINE), # from gradio import
re.compile(r'FastAPI\s*\('), # FastAPI()
re.compile(r'Flask\s*\('), # Flask()
re.compile(r'\.serve\s*\('), # server.serve()
re.compile(r'mcp\.run\s*\('), # mcp.run()
re.compile(r'server\.run\s*\('), # server.run()
]
class BatchSource:
"""
Discovers and prepares local Python files with PraisonAI imports.
Unlike ExamplesSource (for repo examples) or DocsSource (for docs),
this source is designed for quick local debugging of user scripts.
Only files containing 'from praisonaiagents' or 'from praisonai'
are included. Test files are excluded by default.
Parses directives from comments:
- # praisonai: skip=true
- # praisonai: timeout=120
- # praisonai: require_env=KEY1,KEY2
- # praisonai: xfail=reason
"""
def __init__(
self,
root: Optional[Path] = None,
recursive: bool = False,
depth: Optional[int] = None,
exclude_tests: bool = True,
include_patterns: Optional[List[str]] = None,
exclude_patterns: Optional[List[str]] = None,
groups: Optional[List[str]] = None,
exclude_servers: bool = True,
server_only: bool = False,
filter_type: Optional[str] = None,
):
"""
Initialize batch source.
Args:
root: Root directory to search (default: current directory).
recursive: Whether to search subdirectories.
depth: Maximum recursion depth (None = unlimited when recursive).
exclude_tests: Whether to exclude test files (test_*.py, *_test.py).
include_patterns: Glob patterns to include.
exclude_patterns: Glob patterns to exclude.
groups: Specific groups (subdirs) to include.
exclude_servers: Whether to exclude server scripts (default: True).
server_only: Only include server scripts (for --server mode).
filter_type: Filter by agent type ('agent', 'agents', 'workflow').
"""
self.root = Path(root).resolve() if root else Path.cwd().resolve()
self.recursive = recursive
self.depth = depth
self.exclude_tests = exclude_tests
self.include_patterns = include_patterns
self.exclude_patterns = exclude_patterns or []
self.groups = groups
self.exclude_servers = exclude_servers
self.server_only = server_only
self.filter_type = filter_type
# Add test exclusions if enabled
if exclude_tests:
self.exclude_patterns = list(self.exclude_patterns) + [
'test_*.py', '*_test.py', 'conftest.py'
]
self._discovery = FileDiscovery(
root=self.root,
extensions=['.py'],
include_patterns=include_patterns,
exclude_patterns=self.exclude_patterns,
ignore_underscore=True,
)
def _has_praisonai_import(self, content: str) -> bool:
"""
Check if file content contains PraisonAI imports.
Args:
content: File content to check.
Returns:
True if file contains 'from praisonai...' or 'import praisonai...'.
"""
# Check each line to avoid matching commented imports
for line in content.split('\n'):
stripped = line.strip()
# Skip comments
if stripped.startswith('#'):
continue
# Check for import
if PRAISONAI_IMPORT_PATTERN.match(stripped):
return True
return False
def _is_server_script(self, content: str) -> bool:
"""
Check if file content contains server patterns that would hang.
Detects: uvicorn.run, .launch(), app.run, streamlit, gradio, FastAPI, Flask, etc.
Args:
content: File content to check.
Returns:
True if file contains server patterns.
"""
for pattern in SERVER_PATTERNS:
if pattern.search(content):
return True
return False
def _get_files_at_depth(self, max_depth: Optional[int] = None) -> List[Path]:
"""
Get files respecting depth limit.
Args:
max_depth: Maximum depth (0 = root only, 1 = root + immediate subdirs, etc.)
Returns:
List of file paths.
"""
if not self.recursive:
# Non-recursive: only files in root directory
files = []
for path in self.root.glob('*.py'):
if path.is_file() and not path.name.startswith('_'):
# Apply exclude patterns
if self.exclude_patterns:
import fnmatch
if any(fnmatch.fnmatch(path.name, p) for p in self.exclude_patterns):
continue
files.append(path)
return sorted(files)
# Recursive with optional depth limit
all_files = self._discovery.discover()
if max_depth is None:
return all_files
# Filter by depth
filtered = []
for path in all_files:
rel_path = path.relative_to(self.root)
# Depth is number of parent directories
depth = len(rel_path.parts) - 1 # -1 because last part is filename
if depth <= max_depth:
filtered.append(path)
return filtered
def discover(self) -> List[RunItem]:
"""
Discover all Python files with PraisonAI imports.
Returns:
List of RunItem objects for files that contain PraisonAI imports.
"""
files = self._get_files_at_depth(self.depth)
# Filter by groups if specified
if self.groups:
filtered_files = []
for path in files:
group = self._get_group_for_path(path)
if group in self.groups:
filtered_files.append(path)
files = filtered_files
items = []
for path in files:
# Read file content
try:
content = path.read_text(encoding='utf-8', errors='ignore')
except Exception:
continue
# Check for PraisonAI imports
if not self._has_praisonai_import(content):
continue
# Create RunItem
item = self._create_item(path, content)
# Apply server filtering
if self.server_only:
# Only include server scripts
if not item.is_server:
continue
elif self.exclude_servers:
# Exclude server scripts (default behavior)
if item.is_server:
continue
# Apply agent type filtering
if self.filter_type:
if self.filter_type == "agent" and not item.uses_agent:
continue
elif self.filter_type == "agents" and not item.uses_agents:
continue
elif self.filter_type == "workflow" and not item.uses_workflow:
continue
items.append(item)
return items
def _get_group_for_path(self, path: Path) -> str:
"""Get group name for a file path."""
try:
rel_path = path.relative_to(self.root)
if len(rel_path.parts) > 1:
return rel_path.parts[0]
return "root"
except ValueError:
return "unknown"
def _create_item(self, path: Path, content: str) -> RunItem:
"""Create RunItem from file."""
group = self._get_group_for_path(path)
rel_path = path.relative_to(self.root).as_posix()
item_id = rel_path.replace('/', '__').replace('.py', '')
# Parse directives from first 30 lines
lines = content.split('\n')[:30]
header = '\n'.join(lines)
skip = False
skip_reason = None
timeout = None
require_env = []
xfail = None
for match in DIRECTIVE_PATTERN.finditer(header):
key, value = match.group(1), match.group(2).strip()
if key == 'skip':
skip = value.lower() in ('true', '1', 'yes')
if skip:
skip_reason = "skip=true directive"
elif key == 'timeout':
try:
timeout = int(value)
except ValueError:
pass
elif key == 'require_env':
require_env = [k.strip() for k in value.split(',') if k.strip()]
elif key == 'xfail':
xfail = value
# Detect interactive input() calls
is_interactive = bool(INPUT_PATTERN.search(content))
if is_interactive and not skip:
skip = True
skip_reason = "Interactive script (contains input())"
# Detect agent-centric usage
uses_agent = bool(AGENT_PATTERN.search(content))
uses_agents = bool(AGENTS_PATTERN.search(content))
uses_workflow = bool(WORKFLOW_PATTERN.search(content))
# Detect server scripts
is_server = self._is_server_script(content)
# Determine runnable status
runnable = not skip
runnable_decision = "runnable" if runnable else (skip_reason or "skipped")
return RunItem(
item_id=item_id,
suite="batch",
group=group,
source_path=path,
block_index=0,
language="python",
code=content,
script_path=path, # Run directly
runnable=runnable,
runnable_decision=runnable_decision,
skip=skip,
skip_reason=skip_reason,
timeout=timeout,
require_env=require_env,
xfail=xfail,
is_interactive=is_interactive,
uses_agent=uses_agent,
uses_agents=uses_agents,
uses_workflow=uses_workflow,
is_server=is_server,
)
def get_groups(self) -> List[str]:
"""Get available groups (directories containing matching files)."""
items = self.discover()
groups = set(item.group for item in items)
return sorted(groups)
def get_pythonpath(self) -> List[str]:
"""Get PYTHONPATH additions for dev mode."""
return get_pythonpath_for_dev(self.root)
@@ -59,6 +59,9 @@ class RunItem:
uses_agents: bool = False # Agents() / PraisonAIAgents class
uses_workflow: bool = False # Workflow class
# Server detection
is_server: bool = False # Contains server code (uvicorn, Flask, streamlit, etc.)
@property
def agent_type(self) -> str:
"""Get agent type used in this item."""
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.9.0"
__version__ = "3.9.2"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.12.0",
"praisonaiagents>=0.12.2",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -307,3 +307,65 @@ class TestObsChecks:
# Should pass or skip
assert result.status in [CheckStatus.PASS, CheckStatus.SKIP]
class TestStalePackagesCheck:
"""Tests for stale packages check (namespace package detection)."""
def setup_method(self):
"""Reset registry before each test."""
CheckRegistry.reset()
from praisonai.cli.features.doctor.checks import env_checks
def test_stale_packages_check_passes_when_clean(self):
"""Test stale packages check passes when no stale artifacts exist."""
from praisonai.cli.features.doctor.checks.env_checks import check_stale_packages
config = DoctorConfig()
result = check_stale_packages(config)
# Should pass when environment is clean
assert result.status == CheckStatus.PASS
assert "No stale package artifacts" in result.message
def test_stale_packages_check_detects_namespace_package(self):
"""Test stale packages check detects when praisonaiagents is namespace pkg."""
from praisonai.cli.features.doctor.checks.env_checks import check_stale_packages
# Mock praisonaiagents with __file__ = None (namespace package)
with patch('praisonai.cli.features.doctor.checks.env_checks.check_stale_packages') as mock_check:
# This test verifies the check function exists and is callable
# The actual namespace detection is tested in praisonaiagents tests
config = DoctorConfig()
result = check_stale_packages(config)
# In clean environment, should pass
assert result.status == CheckStatus.PASS
def test_praisonaiagents_file_not_none(self):
"""Test that praisonaiagents.__file__ is not None (CI check)."""
import praisonaiagents
# This is the critical CI check - __file__ must not be None
assert praisonaiagents.__file__ is not None, (
"praisonaiagents.__file__ is None - this indicates a namespace package "
"shadowing issue. Remove stale praisonaiagents directory from site-packages."
)
def test_praisonaiagents_spec_has_origin(self):
"""Test that praisonaiagents.__spec__.origin is not None (CI check)."""
import praisonaiagents
# Namespace packages have origin=None
assert praisonaiagents.__spec__ is not None
assert praisonaiagents.__spec__.origin is not None, (
"praisonaiagents.__spec__.origin is None - namespace package detected"
)
def test_agent_import_succeeds(self):
"""Test that Agent can be imported (CI check)."""
from praisonaiagents import Agent
assert Agent is not None
assert hasattr(Agent, '__init__')
assert hasattr(Agent, 'start')
@@ -0,0 +1,597 @@
"""
Tests for BatchSource - discovers Python files with PraisonAI imports.
TDD: These tests define the expected behavior before implementation.
"""
from pathlib import Path
import tempfile
class TestBatchSourceImportDetection:
"""Test import pattern detection."""
def test_detects_praisonaiagents_import(self):
"""Should detect 'from praisonaiagents import ...'"""
from praisonai.suite_runner.batch_source import BatchSource
content = """
from praisonaiagents import Agent
agent = Agent(instructions="Test")
"""
source = BatchSource(root=Path("."))
assert source._has_praisonai_import(content) is True
def test_detects_praisonai_import(self):
"""Should detect 'from praisonai import ...'"""
from praisonai.suite_runner.batch_source import BatchSource
content = """
from praisonai import Agent
agent = Agent()
"""
source = BatchSource(root=Path("."))
assert source._has_praisonai_import(content) is True
def test_detects_import_praisonaiagents(self):
"""Should detect 'import praisonaiagents'"""
from praisonai.suite_runner.batch_source import BatchSource
content = """
import praisonaiagents
agent = praisonaiagents.Agent()
"""
source = BatchSource(root=Path("."))
assert source._has_praisonai_import(content) is True
def test_rejects_non_praisonai_file(self):
"""Should reject files without PraisonAI imports."""
from praisonai.suite_runner.batch_source import BatchSource
content = """
import os
import sys
print("Hello world")
"""
source = BatchSource(root=Path("."))
assert source._has_praisonai_import(content) is False
def test_rejects_commented_import(self):
"""Should reject commented imports."""
from praisonai.suite_runner.batch_source import BatchSource
content = """
# from praisonaiagents import Agent
print("Hello")
"""
source = BatchSource(root=Path("."))
# Commented imports should not match
assert source._has_praisonai_import(content) is False
class TestBatchSourceDiscovery:
"""Test file discovery with filtering."""
def test_discovers_files_in_current_directory(self):
"""Should discover .py files in root directory only (non-recursive)."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Create a file with praisonai import (not a test file name)
(root / "my_agent.py").write_text("from praisonaiagents import Agent\n")
# Create a file without praisonai import
(root / "other.py").write_text("print('hello')\n")
# Create a subdirectory with a file (should not be found without recursive)
subdir = root / "subdir"
subdir.mkdir()
(subdir / "nested.py").write_text("from praisonaiagents import Agent\n")
source = BatchSource(root=root, recursive=False)
items = source.discover()
# Should only find my_agent.py (has praisonai import, in root)
assert len(items) == 1
assert items[0].source_path.name == "my_agent.py"
def test_discovers_files_recursively(self):
"""Should discover .py files recursively when recursive=True."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Create files
(root / "root_agent.py").write_text("from praisonaiagents import Agent\n")
subdir = root / "subdir"
subdir.mkdir()
(subdir / "nested_agent.py").write_text("from praisonai import Agent\n")
source = BatchSource(root=root, recursive=True)
items = source.discover()
# Should find both files
assert len(items) == 2
names = {item.source_path.name for item in items}
assert names == {"root_agent.py", "nested_agent.py"}
def test_respects_depth_limit(self):
"""Should respect depth limit when recursive."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Create nested structure
(root / "level0.py").write_text("from praisonaiagents import Agent\n")
level1 = root / "level1"
level1.mkdir()
(level1 / "level1.py").write_text("from praisonaiagents import Agent\n")
level2 = level1 / "level2"
level2.mkdir()
(level2 / "level2.py").write_text("from praisonaiagents import Agent\n")
# Depth 1 should only find level0 and level1
source = BatchSource(root=root, recursive=True, depth=1)
items = source.discover()
names = {item.source_path.name for item in items}
assert "level0.py" in names
assert "level1.py" in names
assert "level2.py" not in names
def test_excludes_test_files_by_default(self):
"""Should exclude test_*.py and *_test.py by default."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Create test files
(root / "test_agent.py").write_text("from praisonaiagents import Agent\n")
(root / "agent_test.py").write_text("from praisonaiagents import Agent\n")
(root / "conftest.py").write_text("from praisonaiagents import Agent\n")
# Create regular file
(root / "my_agent.py").write_text("from praisonaiagents import Agent\n")
source = BatchSource(root=root, exclude_tests=True)
items = source.discover()
# Should only find my_agent.py
assert len(items) == 1
assert items[0].source_path.name == "my_agent.py"
def test_includes_test_files_when_disabled(self):
"""Should include test files when exclude_tests=False."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "test_agent.py").write_text("from praisonaiagents import Agent\n")
(root / "my_agent.py").write_text("from praisonaiagents import Agent\n")
source = BatchSource(root=root, exclude_tests=False)
items = source.discover()
assert len(items) == 2
class TestBatchSourceRunItem:
"""Test RunItem creation."""
def test_creates_runitem_with_correct_fields(self):
"""Should create RunItem with correct metadata."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
content = """
from praisonaiagents import Agent
agent = Agent(instructions="Test")
agent.start()
"""
(root / "my_agent.py").write_text(content)
source = BatchSource(root=root)
items = source.discover()
assert len(items) == 1
item = items[0]
assert item.suite == "batch"
assert item.language == "python"
assert item.runnable is True
assert item.uses_agent is True
def test_detects_agent_usage(self):
"""Should detect Agent class usage."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "agent.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
""")
source = BatchSource(root=root)
items = source.discover()
assert items[0].uses_agent is True
def test_detects_agents_usage(self):
"""Should detect Agents/PraisonAIAgents class usage."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "agents.py").write_text("""
from praisonaiagents import Agent, Agents
agents = Agents(agents=[Agent()])
""")
source = BatchSource(root=root)
items = source.discover()
assert items[0].uses_agents is True
class TestBatchSourceGroups:
"""Test group detection."""
def test_assigns_root_group_for_top_level_files(self):
"""Files in root should have group 'root'."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "agent.py").write_text("from praisonaiagents import Agent\n")
source = BatchSource(root=root)
items = source.discover()
assert items[0].group == "root"
def test_assigns_directory_group_for_nested_files(self):
"""Files in subdirs should have group = subdir name."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
subdir = root / "my_agents"
subdir.mkdir()
(subdir / "agent.py").write_text("from praisonaiagents import Agent\n")
source = BatchSource(root=root, recursive=True)
items = source.discover()
assert items[0].group == "my_agents"
def test_get_groups_returns_all_groups(self):
"""get_groups() should return all discovered groups."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "root.py").write_text("from praisonaiagents import Agent\n")
dir1 = root / "agents"
dir1.mkdir()
(dir1 / "a.py").write_text("from praisonaiagents import Agent\n")
dir2 = root / "tools"
dir2.mkdir()
(dir2 / "b.py").write_text("from praisonaiagents import Agent\n")
source = BatchSource(root=root, recursive=True)
groups = source.get_groups()
assert set(groups) == {"root", "agents", "tools"}
class TestBatchSourceServerDetection:
"""Test server script detection."""
def test_detects_uvicorn_server(self):
"""Should detect uvicorn.run() as server script."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "server.py").write_text("""
from praisonaiagents import Agent
import uvicorn
from fastapi import FastAPI
app = FastAPI()
uvicorn.run(app, host="0.0.0.0", port=8000)
""")
# Need exclude_servers=False to include server scripts
source = BatchSource(root=root, exclude_servers=False)
items = source.discover()
assert len(items) == 1
assert items[0].is_server is True
def test_detects_launch_method(self):
"""Should detect .launch() as server script."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "api.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
agent.launch(path="/ask", port=3030)
""")
source = BatchSource(root=root, exclude_servers=False)
items = source.discover()
assert len(items) == 1
assert items[0].is_server is True
def test_detects_flask_app_run(self):
"""Should detect app.run() (Flask) as server script."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "flask_app.py").write_text("""
from praisonaiagents import Agent
from flask import Flask
app = Flask(__name__)
app.run(debug=True)
""")
source = BatchSource(root=root, exclude_servers=False)
items = source.discover()
assert len(items) == 1
assert items[0].is_server is True
def test_detects_streamlit_import(self):
"""Should detect streamlit import as server script."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "streamlit_app.py").write_text("""
from praisonaiagents import Agent
import streamlit as st
st.title("Agent App")
""")
source = BatchSource(root=root, exclude_servers=False)
items = source.discover()
assert len(items) == 1
assert items[0].is_server is True
def test_detects_gradio_import(self):
"""Should detect gradio import as server script."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "gradio_app.py").write_text("""
from praisonaiagents import Agent
import gradio as gr
demo = gr.Interface(fn=lambda x: x, inputs="text", outputs="text")
demo.launch()
""")
source = BatchSource(root=root, exclude_servers=False)
items = source.discover()
assert len(items) == 1
assert items[0].is_server is True
def test_non_server_script(self):
"""Should not mark regular scripts as server."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "agent.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
result = agent.start("Hello")
print(result)
""")
source = BatchSource(root=root)
items = source.discover()
assert len(items) == 1
assert items[0].is_server is False
class TestBatchSourceServerExclusion:
"""Test server script exclusion."""
def test_excludes_servers_by_default(self):
"""Should exclude server scripts by default."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Server script
(root / "server.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
agent.launch(path="/ask", port=3030)
""")
# Regular script
(root / "agent.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
print(agent.start("Hello"))
""")
source = BatchSource(root=root, exclude_servers=True)
items = source.discover()
# Should only find agent.py
assert len(items) == 1
assert items[0].source_path.name == "agent.py"
def test_includes_servers_when_disabled(self):
"""Should include server scripts when exclude_servers=False."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "server.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
agent.launch(path="/ask", port=3030)
""")
(root / "agent.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
""")
source = BatchSource(root=root, exclude_servers=False)
items = source.discover()
assert len(items) == 2
def test_server_only_mode(self):
"""Should only return server scripts when server_only=True."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "server.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
agent.launch(path="/ask", port=3030)
""")
(root / "agent.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
""")
source = BatchSource(root=root, server_only=True)
items = source.discover()
# Should only find server.py
assert len(items) == 1
assert items[0].source_path.name == "server.py"
class TestBatchSourceFiltering:
"""Test filtering by agent type."""
def test_filter_agent_only(self):
"""Should filter to only scripts using Agent class."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Agent only
(root / "single.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
""")
# Agents (multi-agent)
(root / "multi.py").write_text("""
from praisonaiagents import Agent, Agents
agents = Agents(agents=[Agent()])
""")
source = BatchSource(root=root, filter_type="agent")
items = source.discover()
# Should find both (both use Agent)
assert len(items) == 2
def test_filter_agents_only(self):
"""Should filter to only scripts using Agents/PraisonAIAgents class."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Agent only
(root / "single.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
""")
# Agents (multi-agent)
(root / "multi.py").write_text("""
from praisonaiagents import Agent, Agents
agents = Agents(agents=[Agent()])
""")
source = BatchSource(root=root, filter_type="agents")
items = source.discover()
# Should only find multi.py
assert len(items) == 1
assert items[0].source_path.name == "multi.py"
def test_filter_workflow_only(self):
"""Should filter to only scripts using Workflow class."""
from praisonai.suite_runner.batch_source import BatchSource
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Agent only
(root / "agent.py").write_text("""
from praisonaiagents import Agent
agent = Agent(instructions="Test")
""")
# Workflow
(root / "workflow.py").write_text("""
from praisonaiagents import Agent, Workflow
workflow = Workflow(agents=[Agent()])
""")
source = BatchSource(root=root, filter_type="workflow")
items = source.discover()
# Should only find workflow.py
assert len(items) == 1
assert items[0].source_path.name == "workflow.py"
+4 -4
View File
@@ -4276,7 +4276,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.12.0" },
{ name = "praisonaiagents", specifier = ">=0.12.2" },
{ 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" },
@@ -4324,7 +4324,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.12.0"
version = "0.12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4333,9 +4333,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/f0/9ca6d4109082753f397558a7b0033985087c3962742a6f4a61cf1fc88171/praisonaiagents-0.12.0.tar.gz", hash = "sha256:ae5f4b2f1ca6b53283b16d62e22df3eea930086e30cb9945e4ea0674800ed633", size = 966405, upload-time = "2026-01-15T16:02:13.436Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/e4/624f8ceca4b55f9137a254b88013b21c08c89694b2e90c4f30b0a0f24279/praisonaiagents-0.12.2.tar.gz", hash = "sha256:16da2ff1a4f32f5f08b1296611f4f2f426eb441a29840b04bf76e1a7b3775f71", size = 970379, upload-time = "2026-01-15T20:06:00.593Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/8d/393af2594ffdc5b8b5ffd3f2f5bd3df40b4197c198e9fac5c3b17bc50fd6/praisonaiagents-0.12.0-py3-none-any.whl", hash = "sha256:2d2643938ca52aa800b04f8dd26500819dac007342b4ccc2e9036f81b03ddc77", size = 949246, upload-time = "2026-01-15T16:02:11.606Z" },
{ url = "https://files.pythonhosted.org/packages/aa/16/8fc51400f307b0ce613a7fb3863ebffc5b501406496867f0dfc2733025e8/praisonaiagents-0.12.2-py3-none-any.whl", hash = "sha256:5f49d4c49713cc1d671ec9da8ab34a6fbd4f23ecf011c0d43648cc3df4729c7c", size = 951384, upload-time = "2026-01-15T20:05:58.384Z" },
]
[[package]]