mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
657fd93743 | ||
|
|
f0a2936b7f | ||
|
|
58778492ca |
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.12.0"
|
||||
version = "0.12.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "3.9.0"
|
||||
__version__ = "3.9.2"
|
||||
@@ -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"
|
||||
Generated
+4
-4
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user