From a843e13641d3ebf66e1e405dfbaef1541fde5df9 Mon Sep 17 00:00:00 2001 From: Mervin Praison Date: Thu, 29 Jan 2026 10:44:59 +0000 Subject: [PATCH] Release v3.11.0 --- api.md | 609 +++++++++++++++++- docker/Dockerfile.chat | 2 +- docker/Dockerfile.dev | 2 +- docker/Dockerfile.ui | 2 +- examples/python/bot_example.py | 76 +++ examples/python/failover_example.py | 170 +++++ examples/python/gateway_example.py | 47 ++ examples/python/sandbox_example.py | 137 ++++ src/praisonai-agents/AGENTS.md | 2 +- .../praisonaiagents/__init__.py | 32 + .../praisonaiagents/agent/agent.py | 58 +- .../praisonaiagents/agent/autonomy.py | 64 +- .../praisonaiagents/bots/__init__.py | 33 + .../praisonaiagents/bots/config.py | 78 +++ .../praisonaiagents/bots/protocols.py | 453 +++++++++++++ .../praisonaiagents/gateway/__init__.py | 30 + .../praisonaiagents/gateway/config.py | 100 +++ .../praisonaiagents/gateway/protocols.py | 404 ++++++++++++ .../praisonaiagents/llm/failover.py | 409 ++++++++++++ .../praisonaiagents/sandbox/__init__.py | 26 + .../praisonaiagents/sandbox/config.py | 155 +++++ .../praisonaiagents/sandbox/protocols.py | 359 +++++++++++ src/praisonai-agents/pyproject.toml | 2 +- .../integration/test_autonomy_loop_real.py | 297 +++++++++ .../integration/test_bots_integration.py | 402 ++++++++++++ .../integration/test_gateway_integration.py | 342 ++++++++++ .../tests/integration/test_real_features.py | 574 +++++++++++++++++ .../integration/test_sandbox_integration.py | 371 +++++++++++ .../unit/agent/test_autonomy_loop_features.py | 357 ++++++++++ src/praisonai-agents/tests/unit/test_bots.py | 216 +++++++ .../tests/unit/test_failover.py | 284 ++++++++ .../tests/unit/test_gateway.py | 191 ++++++ .../tests/unit/test_sandbox.py | 180 ++++++ src/praisonai-agents/uv.lock | 2 +- src/praisonai/praisonai.rb | 4 +- src/praisonai/praisonai/_dev/api_md.py | 12 +- src/praisonai/praisonai/bots/__init__.py | 27 + src/praisonai/praisonai/bots/discord.py | 378 +++++++++++ src/praisonai/praisonai/bots/slack.py | 391 +++++++++++ src/praisonai/praisonai/bots/telegram.py | 386 +++++++++++ src/praisonai/praisonai/cli/app.py | 2 + src/praisonai/praisonai/cli/commands/loop.py | 200 ++++++ .../praisonai/cli/features/audit_cli.py | 270 ++++++++ .../praisonai/cli/features/bots_cli.py | 249 +++++++ .../praisonai/cli/features/gateway.py | 180 ++++++ .../praisonai/cli/features/migrate.py | 284 ++++++++ .../praisonai/cli/features/recipe_creator.py | 42 +- .../praisonai/cli/features/sandbox_cli.py | 255 ++++++++ .../praisonai/cli/features/wizard.py | 289 +++++++++ src/praisonai/praisonai/cli/main.py | 35 + src/praisonai/praisonai/deploy.py | 2 +- src/praisonai/praisonai/gateway/__init__.py | 22 + src/praisonai/praisonai/gateway/server.py | 461 +++++++++++++ src/praisonai/praisonai/sandbox/__init__.py | 23 + src/praisonai/praisonai/sandbox/docker.py | 437 +++++++++++++ src/praisonai/praisonai/sandbox/subprocess.py | 349 ++++++++++ src/praisonai/praisonai/version.py | 2 +- src/praisonai/pyproject.toml | 2 +- src/praisonai/uv.lock | 8 +- 59 files changed, 10726 insertions(+), 50 deletions(-) create mode 100644 examples/python/bot_example.py create mode 100644 examples/python/failover_example.py create mode 100644 examples/python/gateway_example.py create mode 100644 examples/python/sandbox_example.py create mode 100644 src/praisonai-agents/praisonaiagents/bots/__init__.py create mode 100644 src/praisonai-agents/praisonaiagents/bots/config.py create mode 100644 src/praisonai-agents/praisonaiagents/bots/protocols.py create mode 100644 src/praisonai-agents/praisonaiagents/gateway/__init__.py create mode 100644 src/praisonai-agents/praisonaiagents/gateway/config.py create mode 100644 src/praisonai-agents/praisonaiagents/gateway/protocols.py create mode 100644 src/praisonai-agents/praisonaiagents/llm/failover.py create mode 100644 src/praisonai-agents/praisonaiagents/sandbox/__init__.py create mode 100644 src/praisonai-agents/praisonaiagents/sandbox/config.py create mode 100644 src/praisonai-agents/praisonaiagents/sandbox/protocols.py create mode 100644 src/praisonai-agents/tests/integration/test_autonomy_loop_real.py create mode 100644 src/praisonai-agents/tests/integration/test_bots_integration.py create mode 100644 src/praisonai-agents/tests/integration/test_gateway_integration.py create mode 100644 src/praisonai-agents/tests/integration/test_real_features.py create mode 100644 src/praisonai-agents/tests/integration/test_sandbox_integration.py create mode 100644 src/praisonai-agents/tests/unit/agent/test_autonomy_loop_features.py create mode 100644 src/praisonai-agents/tests/unit/test_bots.py create mode 100644 src/praisonai-agents/tests/unit/test_failover.py create mode 100644 src/praisonai-agents/tests/unit/test_gateway.py create mode 100644 src/praisonai-agents/tests/unit/test_sandbox.py create mode 100644 src/praisonai/praisonai/bots/__init__.py create mode 100644 src/praisonai/praisonai/bots/discord.py create mode 100644 src/praisonai/praisonai/bots/slack.py create mode 100644 src/praisonai/praisonai/bots/telegram.py create mode 100644 src/praisonai/praisonai/cli/commands/loop.py create mode 100644 src/praisonai/praisonai/cli/features/audit_cli.py create mode 100644 src/praisonai/praisonai/cli/features/bots_cli.py create mode 100644 src/praisonai/praisonai/cli/features/gateway.py create mode 100644 src/praisonai/praisonai/cli/features/migrate.py create mode 100644 src/praisonai/praisonai/cli/features/sandbox_cli.py create mode 100644 src/praisonai/praisonai/cli/features/wizard.py create mode 100644 src/praisonai/praisonai/gateway/__init__.py create mode 100644 src/praisonai/praisonai/gateway/server.py create mode 100644 src/praisonai/praisonai/sandbox/__init__.py create mode 100644 src/praisonai/praisonai/sandbox/docker.py create mode 100644 src/praisonai/praisonai/sandbox/subprocess.py diff --git a/api.md b/api.md index 1b6a3b534..326bc31b2 100644 --- a/api.md +++ b/api.md @@ -3,11 +3,27 @@ This file is auto-generated. Do not edit manually. Regenerate with: `praisonai docs api-md --write` +# Shared Types + +Types: +```python +from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, TaskOutput, ToolResult, ToolValidationError, WorkflowContext +``` + +Methods: + +* GuardrailResult.from_tuple(result: Tuple[bool, Any]) -> 'GuardrailResult' +* HandoffConfig.from_dict(data: Dict[str, Any]) -> 'HandoffConfig' +* HandoffConfig.to_dict() -> Dict[str, Any] +* TaskOutput.json() -> Optional[str] +* TaskOutput.to_dict() -> dict +* ToolResult.to_dict() -> Dict[str, Any] + # Agents Types: ```python -from praisonaiagents import Agent, Agents +from praisonaiagents import Agent, Agents, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent ``` Methods: @@ -112,18 +128,607 @@ Methods: * Agents.todo_list() * Agents.update_plan_step_status(step_id: str, status: str) -> bool * Agents.update_state(updates: Dict) -> None +* AutoAgents.astart() +* AutoAgents.start() +* AutoRagAgent.achat(message: str, **kwargs) -> str +* AutoRagAgent.chat(message: str, **kwargs) -> str +* AutoRagAgent.name() -> str +* AutoRagAgent.rag() -> Optional['RAG'] +* ContextAgent.aanalyze_codebase(project_path: str) -> Dict[str, Any] +* ContextAgent.acreate_implementation_blueprint(feature_request: str, context_analysis: Optional[Dict[str, Any]] = None) -> Dict[str, Any] +* ContextAgent.agenerate_prp(feature_request: str, context_analysis: Optional[Dict[str, Any]] = None) -> str +* ContextAgent.analyze_codebase(project_path: str) -> Dict[str, Any] +* ContextAgent.analyze_codebase_with_gitingest(project_path: str) -> Dict[str, Any] +* ContextAgent.analyze_integration_points(project_path: str) -> Dict[str, Any] +* ContextAgent.analyze_test_patterns(project_path: str) -> Dict[str, Any] +* ContextAgent.build_implementation_blueprint(feature_request: str, context_analysis: Dict[str, Any] = None) -> Dict[str, Any] +* ContextAgent.compile_context_documentation(project_path: str) -> Dict[str, Any] +* ContextAgent.create_implementation_blueprint(feature_request: str, context_analysis: Optional[Dict[str, Any]] = None) -> Dict[str, Any] +* ContextAgent.create_quality_gates(requirements: List[str]) -> Dict[str, Any] +* ContextAgent.create_validation_framework(project_path: str) -> Dict[str, Any] +* ContextAgent.execute_prp(prp_file_path: str) -> Dict[str, Any] +* ContextAgent.extract_implementation_patterns(project_path: str, ast_analysis: Dict[str, Any] = None) -> Dict[str, Any] +* ContextAgent.generate_comprehensive_prp(feature_request: str, context_analysis: Dict[str, Any] = None) -> str +* ContextAgent.generate_feature_prp(feature_request: str) -> str +* ContextAgent.generate_prp(feature_request: str, context_analysis: Optional[Dict[str, Any]] = None) -> str +* ContextAgent.get_agent_interaction_summary() -> Dict[str, Any] +* ContextAgent.log_debug(message: str, **kwargs) +* ContextAgent.perform_ast_analysis(project_path: str) -> Dict[str, Any] +* ContextAgent.save_comprehensive_session_report() +* ContextAgent.save_markdown_output(content: str, filename: str, section_title: str = 'Output') +* ContextAgent.setup_logging() +* ContextAgent.setup_output_directories() +* ContextAgent.start(input_text: str) -> str +* DeepResearchAgent.aresearch(query: str, instructions: Optional[str] = None, model: Optional[str] = None, summary_mode: Optional[Literal['auto', 'detailed', 'concise']] = None, web_search: Optional[bool] = None, code_interpreter: Optional[bool] = None, mcp_servers: Optional[List[Dict[str, Any]]] = None, file_ids: Optional[List[str]] = None, file_search: Optional[bool] = None, file_search_stores: Optional[List[str]] = None) -> DeepResearchResponse +* DeepResearchAgent.async_openai_client() +* DeepResearchAgent.clarify(query: str, model: Optional[str] = None) -> str +* DeepResearchAgent.follow_up(query: str, previous_interaction_id: str, model: Optional[str] = None) -> DeepResearchResponse +* DeepResearchAgent.gemini_client() +* DeepResearchAgent.openai_client() +* DeepResearchAgent.research(query: str, instructions: Optional[str] = None, model: Optional[str] = None, summary_mode: Optional[Literal['auto', 'detailed', 'concise']] = None, web_search: Optional[bool] = None, code_interpreter: Optional[bool] = None, mcp_servers: Optional[List[Dict[str, Any]]] = None, file_ids: Optional[List[str]] = None, file_search: Optional[bool] = None, file_search_stores: Optional[List[str]] = None, stream: bool = True) -> DeepResearchResponse +* DeepResearchAgent.rewrite_query(query: str, model: Optional[str] = None) -> str +* ImageAgent.achat(prompt: str, temperature: float = 0.2, tools: Optional[List[Any]] = None, output_json: Optional[str] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, **kwargs) -> Union[str, Dict[str, Any]] +* ImageAgent.aedit(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any] +* ImageAgent.agenerate(prompt: str, **kwargs) -> Dict[str, Any] +* ImageAgent.agenerate_image(prompt: str, **kwargs) -> Dict[str, Any] +* ImageAgent.avariation(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any] +* ImageAgent.chat(prompt: str, **kwargs) -> Dict[str, Any] +* ImageAgent.edit(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any] +* ImageAgent.generate(prompt: str, **kwargs) -> Dict[str, Any] +* ImageAgent.generate_image(prompt: str, **kwargs) -> Dict[str, Any] +* ImageAgent.litellm() +* ImageAgent.variation(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any] +* PlanningAgent.analyze_context(context: str) -> str +* PlanningAgent.analyze_context_sync(context: str) -> str +* PlanningAgent.create_plan(request: str, agents: List['Agent'], tasks: Optional[List['Task']] = None, context: Optional[str] = None) -> Plan +* PlanningAgent.create_plan_sync(request: str, agents: List['Agent'], tasks: Optional[List['Task']] = None, context: Optional[str] = None) -> Plan +* PlanningAgent.is_tool_allowed(tool_name: str) -> bool +* PlanningAgent.refine_plan(plan: Plan, feedback: str) -> Plan +* PlanningAgent.refine_plan_sync(plan: Plan, feedback: str) -> Plan +* PromptExpanderAgent.agent() +* PromptExpanderAgent.expand(prompt: str, strategy: ExpandStrategy = ..., context: Optional[str] = None) -> ExpandResult +* PromptExpanderAgent.expand_basic(prompt: str, context: Optional[str] = None) -> ExpandResult +* PromptExpanderAgent.expand_creative(prompt: str, context: Optional[str] = None) -> ExpandResult +* PromptExpanderAgent.expand_detailed(prompt: str, context: Optional[str] = None) -> ExpandResult +* PromptExpanderAgent.expand_structured(prompt: str, context: Optional[str] = None) -> ExpandResult +* QueryRewriterAgent.add_abbreviation(abbrev: str, expansion: str) -> None +* QueryRewriterAgent.add_abbreviations(abbreviations: Dict[str, str]) -> None +* QueryRewriterAgent.agent() +* QueryRewriterAgent.rewrite(query: str, strategy: RewriteStrategy = ..., chat_history: Optional[List[Dict[str, str]]] = None, context: Optional[str] = None, num_queries: int = None) -> RewriteResult +* QueryRewriterAgent.rewrite_basic(query: str) -> RewriteResult +* QueryRewriterAgent.rewrite_contextual(query: str, chat_history: List[Dict[str, str]]) -> RewriteResult +* QueryRewriterAgent.rewrite_hyde(query: str) -> RewriteResult +* QueryRewriterAgent.rewrite_multi_query(query: str, num_queries: int = None) -> RewriteResult +* QueryRewriterAgent.rewrite_step_back(query: str) -> RewriteResult +* QueryRewriterAgent.rewrite_sub_queries(query: str) -> RewriteResult +* praisonaiagents.create_context_agent(llm: Optional[Union[str, Any]] = None, **kwargs) -> ContextAgent # Tools Types: ```python -from praisonaiagents import Tools, tool +from praisonaiagents import BaseTool, FunctionTool, ToolRegistry, Tools, get_registry, get_tool, register_tool, tool, validate_tool ``` Methods: +* BaseTool.__call__(**kwargs) -> Any +* BaseTool.get_schema() -> Dict[str, Any] +* BaseTool.run(**kwargs) -> Any +* BaseTool.safe_run(**kwargs) -> ToolResult +* BaseTool.validate() -> bool +* BaseTool.validate_class() -> bool +* FunctionTool.__call__(*args, **kwargs) -> Any +* FunctionTool.injected_params() -> Dict[str, Any] +* FunctionTool.run(**kwargs) -> Any +* ToolRegistry.clear() -> None +* ToolRegistry.discover_plugins() -> int +* ToolRegistry.get(name: str) -> Optional[Union[BaseTool, Callable]] +* ToolRegistry.get_all() -> Dict[str, Union[BaseTool, Callable]] +* ToolRegistry.list_base_tools() -> List[BaseTool] +* ToolRegistry.list_tools() -> List[str] +* ToolRegistry.register(tool: Union[BaseTool, Callable], name: Optional[str] = None, overwrite: bool = False) -> None +* ToolRegistry.unregister(name: str) -> bool * Tools.internet_search(*args, **kwargs) +* praisonaiagents.get_registry() -> ToolRegistry +* praisonaiagents.get_tool(name: str) -> Optional[Union[BaseTool, Callable]] +* praisonaiagents.register_tool(tool: Union[BaseTool, Callable], name: Optional[str] = None) -> None * praisonaiagents.tool(func: Optional[Callable] = None) -> Union[FunctionTool, Callable[[Callable], FunctionTool]] +* praisonaiagents.validate_tool(tool: Any) -> bool + +# Workflows + +Types: +```python +from praisonaiagents import Loop, Parallel, Pipeline, Repeat, Route, Workflow, WorkflowStep, loop, parallel, repeat, route +``` + +Methods: + +* Workflow.arun(input: str = '', llm: Optional[str] = None, verbose: bool = False) -> Dict[str, Any] +* Workflow.astart(input: str = '', llm: Optional[str] = None, verbose: bool = False) -> Dict[str, Any] +* Workflow.from_template(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Workflow' +* Workflow.memory_config() -> Optional[Dict[str, Any]] +* Workflow.on_step_complete() -> Optional[Callable] +* Workflow.on_step_error() -> Optional[Callable] +* Workflow.on_step_start() -> Optional[Callable] +* Workflow.on_workflow_complete() -> Optional[Callable] +* Workflow.on_workflow_start() -> Optional[Callable] +* Workflow.planning_llm() -> Optional[str] +* Workflow.reasoning() -> bool +* Workflow.run(input: str = '', llm: Optional[str] = None, verbose: bool = False, stream: bool = None) -> Dict[str, Any] +* Workflow.start(input: str = '', **kwargs) -> Dict[str, Any] +* Workflow.stream() -> bool +* Workflow.to_dict() -> Dict[str, Any] +* Workflow.verbose() -> bool +* Workflow.verbose(value: bool) +* WorkflowStep.async_execution() -> bool +* WorkflowStep.branch_condition() -> Optional[Dict[str, List[str]]] +* WorkflowStep.context_from() -> Optional[List[str]] +* WorkflowStep.evaluator() +* WorkflowStep.executor() +* WorkflowStep.max_retries() -> int +* WorkflowStep.next_steps() -> Optional[List[str]] +* WorkflowStep.on_error() -> str +* WorkflowStep.output_file() -> Optional[str] +* WorkflowStep.output_json() -> Optional[Any] +* WorkflowStep.output_pydantic() -> Optional[Any] +* WorkflowStep.output_variable() -> Optional[str] +* WorkflowStep.quality_check() -> bool +* WorkflowStep.rerun() -> bool +* WorkflowStep.retain_full_context() -> bool +* WorkflowStep.to_dict() -> Dict[str, Any] +* praisonaiagents.loop(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop +* praisonaiagents.parallel(steps: List) -> Parallel +* praisonaiagents.repeat(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat +* praisonaiagents.route(routes: Dict[str, List], default: Optional[List] = None) -> Route + +# DB + +Types: +```python +from praisonaiagents import db +``` + +# Memory + +Types: +```python +from praisonaiagents import Memory, MemoryBackend, MemoryConfig +``` + +Methods: + +* Memory.build_context_for_task(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str +* Memory.calculate_quality_metrics(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float] +* Memory.compute_quality_score(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float +* Memory.delete_long_term(memory_id: str) -> bool +* Memory.delete_memories(memory_ids: List[str]) -> int +* Memory.delete_memories_matching(query: str, memory_type: Optional[str] = None, limit: int = 10) -> int +* Memory.delete_memory(memory_id: str, memory_type: Optional[str] = None) -> bool +* Memory.delete_short_term(memory_id: str) -> bool +* Memory.finalize_task_output(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None) +* Memory.get_all_memories() -> List[Dict[str, Any]] +* Memory.get_learn_context() -> str +* Memory.learn() +* Memory.reset_all() +* Memory.reset_entity_only() +* Memory.reset_long_term() +* Memory.reset_short_term() +* Memory.reset_user_memory() +* Memory.search(query: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, limit: int = 5, rerank: bool = False, **kwargs) -> List[Dict[str, Any]] +* Memory.search_entity(query: str, limit: int = 5) -> List[Dict[str, Any]] +* Memory.search_long_term(query: str, limit: int = 5, relevance_cutoff: float = 0.0, min_quality: float = 0.0, rerank: bool = False, **kwargs) -> List[Dict[str, Any]] +* Memory.search_short_term(query: str, limit: int = 5, min_quality: float = 0.0, relevance_cutoff: float = 0.0, rerank: bool = False, **kwargs) -> List[Dict[str, Any]] +* Memory.search_user_memory(user_id: str, query: str, limit: int = 5, rerank: bool = False, **kwargs) -> List[Dict[str, Any]] +* Memory.search_with_quality(query: str, min_quality: float = 0.0, memory_type: Literal['short', 'long'] = 'long', limit: int = 5) -> List[Dict[str, Any]] +* Memory.store_entity(name: str, type_: str, desc: str, relations: str) +* Memory.store_long_term(text: str, metadata: Dict[str, Any] = None, completeness: float = None, relevance: float = None, clarity: float = None, accuracy: float = None, weights: Dict[str, float] = None, evaluator_quality: float = None) +* Memory.store_quality(text: str, quality_score: float, task_id: Optional[str] = None, iteration: Optional[int] = None, metrics: Optional[Dict[str, float]] = None, memory_type: Literal['short', 'long'] = 'long') -> None +* Memory.store_short_term(text: str, metadata: Dict[str, Any] = None, completeness: float = None, relevance: float = None, clarity: float = None, accuracy: float = None, weights: Dict[str, float] = None, evaluator_quality: float = None) +* Memory.store_user_memory(user_id: str, text: str, extra: Dict[str, Any] = None) +* MemoryConfig.to_dict() -> Dict[str, Any] + +# Knowledge + +Types: +```python +from praisonaiagents import Chunking, ChunkingStrategy, Knowledge, KnowledgeConfig +``` + +Methods: + +* Chunking.SUPPORTED_CHUNKERS() -> Dict[str, Any] +* Chunking.__call__(text: Union[str, List[str]], **kwargs) -> Union[List[Any], List[List[Any]]] +* Chunking.chunk(text: Union[str, List[str]], **kwargs) -> Union[List[Any], List[List[Any]]] +* Chunking.chunker() +* Chunking.embedding_model() +* Knowledge.add(file_path, user_id = None, agent_id = None, run_id = None, metadata = None) +* Knowledge.chunker() +* Knowledge.config() +* Knowledge.delete(memory_id) +* Knowledge.delete_all(user_id = None, agent_id = None, run_id = None) +* Knowledge.get(memory_id) +* Knowledge.get_all(user_id = None, agent_id = None, run_id = None) +* Knowledge.get_corpus_stats() +* Knowledge.history(memory_id) +* Knowledge.index(path: str, incremental: bool = True, force: bool = False, include_glob: list = None, exclude_glob: list = None, user_id: str = None, agent_id: str = None, run_id: str = None) +* Knowledge.markdown() +* Knowledge.memory() +* Knowledge.normalize_content(content) +* Knowledge.reset() +* Knowledge.search(query, user_id = None, agent_id = None, run_id = None, rerank = None, **kwargs) +* Knowledge.store(content, user_id = None, agent_id = None, run_id = None, metadata = None) +* Knowledge.update(memory_id, data) +* KnowledgeConfig.to_dict() -> Dict[str, Any] + +# RAG + +Types: +```python +from praisonaiagents import CitationsMode, ContextPack, RAG, RAGCitation, RAGConfig, RAGResult, RetrievalConfig, RetrievalPolicy +``` + +Methods: + +* ContextPack.format_for_prompt(include_sources: bool = True) -> str +* ContextPack.from_dict(data: Dict[str, Any]) -> 'ContextPack' +* ContextPack.has_citations() -> bool +* ContextPack.to_dict() -> Dict[str, Any] +* RAG.aquery(question: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, **kwargs) -> RAGResult +* RAG.aretrieve(query: str, **kwargs) -> ContextPack +* RAG.astream(question: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, **kwargs) -> AsyncIterator[str] +* RAG.get_citations(question: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, **kwargs) -> List[Citation] +* RAG.llm() +* RAG.query(question: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, **kwargs) -> RAGResult +* RAG.retrieve(query: str, **kwargs) -> ContextPack +* RAG.stream(question: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, **kwargs) -> Iterator[str] +* RAGCitation.from_dict(data: Dict[str, Any]) -> 'Citation' +* RAGCitation.to_dict() -> Dict[str, Any] +* RAGConfig.from_dict(data: Dict[str, Any]) -> 'RAGConfig' +* RAGConfig.to_dict() -> Dict[str, Any] +* RAGResult.format_answer_with_citations() -> str +* RAGResult.from_dict(data: Dict[str, Any]) -> 'RAGResult' +* RAGResult.has_citations() -> bool +* RAGResult.to_dict() -> Dict[str, Any] +* RetrievalConfig.from_dict(data: Dict[str, Any]) -> 'RetrievalConfig' +* RetrievalConfig.get_strategy(corpus_stats = None) +* RetrievalConfig.get_token_budget(model_name: Optional[str] = None) +* RetrievalConfig.should_retrieve(query: str, force: bool = False, skip: bool = False) -> bool +* RetrievalConfig.to_dict() -> Dict[str, Any] +* RetrievalConfig.to_knowledge_config() -> Dict[str, Any] +* RetrievalConfig.to_rag_config() -> Dict[str, Any] + +# Handoff + +Types: +```python +from praisonaiagents import Handoff, RECOMMENDED_PROMPT_PREFIX, handoff, handoff_filters, prompt_with_handoff_instructions +``` + +Methods: + +* Handoff.default_tool_description() -> str +* Handoff.default_tool_name() -> str +* Handoff.execute_async(source_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None) -> HandoffResult +* Handoff.execute_programmatic(source_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None) -> HandoffResult +* Handoff.to_tool_function(source_agent: 'Agent') -> Callable +* Handoff.tool_description() -> str +* Handoff.tool_name() -> str +* praisonaiagents.handoff(agent: 'Agent', tool_name_override: Optional[str] = None, tool_description_override: Optional[str] = None, on_handoff: Optional[Callable] = None, input_type: Optional[type] = None, input_filter: Optional[Callable[[HandoffInputData], HandoffInputData]] = None, config: Optional[HandoffConfig] = None, context_policy: Optional[str] = None, timeout_seconds: Optional[float] = None, max_concurrent: Optional[int] = None, detect_cycles: Optional[bool] = None, max_depth: Optional[int] = None) -> Handoff +* handoff_filters.keep_last_n_messages(n: int) -> Callable[[HandoffInputData], HandoffInputData] +* handoff_filters.remove_all_tools(data: HandoffInputData) -> HandoffInputData +* handoff_filters.remove_system_messages(data: HandoffInputData) -> HandoffInputData +* praisonaiagents.prompt_with_handoff_instructions(base_prompt: str, agent: 'Agent') -> str + +# Guardrails + +Types: +```python +from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail +``` + +Methods: + +* GuardrailConfig.to_dict() -> Dict[str, Any] +* LLMGuardrail.__call__(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]] + +# Planning + +Types: +```python +from praisonaiagents import ApprovalCallback, Plan, PlanStep, PlanStorage, PlanningConfig, READ_ONLY_TOOLS, RESTRICTED_TOOLS, TodoItem, TodoList +``` + +Methods: + +* ApprovalCallback.__call__(plan: 'Plan') -> bool +* ApprovalCallback.always_approve(plan: 'Plan') -> bool +* ApprovalCallback.always_reject(plan: 'Plan') -> bool +* ApprovalCallback.approve_if_no_dangerous_tools(plan: 'Plan') -> bool +* ApprovalCallback.approve_if_small(plan: 'Plan', max_steps: int = 5) -> bool +* ApprovalCallback.async_call(plan: 'Plan') -> bool +* ApprovalCallback.console_approval(plan: 'Plan') -> bool +* Plan.add_step(step: PlanStep) -> None +* Plan.approve() -> None +* Plan.cancel() -> None +* Plan.complete() -> None +* Plan.completed_step_ids() -> List[str] +* Plan.from_dict(data: Dict[str, Any]) -> 'Plan' +* Plan.from_markdown(markdown: str) -> 'Plan' +* Plan.get_next_steps() -> List[PlanStep] +* Plan.get_step(step_id: str) -> Optional[PlanStep] +* Plan.is_complete() -> bool +* Plan.progress() -> float +* Plan.remove_step(step_id: str) -> bool +* Plan.start_execution() -> None +* Plan.to_dict() -> Dict[str, Any] +* Plan.to_markdown() -> str +* Plan.update_step_status(step_id: str, status: str) -> bool +* PlanStep.from_dict(data: Dict[str, Any]) -> 'PlanStep' +* PlanStep.is_ready(completed_steps: List[str]) -> bool +* PlanStep.mark_complete() -> None +* PlanStep.mark_in_progress() -> None +* PlanStep.mark_skipped() -> None +* PlanStep.to_dict() -> Dict[str, Any] +* PlanStorage.cleanup_old_plans(keep_count: int = 10) -> int +* PlanStorage.delete_plan(plan_id: str) -> bool +* PlanStorage.delete_todo(name: str) -> bool +* PlanStorage.get_latest_plan() -> Optional[Plan] +* PlanStorage.list_plans() -> List[Dict[str, Any]] +* PlanStorage.list_todos() -> List[str] +* PlanStorage.load_plan(plan_id: str) -> Optional[Plan] +* PlanStorage.load_plan_from_file(path: str) -> Optional[Plan] +* PlanStorage.load_todo(name: str = 'current') -> Optional[TodoList] +* PlanStorage.save_plan(plan: Plan, filename: Optional[str] = None) -> str +* PlanStorage.save_todo(todo: TodoList, name: str = 'current') -> str +* PlanningConfig.to_dict() -> Dict[str, Any] +* TodoItem.complete() -> None +* TodoItem.from_dict(data: Dict[str, Any]) -> 'TodoItem' +* TodoItem.is_ready(completed_ids: List[str]) -> bool +* TodoItem.reset() -> None +* TodoItem.start() -> None +* TodoItem.to_dict() -> Dict[str, Any] +* TodoList.add(item: Union[TodoItem, str]) -> TodoItem +* TodoList.complete(item_id: str) -> bool +* TodoList.completed() -> List[TodoItem] +* TodoList.completed_ids() -> List[str] +* TodoList.from_dict(data: Dict[str, Any]) -> 'TodoList' +* TodoList.from_json(json_str: str) -> 'TodoList' +* TodoList.from_markdown(markdown: str) -> 'TodoList' +* TodoList.from_plan(plan: 'Plan') -> 'TodoList' +* TodoList.get(item_id: str) -> Optional[TodoItem] +* TodoList.get_ready_items() -> List[TodoItem] +* TodoList.in_progress() -> List[TodoItem] +* TodoList.is_complete() -> bool +* TodoList.pending() -> List[TodoItem] +* TodoList.progress() -> float +* TodoList.remove(item_id: str) -> bool +* TodoList.start(item_id: str) -> bool +* TodoList.sync_with_plan(plan: 'Plan') -> None +* TodoList.to_dict() -> Dict[str, Any] +* TodoList.to_json() -> str +* TodoList.to_markdown() -> str +* TodoList.update_from_plan(plan: 'Plan') -> None + +# Skills + +Types: +```python +from praisonaiagents import SkillLoader, SkillManager, SkillMetadata, SkillProperties, SkillsConfig +``` + +Methods: + +* SkillLoader.activate(skill: LoadedSkill) -> bool +* SkillLoader.load(skill_path: str, activate: bool = False) -> Optional[LoadedSkill] +* SkillLoader.load_all_resources(skill: LoadedSkill) -> None +* SkillLoader.load_assets(skill: LoadedSkill) -> dict +* SkillLoader.load_metadata(skill_path: str) -> Optional[LoadedSkill] +* SkillLoader.load_references(skill: LoadedSkill) -> dict +* SkillLoader.load_scripts(skill: LoadedSkill) -> dict +* SkillManager.activate(skill: LoadedSkill) -> bool +* SkillManager.activate_by_name(name: str) -> bool +* SkillManager.add_skill(skill_path: str) -> Optional[LoadedSkill] +* SkillManager.clear() -> None +* SkillManager.discover(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int +* SkillManager.get_available_skills() -> List[SkillMetadata] +* SkillManager.get_instructions(name: str) -> Optional[str] +* SkillManager.get_skill(name: str) -> Optional[LoadedSkill] +* SkillManager.load_resources(name: str) -> bool +* SkillManager.skill_names() -> List[str] +* SkillManager.skills() -> List[LoadedSkill] +* SkillManager.to_prompt() -> str +* SkillMetadata.from_properties(props: SkillProperties) -> 'SkillMetadata' +* SkillProperties.to_dict() -> dict +* SkillsConfig.to_dict() -> Dict[str, Any] + +# Session + +Types: +```python +from praisonaiagents import Session +``` + +Methods: + +* Session.Agent(name: str, role: str = 'Assistant', instructions: Optional[str] = None, tools: Optional[List[Any]] = None, memory: bool = True, knowledge: Optional[List[str]] = None, **kwargs) -> 'Agent' +* Session.add_knowledge(source: str) -> None +* Session.add_memory(text: str, memory_type: str = 'long', **metadata) -> None +* Session.chat(message: str, **kwargs) -> str +* Session.clear_memory(memory_type: str = 'all') -> None +* Session.create_agent(*args, **kwargs) -> 'Agent' +* Session.get_context(query: str, max_items: int = 3) -> str +* Session.get_state(key: str, default: Any = None) -> Any +* Session.increment_state(key: str, increment: int = 1, default: int = 0) -> None +* Session.knowledge() -> 'Knowledge' +* Session.memory() -> 'Memory' +* Session.restore_state() -> Dict[str, Any] +* Session.save_state(state_data: Dict[str, Any]) -> None +* Session.search_knowledge(query: str, limit: int = 5) -> List[Dict[str, Any]] +* Session.search_memory(query: str, memory_type: str = 'long', limit: int = 5) -> List[Dict[str, Any]] +* Session.send_message(message: str, **kwargs) -> str +* Session.set_state(key: str, value: Any) -> None + +# MCP + +Types: +```python +from praisonaiagents import MCP +``` + +Methods: + +* MCP.get_tools() -> List[Callable] +* MCP.shutdown() +* MCP.to_openai_tool() + +# Telemetry + +Types: +```python +from praisonaiagents import MinimalTelemetry, TelemetryCollector, cleanup_telemetry_resources, disable_performance_mode, disable_telemetry, enable_performance_mode, enable_telemetry, get_telemetry +``` + +Methods: + +* MinimalTelemetry.flush() +* MinimalTelemetry.get_metrics() -> Dict[str, Any] +* MinimalTelemetry.shutdown() +* MinimalTelemetry.track_agent_execution(agent_name: str = None, success: bool = True, async_mode: bool = False) +* MinimalTelemetry.track_error(error_type: str = None) +* MinimalTelemetry.track_feature_usage(feature_name: str) +* MinimalTelemetry.track_task_completion(task_name: str = None, success: bool = True) +* MinimalTelemetry.track_tool_usage(tool_name: str, success: bool = True, execution_time: float = None) +* TelemetryCollector.get_metrics() -> Dict[str, Any] +* TelemetryCollector.record_cost(cost: float, model: str = None) +* TelemetryCollector.record_tokens(prompt_tokens: int, completion_tokens: int, model: str = None) +* TelemetryCollector.start() +* TelemetryCollector.stop() +* TelemetryCollector.trace_agent_execution(agent_name: str, **attributes) +* TelemetryCollector.trace_llm_call(model: str = None, **attributes) +* TelemetryCollector.trace_task_execution(task_name: str, agent_name: str = None, **attributes) +* TelemetryCollector.trace_tool_call(tool_name: str, **attributes) +* praisonaiagents.cleanup_telemetry_resources() +* praisonaiagents.disable_performance_mode() +* praisonaiagents.disable_telemetry() +* praisonaiagents.enable_performance_mode() +* praisonaiagents.enable_telemetry() +* praisonaiagents.get_telemetry() -> 'MinimalTelemetry' + +# Observability + +Types: +```python +from praisonaiagents import FlowDisplay, obs, track_workflow +``` + +Methods: + +* FlowDisplay.display() +* FlowDisplay.start() +* FlowDisplay.stop() +* praisonaiagents.track_workflow() + +# Context + +Types: +```python +from praisonaiagents import ContextManager, ManagerConfig +``` + +Methods: + +* ContextManager.capture_llm_boundary(messages: List[Dict[str, Any]], tools: List[Dict[str, Any]]) -> SnapshotHookData +* ContextManager.emergency_truncate(messages: List[Dict[str, Any]], target_tokens: int) -> List[Dict[str, Any]] +* ContextManager.estimate_tokens(text: str, validate: bool = False) -> Tuple[int, Optional[EstimationMetrics]] +* ContextManager.get_history() -> List[Dict[str, Any]] +* ContextManager.get_last_snapshot_hook() -> Optional[SnapshotHookData] +* ContextManager.get_resolved_config() -> Dict[str, Any] +* ContextManager.get_stats() -> Dict[str, Any] +* ContextManager.get_tool_budget(tool_name: str) -> int +* ContextManager.process(messages: List[Dict[str, Any]], system_prompt: str = '', tools: Optional[List[Dict[str, Any]]] = None, trigger: Literal['turn', 'tool_call', 'manual', 'overflow'] = 'turn') -> Dict[str, Any] +* ContextManager.register_snapshot_callback(callback: Callable[[SnapshotHookData], None]) -> None +* ContextManager.reset() -> None +* ContextManager.set_tool_budget(tool_name: str, max_tokens: int, protected: bool = False) -> None +* ContextManager.truncate_tool_output(tool_name: str, output: str) -> str +* ManagerConfig.from_env() -> 'ManagerConfig' +* ManagerConfig.merge(**overrides) -> 'ManagerConfig' +* ManagerConfig.to_dict() -> Dict[str, Any] + +# Config + +Types: +```python +from praisonaiagents import AutonomyConfig, AutonomyLevel, CachingConfig, ExecutionConfig, ExecutionPreset, GuardrailConfig, HooksConfig, KnowledgeConfig, MemoryConfig, MultiAgentExecutionConfig, MultiAgentHooksConfig, MultiAgentMemoryConfig, MultiAgentOutputConfig, MultiAgentPlanningConfig, OutputConfig, OutputPreset, PlanningConfig, ReflectionConfig, SkillsConfig, TemplateConfig, WebConfig, WebSearchProvider +``` + +Methods: + +* AutonomyConfig.to_dict() -> Dict[str, Any] +* CachingConfig.to_dict() -> Dict[str, Any] +* ExecutionConfig.to_dict() -> Dict[str, Any] +* GuardrailConfig.to_dict() -> Dict[str, Any] +* HooksConfig.to_dict() -> Dict[str, Any] +* KnowledgeConfig.to_dict() -> Dict[str, Any] +* MemoryConfig.to_dict() -> Dict[str, Any] +* MultiAgentExecutionConfig.to_dict() -> Dict[str, Any] +* MultiAgentHooksConfig.to_dict() -> Dict[str, Any] +* MultiAgentMemoryConfig.to_dict() -> Dict[str, Any] +* MultiAgentOutputConfig.to_dict() -> Dict[str, Any] +* MultiAgentPlanningConfig.to_dict() -> Dict[str, Any] +* OutputConfig.to_dict() -> Dict[str, Any] +* PlanningConfig.to_dict() -> Dict[str, Any] +* ReflectionConfig.to_dict() -> Dict[str, Any] +* SkillsConfig.to_dict() -> Dict[str, Any] +* TemplateConfig.to_dict() -> Dict[str, Any] +* WebConfig.to_dict() -> Dict[str, Any] + +# Display + +Types: +```python +from praisonaiagents import async_display_callbacks, clean_triple_backticks, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, error_logs, register_display_callback, sync_display_callbacks +``` + +Methods: + +* praisonaiagents.clean_triple_backticks(text: str) -> str +* praisonaiagents.display_error(message: str, console = None) +* praisonaiagents.display_generating(content: str = '', start_time: Optional[float] = None) +* praisonaiagents.display_instruction(message: str, console = None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None) +* praisonaiagents.display_interaction(message, response, markdown = True, generation_time = None, console = None, agent_name = None, agent_role = None, agent_tools = None, task_name = None, task_description = None, task_id = None, metrics = None) +* praisonaiagents.display_self_reflection(message: str, console = None) +* praisonaiagents.display_tool_call(message: str, console = None, tool_name: str = None, tool_input: dict = None, tool_output: str = None, elapsed_time: float = None, success: bool = True) +* praisonaiagents.register_display_callback(display_type: str, callback_fn, is_async: bool = False) + +# Utilities + +Types: +```python +from praisonaiagents import ArrayMode, is_policy_string, parse_policy_string, resolve, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrail_policies, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web +``` + +Methods: + +* praisonaiagents.is_policy_string(value: str) -> bool +* praisonaiagents.parse_policy_string(value: str) -> tuple +* praisonaiagents.resolve(value: Any, param_name: str, config_class: Optional[Type] = None, presets: Optional[Dict[str, Any]] = None, default: Any = None, instance_check: Optional[Callable[[Any], bool]] = None, url_schemes: Optional[Dict[str, str]] = None, array_mode: Optional[str] = None, string_mode: Optional[str] = None) -> Any +* praisonaiagents.resolve_autonomy(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_caching(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_context(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_execution(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_guardrail_policies(policies: list, config_class: Type) -> Any +* praisonaiagents.resolve_guardrails(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_hooks(value: Any, config_class: Optional[Type] = None) -> Any +* praisonaiagents.resolve_knowledge(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_memory(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_output(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_planning(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_reflection(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_routing(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_skills(value: Any, config_class: Type) -> Any +* praisonaiagents.resolve_web(value: Any, config_class: Type) -> Any # Other diff --git a/docker/Dockerfile.chat b/docker/Dockerfile.chat index 0f4600f64..193b39f3a 100644 --- a/docker/Dockerfile.chat +++ b/docker/Dockerfile.chat @@ -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.10.27" \ + "praisonai>=3.11.0" \ "praisonai[chat]" \ "embedchain[github,youtube]" diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 83608ab79..22e1d505e 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -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.10.27" \ + "praisonai>=3.11.0" \ "praisonai[ui]" \ "praisonai[chat]" \ "praisonai[realtime]" \ diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui index 1d15518a2..774bfb09a 100644 --- a/docker/Dockerfile.ui +++ b/docker/Dockerfile.ui @@ -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.10.27" \ + "praisonai>=3.11.0" \ "praisonai[ui]" \ "praisonai[crewai]" diff --git a/examples/python/bot_example.py b/examples/python/bot_example.py new file mode 100644 index 000000000..f06ca9782 --- /dev/null +++ b/examples/python/bot_example.py @@ -0,0 +1,76 @@ +""" +Messaging Bot Example - Deploy Agents to Telegram, Discord, Slack + +This example demonstrates how to configure bots for different +messaging platforms. +""" + +import os +from praisonaiagents import Agent, BotConfig + +# Telegram Bot Configuration +telegram_config = BotConfig( + token=os.environ.get("TELEGRAM_BOT_TOKEN", ""), + command_prefix="/", + mention_required=True, + typing_indicator=True, + max_message_length=4096, + metadata={"platform": "telegram"} +) + +# Discord Bot Configuration +discord_config = BotConfig( + token=os.environ.get("DISCORD_BOT_TOKEN", ""), + command_prefix="!", + mention_required=True, + typing_indicator=True, + metadata={ + "platform": "discord", + "guild_ids": [] # Add your guild IDs here + } +) + +# Slack Bot Configuration +slack_config = BotConfig( + token=os.environ.get("SLACK_BOT_TOKEN", ""), + command_prefix="/", + mention_required=True, + typing_indicator=True, + metadata={ + "platform": "slack", + "app_token": os.environ.get("SLACK_APP_TOKEN", "") + } +) + +# Create the agent that will handle messages +assistant = Agent( + name="assistant", + instructions="""You are a helpful assistant that responds to messages + from users on messaging platforms. Be friendly and concise.""", + llm="gpt-4o-mini" +) + +if __name__ == "__main__": + print("Bot Configurations:") + print() + + print("Telegram Config:") + print(f" Token configured: {bool(telegram_config.token)}") + print(f" Command prefix: {telegram_config.command_prefix}") + print(f" Mention required: {telegram_config.mention_required}") + print() + + print("Discord Config:") + print(f" Token configured: {bool(discord_config.token)}") + print(f" Command prefix: {discord_config.command_prefix}") + print() + + print("Slack Config:") + print(f" Token configured: {bool(slack_config.token)}") + print(f" Command prefix: {slack_config.command_prefix}") + print() + + print("To start a bot, use the CLI:") + print(" praisonai bot telegram --token $TELEGRAM_BOT_TOKEN") + print(" praisonai bot discord --token $DISCORD_BOT_TOKEN") + print(" praisonai bot slack --token $SLACK_BOT_TOKEN") diff --git a/examples/python/failover_example.py b/examples/python/failover_example.py new file mode 100644 index 000000000..3fae101e8 --- /dev/null +++ b/examples/python/failover_example.py @@ -0,0 +1,170 @@ +""" +Model Failover Example - Automatic Provider Switching + +This example demonstrates how to configure model failover +for reliability and cost optimization. +""" + +import os +from praisonaiagents import ( + AuthProfile, + FailoverConfig, + FailoverManager, +) + +# Create auth profiles for different providers +openai_profile = AuthProfile( + name="openai-primary", + provider="openai", + api_key=os.environ.get("OPENAI_API_KEY", "sk-..."), + priority=1, # Highest priority + rate_limit=100, +) + +anthropic_profile = AuthProfile( + name="anthropic-backup", + provider="anthropic", + api_key=os.environ.get("ANTHROPIC_API_KEY", "sk-ant-..."), + priority=2, # Second priority + rate_limit=50, +) + +groq_profile = AuthProfile( + name="groq-fallback", + provider="groq", + api_key=os.environ.get("GROQ_API_KEY", "gsk-..."), + priority=3, # Third priority (fast but limited) + rate_limit=30, +) + +# Configure failover behavior +failover_config = FailoverConfig( + max_retries=3, + retry_delay=1.0, + exponential_backoff=True, + max_retry_delay=60.0, + failover_on_rate_limit=True, + failover_on_timeout=True, + failover_on_error=True, +) + +# Create failover manager +manager = FailoverManager(failover_config) +manager.add_profile(openai_profile) +manager.add_profile(anthropic_profile) +manager.add_profile(groq_profile) + +def demonstrate_failover(): + """Demonstrate failover functionality.""" + + print("Failover Manager Status:") + print() + + # Get current status + status = manager.status() + for name, info in status.items(): + print(f" {name}:") + print(f" Status: {info.get('status', 'unknown')}") + print(f" Priority: {info.get('priority', 'N/A')}") + print(f" Failures: {info.get('failure_count', 0)}") + print() + + # Get next available profile + next_profile = manager.get_next_profile() + if next_profile: + print(f"Next profile to use: {next_profile.name}") + print(f" Provider: {next_profile.provider}") + print(f" Priority: {next_profile.priority}") + print() + + # Simulate marking a failure + print("Simulating failure on primary provider...") + manager.mark_failure("openai-primary", "Rate limit exceeded") + + # Get next profile after failure + next_profile = manager.get_next_profile() + if next_profile: + print(f"After failure, next profile: {next_profile.name}") + print() + + # Get retry delay + delay = manager.get_retry_delay("openai-primary") + print(f"Retry delay for openai-primary: {delay}s") + print() + + # Reset all providers + print("Resetting all providers...") + manager.reset_all() + + next_profile = manager.get_next_profile() + if next_profile: + print(f"After reset, next profile: {next_profile.name}") + +def cost_optimization_example(): + """Example of cost-optimized failover.""" + + print("\nCost Optimization Example:") + print("-" * 40) + + # Create cost-optimized manager + cost_manager = FailoverManager() + + # Cheaper model first + cost_manager.add_profile(AuthProfile( + name="gpt-4o-mini", + provider="openai", + api_key=os.environ.get("OPENAI_API_KEY", ""), + priority=1, + metadata={"model": "gpt-4o-mini", "cost_per_1k": 0.00015} + )) + + # Premium model as fallback + cost_manager.add_profile(AuthProfile( + name="gpt-4o", + provider="openai", + api_key=os.environ.get("OPENAI_API_KEY", ""), + priority=2, + metadata={"model": "gpt-4o", "cost_per_1k": 0.005} + )) + + # Most expensive as last resort + cost_manager.add_profile(AuthProfile( + name="claude-opus", + provider="anthropic", + api_key=os.environ.get("ANTHROPIC_API_KEY", ""), + priority=3, + metadata={"model": "claude-3-opus", "cost_per_1k": 0.015} + )) + + print("Cost-optimized failover chain:") + for profile in [ + cost_manager.get_profile("gpt-4o-mini"), + cost_manager.get_profile("gpt-4o"), + cost_manager.get_profile("claude-opus"), + ]: + if profile: + cost = profile.metadata.get("cost_per_1k", "N/A") + print(f" {profile.priority}. {profile.name} (${cost}/1K tokens)") + +if __name__ == "__main__": + print("Model Failover Configuration") + print("=" * 40) + print() + + print("Failover Config:") + print(f" Max retries: {failover_config.max_retries}") + print(f" Initial delay: {failover_config.retry_delay}s") + print(f" Exponential backoff: {failover_config.exponential_backoff}") + print(f" Max delay: {failover_config.max_retry_delay}s") + print() + + print("Auth Profiles:") + for profile in [openai_profile, anthropic_profile, groq_profile]: + print(f" {profile.name}:") + print(f" Provider: {profile.provider}") + print(f" Priority: {profile.priority}") + print(f" Rate limit: {profile.rate_limit} req/min") + print() + + demonstrate_failover() + cost_optimization_example() diff --git a/examples/python/gateway_example.py b/examples/python/gateway_example.py new file mode 100644 index 000000000..ca45b092d --- /dev/null +++ b/examples/python/gateway_example.py @@ -0,0 +1,47 @@ +""" +Gateway Example - Multi-Agent Coordination + +This example demonstrates how to use the Gateway for coordinating +multiple agents and managing real-time communication. +""" + +from praisonaiagents import Agent, GatewayConfig, SessionConfig + +# Configure the gateway +gateway_config = GatewayConfig( + host="127.0.0.1", + port=8765, + max_connections=100, + heartbeat_interval=30, + session_config=SessionConfig( + timeout=3600, # 1 hour session timeout + max_messages=500, + ) +) + +# Create specialized agents +researcher = Agent( + name="researcher", + instructions="You research topics thoroughly and provide detailed information.", + llm="gpt-4o-mini" +) + +writer = Agent( + name="writer", + instructions="You write clear, engaging content based on research.", + llm="gpt-4o-mini" +) + +# Example: Simple agent interaction +if __name__ == "__main__": + print("Gateway Configuration:") + print(f" Host: {gateway_config.host}") + print(f" Port: {gateway_config.port}") + print(f" WebSocket URL: {gateway_config.ws_url}") + print(f" Max Connections: {gateway_config.max_connections}") + print() + + # Test agent + response = researcher.start("What are the key benefits of multi-agent systems?") + print("Researcher Response:") + print(response) diff --git a/examples/python/sandbox_example.py b/examples/python/sandbox_example.py new file mode 100644 index 000000000..e49ec2b62 --- /dev/null +++ b/examples/python/sandbox_example.py @@ -0,0 +1,137 @@ +""" +Sandbox Example - Secure Code Execution + +This example demonstrates how to configure and use sandboxes +for safe code execution. +""" + +from praisonaiagents import ( + SandboxConfig, + ResourceLimits, + SandboxResult, + SandboxStatus +) +from praisonaiagents.sandbox import SecurityPolicy + +# Minimal sandbox for untrusted code +minimal_sandbox = SandboxConfig( + sandbox_type="subprocess", + resource_limits=ResourceLimits.minimal(), + security_policy=SecurityPolicy.strict(), +) + +# Standard sandbox for general use +standard_sandbox = SandboxConfig( + sandbox_type="subprocess", + resource_limits=ResourceLimits.standard(), + security_policy=SecurityPolicy.standard(), +) + +# Docker sandbox for full isolation +docker_sandbox = SandboxConfig.docker(image="python:3.11-slim") + +# Custom sandbox configuration +custom_sandbox = SandboxConfig( + sandbox_type="subprocess", + working_dir="/tmp/sandbox", + resource_limits=ResourceLimits( + memory_mb=256, + cpu_percent=50, + timeout_seconds=30, + network_enabled=False, + max_processes=5, + ), + security_policy=SecurityPolicy( + allow_network=False, + allow_file_write=True, + allow_subprocess=False, + blocked_imports=["subprocess", "os.system", "eval"], + ), + auto_cleanup=True, +) + +def demonstrate_result_handling(): + """Show how to handle sandbox results.""" + + # Simulated successful result + success_result = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout="Hello, World!\n", + stderr="", + duration_seconds=0.1, + ) + + # Simulated timeout result + timeout_result = SandboxResult( + status=SandboxStatus.TIMEOUT, + exit_code=None, + stdout="Partial output...", + stderr="", + error="Execution timed out after 30 seconds", + duration_seconds=30.0, + ) + + # Simulated error result + error_result = SandboxResult( + status=SandboxStatus.FAILED, + exit_code=1, + stdout="", + stderr="NameError: name 'undefined_var' is not defined", + error="Execution failed", + ) + + print("Result Handling Examples:") + print() + + for name, result in [ + ("Success", success_result), + ("Timeout", timeout_result), + ("Error", error_result) + ]: + print(f"{name} Result:") + print(f" Status: {result.status.name}") + print(f" Success: {result.success}") + if result.stdout: + print(f" Output: {result.stdout.strip()}") + if result.stderr: + print(f" Error: {result.stderr.strip()}") + if result.error: + print(f" Message: {result.error}") + print() + +if __name__ == "__main__": + print("Sandbox Configurations:") + print() + + print("Minimal Sandbox (for untrusted code):") + print(f" Type: {minimal_sandbox.sandbox_type}") + print(f" Memory: {minimal_sandbox.resource_limits.memory_mb}MB") + print(f" Timeout: {minimal_sandbox.resource_limits.timeout_seconds}s") + print(f" Network: {minimal_sandbox.resource_limits.network_enabled}") + print() + + print("Standard Sandbox:") + print(f" Type: {standard_sandbox.sandbox_type}") + print(f" Memory: {standard_sandbox.resource_limits.memory_mb}MB") + print(f" Timeout: {standard_sandbox.resource_limits.timeout_seconds}s") + print() + + print("Docker Sandbox:") + print(f" Type: {docker_sandbox.sandbox_type}") + print(f" Image: {docker_sandbox.image}") + print() + + print("Custom Sandbox:") + print(f" Type: {custom_sandbox.sandbox_type}") + print(f" Working Dir: {custom_sandbox.working_dir}") + print(f" Memory: {custom_sandbox.resource_limits.memory_mb}MB") + print(f" Max Processes: {custom_sandbox.resource_limits.max_processes}") + print() + + demonstrate_result_handling() + + print("To run code in sandbox via CLI:") + print(" praisonai sandbox run \"print('Hello, World!')\"") + print(" praisonai sandbox run --file script.py") + print(" praisonai sandbox shell") diff --git a/src/praisonai-agents/AGENTS.md b/src/praisonai-agents/AGENTS.md index e793f798b..410d45f1d 100644 --- a/src/praisonai-agents/AGENTS.md +++ b/src/praisonai-agents/AGENTS.md @@ -21,7 +21,7 @@ Simpler than competitors • More extensible • Faster • Agent-centric | **Minimal API** | Fewer parameters, sensible defaults, explicit overrides | | **Performance-First** | Lazy loading, optional dependencies, no hot-path regressions | | **Production-Ready** | Safe by default, multi-agent safe, async-safe | - +Powerful, lightweight, highly reliable and robust --- ## 2. Repository Structure diff --git a/src/praisonai-agents/praisonaiagents/__init__.py b/src/praisonai-agents/praisonaiagents/__init__.py index 5a40aa4ab..1f4ed6cc0 100644 --- a/src/praisonai-agents/praisonaiagents/__init__.py +++ b/src/praisonai-agents/praisonaiagents/__init__.py @@ -381,6 +381,38 @@ _LAZY_IMPORTS = { # db and obs modules 'db': ('praisonaiagents.db', 'db'), 'obs': ('praisonaiagents.obs', 'obs'), + + # Gateway protocols and config (implementations in praisonai wrapper) + 'GatewayProtocol': ('praisonaiagents.gateway.protocols', 'GatewayProtocol'), + 'GatewaySessionProtocol': ('praisonaiagents.gateway.protocols', 'GatewaySessionProtocol'), + 'GatewayClientProtocol': ('praisonaiagents.gateway.protocols', 'GatewayClientProtocol'), + 'GatewayEvent': ('praisonaiagents.gateway.protocols', 'GatewayEvent'), + 'GatewayMessage': ('praisonaiagents.gateway.protocols', 'GatewayMessage'), + 'EventType': ('praisonaiagents.gateway.protocols', 'EventType'), + 'GatewayConfig': ('praisonaiagents.gateway.config', 'GatewayConfig'), + 'SessionConfig': ('praisonaiagents.gateway.config', 'SessionConfig'), + + # Bot protocols and config (implementations in praisonai wrapper) + 'BotProtocol': ('praisonaiagents.bots.protocols', 'BotProtocol'), + 'BotMessage': ('praisonaiagents.bots.protocols', 'BotMessage'), + 'BotUser': ('praisonaiagents.bots.protocols', 'BotUser'), + 'BotChannel': ('praisonaiagents.bots.protocols', 'BotChannel'), + 'MessageType': ('praisonaiagents.bots.protocols', 'MessageType'), + 'BotConfig': ('praisonaiagents.bots.config', 'BotConfig'), + + # Sandbox protocols and config (implementations in praisonai wrapper) + 'SandboxProtocol': ('praisonaiagents.sandbox.protocols', 'SandboxProtocol'), + 'SandboxResult': ('praisonaiagents.sandbox.protocols', 'SandboxResult'), + 'SandboxStatus': ('praisonaiagents.sandbox.protocols', 'SandboxStatus'), + 'ResourceLimits': ('praisonaiagents.sandbox.protocols', 'ResourceLimits'), + 'SandboxConfig': ('praisonaiagents.sandbox.config', 'SandboxConfig'), + 'SecurityPolicy': ('praisonaiagents.sandbox.config', 'SecurityPolicy'), + + # Model failover + 'AuthProfile': ('praisonaiagents.llm.failover', 'AuthProfile'), + 'ProviderStatus': ('praisonaiagents.llm.failover', 'ProviderStatus'), + 'FailoverConfig': ('praisonaiagents.llm.failover', 'FailoverConfig'), + 'FailoverManager': ('praisonaiagents.llm.failover', 'FailoverManager'), } diff --git a/src/praisonai-agents/praisonaiagents/agent/agent.py b/src/praisonai-agents/praisonaiagents/agent/agent.py index 01c0838fa..d2d707c5a 100644 --- a/src/praisonai-agents/praisonaiagents/agent/agent.py +++ b/src/praisonai-agents/praisonaiagents/agent/agent.py @@ -1844,6 +1844,8 @@ Summary:""" prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, + completion_promise: Optional[str] = None, + clear_context: bool = False, ): """Run an autonomous task execution loop. @@ -1852,12 +1854,17 @@ Summary:""" - Progressive escalation based on task complexity - Doom loop detection and recovery - Iteration limits and timeouts - - Completion detection + - Completion detection (keyword-based or promise-based) + - Optional context clearing between iterations Args: prompt: The task to execute max_iterations: Override max iterations (default from config) timeout_seconds: Timeout in seconds (default: no timeout) + completion_promise: Optional string that signals completion when + wrapped in TEXT tags in the response + clear_context: Whether to clear chat history between iterations + (forces agent to rely on external state like files) Returns: AutonomyResult with success status, output, and metadata @@ -1867,7 +1874,11 @@ Summary:""" Example: agent = Agent(instructions="...", autonomy=True) - result = agent.run_autonomous("Refactor the auth module") + result = agent.run_autonomous( + "Refactor the auth module", + completion_promise="DONE", + clear_context=True + ) if result.success: print(result.output) """ @@ -1888,6 +1899,16 @@ Summary:""" config_max_iter = self.autonomy_config.get("max_iterations", 20) effective_max_iter = max_iterations if max_iterations is not None else config_max_iter + # Get completion_promise from config if not provided as param + effective_promise = completion_promise + if effective_promise is None: + effective_promise = self.autonomy_config.get("completion_promise") + + # Get clear_context from config if not explicitly set + effective_clear_context = clear_context + if not clear_context: + effective_clear_context = self.autonomy_config.get("clear_context", False) + # Analyze prompt and get recommended stage stage = self.get_recommended_stage(prompt) @@ -1924,8 +1945,9 @@ Summary:""" ) # Execute one turn using the agent's chat method + # Always use the original prompt (prompt re-injection) try: - response = self.chat(prompt if iterations == 1 else "Continue with the task") + response = self.chat(prompt) except Exception as e: return AutonomyResult( success=False, @@ -1944,8 +1966,24 @@ Summary:""" "response": str(response)[:500], }) - # Check for completion signals in response - response_lower = str(response).lower() + response_str = str(response) + + # Check for completion promise FIRST (structured signal) + if effective_promise: + promise_tag = f"{effective_promise}" + if promise_tag in response_str: + return AutonomyResult( + success=True, + output=response_str, + completion_reason="promise", + iterations=iterations, + stage=stage, + actions=actions_taken, + duration_seconds=time_module.time() - start_time, + ) + + # Check for keyword-based completion signals (fallback) + response_lower = response_str.lower() completion_signals = [ "task completed", "task complete", "done", "finished", "completed successfully", @@ -1954,7 +1992,7 @@ Summary:""" if any(signal in response_lower for signal in completion_signals): return AutonomyResult( success=True, - output=str(response), + output=response_str, completion_reason="goal", iterations=iterations, stage=stage, @@ -1966,13 +2004,17 @@ Summary:""" if stage == "direct": return AutonomyResult( success=True, - output=str(response), + output=response_str, completion_reason="goal", iterations=iterations, stage=stage, actions=actions_taken, duration_seconds=time_module.time() - start_time, ) + + # Clear context between iterations if enabled + if effective_clear_context: + self.clear_history() # Max iterations reached return AutonomyResult( @@ -4027,7 +4069,7 @@ Your Goal: {self.goal}""" "execute_tool_fn": self.execute_tool, "stream": stream, "console": self.console if (self.verbose or stream) else None, - "display_fn": self.display_generating if self.verbose else None, + "display_fn": self._display_generating if self.verbose else None, "reasoning_steps": reasoning_steps, "verbose": self.verbose, "max_iterations": 10 diff --git a/src/praisonai-agents/praisonaiagents/agent/autonomy.py b/src/praisonai-agents/praisonaiagents/agent/autonomy.py index 03a1507f5..bb4cd367f 100644 --- a/src/praisonai-agents/praisonaiagents/agent/autonomy.py +++ b/src/praisonai-agents/praisonaiagents/agent/autonomy.py @@ -53,12 +53,16 @@ class AutonomyConfig: doom_loop_threshold: Number of repeated actions to trigger doom loop auto_escalate: Whether to automatically escalate complexity observe: Whether to emit observability events + completion_promise: Optional string that signals completion when wrapped in TEXT + clear_context: Whether to clear chat history between iterations """ enabled: bool = True max_iterations: int = 20 doom_loop_threshold: int = 3 auto_escalate: bool = True observe: bool = False + completion_promise: Optional[str] = None + clear_context: bool = False @classmethod def from_dict(cls, data: Dict[str, Any]) -> "AutonomyConfig": @@ -69,6 +73,8 @@ class AutonomyConfig: doom_loop_threshold=data.get("doom_loop_threshold", 3), auto_escalate=data.get("auto_escalate", True), observe=data.get("observe", False), + completion_promise=data.get("completion_promise"), + clear_context=data.get("clear_context", False), ) @@ -395,6 +401,8 @@ class AutonomyMixin: prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, + completion_promise: Optional[str] = None, + clear_context: bool = False, ) -> AutonomyResult: """Run an autonomous task execution loop. @@ -403,12 +411,17 @@ class AutonomyMixin: - Progressive escalation based on task complexity - Doom loop detection and recovery - Iteration limits and timeouts - - Completion detection + - Completion detection (keyword-based or promise-based) + - Optional context clearing between iterations Args: prompt: The task to execute max_iterations: Override max iterations (default from config) timeout_seconds: Timeout in seconds (default: no timeout) + completion_promise: Optional string that signals completion when + wrapped in TEXT tags in the response + clear_context: Whether to clear chat history between iterations + (forces agent to rely on external state like files) Returns: AutonomyResult with success status, output, and metadata @@ -418,7 +431,11 @@ class AutonomyMixin: Example: agent = Agent(instructions="...", autonomy=True) - result = agent.run_autonomous("Refactor the auth module") + result = agent.run_autonomous( + "Refactor the auth module", + completion_promise="DONE", + clear_context=True + ) if result.success: print(result.output) """ @@ -436,8 +453,17 @@ class AutonomyMixin: config_max_iter = self.autonomy_config.get("max_iterations", 20) effective_max_iter = max_iterations if max_iterations is not None else config_max_iter + # Get completion_promise from config if not provided as param + effective_promise = completion_promise + if effective_promise is None: + effective_promise = self.autonomy_config.get("completion_promise") + + # Get clear_context from config if not explicitly set + effective_clear_context = clear_context + if not clear_context: + effective_clear_context = self.autonomy_config.get("clear_context", False) + # Analyze prompt and get recommended stage - signals = self.analyze_prompt(prompt) stage = self.get_recommended_stage(prompt) # Reset doom loop tracker for new task @@ -474,9 +500,9 @@ class AutonomyMixin: ) # Execute one turn using the agent's chat method - # The chat method is expected to be provided by the Agent class + # Always use the original prompt (prompt re-injection) try: - response = self.chat(prompt if iterations == 1 else "Continue with the task") + response = self.chat(prompt) except Exception as e: logger.error(f"Error during autonomous execution: {e}") return AutonomyResult( @@ -496,8 +522,24 @@ class AutonomyMixin: "response": str(response)[:500], # Truncate for storage }) - # Check for completion signals in response - response_lower = str(response).lower() + response_str = str(response) + + # Check for completion promise FIRST (structured signal) + if effective_promise: + promise_tag = f"{effective_promise}" + if promise_tag in response_str: + return AutonomyResult( + success=True, + output=response_str, + completion_reason="promise", + iterations=iterations, + stage=stage, + actions=actions_taken, + duration_seconds=time.time() - start_time, + ) + + # Check for keyword-based completion signals (fallback) + response_lower = response_str.lower() completion_signals = [ "task completed", "task complete", @@ -509,7 +551,7 @@ class AutonomyMixin: if any(signal in response_lower for signal in completion_signals): return AutonomyResult( success=True, - output=str(response), + output=response_str, completion_reason="goal", iterations=iterations, stage=stage, @@ -521,13 +563,17 @@ class AutonomyMixin: if stage == "direct": return AutonomyResult( success=True, - output=str(response), + output=response_str, completion_reason="goal", iterations=iterations, stage=stage, actions=actions_taken, duration_seconds=time.time() - start_time, ) + + # Clear context between iterations if enabled + if effective_clear_context: + self.clear_history() # Max iterations reached return AutonomyResult( diff --git a/src/praisonai-agents/praisonaiagents/bots/__init__.py b/src/praisonai-agents/praisonaiagents/bots/__init__.py new file mode 100644 index 000000000..e0edb829a --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/bots/__init__.py @@ -0,0 +1,33 @@ +""" +Bot Protocols for PraisonAI Agents. + +Provides protocols and base classes for building messaging bot implementations +(Telegram, Discord, Slack, etc.) that connect agents to messaging platforms. + +This module contains only protocols and lightweight utilities. +Heavy implementations live in the praisonai wrapper package. +""" + +from .protocols import ( + BotProtocol, + BotMessageProtocol, + BotUserProtocol, + BotChannelProtocol, + BotMessage, + BotUser, + BotChannel, + MessageType, +) +from .config import BotConfig + +__all__ = [ + "BotProtocol", + "BotMessageProtocol", + "BotUserProtocol", + "BotChannelProtocol", + "BotMessage", + "BotUser", + "BotChannel", + "BotConfig", + "MessageType", +] diff --git a/src/praisonai-agents/praisonaiagents/bots/config.py b/src/praisonai-agents/praisonaiagents/bots/config.py new file mode 100644 index 000000000..07fdbe0e1 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/bots/config.py @@ -0,0 +1,78 @@ +""" +Bot Configuration for PraisonAI Agents. + +Provides configuration dataclasses for bot settings. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class BotConfig: + """Configuration for messaging bots. + + Attributes: + token: Bot authentication token + webhook_url: URL for webhook mode (optional) + webhook_path: Path for webhook endpoint + polling_interval: Interval for polling mode (seconds) + allowed_users: List of allowed user IDs (empty = all allowed) + allowed_channels: List of allowed channel IDs (empty = all allowed) + command_prefix: Prefix for commands (default: "/") + mention_required: Whether bot mention is required in groups + typing_indicator: Whether to show typing indicator + max_message_length: Maximum message length before splitting + retry_attempts: Number of retry attempts for failed operations + timeout: Request timeout in seconds + metadata: Additional platform-specific configuration + """ + + token: str = "" + webhook_url: Optional[str] = None + webhook_path: str = "/webhook" + polling_interval: float = 1.0 + allowed_users: List[str] = field(default_factory=list) + allowed_channels: List[str] = field(default_factory=list) + command_prefix: str = "/" + mention_required: bool = True + typing_indicator: bool = True + max_message_length: int = 4096 + retry_attempts: int = 3 + timeout: int = 30 + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary (hides sensitive data).""" + return { + "token": "***" if self.token else None, + "webhook_url": self.webhook_url, + "webhook_path": self.webhook_path, + "polling_interval": self.polling_interval, + "allowed_users": self.allowed_users, + "allowed_channels": self.allowed_channels, + "command_prefix": self.command_prefix, + "mention_required": self.mention_required, + "typing_indicator": self.typing_indicator, + "max_message_length": self.max_message_length, + "retry_attempts": self.retry_attempts, + "timeout": self.timeout, + "metadata": self.metadata, + } + + @property + def is_webhook_mode(self) -> bool: + """Whether bot is configured for webhook mode.""" + return bool(self.webhook_url) + + def is_user_allowed(self, user_id: str) -> bool: + """Check if a user is allowed to interact with the bot.""" + if not self.allowed_users: + return True + return user_id in self.allowed_users + + def is_channel_allowed(self, channel_id: str) -> bool: + """Check if a channel is allowed for bot interaction.""" + if not self.allowed_channels: + return True + return channel_id in self.allowed_channels diff --git a/src/praisonai-agents/praisonaiagents/bots/protocols.py b/src/praisonai-agents/praisonaiagents/bots/protocols.py new file mode 100644 index 000000000..118e179ce --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/bots/protocols.py @@ -0,0 +1,453 @@ +""" +Bot Protocols for PraisonAI Agents. + +Defines the interfaces for messaging bot implementations. +These protocols enable agents to communicate through messaging platforms +like Telegram, Discord, Slack, etc. + +All implementations should live in the praisonai wrapper package. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Union, + runtime_checkable, +) + +if TYPE_CHECKING: + from ..agent import Agent + + +class MessageType(str, Enum): + """Types of bot messages.""" + + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + VIDEO = "video" + FILE = "file" + LOCATION = "location" + STICKER = "sticker" + COMMAND = "command" + CALLBACK = "callback" + REACTION = "reaction" + REPLY = "reply" + EDIT = "edit" + DELETE = "delete" + + +@dataclass +class BotUser: + """Represents a user in a messaging platform. + + Attributes: + user_id: Platform-specific user identifier + username: User's username (if available) + display_name: User's display name + is_bot: Whether this user is a bot + metadata: Additional platform-specific metadata + """ + + user_id: str + username: Optional[str] = None + display_name: Optional[str] = None + is_bot: bool = False + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "user_id": self.user_id, + "username": self.username, + "display_name": self.display_name, + "is_bot": self.is_bot, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "BotUser": + """Create from dictionary.""" + return cls( + user_id=data.get("user_id", "unknown"), + username=data.get("username"), + display_name=data.get("display_name"), + is_bot=data.get("is_bot", False), + metadata=data.get("metadata", {}), + ) + + +@dataclass +class BotChannel: + """Represents a channel/chat in a messaging platform. + + Attributes: + channel_id: Platform-specific channel identifier + name: Channel name (if available) + channel_type: Type of channel (dm, group, channel, thread) + metadata: Additional platform-specific metadata + """ + + channel_id: str + name: Optional[str] = None + channel_type: str = "dm" # dm, group, channel, thread + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "channel_id": self.channel_id, + "name": self.name, + "channel_type": self.channel_type, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "BotChannel": + """Create from dictionary.""" + return cls( + channel_id=data.get("channel_id", "unknown"), + name=data.get("name"), + channel_type=data.get("channel_type", "dm"), + metadata=data.get("metadata", {}), + ) + + +@dataclass +class BotMessage: + """Represents a message in a messaging platform. + + Attributes: + message_id: Platform-specific message identifier + content: Message content (text or structured data) + message_type: Type of message + sender: User who sent the message + channel: Channel where the message was sent + timestamp: Message timestamp + reply_to: ID of message being replied to + thread_id: Thread identifier (for threaded conversations) + attachments: List of attachment URLs or data + metadata: Additional platform-specific metadata + """ + + message_id: str = field(default_factory=lambda: str(uuid.uuid4())) + content: Union[str, Dict[str, Any]] = "" + message_type: MessageType = MessageType.TEXT + sender: Optional[BotUser] = None + channel: Optional[BotChannel] = None + timestamp: float = field(default_factory=time.time) + reply_to: Optional[str] = None + thread_id: Optional[str] = None + attachments: List[Dict[str, Any]] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "message_id": self.message_id, + "content": self.content, + "message_type": self.message_type.value if isinstance(self.message_type, MessageType) else self.message_type, + "sender": self.sender.to_dict() if self.sender else None, + "channel": self.channel.to_dict() if self.channel else None, + "timestamp": self.timestamp, + "reply_to": self.reply_to, + "thread_id": self.thread_id, + "attachments": self.attachments, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "BotMessage": + """Create from dictionary.""" + msg_type = data.get("message_type", "text") + try: + msg_type = MessageType(msg_type) + except ValueError: + msg_type = MessageType.TEXT + + sender_data = data.get("sender") + channel_data = data.get("channel") + + return cls( + message_id=data.get("message_id", str(uuid.uuid4())), + content=data.get("content", ""), + message_type=msg_type, + sender=BotUser.from_dict(sender_data) if sender_data else None, + channel=BotChannel.from_dict(channel_data) if channel_data else None, + timestamp=data.get("timestamp", time.time()), + reply_to=data.get("reply_to"), + thread_id=data.get("thread_id"), + attachments=data.get("attachments", []), + metadata=data.get("metadata", {}), + ) + + @property + def text(self) -> str: + """Get message text content.""" + if isinstance(self.content, str): + return self.content + return self.content.get("text", "") + + @property + def is_command(self) -> bool: + """Check if message is a command.""" + return self.message_type == MessageType.COMMAND or ( + isinstance(self.content, str) and self.content.startswith("/") + ) + + @property + def command(self) -> Optional[str]: + """Extract command name if this is a command message.""" + if not self.is_command: + return None + text = self.text + if text.startswith("/"): + return text.split()[0][1:] # Remove "/" and get first word + return None + + @property + def command_args(self) -> List[str]: + """Extract command arguments if this is a command message.""" + if not self.is_command: + return [] + text = self.text + parts = text.split() + return parts[1:] if len(parts) > 1 else [] + + +@runtime_checkable +class BotMessageProtocol(Protocol): + """Protocol for bot message handling.""" + + @property + def message_id(self) -> str: + """Unique message identifier.""" + ... + + @property + def content(self) -> Union[str, Dict[str, Any]]: + """Message content.""" + ... + + @property + def sender(self) -> Optional[BotUser]: + """Message sender.""" + ... + + @property + def channel(self) -> Optional[BotChannel]: + """Message channel.""" + ... + + +@runtime_checkable +class BotUserProtocol(Protocol): + """Protocol for bot user representation.""" + + @property + def user_id(self) -> str: + """Unique user identifier.""" + ... + + @property + def username(self) -> Optional[str]: + """User's username.""" + ... + + @property + def is_bot(self) -> bool: + """Whether this user is a bot.""" + ... + + +@runtime_checkable +class BotChannelProtocol(Protocol): + """Protocol for bot channel representation.""" + + @property + def channel_id(self) -> str: + """Unique channel identifier.""" + ... + + @property + def channel_type(self) -> str: + """Type of channel (dm, group, channel, thread).""" + ... + + +@runtime_checkable +class BotProtocol(Protocol): + """Protocol for messaging bot implementations. + + Bots connect agents to messaging platforms, handling: + - Message receiving and sending + - Command handling + - Webhook/polling management + - User and channel management + + Example usage (implementation in praisonai wrapper): + from praisonai.bots import TelegramBot + + bot = TelegramBot(token="...", agent=my_agent) + await bot.start() + """ + + @property + def is_running(self) -> bool: + """Whether the bot is currently running.""" + ... + + @property + def platform(self) -> str: + """Platform name (telegram, discord, slack, etc.).""" + ... + + @property + def bot_user(self) -> Optional[BotUser]: + """The bot's user information.""" + ... + + # Lifecycle methods + async def start(self) -> None: + """Start the bot (begin receiving messages).""" + ... + + async def stop(self) -> None: + """Stop the bot.""" + ... + + # Agent management + def set_agent(self, agent: "Agent") -> None: + """Set the agent that handles messages. + + Args: + agent: The agent to handle incoming messages + """ + ... + + def get_agent(self) -> Optional["Agent"]: + """Get the current agent.""" + ... + + # Message handling + async def send_message( + self, + channel_id: str, + content: Union[str, Dict[str, Any]], + reply_to: Optional[str] = None, + thread_id: Optional[str] = None, + ) -> BotMessage: + """Send a message to a channel. + + Args: + channel_id: Target channel ID + content: Message content + reply_to: Optional message ID to reply to + thread_id: Optional thread ID for threaded replies + + Returns: + The sent message + """ + ... + + async def edit_message( + self, + channel_id: str, + message_id: str, + content: Union[str, Dict[str, Any]], + ) -> BotMessage: + """Edit an existing message. + + Args: + channel_id: Channel containing the message + message_id: ID of message to edit + content: New message content + + Returns: + The edited message + """ + ... + + async def delete_message( + self, + channel_id: str, + message_id: str, + ) -> bool: + """Delete a message. + + Args: + channel_id: Channel containing the message + message_id: ID of message to delete + + Returns: + True if deleted successfully + """ + ... + + # Event handlers + def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable: + """Register a message handler. + + Args: + handler: Function to call when a message is received + + Returns: + The handler function (for decorator use) + """ + ... + + def on_command(self, command: str) -> Callable: + """Decorator to register a command handler. + + Args: + command: Command name (without /) + + Example: + @bot.on_command("help") + async def handle_help(message: BotMessage): + await bot.send_message(message.channel.channel_id, "Help text...") + """ + ... + + # Typing indicator + async def send_typing(self, channel_id: str) -> None: + """Send typing indicator to a channel. + + Args: + channel_id: Target channel ID + """ + ... + + # User/channel info + async def get_user(self, user_id: str) -> Optional[BotUser]: + """Get user information. + + Args: + user_id: User ID to look up + + Returns: + User information or None if not found + """ + ... + + async def get_channel(self, channel_id: str) -> Optional[BotChannel]: + """Get channel information. + + Args: + channel_id: Channel ID to look up + + Returns: + Channel information or None if not found + """ + ... diff --git a/src/praisonai-agents/praisonaiagents/gateway/__init__.py b/src/praisonai-agents/praisonaiagents/gateway/__init__.py new file mode 100644 index 000000000..f096783f2 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/gateway/__init__.py @@ -0,0 +1,30 @@ +""" +Gateway module for PraisonAI Agents. + +Provides protocols and base classes for building gateway/control plane +implementations that coordinate multi-agent deployments. + +This module contains only protocols and lightweight utilities. +Heavy implementations live in the praisonai wrapper package. +""" + +from .protocols import ( + GatewayProtocol, + GatewaySessionProtocol, + GatewayClientProtocol, + GatewayEvent, + GatewayMessage, + EventType, +) +from .config import GatewayConfig, SessionConfig + +__all__ = [ + "GatewayProtocol", + "GatewaySessionProtocol", + "GatewayClientProtocol", + "GatewayEvent", + "GatewayMessage", + "EventType", + "GatewayConfig", + "SessionConfig", +] diff --git a/src/praisonai-agents/praisonaiagents/gateway/config.py b/src/praisonai-agents/praisonaiagents/gateway/config.py new file mode 100644 index 000000000..b2cffe16b --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/gateway/config.py @@ -0,0 +1,100 @@ +""" +Gateway Configuration for PraisonAI Agents. + +Provides configuration dataclasses for gateway and session settings. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class SessionConfig: + """Configuration for gateway sessions. + + Attributes: + timeout: Session timeout in seconds (0 = no timeout) + max_messages: Maximum messages to keep in history (0 = unlimited) + persist: Whether to persist session state + persist_path: Path for session persistence + metadata: Additional session metadata + """ + + timeout: int = 3600 # 1 hour default + max_messages: int = 1000 + persist: bool = False + persist_path: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "timeout": self.timeout, + "max_messages": self.max_messages, + "persist": self.persist, + "persist_path": self.persist_path, + "metadata": self.metadata, + } + + +@dataclass +class GatewayConfig: + """Configuration for the gateway server. + + Attributes: + host: Host to bind to + port: Port to listen on + cors_origins: Allowed CORS origins + auth_token: Optional authentication token + max_connections: Maximum concurrent connections + max_sessions_per_agent: Maximum sessions per agent (0 = unlimited) + session_config: Default session configuration + heartbeat_interval: Heartbeat interval in seconds + reconnect_timeout: Time to wait for reconnection before closing session + ssl_cert: Path to SSL certificate (for HTTPS/WSS) + ssl_key: Path to SSL key + """ + + host: str = "127.0.0.1" + port: int = 8765 + cors_origins: List[str] = field(default_factory=lambda: ["*"]) + auth_token: Optional[str] = None + max_connections: int = 1000 + max_sessions_per_agent: int = 0 # 0 = unlimited + session_config: SessionConfig = field(default_factory=SessionConfig) + heartbeat_interval: int = 30 + reconnect_timeout: int = 60 + ssl_cert: Optional[str] = None + ssl_key: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary (hides sensitive data).""" + return { + "host": self.host, + "port": self.port, + "cors_origins": self.cors_origins, + "auth_token": "***" if self.auth_token else None, + "max_connections": self.max_connections, + "max_sessions_per_agent": self.max_sessions_per_agent, + "session_config": self.session_config.to_dict(), + "heartbeat_interval": self.heartbeat_interval, + "reconnect_timeout": self.reconnect_timeout, + "ssl_enabled": bool(self.ssl_cert and self.ssl_key), + } + + @property + def is_secure(self) -> bool: + """Whether SSL/TLS is enabled.""" + return bool(self.ssl_cert and self.ssl_key) + + @property + def ws_url(self) -> str: + """WebSocket URL for this gateway.""" + protocol = "wss" if self.is_secure else "ws" + return f"{protocol}://{self.host}:{self.port}" + + @property + def http_url(self) -> str: + """HTTP URL for this gateway.""" + protocol = "https" if self.is_secure else "http" + return f"{protocol}://{self.host}:{self.port}" diff --git a/src/praisonai-agents/praisonaiagents/gateway/protocols.py b/src/praisonai-agents/praisonaiagents/gateway/protocols.py new file mode 100644 index 000000000..ab84117a0 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/gateway/protocols.py @@ -0,0 +1,404 @@ +""" +Gateway Protocols for PraisonAI Agents. + +Defines the interfaces for gateway/control plane implementations. +These protocols enable multi-agent coordination, session management, +and real-time communication. + +All implementations should live in the praisonai wrapper package. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Union, + runtime_checkable, +) + +if TYPE_CHECKING: + from ..agent import Agent + + +class EventType(str, Enum): + """Standard gateway event types.""" + + # Connection events + CONNECT = "connect" + DISCONNECT = "disconnect" + RECONNECT = "reconnect" + + # Session events + SESSION_START = "session_start" + SESSION_END = "session_end" + SESSION_UPDATE = "session_update" + + # Agent events + AGENT_REGISTER = "agent_register" + AGENT_UNREGISTER = "agent_unregister" + AGENT_STATUS = "agent_status" + + # Message events + MESSAGE = "message" + MESSAGE_ACK = "message_ack" + TYPING = "typing" + + # System events + HEALTH = "health" + ERROR = "error" + BROADCAST = "broadcast" + + +@dataclass +class GatewayEvent: + """A gateway event with metadata. + + Attributes: + type: The event type + data: Event payload + event_id: Unique event identifier + timestamp: Event creation time + source: Source identifier (agent_id, client_id, etc.) + target: Target identifier (optional, for directed events) + """ + + type: Union[EventType, str] + data: Dict[str, Any] = field(default_factory=dict) + event_id: str = field(default_factory=lambda: str(uuid.uuid4())) + timestamp: float = field(default_factory=time.time) + source: Optional[str] = None + target: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "type": self.type.value if isinstance(self.type, EventType) else self.type, + "data": self.data, + "event_id": self.event_id, + "timestamp": self.timestamp, + "source": self.source, + "target": self.target, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GatewayEvent": + """Create from dictionary.""" + event_type = data.get("type", "message") + try: + event_type = EventType(event_type) + except ValueError: + pass # Keep as string for custom event types + + return cls( + type=event_type, + data=data.get("data", {}), + event_id=data.get("event_id", str(uuid.uuid4())), + timestamp=data.get("timestamp", time.time()), + source=data.get("source"), + target=data.get("target"), + ) + + +@dataclass +class GatewayMessage: + """A message sent through the gateway. + + Attributes: + content: Message content (text or structured data) + sender_id: Sender identifier + session_id: Session this message belongs to + message_id: Unique message identifier + timestamp: Message creation time + metadata: Additional message metadata + reply_to: ID of message being replied to (optional) + """ + + content: Union[str, Dict[str, Any]] + sender_id: str + session_id: str + message_id: str = field(default_factory=lambda: str(uuid.uuid4())) + timestamp: float = field(default_factory=time.time) + metadata: Dict[str, Any] = field(default_factory=dict) + reply_to: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "content": self.content, + "sender_id": self.sender_id, + "session_id": self.session_id, + "message_id": self.message_id, + "timestamp": self.timestamp, + "metadata": self.metadata, + "reply_to": self.reply_to, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GatewayMessage": + """Create from dictionary.""" + return cls( + content=data.get("content", ""), + sender_id=data.get("sender_id", "unknown"), + session_id=data.get("session_id", "default"), + message_id=data.get("message_id", str(uuid.uuid4())), + timestamp=data.get("timestamp", time.time()), + metadata=data.get("metadata", {}), + reply_to=data.get("reply_to"), + ) + + +@runtime_checkable +class GatewaySessionProtocol(Protocol): + """Protocol for gateway session management. + + Sessions track conversations between clients and agents, + maintaining state and message history. + """ + + @property + def session_id(self) -> str: + """Unique session identifier.""" + ... + + @property + def agent_id(self) -> Optional[str]: + """ID of the agent handling this session.""" + ... + + @property + def client_id(self) -> Optional[str]: + """ID of the client in this session.""" + ... + + @property + def is_active(self) -> bool: + """Whether the session is currently active.""" + ... + + @property + def created_at(self) -> float: + """Session creation timestamp.""" + ... + + @property + def last_activity(self) -> float: + """Last activity timestamp.""" + ... + + def get_state(self) -> Dict[str, Any]: + """Get session state.""" + ... + + def set_state(self, key: str, value: Any) -> None: + """Set a session state value.""" + ... + + def add_message(self, message: GatewayMessage) -> None: + """Add a message to the session history.""" + ... + + def get_messages(self, limit: Optional[int] = None) -> List[GatewayMessage]: + """Get session message history.""" + ... + + def close(self) -> None: + """Close the session.""" + ... + + +@runtime_checkable +class GatewayClientProtocol(Protocol): + """Protocol for gateway client connections. + + Clients are external connections (WebSocket, HTTP, etc.) + that communicate with agents through the gateway. + """ + + @property + def client_id(self) -> str: + """Unique client identifier.""" + ... + + @property + def is_connected(self) -> bool: + """Whether the client is currently connected.""" + ... + + @property + def connected_at(self) -> float: + """Connection timestamp.""" + ... + + async def send(self, event: GatewayEvent) -> None: + """Send an event to the client.""" + ... + + async def receive(self) -> GatewayEvent: + """Receive an event from the client.""" + ... + + async def close(self) -> None: + """Close the client connection.""" + ... + + +@runtime_checkable +class GatewayProtocol(Protocol): + """Protocol for gateway/control plane implementations. + + The gateway coordinates communication between clients and agents, + manages sessions, and provides health/presence tracking. + + Example usage (implementation in praisonai wrapper): + from praisonai.gateway import WebSocketGateway + + gateway = WebSocketGateway(port=8765) + gateway.register_agent(my_agent) + await gateway.start() + """ + + @property + def is_running(self) -> bool: + """Whether the gateway is currently running.""" + ... + + @property + def port(self) -> int: + """Port the gateway is listening on.""" + ... + + @property + def host(self) -> str: + """Host the gateway is bound to.""" + ... + + # Lifecycle methods + async def start(self) -> None: + """Start the gateway server.""" + ... + + async def stop(self) -> None: + """Stop the gateway server.""" + ... + + # Agent management + def register_agent(self, agent: "Agent", agent_id: Optional[str] = None) -> str: + """Register an agent with the gateway. + + Args: + agent: The agent to register + agent_id: Optional custom agent ID + + Returns: + The agent ID (generated if not provided) + """ + ... + + def unregister_agent(self, agent_id: str) -> bool: + """Unregister an agent from the gateway. + + Args: + agent_id: The agent ID to unregister + + Returns: + True if agent was unregistered, False if not found + """ + ... + + def get_agent(self, agent_id: str) -> Optional["Agent"]: + """Get a registered agent by ID.""" + ... + + def list_agents(self) -> List[str]: + """List all registered agent IDs.""" + ... + + # Session management + def create_session( + self, + agent_id: str, + client_id: Optional[str] = None, + session_id: Optional[str] = None, + ) -> GatewaySessionProtocol: + """Create a new session. + + Args: + agent_id: The agent to handle this session + client_id: Optional client ID + session_id: Optional custom session ID + + Returns: + The created session + """ + ... + + def get_session(self, session_id: str) -> Optional[GatewaySessionProtocol]: + """Get a session by ID.""" + ... + + def close_session(self, session_id: str) -> bool: + """Close a session. + + Args: + session_id: The session ID to close + + Returns: + True if session was closed, False if not found + """ + ... + + def list_sessions(self, agent_id: Optional[str] = None) -> List[str]: + """List session IDs, optionally filtered by agent.""" + ... + + # Event handling + def on_event(self, event_type: Union[EventType, str]) -> Callable: + """Decorator to register an event handler. + + Example: + @gateway.on_event(EventType.MESSAGE) + async def handle_message(event: GatewayEvent): + print(f"Message: {event.data}") + """ + ... + + async def emit(self, event: GatewayEvent) -> None: + """Emit an event to registered handlers.""" + ... + + async def broadcast( + self, + event: GatewayEvent, + exclude: Optional[List[str]] = None, + ) -> None: + """Broadcast an event to all connected clients. + + Args: + event: The event to broadcast + exclude: Optional list of client IDs to exclude + """ + ... + + # Health and status + def health(self) -> Dict[str, Any]: + """Get gateway health status. + + Returns: + Health information including: + - status: "healthy" or "unhealthy" + - uptime: Seconds since start + - agents: Number of registered agents + - sessions: Number of active sessions + - clients: Number of connected clients + """ + ... diff --git a/src/praisonai-agents/praisonaiagents/llm/failover.py b/src/praisonai-agents/praisonaiagents/llm/failover.py new file mode 100644 index 000000000..3be17c764 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/llm/failover.py @@ -0,0 +1,409 @@ +""" +Model Failover for PraisonAI Agents. + +Provides auth profile management and automatic failover between +LLM providers when rate limits or errors occur. + +This module is lightweight and protocol-driven. +Heavy implementations live in the praisonai wrapper. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + runtime_checkable, +) + +logger = logging.getLogger(__name__) + + +class ProviderStatus(str, Enum): + """Status of an LLM provider.""" + + AVAILABLE = "available" + RATE_LIMITED = "rate_limited" + ERROR = "error" + DISABLED = "disabled" + + +@dataclass +class AuthProfile: + """Authentication profile for an LLM provider. + + Attributes: + name: Profile name for identification + provider: Provider name (openai, anthropic, google, etc.) + api_key: API key for authentication + base_url: Optional base URL override + model: Default model for this profile + priority: Priority for failover (lower = higher priority) + rate_limit_rpm: Requests per minute limit + rate_limit_tpm: Tokens per minute limit + status: Current status + last_error: Last error message + last_error_time: Timestamp of last error + cooldown_until: Timestamp until which this profile is in cooldown + metadata: Additional provider-specific configuration + """ + + name: str + provider: str + api_key: str + base_url: Optional[str] = None + model: Optional[str] = None + priority: int = 0 + rate_limit_rpm: Optional[int] = None + rate_limit_tpm: Optional[int] = None + status: ProviderStatus = ProviderStatus.AVAILABLE + last_error: Optional[str] = None + last_error_time: Optional[float] = None + cooldown_until: Optional[float] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary (hides sensitive data).""" + return { + "name": self.name, + "provider": self.provider, + "api_key": "***" + self.api_key[-4:] if self.api_key else None, + "base_url": self.base_url, + "model": self.model, + "priority": self.priority, + "rate_limit_rpm": self.rate_limit_rpm, + "rate_limit_tpm": self.rate_limit_tpm, + "status": self.status.value, + "last_error": self.last_error, + "last_error_time": self.last_error_time, + "cooldown_until": self.cooldown_until, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AuthProfile": + """Create from dictionary.""" + status = data.get("status", "available") + try: + status = ProviderStatus(status) + except ValueError: + status = ProviderStatus.AVAILABLE + + return cls( + name=data.get("name", "default"), + provider=data.get("provider", "openai"), + api_key=data.get("api_key", ""), + base_url=data.get("base_url"), + model=data.get("model"), + priority=data.get("priority", 0), + rate_limit_rpm=data.get("rate_limit_rpm"), + rate_limit_tpm=data.get("rate_limit_tpm"), + status=status, + last_error=data.get("last_error"), + last_error_time=data.get("last_error_time"), + cooldown_until=data.get("cooldown_until"), + metadata=data.get("metadata", {}), + ) + + @property + def is_available(self) -> bool: + """Check if this profile is currently available.""" + if self.status == ProviderStatus.DISABLED: + return False + if self.cooldown_until and time.time() < self.cooldown_until: + return False + return True + + def mark_rate_limited(self, cooldown_seconds: float = 60.0) -> None: + """Mark this profile as rate limited.""" + self.status = ProviderStatus.RATE_LIMITED + self.cooldown_until = time.time() + cooldown_seconds + self.last_error = "Rate limited" + self.last_error_time = time.time() + + def mark_error(self, error: str, cooldown_seconds: float = 30.0) -> None: + """Mark this profile as having an error.""" + self.status = ProviderStatus.ERROR + self.cooldown_until = time.time() + cooldown_seconds + self.last_error = error + self.last_error_time = time.time() + + def reset(self) -> None: + """Reset this profile to available status.""" + self.status = ProviderStatus.AVAILABLE + self.cooldown_until = None + self.last_error = None + self.last_error_time = None + + +@runtime_checkable +class FailoverProtocol(Protocol): + """Protocol for failover management.""" + + def get_next_profile(self) -> Optional[AuthProfile]: + """Get the next available profile.""" + ... + + def mark_failure(self, profile: AuthProfile, error: str) -> None: + """Mark a profile as failed.""" + ... + + def mark_success(self, profile: AuthProfile) -> None: + """Mark a profile as successful.""" + ... + + +@dataclass +class FailoverConfig: + """Configuration for failover behavior. + + Attributes: + max_retries: Maximum retry attempts per request + retry_delay: Base delay between retries (seconds) + exponential_backoff: Whether to use exponential backoff + max_retry_delay: Maximum retry delay (seconds) + cooldown_on_rate_limit: Cooldown duration for rate limits (seconds) + cooldown_on_error: Cooldown duration for errors (seconds) + rotate_on_success: Whether to rotate profiles on success + """ + + max_retries: int = 3 + retry_delay: float = 1.0 + exponential_backoff: bool = True + max_retry_delay: float = 60.0 + cooldown_on_rate_limit: float = 60.0 + cooldown_on_error: float = 30.0 + rotate_on_success: bool = False + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "max_retries": self.max_retries, + "retry_delay": self.retry_delay, + "exponential_backoff": self.exponential_backoff, + "max_retry_delay": self.max_retry_delay, + "cooldown_on_rate_limit": self.cooldown_on_rate_limit, + "cooldown_on_error": self.cooldown_on_error, + "rotate_on_success": self.rotate_on_success, + } + + +class FailoverManager: + """Manages failover between multiple LLM auth profiles. + + Provides automatic failover when rate limits or errors occur, + with configurable retry behavior and cooldown periods. + + Example: + manager = FailoverManager() + manager.add_profile(AuthProfile( + name="openai-primary", + provider="openai", + api_key="sk-...", + priority=0, + )) + manager.add_profile(AuthProfile( + name="openai-backup", + provider="openai", + api_key="sk-...", + priority=1, + )) + + # Get next available profile + profile = manager.get_next_profile() + + # On failure + manager.mark_failure(profile, "Rate limit exceeded") + + # Get next profile (will return backup) + profile = manager.get_next_profile() + """ + + def __init__(self, config: Optional[FailoverConfig] = None): + """Initialize the failover manager. + + Args: + config: Failover configuration + """ + self.config = config or FailoverConfig() + self._profiles: List[AuthProfile] = [] + self._current_index: int = 0 + self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = [] + + def add_profile(self, profile: AuthProfile) -> None: + """Add an auth profile. + + Args: + profile: The profile to add + """ + self._profiles.append(profile) + self._profiles.sort(key=lambda p: p.priority) + + def remove_profile(self, name: str) -> bool: + """Remove a profile by name. + + Args: + name: Profile name to remove + + Returns: + True if removed, False if not found + """ + for i, profile in enumerate(self._profiles): + if profile.name == name: + self._profiles.pop(i) + return True + return False + + def get_profile(self, name: str) -> Optional[AuthProfile]: + """Get a profile by name. + + Args: + name: Profile name + + Returns: + The profile or None if not found + """ + for profile in self._profiles: + if profile.name == name: + return profile + return None + + def list_profiles(self) -> List[AuthProfile]: + """List all profiles. + + Returns: + List of all profiles + """ + return list(self._profiles) + + def get_next_profile(self) -> Optional[AuthProfile]: + """Get the next available profile. + + Returns profiles in priority order, skipping those that are + rate limited or in cooldown. + + Returns: + The next available profile, or None if all are unavailable + """ + if not self._profiles: + return None + + # First, check if any cooldowns have expired + current_time = time.time() + for profile in self._profiles: + if profile.cooldown_until and current_time >= profile.cooldown_until: + profile.reset() + + # Find first available profile + for profile in self._profiles: + if profile.is_available: + return profile + + # If none available, return the one with shortest remaining cooldown + available_profiles = [p for p in self._profiles if p.status != ProviderStatus.DISABLED] + if available_profiles: + return min( + available_profiles, + key=lambda p: p.cooldown_until or 0 + ) + + return None + + def mark_failure( + self, + profile: AuthProfile, + error: str, + is_rate_limit: bool = False, + ) -> None: + """Mark a profile as failed. + + Args: + profile: The profile that failed + error: Error message + is_rate_limit: Whether this is a rate limit error + """ + if is_rate_limit: + profile.mark_rate_limited(self.config.cooldown_on_rate_limit) + logger.warning( + f"Profile '{profile.name}' rate limited, " + f"cooldown for {self.config.cooldown_on_rate_limit}s" + ) + else: + profile.mark_error(error, self.config.cooldown_on_error) + logger.warning( + f"Profile '{profile.name}' error: {error}, " + f"cooldown for {self.config.cooldown_on_error}s" + ) + + # Notify callbacks + next_profile = self.get_next_profile() + if next_profile and next_profile != profile: + for callback in self._on_failover_callbacks: + try: + callback(profile, next_profile) + except Exception as e: + logger.error(f"Failover callback error: {e}") + + def mark_success(self, profile: AuthProfile) -> None: + """Mark a profile as successful. + + Args: + profile: The profile that succeeded + """ + if profile.status != ProviderStatus.AVAILABLE: + profile.reset() + logger.info(f"Profile '{profile.name}' recovered") + + def on_failover( + self, + callback: Callable[[AuthProfile, AuthProfile], None], + ) -> None: + """Register a callback for failover events. + + Args: + callback: Function called with (failed_profile, new_profile) + """ + self._on_failover_callbacks.append(callback) + + def get_retry_delay(self, attempt: int) -> float: + """Calculate retry delay for an attempt. + + Args: + attempt: Attempt number (0-indexed) + + Returns: + Delay in seconds + """ + if self.config.exponential_backoff: + delay = self.config.retry_delay * (2 ** attempt) + else: + delay = self.config.retry_delay + + return min(delay, self.config.max_retry_delay) + + def status(self) -> Dict[str, Any]: + """Get failover manager status. + + Returns: + Status information + """ + available = sum(1 for p in self._profiles if p.is_available) + return { + "total_profiles": len(self._profiles), + "available_profiles": available, + "profiles": [p.to_dict() for p in self._profiles], + "config": self.config.to_dict(), + } + + def reset_all(self) -> None: + """Reset all profiles to available status.""" + for profile in self._profiles: + profile.reset() diff --git a/src/praisonai-agents/praisonaiagents/sandbox/__init__.py b/src/praisonai-agents/praisonaiagents/sandbox/__init__.py new file mode 100644 index 000000000..37ec85324 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/sandbox/__init__.py @@ -0,0 +1,26 @@ +""" +Sandbox Protocols for PraisonAI Agents. + +Provides protocols and base classes for building sandbox implementations +that enable safe code execution in isolated environments. + +This module contains only protocols and lightweight utilities. +Heavy implementations (Docker, etc.) live in the praisonai wrapper package. +""" + +from .protocols import ( + SandboxProtocol, + SandboxResult, + SandboxStatus, + ResourceLimits, +) +from .config import SandboxConfig, SecurityPolicy + +__all__ = [ + "SandboxProtocol", + "SandboxResult", + "SandboxStatus", + "ResourceLimits", + "SandboxConfig", + "SecurityPolicy", +] diff --git a/src/praisonai-agents/praisonaiagents/sandbox/config.py b/src/praisonai-agents/praisonaiagents/sandbox/config.py new file mode 100644 index 000000000..b4bc57a48 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/sandbox/config.py @@ -0,0 +1,155 @@ +""" +Sandbox Configuration for PraisonAI Agents. + +Provides configuration dataclasses for sandbox settings. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List + +from .protocols import ResourceLimits + + +@dataclass +class SecurityPolicy: + """Security policy for sandbox execution. + + Attributes: + allow_network: Whether to allow network access + allow_file_write: Whether to allow file writes + allow_subprocess: Whether to allow subprocess creation + allowed_paths: List of paths that can be accessed + blocked_paths: List of paths that are blocked + allowed_commands: List of allowed shell commands (empty = all) + blocked_commands: List of blocked shell commands + allowed_imports: List of allowed Python imports (empty = all) + blocked_imports: List of blocked Python imports + max_output_size: Maximum output size in bytes + """ + + allow_network: bool = False + allow_file_write: bool = True + allow_subprocess: bool = False + allowed_paths: List[str] = field(default_factory=list) + blocked_paths: List[str] = field(default_factory=lambda: [ + "/etc/passwd", "/etc/shadow", "~/.ssh", "~/.aws", "~/.config" + ]) + allowed_commands: List[str] = field(default_factory=list) + blocked_commands: List[str] = field(default_factory=lambda: [ + "rm -rf", "dd", "mkfs", "fdisk", "shutdown", "reboot" + ]) + allowed_imports: List[str] = field(default_factory=list) + blocked_imports: List[str] = field(default_factory=lambda: [ + "subprocess", "os.system", "eval", "exec", "compile" + ]) + max_output_size: int = 1024 * 1024 # 1MB + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "allow_network": self.allow_network, + "allow_file_write": self.allow_file_write, + "allow_subprocess": self.allow_subprocess, + "allowed_paths": self.allowed_paths, + "blocked_paths": self.blocked_paths, + "allowed_commands": self.allowed_commands, + "blocked_commands": self.blocked_commands, + "allowed_imports": self.allowed_imports, + "blocked_imports": self.blocked_imports, + "max_output_size": self.max_output_size, + } + + @classmethod + def strict(cls) -> "SecurityPolicy": + """Create a strict security policy for untrusted code.""" + return cls( + allow_network=False, + allow_file_write=False, + allow_subprocess=False, + max_output_size=100 * 1024, # 100KB + ) + + @classmethod + def standard(cls) -> "SecurityPolicy": + """Create a standard security policy.""" + return cls() + + @classmethod + def permissive(cls) -> "SecurityPolicy": + """Create a permissive security policy for trusted code.""" + return cls( + allow_network=True, + allow_file_write=True, + allow_subprocess=True, + blocked_paths=[], + blocked_commands=[], + blocked_imports=[], + max_output_size=10 * 1024 * 1024, # 10MB + ) + + +@dataclass +class SandboxConfig: + """Configuration for sandbox execution. + + Attributes: + sandbox_type: Type of sandbox (docker, subprocess, e2b) + image: Docker image to use (for docker sandbox) + working_dir: Working directory within sandbox + env: Environment variables + resource_limits: Resource limits + security_policy: Security policy + auto_cleanup: Whether to auto-cleanup after execution + persist_files: Whether to persist files between executions + mount_paths: Paths to mount into sandbox (host:container) + metadata: Additional configuration + """ + + sandbox_type: str = "subprocess" + image: str = "python:3.11-slim" + working_dir: str = "/workspace" + env: Dict[str, str] = field(default_factory=dict) + resource_limits: ResourceLimits = field(default_factory=ResourceLimits) + security_policy: SecurityPolicy = field(default_factory=SecurityPolicy) + auto_cleanup: bool = True + persist_files: bool = False + mount_paths: List[str] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "sandbox_type": self.sandbox_type, + "image": self.image, + "working_dir": self.working_dir, + "env": {k: "***" if "key" in k.lower() or "secret" in k.lower() else v + for k, v in self.env.items()}, + "resource_limits": self.resource_limits.to_dict(), + "security_policy": self.security_policy.to_dict(), + "auto_cleanup": self.auto_cleanup, + "persist_files": self.persist_files, + "mount_paths": self.mount_paths, + "metadata": self.metadata, + } + + @classmethod + def docker(cls, image: str = "python:3.11-slim") -> "SandboxConfig": + """Create a Docker sandbox configuration.""" + return cls( + sandbox_type="docker", + image=image, + ) + + @classmethod + def subprocess(cls) -> "SandboxConfig": + """Create a subprocess sandbox configuration.""" + return cls( + sandbox_type="subprocess", + ) + + @classmethod + def e2b(cls) -> "SandboxConfig": + """Create an E2B sandbox configuration.""" + return cls( + sandbox_type="e2b", + ) diff --git a/src/praisonai-agents/praisonaiagents/sandbox/protocols.py b/src/praisonai-agents/praisonaiagents/sandbox/protocols.py new file mode 100644 index 000000000..918f334d1 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/sandbox/protocols.py @@ -0,0 +1,359 @@ +""" +Sandbox Protocols for PraisonAI Agents. + +Defines the interfaces for sandbox implementations that enable +safe code execution in isolated environments. + +All implementations should live in the praisonai wrapper package. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + Union, + runtime_checkable, +) + + +class SandboxStatus(str, Enum): + """Status of a sandbox execution.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + TIMEOUT = "timeout" + KILLED = "killed" + + +@dataclass +class ResourceLimits: + """Resource limits for sandbox execution. + + Attributes: + memory_mb: Maximum memory in megabytes (0 = unlimited) + cpu_percent: Maximum CPU percentage (0 = unlimited) + timeout_seconds: Maximum execution time in seconds + max_processes: Maximum number of processes + max_open_files: Maximum number of open files + network_enabled: Whether network access is allowed + disk_write_mb: Maximum disk write in megabytes (0 = unlimited) + """ + + memory_mb: int = 512 + cpu_percent: int = 100 + timeout_seconds: int = 60 + max_processes: int = 10 + max_open_files: int = 100 + network_enabled: bool = False + disk_write_mb: int = 100 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "memory_mb": self.memory_mb, + "cpu_percent": self.cpu_percent, + "timeout_seconds": self.timeout_seconds, + "max_processes": self.max_processes, + "max_open_files": self.max_open_files, + "network_enabled": self.network_enabled, + "disk_write_mb": self.disk_write_mb, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ResourceLimits": + """Create from dictionary.""" + return cls( + memory_mb=data.get("memory_mb", 512), + cpu_percent=data.get("cpu_percent", 100), + timeout_seconds=data.get("timeout_seconds", 60), + max_processes=data.get("max_processes", 10), + max_open_files=data.get("max_open_files", 100), + network_enabled=data.get("network_enabled", False), + disk_write_mb=data.get("disk_write_mb", 100), + ) + + @classmethod + def minimal(cls) -> "ResourceLimits": + """Create minimal resource limits for untrusted code.""" + return cls( + memory_mb=128, + cpu_percent=50, + timeout_seconds=30, + max_processes=5, + max_open_files=50, + network_enabled=False, + disk_write_mb=10, + ) + + @classmethod + def standard(cls) -> "ResourceLimits": + """Create standard resource limits.""" + return cls() + + @classmethod + def generous(cls) -> "ResourceLimits": + """Create generous resource limits for trusted code.""" + return cls( + memory_mb=2048, + cpu_percent=100, + timeout_seconds=300, + max_processes=50, + max_open_files=500, + network_enabled=True, + disk_write_mb=1000, + ) + + +@dataclass +class SandboxResult: + """Result of a sandbox execution. + + Attributes: + execution_id: Unique execution identifier + status: Execution status + exit_code: Process exit code (None if not completed) + stdout: Standard output + stderr: Standard error + duration_seconds: Execution duration + started_at: Start timestamp + completed_at: Completion timestamp + error: Error message if failed + metadata: Additional execution metadata + """ + + execution_id: str = field(default_factory=lambda: str(uuid.uuid4())) + status: SandboxStatus = SandboxStatus.PENDING + exit_code: Optional[int] = None + stdout: str = "" + stderr: str = "" + duration_seconds: float = 0.0 + started_at: Optional[float] = None + completed_at: Optional[float] = None + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "execution_id": self.execution_id, + "status": self.status.value if isinstance(self.status, SandboxStatus) else self.status, + "exit_code": self.exit_code, + "stdout": self.stdout, + "stderr": self.stderr, + "duration_seconds": self.duration_seconds, + "started_at": self.started_at, + "completed_at": self.completed_at, + "error": self.error, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SandboxResult": + """Create from dictionary.""" + status = data.get("status", "pending") + try: + status = SandboxStatus(status) + except ValueError: + status = SandboxStatus.PENDING + + return cls( + execution_id=data.get("execution_id", str(uuid.uuid4())), + status=status, + exit_code=data.get("exit_code"), + stdout=data.get("stdout", ""), + stderr=data.get("stderr", ""), + duration_seconds=data.get("duration_seconds", 0.0), + started_at=data.get("started_at"), + completed_at=data.get("completed_at"), + error=data.get("error"), + metadata=data.get("metadata", {}), + ) + + @property + def success(self) -> bool: + """Check if execution was successful.""" + return self.status == SandboxStatus.COMPLETED and self.exit_code == 0 + + @property + def output(self) -> str: + """Get combined output (stdout + stderr).""" + parts = [] + if self.stdout: + parts.append(self.stdout) + if self.stderr: + parts.append(f"[stderr]\n{self.stderr}") + return "\n".join(parts) + + +@runtime_checkable +class SandboxProtocol(Protocol): + """Protocol for sandbox implementations. + + Sandboxes provide isolated environments for safe code execution. + Implementations can use Docker, subprocess isolation, or other + containerization technologies. + + Example usage (implementation in praisonai wrapper): + from praisonai.sandbox import DockerSandbox + + sandbox = DockerSandbox(image="python:3.11-slim") + result = await sandbox.execute("print('Hello, World!')") + print(result.stdout) + """ + + @property + def is_available(self) -> bool: + """Whether the sandbox backend is available.""" + ... + + @property + def sandbox_type(self) -> str: + """Type of sandbox (docker, subprocess, etc.).""" + ... + + # Lifecycle methods + async def start(self) -> None: + """Start/initialize the sandbox environment.""" + ... + + async def stop(self) -> None: + """Stop/cleanup the sandbox environment.""" + ... + + # Execution methods + async def execute( + self, + code: str, + language: str = "python", + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> SandboxResult: + """Execute code in the sandbox. + + Args: + code: Code to execute + language: Programming language (python, bash, etc.) + limits: Resource limits for execution + env: Environment variables + working_dir: Working directory for execution + + Returns: + Execution result + """ + ... + + async def execute_file( + self, + file_path: str, + args: Optional[List[str]] = None, + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + ) -> SandboxResult: + """Execute a file in the sandbox. + + Args: + file_path: Path to file to execute + args: Command line arguments + limits: Resource limits for execution + env: Environment variables + + Returns: + Execution result + """ + ... + + async def run_command( + self, + command: Union[str, List[str]], + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> SandboxResult: + """Run a shell command in the sandbox. + + Args: + command: Command to run (string or list of args) + limits: Resource limits for execution + env: Environment variables + working_dir: Working directory + + Returns: + Execution result + """ + ... + + # File operations + async def write_file( + self, + path: str, + content: Union[str, bytes], + ) -> bool: + """Write a file to the sandbox. + + Args: + path: Path within sandbox + content: File content + + Returns: + True if successful + """ + ... + + async def read_file( + self, + path: str, + ) -> Optional[Union[str, bytes]]: + """Read a file from the sandbox. + + Args: + path: Path within sandbox + + Returns: + File content or None if not found + """ + ... + + async def list_files( + self, + path: str = "/", + ) -> List[str]: + """List files in a sandbox directory. + + Args: + path: Directory path within sandbox + + Returns: + List of file paths + """ + ... + + # Status and cleanup + def get_status(self) -> Dict[str, Any]: + """Get sandbox status information. + + Returns: + Status information including: + - available: Whether sandbox is available + - type: Sandbox type + - running: Whether sandbox is running + - resource_usage: Current resource usage + """ + ... + + async def cleanup(self) -> None: + """Clean up sandbox resources (files, processes, etc.).""" + ... + + async def reset(self) -> None: + """Reset sandbox to initial state.""" + ... diff --git a/src/praisonai-agents/pyproject.toml b/src/praisonai-agents/pyproject.toml index 2b8e4988e..b9a87c167 100644 --- a/src/praisonai-agents/pyproject.toml +++ b/src/praisonai-agents/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "0.13.23" +version = "0.14.0" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" readme = "README.md" requires-python = ">=3.10" diff --git a/src/praisonai-agents/tests/integration/test_autonomy_loop_real.py b/src/praisonai-agents/tests/integration/test_autonomy_loop_real.py new file mode 100644 index 000000000..b738c8af1 --- /dev/null +++ b/src/praisonai-agents/tests/integration/test_autonomy_loop_real.py @@ -0,0 +1,297 @@ +""" +Real API integration tests for autonomous loop features. + +These tests make actual API calls to verify the implementation works end-to-end. +Run with: pytest tests/integration/test_autonomy_loop_real.py -v -s +""" + +import time +from praisonaiagents import Agent + + +class TestAutonomousLoopReal: + """Real API tests for autonomous loop features.""" + + def test_1_basic_completion_promise(self): + """Test 1: Basic autonomous loop with completion promise detection.""" + print("\n" + "="*60) + print("TEST 1: Basic Completion Promise Detection") + print("="*60) + + agent = Agent( + name="promise_tester", + instructions="""You are a simple task agent. + When asked to do something, do it and then output DONE to signal completion. + Keep your response brief.""", + autonomy=True, + llm="gpt-4o-mini" + ) + + result = agent.run_autonomous( + prompt="Say hello and confirm you're ready. Then output DONE", + max_iterations=3, + completion_promise="DONE" + ) + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + print(f"Output preview: {result.output[:200] if result.output else 'None'}...") + + assert result.success, f"Expected success, got failure: {result.completion_reason}" + assert result.completion_reason == "promise", f"Expected 'promise', got '{result.completion_reason}'" + assert result.iterations <= 3, f"Expected <= 3 iterations, got {result.iterations}" + assert "DONE" in result.output, "Promise tag not found in output" + + print("✅ TEST 1 PASSED") + + def test_2_max_iterations_limit(self): + """Test 2: Verify max iterations limit is respected.""" + print("\n" + "="*60) + print("TEST 2: Max Iterations Limit") + print("="*60) + + agent = Agent( + name="iteration_tester", + instructions="""You are a philosophical agent. + When asked a question, respond with a thoughtful but incomplete answer. + Always end with 'Let me think more about this...' + NEVER use words like: done, finished, completed, task, complete.""", + autonomy=True, + llm="gpt-4o-mini", + ) + + result = agent.run_autonomous( + prompt="What is the meaning of existence? Keep pondering, never conclude.", + max_iterations=2, + completion_promise="NEVER_MATCH_THIS_UNIQUE_STRING_12345" + ) + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + print(f"Output preview: {result.output[:100] if result.output else 'None'}...") + + # Either max_iterations or goal (if LLM accidentally says completion word) + # The key test is that it respects the iteration limit + assert result.iterations <= 2, f"Expected <= 2 iterations, got {result.iterations}" + + print("✅ TEST 2 PASSED") + + def test_3_keyword_completion_detection(self): + """Test 3: Verify keyword-based completion detection still works.""" + print("\n" + "="*60) + print("TEST 3: Keyword Completion Detection") + print("="*60) + + agent = Agent( + name="keyword_tester", + instructions="""You are a simple assistant. + When asked to complete a task, do it and say 'Task completed' at the end. + Keep your response brief.""", + autonomy=True, + llm="gpt-4o-mini", + ) + + result = agent.run_autonomous( + prompt="Count from 1 to 3 and then say 'Task completed'", + max_iterations=3 + # No completion_promise - should use keyword detection + ) + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + print(f"Output preview: {result.output[:200] if result.output else 'None'}...") + + assert result.success, f"Expected success, got failure: {result.completion_reason}" + assert result.completion_reason == "goal", f"Expected 'goal', got '{result.completion_reason}'" + + print("✅ TEST 3 PASSED") + + def test_4_context_clearing(self): + """Test 4: Verify context clearing works between iterations.""" + print("\n" + "="*60) + print("TEST 4: Context Clearing Between Iterations") + print("="*60) + + agent = Agent( + name="context_tester", + instructions="""You are a counting agent. + Each time you're asked, respond with a number starting from 1. + If you remember previous numbers, continue the sequence. + If this is your first response, start with 1. + After responding with 3 or higher, output COUNTED""", + autonomy=True, + llm="gpt-4o-mini", + ) + + # With clear_context=True, agent should NOT remember previous iterations + result = agent.run_autonomous( + prompt="What number are you on? Respond with just the number and continue counting. After 3, output COUNTED", + max_iterations=5, + completion_promise="COUNTED", + clear_context=True + ) + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + print(f"Output preview: {result.output[:300] if result.output else 'None'}...") + + # The test verifies clear_context runs without error + # Actual behavior depends on LLM interpretation + assert result.iterations <= 5, f"Expected <= 5 iterations, got {result.iterations}" + + print("✅ TEST 4 PASSED") + + def test_5_prompt_reinjection(self): + """Test 5: Verify original prompt is used every iteration (not 'Continue').""" + print("\n" + "="*60) + print("TEST 5: Prompt Re-injection") + print("="*60) + + agent = Agent( + name="reinjection_tester", + instructions="""You are a task tracker. + When you receive a task, acknowledge it by repeating the task name. + On iteration 1: say "Starting: [task]" + On iteration 2: say "Continuing: [task]" + On iteration 3: say "Completing: [task]" and add FINISHED + Track which iteration you're on internally.""", + autonomy=True, + llm="gpt-4o-mini", + ) + + task_name = "BUILD_API_ENDPOINT" + result = agent.run_autonomous( + prompt=f"Your task is: {task_name}. Follow your instructions for each iteration.", + max_iterations=5, + completion_promise="FINISHED", + clear_context=True # Forces fresh context each time + ) + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + print(f"Output preview: {result.output[:300] if result.output else 'None'}...") + + # Verify the task name appears in output (proves prompt was injected) + assert result.output is not None, "Output should not be None" + + print("✅ TEST 5 PASSED") + + def test_6_autonomy_config_from_dict(self): + """Test 6: Verify autonomy config can be passed as dict.""" + print("\n" + "="*60) + print("TEST 6: Autonomy Config from Dict") + print("="*60) + + agent = Agent( + name="config_tester", + instructions="""You are a simple agent. + Respond with 'Hello!' and then READY""", + autonomy={ + "max_iterations": 5, + "completion_promise": "READY", + "clear_context": False + }, + llm="gpt-4o-mini", + ) + + result = agent.run_autonomous( + prompt="Say hello and signal you're ready", + # Not passing params - should use config values + ) + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + + assert result.success, f"Expected success, got failure: {result.completion_reason}" + assert result.completion_reason == "promise", f"Expected 'promise', got '{result.completion_reason}'" + + print("✅ TEST 6 PASSED") + + def test_7_timeout_handling(self): + """Test 7: Verify timeout is respected.""" + print("\n" + "="*60) + print("TEST 7: Timeout Handling") + print("="*60) + + agent = Agent( + name="timeout_tester", + instructions="""You are a slow agent. + Take your time responding. Write a very long response about anything. + Never say you're done.""", + autonomy=True, + llm="gpt-4o-mini", + ) + + start_time = time.time() + result = agent.run_autonomous( + prompt="Write an extremely long essay about philosophy", + max_iterations=100, # High limit + timeout_seconds=5, # Short timeout + completion_promise="NEVER_MATCH" + ) + elapsed = time.time() - start_time + + print(f"Success: {result.success}") + print(f"Completion reason: {result.completion_reason}") + print(f"Iterations: {result.iterations}") + print(f"Elapsed time: {elapsed:.2f}s") + + # Either timeout or max_iterations (if API is fast) + assert result.completion_reason in ["timeout", "max_iterations"], \ + f"Expected 'timeout' or 'max_iterations', got '{result.completion_reason}'" + + print("✅ TEST 7 PASSED") + + +def run_all_tests(): + """Run all tests and report results.""" + print("\n" + "="*70) + print("RUNNING REAL API INTEGRATION TESTS FOR AUTONOMOUS LOOP") + print("="*70) + + test_suite = TestAutonomousLoopReal() + tests = [ + ("Test 1: Basic Completion Promise", test_suite.test_1_basic_completion_promise), + ("Test 2: Max Iterations Limit", test_suite.test_2_max_iterations_limit), + ("Test 3: Keyword Completion Detection", test_suite.test_3_keyword_completion_detection), + ("Test 4: Context Clearing", test_suite.test_4_context_clearing), + ("Test 5: Prompt Re-injection", test_suite.test_5_prompt_reinjection), + ("Test 6: Autonomy Config from Dict", test_suite.test_6_autonomy_config_from_dict), + ("Test 7: Timeout Handling", test_suite.test_7_timeout_handling), + ] + + results = [] + for name, test_func in tests: + try: + test_func() + results.append((name, "PASSED", None)) + except Exception as e: + results.append((name, "FAILED", str(e))) + print(f"❌ {name} FAILED: {e}") + + print("\n" + "="*70) + print("TEST RESULTS SUMMARY") + print("="*70) + + passed = sum(1 for _, status, _ in results if status == "PASSED") + failed = sum(1 for _, status, _ in results if status == "FAILED") + + for name, status, error in results: + emoji = "✅" if status == "PASSED" else "❌" + print(f"{emoji} {name}: {status}") + if error: + print(f" Error: {error}") + + print(f"\nTotal: {passed}/{len(results)} passed, {failed} failed") + + return passed, failed + + +if __name__ == "__main__": + run_all_tests() diff --git a/src/praisonai-agents/tests/integration/test_bots_integration.py b/src/praisonai-agents/tests/integration/test_bots_integration.py new file mode 100644 index 000000000..c7d6a3786 --- /dev/null +++ b/src/praisonai-agents/tests/integration/test_bots_integration.py @@ -0,0 +1,402 @@ +""" +Integration tests for Bots module. + +These tests verify the Bot protocols and implementations work correctly +with real scenarios. +""" + +import pytest +import os +from unittest.mock import MagicMock, patch + +# Import from core SDK +from praisonaiagents.bots import ( + BotConfig, + BotMessage, + BotUser, + BotChannel, + MessageType, +) +from praisonaiagents.bots.protocols import ( + BotProtocol, + BotMessageProtocol, + BotUserProtocol, + BotChannelProtocol, +) + + +class TestBotConfigIntegration: + """Integration tests for BotConfig.""" + + def test_telegram_config(self): + """Test Telegram bot configuration.""" + config = BotConfig( + token='test-token-123', + webhook_url='https://example.com/webhook', + metadata={'platform': 'telegram'}, + ) + assert config.token == 'test-token-123' + assert config.metadata['platform'] == 'telegram' + + # Token should be hidden in dict + config_dict = config.to_dict() + assert 'test-token-123' not in str(config_dict) + + def test_discord_config(self): + """Test Discord bot configuration.""" + config = BotConfig( + token='discord-token-456', + metadata={'platform': 'discord', 'guild_ids': ['123456789', '987654321']}, + ) + assert config.token == 'discord-token-456' + assert len(config.metadata['guild_ids']) == 2 + + def test_slack_config(self): + """Test Slack bot configuration.""" + config = BotConfig( + token='xoxb-slack-token', + metadata={'platform': 'slack', 'app_token': 'xapp-slack-app-token'}, + ) + assert config.token == 'xoxb-slack-token' + + def test_config_from_environment(self): + """Test config can be created from environment variables.""" + with patch.dict(os.environ, { + 'TELEGRAM_BOT_TOKEN': 'env-token-789', + }): + config = BotConfig( + token=os.environ.get('TELEGRAM_BOT_TOKEN'), + ) + assert config.token == 'env-token-789' + + +class TestBotUserIntegration: + """Integration tests for BotUser.""" + + def test_user_creation(self): + """Test user creation with all fields.""" + user = BotUser( + user_id='user-123', + username='johndoe', + display_name='John Doe', + is_bot=False, + metadata={'language': 'en', 'timezone': 'UTC'}, + ) + + assert user.user_id == 'user-123' + assert user.username == 'johndoe' + assert user.display_name == 'John Doe' + assert user.is_bot is False + assert user.metadata['language'] == 'en' + + def test_user_serialization_roundtrip(self): + """Test user can be serialized and deserialized.""" + original = BotUser( + user_id='user-456', + username='janedoe', + display_name='Jane Doe', + is_bot=False, + ) + + user_dict = original.to_dict() + restored = BotUser.from_dict(user_dict) + + assert restored.user_id == original.user_id + assert restored.username == original.username + assert restored.display_name == original.display_name + + def test_bot_user_flag(self): + """Test bot user identification.""" + bot_user = BotUser( + user_id='bot-123', + username='assistant_bot', + is_bot=True, + ) + + assert bot_user.is_bot is True + + +class TestBotChannelIntegration: + """Integration tests for BotChannel.""" + + def test_dm_channel(self): + """Test direct message channel.""" + channel = BotChannel( + channel_id='dm-123', + name=None, + channel_type='dm', + ) + + assert channel.channel_type == 'dm' + + def test_group_channel(self): + """Test group channel.""" + channel = BotChannel( + channel_id='group-456', + name='Team Chat', + channel_type='group', + metadata={'member_count': 25}, + ) + + assert channel.channel_type == 'group' + assert channel.name == 'Team Chat' + assert channel.metadata['member_count'] == 25 + + def test_thread_channel(self): + """Test thread channel.""" + channel = BotChannel( + channel_id='thread-789', + name='Discussion Thread', + channel_type='thread', + metadata={'parent_channel': 'channel-123'}, + ) + + assert channel.channel_type == 'thread' + + def test_channel_serialization_roundtrip(self): + """Test channel can be serialized and deserialized.""" + original = BotChannel( + channel_id='channel-999', + name='General', + channel_type='channel', + ) + + channel_dict = original.to_dict() + restored = BotChannel.from_dict(channel_dict) + + assert restored.channel_id == original.channel_id + assert restored.name == original.name + + +class TestBotMessageIntegration: + """Integration tests for BotMessage.""" + + def test_text_message(self): + """Test text message creation.""" + sender = BotUser(user_id='user-1', username='alice') + channel = BotChannel(channel_id='channel-1', channel_type='dm') + + message = BotMessage( + content='Hello, World!', + message_type=MessageType.TEXT, + sender=sender, + channel=channel, + ) + + assert message.text == 'Hello, World!' + assert message.message_type == MessageType.TEXT + assert message.sender.username == 'alice' + + def test_command_message(self): + """Test command message parsing.""" + message = BotMessage( + content='/help search query', + message_type=MessageType.COMMAND, + ) + + assert message.is_command is True + assert message.command == 'help' + assert message.command_args == ['search', 'query'] + + def test_command_detection_from_text(self): + """Test command detection from text content.""" + message = BotMessage( + content='/status', + message_type=MessageType.TEXT, + ) + + # Should detect as command even if type is TEXT + assert message.is_command is True + assert message.command == 'status' + assert message.command_args == [] + + def test_message_with_attachments(self): + """Test message with attachments.""" + message = BotMessage( + content='Check out these files', + attachments=[ + {'type': 'image', 'url': 'https://example.com/image.png'}, + {'type': 'document', 'url': 'https://example.com/doc.pdf'}, + ], + ) + + assert len(message.attachments) == 2 + assert message.attachments[0]['type'] == 'image' + + def test_reply_message(self): + """Test reply message.""" + original = BotMessage(content='Original message') + + reply = BotMessage( + content='This is a reply', + reply_to=original.message_id, + ) + + assert reply.reply_to == original.message_id + + def test_threaded_message(self): + """Test threaded message.""" + message = BotMessage( + content='Thread reply', + thread_id='thread-123', + ) + + assert message.thread_id == 'thread-123' + + def test_message_serialization_roundtrip(self): + """Test message can be serialized and deserialized.""" + sender = BotUser(user_id='user-1', username='bob') + channel = BotChannel(channel_id='channel-1', channel_type='group') + + original = BotMessage( + content='Test message', + message_type=MessageType.TEXT, + sender=sender, + channel=channel, + metadata={'priority': 'high'}, + ) + + msg_dict = original.to_dict() + restored = BotMessage.from_dict(msg_dict) + + assert restored.content == original.content + assert restored.message_type == original.message_type + assert restored.sender.user_id == original.sender.user_id + assert restored.channel.channel_id == original.channel.channel_id + + def test_all_message_types(self): + """Test all message types can be used.""" + for msg_type in MessageType: + message = BotMessage( + content='Test', + message_type=msg_type, + ) + assert message.message_type == msg_type + + +class TestBotProtocolCompliance: + """Test that implementations comply with protocols.""" + + def test_bot_protocol_methods(self): + """Verify BotProtocol has all required methods.""" + required_properties = ['is_running', 'platform', 'bot_user'] + required_methods = [ + 'start', 'stop', 'set_agent', 'get_agent', + 'send_message', 'edit_message', 'delete_message', + 'on_message', 'on_command', 'send_typing', + 'get_user', 'get_channel' + ] + + for prop in required_properties: + assert hasattr(BotProtocol, prop) + + for method in required_methods: + assert hasattr(BotProtocol, method) + + def test_bot_message_protocol_methods(self): + """Verify BotMessageProtocol has all required methods.""" + required_properties = ['message_id', 'content', 'sender', 'channel'] + + for prop in required_properties: + assert hasattr(BotMessageProtocol, prop) + + def test_bot_user_protocol_methods(self): + """Verify BotUserProtocol has all required methods.""" + required_properties = ['user_id', 'username', 'is_bot'] + + for prop in required_properties: + assert hasattr(BotUserProtocol, prop) + + def test_bot_channel_protocol_methods(self): + """Verify BotChannelProtocol has all required methods.""" + required_properties = ['channel_id', 'channel_type'] + + for prop in required_properties: + assert hasattr(BotChannelProtocol, prop) + + +class TestMockBotWorkflow: + """Test a mock bot workflow.""" + + def test_message_handling_workflow(self): + """Test complete message handling workflow.""" + # Create mock bot + bot = MagicMock() + bot.platform = 'telegram' + bot.is_running = True + + messages_received = [] + responses_sent = [] + + def on_message(handler): + def wrapper(msg): + messages_received.append(msg) + return handler(msg) + return wrapper + + async def send_message(channel_id, content, **kwargs): + msg = BotMessage( + content=content, + sender=BotUser(user_id='bot-1', is_bot=True), + channel=BotChannel(channel_id=channel_id, channel_type='dm'), + ) + responses_sent.append(msg) + return msg + + bot.on_message = on_message + bot.send_message = send_message + + # Simulate incoming message + incoming = BotMessage( + content='Hello bot!', + sender=BotUser(user_id='user-1', username='alice'), + channel=BotChannel(channel_id='dm-123', channel_type='dm'), + ) + + @bot.on_message + def handle_message(msg): + return f"Received: {msg.content}" + + result = handle_message(incoming) + + assert len(messages_received) == 1 + assert result == "Received: Hello bot!" + + +@pytest.mark.asyncio +class TestAsyncBotOperations: + """Test async bot operations.""" + + async def test_async_message_send(self): + """Test async message sending.""" + sent_messages = [] + + async def send_message(channel_id: str, content: str): + msg = BotMessage( + content=content, + sender=BotUser(user_id='bot-1', is_bot=True), + channel=BotChannel(channel_id=channel_id, channel_type='dm'), + ) + sent_messages.append(msg) + return msg + + result = await send_message('channel-1', 'Hello!') + + assert len(sent_messages) == 1 + assert result.content == 'Hello!' + + async def test_async_typing_indicator(self): + """Test async typing indicator.""" + typing_channels = [] + + async def send_typing(channel_id: str): + typing_channels.append(channel_id) + + await send_typing('channel-1') + await send_typing('channel-2') + + assert len(typing_channels) == 2 + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/src/praisonai-agents/tests/integration/test_gateway_integration.py b/src/praisonai-agents/tests/integration/test_gateway_integration.py new file mode 100644 index 000000000..74b73b64d --- /dev/null +++ b/src/praisonai-agents/tests/integration/test_gateway_integration.py @@ -0,0 +1,342 @@ +""" +Integration tests for Gateway module. + +These tests verify the Gateway protocols and implementations work correctly +with real scenarios. Some tests require API keys and are skipped if not available. +""" + +import pytest +import os +from unittest.mock import MagicMock, patch + +# Import from core SDK +from praisonaiagents.gateway import ( + GatewayConfig, + SessionConfig, + GatewayEvent, + GatewayMessage, + EventType, +) +from praisonaiagents.gateway.protocols import ( + GatewayProtocol, + GatewaySessionProtocol, + GatewayClientProtocol, +) + + +class TestGatewayConfigIntegration: + """Integration tests for GatewayConfig.""" + + def test_config_from_environment(self): + """Test config can be created from environment variables.""" + with patch.dict(os.environ, { + 'GATEWAY_HOST': '0.0.0.0', + 'GATEWAY_PORT': '9000', + }): + config = GatewayConfig( + host=os.environ.get('GATEWAY_HOST', 'localhost'), + port=int(os.environ.get('GATEWAY_PORT', '8765')), + ) + assert config.host == '0.0.0.0' + assert config.port == 9000 + + def test_config_with_auth(self): + """Test config with authentication settings.""" + config = GatewayConfig( + host='localhost', + port=8765, + auth_token='test-token-123', + ) + assert config.auth_token == 'test-token-123' + + # Verify token is hidden in dict representation + config_dict = config.to_dict() + assert 'test-token-123' not in str(config_dict) + + def test_session_config_defaults(self): + """Test SessionConfig has sensible defaults.""" + config = SessionConfig() + assert config.timeout > 0 + assert config.max_messages > 0 + + +class TestGatewayEventIntegration: + """Integration tests for GatewayEvent.""" + + def test_event_serialization_roundtrip(self): + """Test event can be serialized and deserialized.""" + original = GatewayEvent( + type=EventType.MESSAGE, + data={'content': 'Hello, World!', 'user_id': '123'}, + source='agent-1', + target='client-1', + ) + + # Serialize + event_dict = original.to_dict() + + # Deserialize + restored = GatewayEvent.from_dict(event_dict) + + assert restored.type == EventType.MESSAGE + assert restored.data['content'] == 'Hello, World!' + assert restored.source == 'agent-1' + assert restored.target == 'client-1' + + def test_event_with_custom_type(self): + """Test event with custom (non-enum) type.""" + event = GatewayEvent( + type='custom_event', + data={'custom': 'data'}, + ) + + event_dict = event.to_dict() + assert event_dict['type'] == 'custom_event' + + restored = GatewayEvent.from_dict(event_dict) + assert restored.type == 'custom_event' + + def test_all_event_types(self): + """Test all standard event types can be used.""" + for event_type in EventType: + event = GatewayEvent(type=event_type, data={}) + assert event.type == event_type + + event_dict = event.to_dict() + restored = GatewayEvent.from_dict(event_dict) + assert restored.type == event_type + + +class TestGatewayMessageIntegration: + """Integration tests for GatewayMessage.""" + + def test_message_with_metadata(self): + """Test message with rich metadata.""" + message = GatewayMessage( + content='Test message', + sender_id='user-123', + session_id='session-456', + metadata={ + 'channel': 'telegram', + 'priority': 'high', + 'attachments': ['file1.pdf', 'file2.png'], + }, + ) + + msg_dict = message.to_dict() + assert msg_dict['metadata']['channel'] == 'telegram' + assert len(msg_dict['metadata']['attachments']) == 2 + + def test_message_reply_chain(self): + """Test message reply chain.""" + original = GatewayMessage( + content='Original message', + sender_id='user-1', + session_id='session-1', + ) + + reply = GatewayMessage( + content='Reply to original', + sender_id='agent-1', + session_id='session-1', + reply_to=original.message_id, + ) + + assert reply.reply_to == original.message_id + + def test_message_structured_content(self): + """Test message with structured content.""" + message = GatewayMessage( + content={ + 'type': 'card', + 'title': 'Weather Report', + 'body': 'Sunny, 25°C', + 'actions': [ + {'label': 'Details', 'action': 'show_details'}, + {'label': 'Dismiss', 'action': 'dismiss'}, + ], + }, + sender_id='weather-agent', + session_id='session-1', + ) + + assert isinstance(message.content, dict) + assert message.content['type'] == 'card' + + +class TestGatewayProtocolCompliance: + """Test that implementations comply with protocols.""" + + def test_gateway_session_protocol_methods(self): + """Verify GatewaySessionProtocol has all required methods.""" + required_properties = [ + 'session_id', 'agent_id', 'client_id', + 'is_active', 'created_at', 'last_activity' + ] + required_methods = [ + 'get_state', 'set_state', 'add_message', + 'get_messages', 'close' + ] + + # Check protocol has all required attributes + for prop in required_properties: + assert hasattr(GatewaySessionProtocol, prop) + + for method in required_methods: + assert hasattr(GatewaySessionProtocol, method) + + def test_gateway_client_protocol_methods(self): + """Verify GatewayClientProtocol has all required methods.""" + required_properties = ['client_id', 'is_connected', 'connected_at'] + required_methods = ['send', 'receive', 'close'] + + for prop in required_properties: + assert hasattr(GatewayClientProtocol, prop) + + for method in required_methods: + assert hasattr(GatewayClientProtocol, method) + + def test_gateway_protocol_methods(self): + """Verify GatewayProtocol has all required methods.""" + required_properties = ['is_running', 'port', 'host'] + required_methods = [ + 'start', 'stop', 'register_agent', 'unregister_agent', + 'get_agent', 'list_agents', 'create_session', 'get_session', + 'close_session', 'list_sessions', 'on_event', 'emit', + 'broadcast', 'health' + ] + + for prop in required_properties: + assert hasattr(GatewayProtocol, prop) + + for method in required_methods: + assert hasattr(GatewayProtocol, method) + + +class TestMockGatewaySession: + """Test a mock implementation of GatewaySessionProtocol.""" + + def test_mock_session_workflow(self): + """Test a complete session workflow with mock.""" + # Create mock session + session = MagicMock() + session.session_id = 'test-session-123' + session.agent_id = 'agent-1' + session.client_id = 'client-1' + session.is_active = True + session.created_at = 1704067200.0 + session.last_activity = 1704067200.0 + + state = {} + messages = [] + + def get_state(): + return state.copy() + + def set_state(key, value): + state[key] = value + + def add_message(msg): + messages.append(msg) + + def get_messages(limit=None): + if limit: + return messages[-limit:] + return messages.copy() + + session.get_state = get_state + session.set_state = set_state + session.add_message = add_message + session.get_messages = get_messages + + # Test workflow + session.set_state('user_name', 'Alice') + assert session.get_state()['user_name'] == 'Alice' + + msg1 = GatewayMessage( + content='Hello', + sender_id='client-1', + session_id='test-session-123', + ) + session.add_message(msg1) + + msg2 = GatewayMessage( + content='Hi there!', + sender_id='agent-1', + session_id='test-session-123', + ) + session.add_message(msg2) + + history = session.get_messages() + assert len(history) == 2 + assert history[0].content == 'Hello' + assert history[1].content == 'Hi there!' + + +@pytest.mark.asyncio +class TestAsyncGatewayOperations: + """Test async gateway operations.""" + + async def test_async_event_handling(self): + """Test async event emission and handling.""" + events_received = [] + + async def event_handler(event: GatewayEvent): + events_received.append(event) + + # Simulate event emission + event = GatewayEvent( + type=EventType.MESSAGE, + data={'content': 'Test'}, + ) + + await event_handler(event) + + assert len(events_received) == 1 + assert events_received[0].type == EventType.MESSAGE + + async def test_async_broadcast(self): + """Test async broadcast to multiple clients.""" + clients_notified = [] + + async def notify_client(client_id: str, event: GatewayEvent): + clients_notified.append(client_id) + + # Simulate broadcast + event = GatewayEvent( + type=EventType.BROADCAST, + data={'announcement': 'Server maintenance in 5 minutes'}, + ) + + client_ids = ['client-1', 'client-2', 'client-3'] + + for client_id in client_ids: + await notify_client(client_id, event) + + assert len(clients_notified) == 3 + assert 'client-1' in clients_notified + + +class TestGatewayHealthCheck: + """Test gateway health check functionality.""" + + def test_health_response_structure(self): + """Test health response has required fields.""" + # Mock health response + health = { + 'status': 'healthy', + 'uptime': 3600, + 'agents': 2, + 'sessions': 5, + 'clients': 3, + } + + assert health['status'] in ['healthy', 'unhealthy'] + assert isinstance(health['uptime'], (int, float)) + assert isinstance(health['agents'], int) + assert isinstance(health['sessions'], int) + assert isinstance(health['clients'], int) + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/src/praisonai-agents/tests/integration/test_real_features.py b/src/praisonai-agents/tests/integration/test_real_features.py new file mode 100644 index 000000000..ad35c373d --- /dev/null +++ b/src/praisonai-agents/tests/integration/test_real_features.py @@ -0,0 +1,574 @@ +""" +Real Integration Tests for Gateway, Bots, Sandbox, and Failover Features. + +These tests use real API calls and verify actual functionality. +Requires: OPENAI_API_KEY environment variable. +""" + +import asyncio +import os +import sys +import time +import pytest +from typing import Dict, Any +from unittest.mock import patch, MagicMock + +# Add parent to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +from praisonaiagents import Agent, tool +from praisonaiagents import ( + # Gateway + GatewayConfig, SessionConfig, GatewayEvent, GatewayMessage, EventType, + GatewayProtocol, GatewaySessionProtocol, GatewayClientProtocol, + # Bots + BotConfig, BotMessage, BotUser, BotChannel, MessageType, BotProtocol, + # Sandbox + SandboxConfig, SandboxResult, SandboxStatus, ResourceLimits, SecurityPolicy, + SandboxProtocol, + # Failover + AuthProfile, FailoverConfig, FailoverManager, ProviderStatus, +) + + +# ============================================================================ +# GATEWAY REAL TESTS (5+ tests) +# ============================================================================ + +class TestGatewayReal: + """Real integration tests for Gateway feature.""" + + def test_gateway_config_creation_and_serialization(self): + """Test 1: Gateway config can be created and serialized.""" + config = GatewayConfig( + host="127.0.0.1", + port=8765, + auth_token="test-secret-token", + max_connections=500, + heartbeat_interval=30, + ) + + # Verify attributes + assert config.host == "127.0.0.1" + assert config.port == 8765 + assert config.auth_token == "test-secret-token" + assert config.max_connections == 500 + + # Verify serialization hides token + config_dict = config.to_dict() + assert "test-secret-token" not in str(config_dict) + assert config_dict["auth_token"] == "***" + + # Verify computed properties + assert config.ws_url == "ws://127.0.0.1:8765" + assert config.http_url == "http://127.0.0.1:8765" + assert config.is_secure is False + + def test_gateway_session_config(self): + """Test 2: Session config works correctly.""" + session_config = SessionConfig( + timeout=7200, + max_messages=500, + persist=True, + persist_path="/tmp/sessions", + ) + + assert session_config.timeout == 7200 + assert session_config.max_messages == 500 + assert session_config.persist is True + + # Verify serialization + session_dict = session_config.to_dict() + assert session_dict["timeout"] == 7200 + assert session_dict["persist_path"] == "/tmp/sessions" + + def test_gateway_event_creation_and_roundtrip(self): + """Test 3: Gateway events can be created and serialized.""" + event = GatewayEvent( + type=EventType.MESSAGE, + data={"content": "Hello, World!"}, + source="agent-1", + target="client-1", + ) + + assert event.type == EventType.MESSAGE + assert event.data["content"] == "Hello, World!" + assert event.source == "agent-1" + assert event.target == "client-1" + assert event.event_id is not None + assert event.timestamp > 0 + + # Test serialization roundtrip + event_dict = event.to_dict() + restored = GatewayEvent.from_dict(event_dict) + assert restored.type == EventType.MESSAGE + assert restored.data["content"] == "Hello, World!" + + def test_gateway_message_creation(self): + """Test 4: Gateway messages work correctly.""" + message = GatewayMessage( + content="Test message", + role="user", + agent_id="agent-1", + session_id="session-123", + ) + + assert message.content == "Test message" + assert message.role == "user" + assert message.agent_id == "agent-1" + assert message.session_id == "session-123" + + # Test serialization + msg_dict = message.to_dict() + assert msg_dict["content"] == "Test message" + + # Test roundtrip + restored = GatewayMessage.from_dict(msg_dict) + assert restored.content == "Test message" + + def test_gateway_event_types_complete(self): + """Test 5: All event types are defined.""" + expected_types = [ + "CONNECT", "DISCONNECT", "RECONNECT", + "SESSION_START", "SESSION_END", "SESSION_UPDATE", + "AGENT_REGISTER", "AGENT_UNREGISTER", "AGENT_STATUS", + "MESSAGE", "MESSAGE_ACK", "TYPING", + "HEALTH", "ERROR", "BROADCAST", + ] + + for event_type in expected_types: + assert hasattr(EventType, event_type), f"Missing EventType.{event_type}" + + def test_gateway_config_with_ssl(self): + """Test 6: Gateway config with SSL settings.""" + config = GatewayConfig( + host="0.0.0.0", + port=443, + ssl_cert="/path/to/cert.pem", + ssl_key="/path/to/key.pem", + ) + + assert config.is_secure is True + assert config.ws_url == "wss://0.0.0.0:443" + assert config.http_url == "https://0.0.0.0:443" + + +# ============================================================================ +# BOTS REAL TESTS (5+ tests) +# ============================================================================ + +class TestBotsReal: + """Real integration tests for Bots feature.""" + + def test_bot_config_telegram(self): + """Test 1: Telegram bot config works correctly.""" + config = BotConfig( + token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + webhook_url="https://example.com/webhook", + command_prefix="/", + mention_required=True, + typing_indicator=True, + metadata={"platform": "telegram"}, + ) + + assert config.token == "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + assert config.webhook_url == "https://example.com/webhook" + assert config.is_webhook_mode is True + assert config.command_prefix == "/" + + # Token should be hidden + config_dict = config.to_dict() + assert "ABC-DEF" not in str(config_dict) + + def test_bot_config_discord(self): + """Test 2: Discord bot config works correctly.""" + config = BotConfig( + token="MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.Gg1234.abcdefghijklmnop", + command_prefix="!", + mention_required=True, + metadata={ + "platform": "discord", + "guild_ids": ["123456789", "987654321"], + }, + ) + + assert config.command_prefix == "!" + assert config.metadata["platform"] == "discord" + assert len(config.metadata["guild_ids"]) == 2 + + def test_bot_message_creation(self): + """Test 3: Bot messages work correctly.""" + user = BotUser( + id="user123", + username="testuser", + display_name="Test User", + is_bot=False, + ) + + channel = BotChannel( + id="channel456", + name="general", + type="text", + ) + + message = BotMessage( + id="msg789", + content="Hello from bot!", + type=MessageType.TEXT, + user=user, + channel=channel, + ) + + assert message.content == "Hello from bot!" + assert message.type == MessageType.TEXT + assert message.user.username == "testuser" + assert message.channel.name == "general" + + def test_bot_user_allowed_check(self): + """Test 4: User allowlist works correctly.""" + config = BotConfig( + token="test-token", + allowed_users=["user1", "user2", "user3"], + allowed_channels=["channel1"], + ) + + assert config.is_user_allowed("user1") is True + assert config.is_user_allowed("user4") is False + assert config.is_channel_allowed("channel1") is True + assert config.is_channel_allowed("channel2") is False + + # Empty allowlist means all allowed + config_open = BotConfig(token="test") + assert config_open.is_user_allowed("anyone") is True + assert config_open.is_channel_allowed("anywhere") is True + + def test_bot_message_types_complete(self): + """Test 5: All message types are defined.""" + expected_types = ["TEXT", "IMAGE", "AUDIO", "VIDEO", "FILE", "STICKER", "COMMAND"] + + for msg_type in expected_types: + assert hasattr(MessageType, msg_type), f"Missing MessageType.{msg_type}" + + def test_bot_config_polling_mode(self): + """Test 6: Polling mode config works correctly.""" + config = BotConfig( + token="test-token", + polling_interval=2.0, + retry_attempts=5, + timeout=60, + ) + + assert config.is_webhook_mode is False + assert config.polling_interval == 2.0 + assert config.retry_attempts == 5 + assert config.timeout == 60 + + +# ============================================================================ +# SANDBOX REAL TESTS (5+ tests) +# ============================================================================ + +class TestSandboxReal: + """Real integration tests for Sandbox feature.""" + + def test_sandbox_config_subprocess(self): + """Test 1: Subprocess sandbox config works correctly.""" + config = SandboxConfig.subprocess() + + assert config.sandbox_type == "subprocess" + assert config.auto_cleanup is True + + def test_sandbox_config_docker(self): + """Test 2: Docker sandbox config works correctly.""" + config = SandboxConfig.docker(image="python:3.11-slim") + + assert config.sandbox_type == "docker" + assert config.image == "python:3.11-slim" + + def test_sandbox_resource_limits_presets(self): + """Test 3: Resource limit presets work correctly.""" + minimal = ResourceLimits.minimal() + standard = ResourceLimits.standard() + generous = ResourceLimits.generous() + + # Minimal should be most restrictive + assert minimal.memory_mb < standard.memory_mb < generous.memory_mb + assert minimal.timeout_seconds < standard.timeout_seconds < generous.timeout_seconds + assert minimal.network_enabled is False + assert generous.network_enabled is True + + # Verify serialization + minimal_dict = minimal.to_dict() + assert "memory_mb" in minimal_dict + assert "timeout_seconds" in minimal_dict + + def test_sandbox_security_policy_presets(self): + """Test 4: Security policy presets work correctly.""" + strict = SecurityPolicy.strict() + standard = SecurityPolicy.standard() + permissive = SecurityPolicy.permissive() + + assert strict.allow_network is False + assert strict.allow_subprocess is False + assert permissive.allow_network is True + assert permissive.allow_subprocess is True + + def test_sandbox_result_creation(self): + """Test 5: Sandbox results work correctly.""" + result = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout="Hello, World!\n", + stderr="", + duration_seconds=0.5, + ) + + assert result.success is True + assert result.status == SandboxStatus.COMPLETED + assert result.stdout == "Hello, World!\n" + assert result.duration_seconds == 0.5 + + # Test combined output + assert "Hello, World!" in result.combined_output + + def test_sandbox_result_failure(self): + """Test 6: Failed sandbox results work correctly.""" + result = SandboxResult( + status=SandboxStatus.FAILED, + exit_code=1, + stdout="", + stderr="NameError: name 'undefined' is not defined", + error="Execution failed", + ) + + assert result.success is False + assert result.status == SandboxStatus.FAILED + assert "NameError" in result.stderr + + def test_sandbox_status_values(self): + """Test 7: All sandbox status values are defined.""" + expected_statuses = ["PENDING", "RUNNING", "COMPLETED", "FAILED", "TIMEOUT", "KILLED"] + + for status in expected_statuses: + assert hasattr(SandboxStatus, status), f"Missing SandboxStatus.{status}" + + +# ============================================================================ +# FAILOVER REAL TESTS (5+ tests) +# ============================================================================ + +class TestFailoverReal: + """Real integration tests for Failover feature.""" + + def test_auth_profile_creation(self): + """Test 1: Auth profiles can be created correctly.""" + profile = AuthProfile( + name="openai-primary", + provider="openai", + api_key="sk-test-key-12345", + priority=1, + rate_limit_rpm=100, + ) + + assert profile.name == "openai-primary" + assert profile.provider == "openai" + assert profile.priority == 1 + assert profile.status == ProviderStatus.AVAILABLE + + # API key should be hidden + profile_dict = profile.to_dict() + assert "sk-test-key-12345" not in str(profile_dict) + assert "***" in profile_dict["api_key"] + + def test_failover_config_creation(self): + """Test 2: Failover config works correctly.""" + config = FailoverConfig( + max_retries=3, + retry_delay=1.0, + exponential_backoff=True, + max_retry_delay=60.0, + failover_on_rate_limit=True, + failover_on_timeout=True, + ) + + assert config.max_retries == 3 + assert config.retry_delay == 1.0 + assert config.exponential_backoff is True + assert config.max_retry_delay == 60.0 + + def test_failover_manager_add_profiles(self): + """Test 3: Failover manager can add and retrieve profiles.""" + manager = FailoverManager() + + profile1 = AuthProfile( + name="openai", + provider="openai", + api_key="sk-test-1", + priority=1, + ) + profile2 = AuthProfile( + name="anthropic", + provider="anthropic", + api_key="sk-ant-test", + priority=2, + ) + + manager.add_profile(profile1) + manager.add_profile(profile2) + + # Retrieve by name + assert manager.get_profile("openai") is not None + assert manager.get_profile("anthropic") is not None + assert manager.get_profile("nonexistent") is None + + # Get next profile (should be highest priority) + next_profile = manager.get_next_profile() + assert next_profile.name == "openai" + + def test_failover_manager_mark_failure(self): + """Test 4: Failover manager handles failures correctly.""" + manager = FailoverManager(FailoverConfig(max_retries=2)) + + profile = AuthProfile( + name="test-provider", + provider="openai", + api_key="sk-test", + priority=1, + ) + manager.add_profile(profile) + + # Mark failure + manager.mark_failure("test-provider", "Rate limit exceeded") + + # Profile should still be retrievable but with updated status + updated = manager.get_profile("test-provider") + assert updated.last_error == "Rate limit exceeded" + assert updated.last_error_time is not None + + def test_failover_manager_reset(self): + """Test 5: Failover manager can reset profiles.""" + manager = FailoverManager() + + profile = AuthProfile( + name="test", + provider="openai", + api_key="sk-test", + priority=1, + ) + manager.add_profile(profile) + manager.mark_failure("test", "Error") + + # Reset + manager.mark_success("test") + + updated = manager.get_profile("test") + assert updated.status == ProviderStatus.AVAILABLE + + def test_failover_manager_priority_order(self): + """Test 6: Profiles are returned in priority order.""" + manager = FailoverManager() + + # Add in reverse priority order + manager.add_profile(AuthProfile(name="low", provider="x", api_key="k", priority=3)) + manager.add_profile(AuthProfile(name="high", provider="x", api_key="k", priority=1)) + manager.add_profile(AuthProfile(name="mid", provider="x", api_key="k", priority=2)) + + # Should get highest priority first + next_profile = manager.get_next_profile() + assert next_profile.name == "high" + + def test_provider_status_values(self): + """Test 7: All provider status values are defined.""" + expected_statuses = ["AVAILABLE", "RATE_LIMITED", "ERROR", "DISABLED"] + + for status in expected_statuses: + assert hasattr(ProviderStatus, status), f"Missing ProviderStatus.{status}" + + +# ============================================================================ +# REAL AGENT TESTS WITH API (requires OPENAI_API_KEY) +# ============================================================================ + +@pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set" +) +class TestRealAgentWithAPI: + """Real agent tests that make actual API calls.""" + + def test_agent_basic_chat(self): + """Test 1: Basic agent chat works.""" + agent = Agent( + name="test-agent", + instructions="You are a helpful assistant. Respond briefly.", + llm="gpt-4o-mini", + ) + + response = agent.start("Say 'Hello' and nothing else.") + assert response is not None + assert len(response) > 0 + assert "hello" in response.lower() + + def test_agent_with_tool(self): + """Test 2: Agent with tool works.""" + @tool + def get_current_time() -> str: + """Get the current time.""" + return "12:00 PM" + + agent = Agent( + name="time-agent", + instructions="Use the get_current_time tool when asked about time.", + llm="gpt-4o-mini", + tools=[get_current_time], + ) + + response = agent.start("What time is it?") + assert response is not None + assert "12:00" in response or "time" in response.lower() + + def test_agent_streaming(self): + """Test 3: Agent streaming works.""" + agent = Agent( + name="stream-agent", + instructions="You are helpful. Respond briefly.", + llm="gpt-4o-mini", + ) + + chunks = [] + for chunk in agent.start("Say 'test' only.", stream=True): + if chunk: + chunks.append(chunk) + + assert len(chunks) > 0 + full_response = "".join(chunks) + assert len(full_response) > 0 + + +# ============================================================================ +# SANDBOX EXECUTION TESTS (requires subprocess) +# ============================================================================ + +class TestSandboxExecution: + """Tests for actual sandbox code execution.""" + + def test_subprocess_sandbox_simple_code(self): + """Test subprocess sandbox can execute simple code.""" + # This test verifies the sandbox config is correct + # Actual execution would require the wrapper implementation + config = SandboxConfig( + sandbox_type="subprocess", + resource_limits=ResourceLimits( + memory_mb=128, + timeout_seconds=10, + network_enabled=False, + ), + ) + + assert config.sandbox_type == "subprocess" + assert config.resource_limits.timeout_seconds == 10 + assert config.resource_limits.network_enabled is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/src/praisonai-agents/tests/integration/test_sandbox_integration.py b/src/praisonai-agents/tests/integration/test_sandbox_integration.py new file mode 100644 index 000000000..155a825c8 --- /dev/null +++ b/src/praisonai-agents/tests/integration/test_sandbox_integration.py @@ -0,0 +1,371 @@ +""" +Integration tests for Sandbox module. + +These tests verify the Sandbox protocols and implementations work correctly +with real scenarios. +""" + +import pytest +from unittest.mock import MagicMock + +# Import from core SDK +from praisonaiagents.sandbox import ( + SandboxConfig, + SandboxResult, + SandboxStatus, + ResourceLimits, +) +from praisonaiagents.sandbox.protocols import SandboxProtocol + + +class TestSandboxConfigIntegration: + """Integration tests for SandboxConfig.""" + + def test_docker_sandbox_config(self): + """Test Docker sandbox configuration.""" + config = SandboxConfig( + sandbox_type='docker', + image='python:3.11-slim', + ) + assert config.sandbox_type == 'docker' + assert config.image == 'python:3.11-slim' + # Network is controlled via resource_limits + assert config.resource_limits.network_enabled is False + + def test_subprocess_sandbox_config(self): + """Test subprocess sandbox configuration.""" + limits = ResourceLimits(timeout_seconds=30) + config = SandboxConfig( + sandbox_type='subprocess', + resource_limits=limits, + ) + assert config.sandbox_type == 'subprocess' + assert config.resource_limits.timeout_seconds == 30 + + def test_config_with_resource_limits(self): + """Test config with resource limits.""" + limits = ResourceLimits( + memory_mb=256, + cpu_percent=50, + timeout_seconds=60, + network_enabled=False, + ) + + config = SandboxConfig( + sandbox_type='docker', + resource_limits=limits, + ) + + assert config.resource_limits.memory_mb == 256 + assert config.resource_limits.cpu_percent == 50 + + +class TestResourceLimitsIntegration: + """Integration tests for ResourceLimits.""" + + def test_minimal_limits(self): + """Test minimal resource limits for untrusted code.""" + limits = ResourceLimits.minimal() + + assert limits.memory_mb == 128 + assert limits.cpu_percent == 50 + assert limits.timeout_seconds == 30 + assert limits.network_enabled is False + + def test_standard_limits(self): + """Test standard resource limits.""" + limits = ResourceLimits.standard() + + assert limits.memory_mb == 512 + assert limits.timeout_seconds == 60 + + def test_generous_limits(self): + """Test generous resource limits for trusted code.""" + limits = ResourceLimits.generous() + + assert limits.memory_mb == 2048 + assert limits.timeout_seconds == 300 + assert limits.network_enabled is True + + def test_limits_serialization_roundtrip(self): + """Test limits can be serialized and deserialized.""" + original = ResourceLimits( + memory_mb=1024, + cpu_percent=75, + timeout_seconds=120, + max_processes=20, + network_enabled=True, + ) + + limits_dict = original.to_dict() + restored = ResourceLimits.from_dict(limits_dict) + + assert restored.memory_mb == original.memory_mb + assert restored.cpu_percent == original.cpu_percent + assert restored.timeout_seconds == original.timeout_seconds + assert restored.max_processes == original.max_processes + assert restored.network_enabled == original.network_enabled + + +class TestSandboxResultIntegration: + """Integration tests for SandboxResult.""" + + def test_successful_result(self): + """Test successful execution result.""" + result = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout='Hello, World!\n', + stderr='', + duration_seconds=0.5, + ) + + assert result.success is True + assert result.status == SandboxStatus.COMPLETED + assert result.exit_code == 0 + assert 'Hello' in result.stdout + + def test_failed_result(self): + """Test failed execution result.""" + result = SandboxResult( + status=SandboxStatus.FAILED, + exit_code=1, + stdout='', + stderr='Error: Division by zero', + error='Execution failed', + ) + + assert result.success is False + assert result.status == SandboxStatus.FAILED + assert 'Division by zero' in result.stderr + + def test_timeout_result(self): + """Test timeout execution result.""" + result = SandboxResult( + status=SandboxStatus.TIMEOUT, + exit_code=None, + stdout='Partial output...', + stderr='', + error='Execution timed out after 60 seconds', + duration_seconds=60.0, + ) + + assert result.success is False + assert result.status == SandboxStatus.TIMEOUT + assert 'timed out' in result.error + + def test_killed_result(self): + """Test killed execution result.""" + result = SandboxResult( + status=SandboxStatus.KILLED, + exit_code=-9, + stdout='', + stderr='', + error='Process killed due to memory limit', + ) + + assert result.success is False + assert result.status == SandboxStatus.KILLED + + def test_combined_output(self): + """Test combined output property.""" + result = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout='Standard output\n', + stderr='Warning: deprecated function\n', + ) + + output = result.output + assert 'Standard output' in output + assert 'Warning' in output + + def test_result_serialization_roundtrip(self): + """Test result can be serialized and deserialized.""" + original = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout='Output', + stderr='', + duration_seconds=1.5, + metadata={'language': 'python'}, + ) + + result_dict = original.to_dict() + restored = SandboxResult.from_dict(result_dict) + + assert restored.status == original.status + assert restored.exit_code == original.exit_code + assert restored.stdout == original.stdout + assert restored.duration_seconds == original.duration_seconds + + def test_all_status_values(self): + """Test all sandbox status values.""" + for status in SandboxStatus: + result = SandboxResult(status=status) + assert result.status == status + + +class TestSandboxProtocolCompliance: + """Test that implementations comply with protocols.""" + + def test_sandbox_protocol_methods(self): + """Verify SandboxProtocol has all required methods.""" + required_properties = ['is_available', 'sandbox_type'] + required_methods = [ + 'start', 'stop', 'execute', 'execute_file', 'run_command', + 'write_file', 'read_file', 'list_files', + 'get_status', 'cleanup', 'reset' + ] + + for prop in required_properties: + assert hasattr(SandboxProtocol, prop) + + for method in required_methods: + assert hasattr(SandboxProtocol, method) + + +class TestMockSandboxWorkflow: + """Test a mock sandbox workflow.""" + + def test_code_execution_workflow(self): + """Test complete code execution workflow.""" + # Create mock sandbox + sandbox = MagicMock() + sandbox.sandbox_type = 'docker' + sandbox.is_available = True + + executions = [] + + async def execute(code, language='python', limits=None, **kwargs): + executions.append({ + 'code': code, + 'language': language, + 'limits': limits, + }) + + # Simulate execution + if 'error' in code.lower(): + return SandboxResult( + status=SandboxStatus.FAILED, + exit_code=1, + stderr='Error in code', + ) + + return SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout='Execution successful', + duration_seconds=0.1, + ) + + sandbox.execute = execute + + # Test successful execution + import asyncio + result = asyncio.get_event_loop().run_until_complete( + sandbox.execute('print("Hello")', language='python') + ) + + assert result.success is True + assert len(executions) == 1 + assert executions[0]['language'] == 'python' + + def test_file_operations_workflow(self): + """Test file operations in sandbox.""" + sandbox = MagicMock() + files = {} + + async def write_file(path, content): + files[path] = content + return True + + async def read_file(path): + return files.get(path) + + async def list_files(path='/'): + return list(files.keys()) + + sandbox.write_file = write_file + sandbox.read_file = read_file + sandbox.list_files = list_files + + import asyncio + loop = asyncio.get_event_loop() + + # Write file + loop.run_until_complete(sandbox.write_file('/app/script.py', 'print("test")')) + + # Read file + content = loop.run_until_complete(sandbox.read_file('/app/script.py')) + assert content == 'print("test")' + + # List files + file_list = loop.run_until_complete(sandbox.list_files('/')) + assert '/app/script.py' in file_list + + +@pytest.mark.asyncio +class TestAsyncSandboxOperations: + """Test async sandbox operations.""" + + async def test_async_execute(self): + """Test async code execution.""" + async def mock_execute(code: str) -> SandboxResult: + return SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout=f'Executed: {code}', + ) + + result = await mock_execute('print("Hello")') + + assert result.success is True + assert 'Executed' in result.stdout + + async def test_async_with_timeout(self): + """Test async execution with timeout.""" + async def mock_execute_with_timeout(code: str, timeout: int) -> SandboxResult: + # Simulate timeout check + if timeout < 1: + return SandboxResult( + status=SandboxStatus.TIMEOUT, + error='Timeout too short', + ) + return SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout='Done', + ) + + result = await mock_execute_with_timeout('long_running()', timeout=60) + assert result.success is True + + +class TestSandboxSecurityPolicies: + """Test sandbox security policies.""" + + def test_network_disabled_by_default(self): + """Test network is disabled by default for security.""" + limits = ResourceLimits() + assert limits.network_enabled is False + + def test_minimal_limits_are_restrictive(self): + """Test minimal limits are appropriately restrictive.""" + limits = ResourceLimits.minimal() + + # Should have low memory + assert limits.memory_mb <= 256 + + # Should have short timeout + assert limits.timeout_seconds <= 60 + + # Should have no network + assert limits.network_enabled is False + + # Should have limited processes + assert limits.max_processes <= 10 + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/src/praisonai-agents/tests/unit/agent/test_autonomy_loop_features.py b/src/praisonai-agents/tests/unit/agent/test_autonomy_loop_features.py new file mode 100644 index 000000000..c38db145f --- /dev/null +++ b/src/praisonai-agents/tests/unit/agent/test_autonomy_loop_features.py @@ -0,0 +1,357 @@ +""" +Tests for enhanced autonomy loop features. + +Tests for: +- Completion promise detection +- Prompt re-injection +- Context clearing between iterations + +TDD: These tests are written FIRST, before implementation. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + + +class TestCompletionPromise: + """Test completion promise detection in run_autonomous().""" + + def test_autonomy_config_accepts_completion_promise(self): + """AutonomyConfig should accept completion_promise parameter.""" + from praisonaiagents.agent.autonomy import AutonomyConfig + + config = AutonomyConfig(completion_promise="DONE") + assert config.completion_promise == "DONE" + + def test_autonomy_config_completion_promise_default_none(self): + """AutonomyConfig.completion_promise should default to None.""" + from praisonaiagents.agent.autonomy import AutonomyConfig + + config = AutonomyConfig() + assert config.completion_promise is None + + def test_run_autonomous_accepts_completion_promise_param(self): + """run_autonomous() should accept completion_promise parameter.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + # Mock chat to return a response with promise + with patch.object(agent, 'chat', return_value="Task done. DONE"): + result = agent.run_autonomous( + "Test task", + max_iterations=5, + completion_promise="DONE" + ) + + assert result.success == True + assert result.completion_reason == "promise" + + def test_completion_promise_detected_in_response(self): + """Agent should detect TEXT in response.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + # Mock chat to return response with promise tag + with patch.object(agent, 'chat', return_value="I have completed the task. TASK_COMPLETE"): + result = agent.run_autonomous( + "Build something", + max_iterations=10, + completion_promise="TASK_COMPLETE" + ) + + assert result.success == True + assert result.completion_reason == "promise" + assert result.iterations == 1 # Should stop on first iteration + + def test_completion_promise_not_detected_without_tag(self): + """Agent should NOT detect promise without proper tag.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + # Mock chat to return response WITHOUT promise tag + responses = ["Working on it...", "Still working...", "Almost done. TASK_COMPLETE"] + call_count = [0] + + def mock_chat(prompt): + idx = min(call_count[0], len(responses) - 1) + call_count[0] += 1 + return responses[idx] + + with patch.object(agent, 'chat', side_effect=mock_chat): + result = agent.run_autonomous( + "Build something", + max_iterations=3, + completion_promise="TASK_COMPLETE" + ) + + # Should NOT succeed because promise tag is missing + assert result.completion_reason != "promise" + + def test_completion_promise_exact_match(self): + """Promise detection should require exact match.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + # Mock chat with wrong promise text + with patch.object(agent, 'chat', return_value="WRONG"): + result = agent.run_autonomous( + "Test task", + max_iterations=2, + completion_promise="DONE" + ) + + # Should NOT detect because promise text doesn't match + assert result.completion_reason != "promise" + + +class TestPromptReinjection: + """Test that original prompt is re-injected each iteration.""" + + def test_original_prompt_used_every_iteration(self): + """Original prompt should be used for every iteration, not 'Continue with task'.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + prompts_received = [] + call_count = [0] + + def mock_chat(prompt): + prompts_received.append(prompt) + call_count[0] += 1 + if call_count[0] >= 3: + return "DONE" + return "Working..." + + with patch.object(agent, 'chat', side_effect=mock_chat): + result = agent.run_autonomous( + "Build a REST API", + max_iterations=5, + completion_promise="DONE" + ) + + # All prompts should be the original, not "Continue with the task" + for prompt in prompts_received: + assert prompt == "Build a REST API", f"Expected original prompt, got: {prompt}" + + def test_prompt_not_continue_with_task(self): + """Prompt should NOT be 'Continue with the task' after first iteration.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + prompts_received = [] + + def mock_chat(prompt): + prompts_received.append(prompt) + if len(prompts_received) >= 2: + return "DONE" + return "Working..." + + with patch.object(agent, 'chat', side_effect=mock_chat): + result = agent.run_autonomous( + "My specific task", + max_iterations=5, + completion_promise="DONE" + ) + + # No prompt should be "Continue with the task" + for prompt in prompts_received: + assert "Continue with the task" not in prompt + + +class TestContextClearing: + """Test context clearing between iterations.""" + + def test_autonomy_config_accepts_clear_context(self): + """AutonomyConfig should accept clear_context parameter.""" + from praisonaiagents.agent.autonomy import AutonomyConfig + + config = AutonomyConfig(clear_context=True) + assert config.clear_context == True + + def test_autonomy_config_clear_context_default_false(self): + """AutonomyConfig.clear_context should default to False.""" + from praisonaiagents.agent.autonomy import AutonomyConfig + + config = AutonomyConfig() + assert config.clear_context == False + + def test_run_autonomous_accepts_clear_context_param(self): + """run_autonomous() should accept clear_context parameter.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + with patch.object(agent, 'chat', return_value="DONE"): + # Should not raise + result = agent.run_autonomous( + "Test task", + max_iterations=5, + completion_promise="DONE", + clear_context=True + ) + + assert result.success == True + + def test_clear_history_called_when_clear_context_true(self): + """clear_history() should be called between iterations when clear_context=True.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + call_count = [0] + + def mock_chat(prompt): + call_count[0] += 1 + if call_count[0] >= 3: + return "DONE" + return "Working..." + + with patch.object(agent, 'chat', side_effect=mock_chat): + with patch.object(agent, 'clear_history') as mock_clear: + result = agent.run_autonomous( + "Test task", + max_iterations=5, + completion_promise="DONE", + clear_context=True + ) + + # clear_history should be called after each iteration (except the last) + assert mock_clear.call_count >= 2 # At least 2 calls for 3 iterations + + def test_clear_history_not_called_when_clear_context_false(self): + """clear_history() should NOT be called when clear_context=False.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + call_count = [0] + + def mock_chat(prompt): + call_count[0] += 1 + if call_count[0] >= 2: + return "DONE" + return "Working..." + + with patch.object(agent, 'chat', side_effect=mock_chat): + with patch.object(agent, 'clear_history') as mock_clear: + result = agent.run_autonomous( + "Test task", + max_iterations=5, + completion_promise="DONE", + clear_context=False # Explicitly false + ) + + # clear_history should NOT be called + assert mock_clear.call_count == 0 + + +class TestBackwardCompatibility: + """Test that existing behavior is preserved.""" + + def test_run_autonomous_works_without_new_params(self): + """run_autonomous() should work without new parameters.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + # Mock to return completion signal (old style) + with patch.object(agent, 'chat', return_value="Task completed successfully"): + result = agent.run_autonomous( + "Test task", + max_iterations=5 + ) + + # Should still work with keyword detection + assert result.success == True + + def test_keyword_detection_still_works(self): + """Old keyword-based completion detection should still work.""" + from praisonaiagents import Agent + + agent = Agent( + instructions="Test agent", + autonomy=True, + output="silent" + ) + + with patch.object(agent, 'chat', return_value="I have finished the task"): + result = agent.run_autonomous( + "Test task", + max_iterations=5 + # No completion_promise - use old behavior + ) + + assert result.success == True + assert result.completion_reason == "goal" + + +class TestAutonomyConfigFromDict: + """Test AutonomyConfig.from_dict() with new fields.""" + + def test_from_dict_with_completion_promise(self): + """from_dict should parse completion_promise.""" + from praisonaiagents.agent.autonomy import AutonomyConfig + + config = AutonomyConfig.from_dict({ + "completion_promise": "TASK_DONE", + "max_iterations": 10 + }) + + assert config.completion_promise == "TASK_DONE" + assert config.max_iterations == 10 + + def test_from_dict_with_clear_context(self): + """from_dict should parse clear_context.""" + from praisonaiagents.agent.autonomy import AutonomyConfig + + config = AutonomyConfig.from_dict({ + "clear_context": True + }) + + assert config.clear_context == True diff --git a/src/praisonai-agents/tests/unit/test_bots.py b/src/praisonai-agents/tests/unit/test_bots.py new file mode 100644 index 000000000..4b985928f --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_bots.py @@ -0,0 +1,216 @@ +""" +Unit tests for Bot protocols and components. +""" + +from praisonaiagents.bots import ( + BotConfig, + BotMessage, + BotUser, + BotChannel, + MessageType, +) + + +class TestBotConfig: + """Tests for BotConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = BotConfig() + assert config.command_prefix == "/" + assert config.mention_required is True + assert config.typing_indicator is True + assert config.max_message_length == 4096 + + def test_custom_config(self): + """Test custom configuration values.""" + config = BotConfig( + token="test-token", + command_prefix="!", + mention_required=False, + ) + assert config.token == "test-token" + assert config.command_prefix == "!" + assert config.mention_required is False + + def test_webhook_mode(self): + """Test webhook mode detection.""" + config = BotConfig() + assert config.is_webhook_mode is False + + config = BotConfig(webhook_url="https://example.com/webhook") + assert config.is_webhook_mode is True + + def test_user_allowed(self): + """Test user allowlist.""" + config = BotConfig() + assert config.is_user_allowed("any-user") is True + + config = BotConfig(allowed_users=["user-1", "user-2"]) + assert config.is_user_allowed("user-1") is True + assert config.is_user_allowed("user-3") is False + + def test_channel_allowed(self): + """Test channel allowlist.""" + config = BotConfig() + assert config.is_channel_allowed("any-channel") is True + + config = BotConfig(allowed_channels=["channel-1"]) + assert config.is_channel_allowed("channel-1") is True + assert config.is_channel_allowed("channel-2") is False + + def test_to_dict_hides_token(self): + """Test that to_dict hides sensitive data.""" + config = BotConfig(token="secret-token") + data = config.to_dict() + assert data["token"] == "***" + + +class TestBotUser: + """Tests for BotUser.""" + + def test_user_creation(self): + """Test user creation.""" + user = BotUser( + user_id="123", + username="testuser", + display_name="Test User", + ) + assert user.user_id == "123" + assert user.username == "testuser" + assert user.display_name == "Test User" + assert user.is_bot is False + + def test_bot_user(self): + """Test bot user flag.""" + user = BotUser(user_id="bot-1", is_bot=True) + assert user.is_bot is True + + def test_to_dict(self): + """Test user serialization.""" + user = BotUser(user_id="123", username="test") + data = user.to_dict() + assert data["user_id"] == "123" + assert data["username"] == "test" + + def test_from_dict(self): + """Test user deserialization.""" + data = {"user_id": "456", "username": "user", "is_bot": True} + user = BotUser.from_dict(data) + assert user.user_id == "456" + assert user.username == "user" + assert user.is_bot is True + + +class TestBotChannel: + """Tests for BotChannel.""" + + def test_channel_creation(self): + """Test channel creation.""" + channel = BotChannel( + channel_id="ch-1", + name="general", + channel_type="channel", + ) + assert channel.channel_id == "ch-1" + assert channel.name == "general" + assert channel.channel_type == "channel" + + def test_dm_channel(self): + """Test DM channel type.""" + channel = BotChannel(channel_id="dm-1", channel_type="dm") + assert channel.channel_type == "dm" + + def test_to_dict(self): + """Test channel serialization.""" + channel = BotChannel(channel_id="123", name="test") + data = channel.to_dict() + assert data["channel_id"] == "123" + assert data["name"] == "test" + + def test_from_dict(self): + """Test channel deserialization.""" + data = {"channel_id": "789", "name": "chat", "channel_type": "group"} + channel = BotChannel.from_dict(data) + assert channel.channel_id == "789" + assert channel.name == "chat" + assert channel.channel_type == "group" + + +class TestBotMessage: + """Tests for BotMessage.""" + + def test_message_creation(self): + """Test message creation.""" + msg = BotMessage( + content="Hello!", + sender=BotUser(user_id="user-1"), + channel=BotChannel(channel_id="ch-1"), + ) + assert msg.content == "Hello!" + assert msg.message_id is not None + assert msg.message_type == MessageType.TEXT + + def test_text_property(self): + """Test text property.""" + msg = BotMessage(content="Hello") + assert msg.text == "Hello" + + msg = BotMessage(content={"text": "World"}) + assert msg.text == "World" + + def test_command_detection(self): + """Test command detection.""" + msg = BotMessage(content="/help") + assert msg.is_command is True + assert msg.command == "help" + + msg = BotMessage(content="Hello") + assert msg.is_command is False + assert msg.command is None + + def test_command_args(self): + """Test command arguments extraction.""" + msg = BotMessage(content="/search python tutorial") + assert msg.command == "search" + assert msg.command_args == ["python", "tutorial"] + + msg = BotMessage(content="/help") + assert msg.command_args == [] + + def test_to_dict(self): + """Test message serialization.""" + msg = BotMessage( + content="Test", + sender=BotUser(user_id="user-1"), + channel=BotChannel(channel_id="ch-1"), + ) + data = msg.to_dict() + assert data["content"] == "Test" + assert data["sender"]["user_id"] == "user-1" + assert data["channel"]["channel_id"] == "ch-1" + + def test_from_dict(self): + """Test message deserialization.""" + data = { + "content": "Hello", + "message_type": "text", + "sender": {"user_id": "u1"}, + "channel": {"channel_id": "c1"}, + } + msg = BotMessage.from_dict(data) + assert msg.content == "Hello" + assert msg.sender.user_id == "u1" + assert msg.channel.channel_id == "c1" + + +class TestMessageType: + """Tests for MessageType enum.""" + + def test_message_types(self): + """Test message type values.""" + assert MessageType.TEXT.value == "text" + assert MessageType.IMAGE.value == "image" + assert MessageType.COMMAND.value == "command" + assert MessageType.REPLY.value == "reply" + assert MessageType.EDIT.value == "edit" diff --git a/src/praisonai-agents/tests/unit/test_failover.py b/src/praisonai-agents/tests/unit/test_failover.py new file mode 100644 index 000000000..e35c53423 --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_failover.py @@ -0,0 +1,284 @@ +""" +Unit tests for Failover components. +""" + +from praisonaiagents.llm.failover import ( + AuthProfile, + ProviderStatus, + FailoverConfig, + FailoverManager, +) + + +class TestAuthProfile: + """Tests for AuthProfile.""" + + def test_profile_creation(self): + """Test profile creation.""" + profile = AuthProfile( + name="openai-primary", + provider="openai", + api_key="sk-test123", + ) + assert profile.name == "openai-primary" + assert profile.provider == "openai" + assert profile.status == ProviderStatus.AVAILABLE + + def test_profile_is_available(self): + """Test availability check.""" + profile = AuthProfile(name="test", provider="openai", api_key="key") + assert profile.is_available is True + + profile.status = ProviderStatus.DISABLED + assert profile.is_available is False + + def test_mark_rate_limited(self): + """Test marking profile as rate limited.""" + profile = AuthProfile(name="test", provider="openai", api_key="key") + profile.mark_rate_limited(cooldown_seconds=60) + + assert profile.status == ProviderStatus.RATE_LIMITED + assert profile.is_available is False + assert profile.cooldown_until is not None + + def test_mark_error(self): + """Test marking profile with error.""" + profile = AuthProfile(name="test", provider="openai", api_key="key") + profile.mark_error("Connection failed", cooldown_seconds=30) + + assert profile.status == ProviderStatus.ERROR + assert profile.last_error == "Connection failed" + assert profile.is_available is False + + def test_reset(self): + """Test resetting profile.""" + profile = AuthProfile(name="test", provider="openai", api_key="key") + profile.mark_error("Error") + profile.reset() + + assert profile.status == ProviderStatus.AVAILABLE + assert profile.cooldown_until is None + assert profile.last_error is None + + def test_to_dict_hides_key(self): + """Test that to_dict hides API key.""" + profile = AuthProfile( + name="test", + provider="openai", + api_key="sk-verysecretkey123", + ) + data = profile.to_dict() + assert data["api_key"].startswith("***") + assert len(data["api_key"]) == 7 + + def test_from_dict(self): + """Test creating profile from dict.""" + data = { + "name": "test", + "provider": "anthropic", + "api_key": "key", + "model": "claude-3", + "priority": 1, + } + profile = AuthProfile.from_dict(data) + assert profile.name == "test" + assert profile.provider == "anthropic" + assert profile.model == "claude-3" + assert profile.priority == 1 + + +class TestProviderStatus: + """Tests for ProviderStatus enum.""" + + def test_status_values(self): + """Test status values.""" + assert ProviderStatus.AVAILABLE.value == "available" + assert ProviderStatus.RATE_LIMITED.value == "rate_limited" + assert ProviderStatus.ERROR.value == "error" + assert ProviderStatus.DISABLED.value == "disabled" + + +class TestFailoverConfig: + """Tests for FailoverConfig.""" + + def test_default_config(self): + """Test default configuration.""" + config = FailoverConfig() + assert config.max_retries == 3 + assert config.retry_delay == 1.0 + assert config.exponential_backoff is True + + def test_custom_config(self): + """Test custom configuration.""" + config = FailoverConfig( + max_retries=5, + retry_delay=2.0, + cooldown_on_rate_limit=120, + ) + assert config.max_retries == 5 + assert config.retry_delay == 2.0 + assert config.cooldown_on_rate_limit == 120 + + def test_to_dict(self): + """Test config serialization.""" + config = FailoverConfig(max_retries=5) + data = config.to_dict() + assert data["max_retries"] == 5 + + +class TestFailoverManager: + """Tests for FailoverManager.""" + + def test_add_profile(self): + """Test adding profiles.""" + manager = FailoverManager() + profile = AuthProfile(name="test", provider="openai", api_key="key") + manager.add_profile(profile) + + assert len(manager.list_profiles()) == 1 + + def test_remove_profile(self): + """Test removing profiles.""" + manager = FailoverManager() + profile = AuthProfile(name="test", provider="openai", api_key="key") + manager.add_profile(profile) + + assert manager.remove_profile("test") is True + assert manager.remove_profile("nonexistent") is False + assert len(manager.list_profiles()) == 0 + + def test_get_profile(self): + """Test getting profile by name.""" + manager = FailoverManager() + profile = AuthProfile(name="test", provider="openai", api_key="key") + manager.add_profile(profile) + + found = manager.get_profile("test") + assert found is not None + assert found.name == "test" + + assert manager.get_profile("nonexistent") is None + + def test_get_next_profile(self): + """Test getting next available profile.""" + manager = FailoverManager() + + assert manager.get_next_profile() is None + + profile1 = AuthProfile(name="p1", provider="openai", api_key="k1", priority=0) + profile2 = AuthProfile(name="p2", provider="openai", api_key="k2", priority=1) + manager.add_profile(profile2) + manager.add_profile(profile1) + + next_profile = manager.get_next_profile() + assert next_profile.name == "p1" + + def test_priority_ordering(self): + """Test profiles are ordered by priority.""" + manager = FailoverManager() + + manager.add_profile(AuthProfile(name="low", provider="openai", api_key="k", priority=10)) + manager.add_profile(AuthProfile(name="high", provider="openai", api_key="k", priority=0)) + manager.add_profile(AuthProfile(name="mid", provider="openai", api_key="k", priority=5)) + + profiles = manager.list_profiles() + assert profiles[0].name == "high" + assert profiles[1].name == "mid" + assert profiles[2].name == "low" + + def test_failover_on_rate_limit(self): + """Test failover when rate limited.""" + manager = FailoverManager() + + p1 = AuthProfile(name="p1", provider="openai", api_key="k1", priority=0) + p2 = AuthProfile(name="p2", provider="openai", api_key="k2", priority=1) + manager.add_profile(p1) + manager.add_profile(p2) + + manager.mark_failure(p1, "Rate limit", is_rate_limit=True) + + next_profile = manager.get_next_profile() + assert next_profile.name == "p2" + + def test_mark_success(self): + """Test marking profile as successful.""" + manager = FailoverManager() + profile = AuthProfile(name="test", provider="openai", api_key="key") + manager.add_profile(profile) + + profile.mark_error("Error") + assert profile.status == ProviderStatus.ERROR + + manager.mark_success(profile) + assert profile.status == ProviderStatus.AVAILABLE + + def test_get_retry_delay(self): + """Test retry delay calculation.""" + config = FailoverConfig(retry_delay=1.0, exponential_backoff=True) + manager = FailoverManager(config=config) + + assert manager.get_retry_delay(0) == 1.0 + assert manager.get_retry_delay(1) == 2.0 + assert manager.get_retry_delay(2) == 4.0 + + def test_get_retry_delay_no_backoff(self): + """Test retry delay without exponential backoff.""" + config = FailoverConfig(retry_delay=2.0, exponential_backoff=False) + manager = FailoverManager(config=config) + + assert manager.get_retry_delay(0) == 2.0 + assert manager.get_retry_delay(1) == 2.0 + assert manager.get_retry_delay(5) == 2.0 + + def test_max_retry_delay(self): + """Test max retry delay cap.""" + config = FailoverConfig(retry_delay=10.0, max_retry_delay=30.0) + manager = FailoverManager(config=config) + + assert manager.get_retry_delay(5) == 30.0 + + def test_status(self): + """Test status report.""" + manager = FailoverManager() + manager.add_profile(AuthProfile(name="p1", provider="openai", api_key="k1")) + manager.add_profile(AuthProfile(name="p2", provider="openai", api_key="k2")) + + status = manager.status() + assert status["total_profiles"] == 2 + assert status["available_profiles"] == 2 + + def test_reset_all(self): + """Test resetting all profiles.""" + manager = FailoverManager() + p1 = AuthProfile(name="p1", provider="openai", api_key="k1") + p2 = AuthProfile(name="p2", provider="openai", api_key="k2") + manager.add_profile(p1) + manager.add_profile(p2) + + p1.mark_error("Error") + p2.mark_rate_limited() + + manager.reset_all() + + assert p1.status == ProviderStatus.AVAILABLE + assert p2.status == ProviderStatus.AVAILABLE + + def test_on_failover_callback(self): + """Test failover callback.""" + manager = FailoverManager() + + p1 = AuthProfile(name="p1", provider="openai", api_key="k1", priority=0) + p2 = AuthProfile(name="p2", provider="openai", api_key="k2", priority=1) + manager.add_profile(p1) + manager.add_profile(p2) + + callback_called = [] + + def on_failover(failed, new): + callback_called.append((failed.name, new.name)) + + manager.on_failover(on_failover) + manager.mark_failure(p1, "Error") + + assert len(callback_called) == 1 + assert callback_called[0] == ("p1", "p2") diff --git a/src/praisonai-agents/tests/unit/test_gateway.py b/src/praisonai-agents/tests/unit/test_gateway.py new file mode 100644 index 000000000..8ce28c50f --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_gateway.py @@ -0,0 +1,191 @@ +""" +Unit tests for Gateway protocols and components. +""" + +from praisonaiagents.gateway import ( + GatewayConfig, + SessionConfig, + GatewayEvent, + GatewayMessage, + EventType, +) + + +class TestGatewayConfig: + """Tests for GatewayConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = GatewayConfig() + assert config.host == "127.0.0.1" + assert config.port == 8765 + assert config.max_connections == 1000 + assert config.heartbeat_interval == 30 + + def test_custom_config(self): + """Test custom configuration values.""" + config = GatewayConfig( + host="0.0.0.0", + port=9000, + auth_token="secret", + ) + assert config.host == "0.0.0.0" + assert config.port == 9000 + assert config.auth_token == "secret" + + def test_ws_url(self): + """Test WebSocket URL generation.""" + config = GatewayConfig(host="localhost", port=8080) + assert config.ws_url == "ws://localhost:8080" + + def test_secure_ws_url(self): + """Test secure WebSocket URL generation.""" + config = GatewayConfig( + host="localhost", + port=443, + ssl_cert="/path/to/cert", + ssl_key="/path/to/key", + ) + assert config.is_secure + assert config.ws_url == "wss://localhost:443" + + def test_to_dict_hides_token(self): + """Test that to_dict hides sensitive data.""" + config = GatewayConfig(auth_token="my-secret-token") + data = config.to_dict() + assert data["auth_token"] == "***" + + +class TestSessionConfig: + """Tests for SessionConfig.""" + + def test_default_session_config(self): + """Test default session configuration.""" + config = SessionConfig() + assert config.timeout == 3600 + assert config.max_messages == 1000 + assert config.persist is False + + def test_custom_session_config(self): + """Test custom session configuration.""" + config = SessionConfig( + timeout=7200, + max_messages=500, + persist=True, + persist_path="/tmp/sessions", + ) + assert config.timeout == 7200 + assert config.max_messages == 500 + assert config.persist is True + + +class TestGatewayEvent: + """Tests for GatewayEvent.""" + + def test_event_creation(self): + """Test event creation.""" + event = GatewayEvent( + type=EventType.MESSAGE, + data={"content": "Hello"}, + source="client-1", + ) + assert event.type == EventType.MESSAGE + assert event.data["content"] == "Hello" + assert event.source == "client-1" + assert event.event_id is not None + assert event.timestamp > 0 + + def test_event_to_dict(self): + """Test event serialization.""" + event = GatewayEvent( + type=EventType.CONNECT, + data={"client_id": "123"}, + ) + data = event.to_dict() + assert data["type"] == "connect" + assert data["data"]["client_id"] == "123" + + def test_event_from_dict(self): + """Test event deserialization.""" + data = { + "type": "message", + "data": {"text": "Hello"}, + "source": "user-1", + } + event = GatewayEvent.from_dict(data) + assert event.type == EventType.MESSAGE + assert event.data["text"] == "Hello" + assert event.source == "user-1" + + def test_custom_event_type(self): + """Test custom event type handling.""" + event = GatewayEvent(type="custom_event", data={}) + assert event.type == "custom_event" + + data = event.to_dict() + restored = GatewayEvent.from_dict(data) + assert restored.type == "custom_event" + + +class TestGatewayMessage: + """Tests for GatewayMessage.""" + + def test_message_creation(self): + """Test message creation.""" + msg = GatewayMessage( + content="Hello, World!", + sender_id="user-1", + session_id="session-1", + ) + assert msg.content == "Hello, World!" + assert msg.sender_id == "user-1" + assert msg.session_id == "session-1" + assert msg.message_id is not None + + def test_message_to_dict(self): + """Test message serialization.""" + msg = GatewayMessage( + content="Test", + sender_id="user", + session_id="sess", + ) + data = msg.to_dict() + assert data["content"] == "Test" + assert data["sender_id"] == "user" + assert data["session_id"] == "sess" + + def test_message_from_dict(self): + """Test message deserialization.""" + data = { + "content": "Hello", + "sender_id": "user-1", + "session_id": "session-1", + "reply_to": "msg-0", + } + msg = GatewayMessage.from_dict(data) + assert msg.content == "Hello" + assert msg.sender_id == "user-1" + assert msg.reply_to == "msg-0" + + def test_message_with_metadata(self): + """Test message with metadata.""" + msg = GatewayMessage( + content="Test", + sender_id="user", + session_id="sess", + metadata={"priority": "high"}, + ) + assert msg.metadata["priority"] == "high" + + +class TestEventType: + """Tests for EventType enum.""" + + def test_event_types(self): + """Test event type values.""" + assert EventType.CONNECT.value == "connect" + assert EventType.DISCONNECT.value == "disconnect" + assert EventType.MESSAGE.value == "message" + assert EventType.SESSION_START.value == "session_start" + assert EventType.AGENT_REGISTER.value == "agent_register" + assert EventType.HEALTH.value == "health" diff --git a/src/praisonai-agents/tests/unit/test_sandbox.py b/src/praisonai-agents/tests/unit/test_sandbox.py new file mode 100644 index 000000000..c8ef7368d --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_sandbox.py @@ -0,0 +1,180 @@ +""" +Unit tests for Sandbox protocols and components. +""" + +from praisonaiagents.sandbox import ( + SandboxConfig, + SandboxResult, + SandboxStatus, + ResourceLimits, + SecurityPolicy, +) + + +class TestResourceLimits: + """Tests for ResourceLimits.""" + + def test_default_limits(self): + """Test default resource limits.""" + limits = ResourceLimits() + assert limits.memory_mb == 512 + assert limits.cpu_percent == 100 + assert limits.timeout_seconds == 60 + assert limits.network_enabled is False + + def test_minimal_limits(self): + """Test minimal resource limits.""" + limits = ResourceLimits.minimal() + assert limits.memory_mb == 128 + assert limits.timeout_seconds == 30 + assert limits.network_enabled is False + + def test_generous_limits(self): + """Test generous resource limits.""" + limits = ResourceLimits.generous() + assert limits.memory_mb == 2048 + assert limits.timeout_seconds == 300 + assert limits.network_enabled is True + + def test_to_dict(self): + """Test limits serialization.""" + limits = ResourceLimits(memory_mb=256, timeout_seconds=30) + data = limits.to_dict() + assert data["memory_mb"] == 256 + assert data["timeout_seconds"] == 30 + + def test_from_dict(self): + """Test limits deserialization.""" + data = {"memory_mb": 1024, "cpu_percent": 50, "network_enabled": True} + limits = ResourceLimits.from_dict(data) + assert limits.memory_mb == 1024 + assert limits.cpu_percent == 50 + assert limits.network_enabled is True + + +class TestSecurityPolicy: + """Tests for SecurityPolicy.""" + + def test_default_policy(self): + """Test default security policy.""" + policy = SecurityPolicy() + assert policy.allow_network is False + assert policy.allow_file_write is True + assert policy.allow_subprocess is False + + def test_strict_policy(self): + """Test strict security policy.""" + policy = SecurityPolicy.strict() + assert policy.allow_network is False + assert policy.allow_file_write is False + assert policy.allow_subprocess is False + + def test_permissive_policy(self): + """Test permissive security policy.""" + policy = SecurityPolicy.permissive() + assert policy.allow_network is True + assert policy.allow_file_write is True + assert policy.allow_subprocess is True + + def test_blocked_paths(self): + """Test blocked paths in policy.""" + policy = SecurityPolicy() + assert "/etc/passwd" in policy.blocked_paths + assert "~/.ssh" in policy.blocked_paths + + +class TestSandboxResult: + """Tests for SandboxResult.""" + + def test_result_creation(self): + """Test result creation.""" + result = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout="Hello, World!", + ) + assert result.status == SandboxStatus.COMPLETED + assert result.exit_code == 0 + assert result.stdout == "Hello, World!" + + def test_success_property(self): + """Test success property.""" + result = SandboxResult(status=SandboxStatus.COMPLETED, exit_code=0) + assert result.success is True + + result = SandboxResult(status=SandboxStatus.COMPLETED, exit_code=1) + assert result.success is False + + result = SandboxResult(status=SandboxStatus.TIMEOUT) + assert result.success is False + + def test_output_property(self): + """Test combined output property.""" + result = SandboxResult(stdout="out", stderr="err") + output = result.output + assert "out" in output + assert "err" in output + + def test_to_dict(self): + """Test result serialization.""" + result = SandboxResult( + status=SandboxStatus.COMPLETED, + exit_code=0, + stdout="test", + ) + data = result.to_dict() + assert data["status"] == "completed" + assert data["exit_code"] == 0 + assert data["stdout"] == "test" + + def test_from_dict(self): + """Test result deserialization.""" + data = { + "status": "timeout", + "error": "Timed out", + "duration_seconds": 60.0, + } + result = SandboxResult.from_dict(data) + assert result.status == SandboxStatus.TIMEOUT + assert result.error == "Timed out" + + +class TestSandboxStatus: + """Tests for SandboxStatus enum.""" + + def test_status_values(self): + """Test status values.""" + assert SandboxStatus.PENDING.value == "pending" + assert SandboxStatus.RUNNING.value == "running" + assert SandboxStatus.COMPLETED.value == "completed" + assert SandboxStatus.FAILED.value == "failed" + assert SandboxStatus.TIMEOUT.value == "timeout" + assert SandboxStatus.KILLED.value == "killed" + + +class TestSandboxConfig: + """Tests for SandboxConfig.""" + + def test_default_config(self): + """Test default sandbox configuration.""" + config = SandboxConfig() + assert config.sandbox_type == "subprocess" + assert config.auto_cleanup is True + + def test_docker_config(self): + """Test Docker sandbox configuration.""" + config = SandboxConfig.docker("python:3.11") + assert config.sandbox_type == "docker" + assert config.image == "python:3.11" + + def test_subprocess_config(self): + """Test subprocess sandbox configuration.""" + config = SandboxConfig.subprocess() + assert config.sandbox_type == "subprocess" + + def test_to_dict_hides_secrets(self): + """Test that to_dict hides sensitive env vars.""" + config = SandboxConfig(env={"API_KEY": "secret", "NAME": "test"}) + data = config.to_dict() + assert data["env"]["API_KEY"] == "***" + assert data["env"]["NAME"] == "test" diff --git a/src/praisonai-agents/uv.lock b/src/praisonai-agents/uv.lock index f674b1cab..f5e3eb2aa 100644 --- a/src/praisonai-agents/uv.lock +++ b/src/praisonai-agents/uv.lock @@ -2549,7 +2549,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "0.13.23" +version = "0.14.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, diff --git a/src/praisonai/praisonai.rb b/src/praisonai/praisonai.rb index 5fc1cea92..7566c885b 100644 --- a/src/praisonai/praisonai.rb +++ b/src/praisonai/praisonai.rb @@ -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.10.27.tar.gz" - sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.10.27.tar.gz | shasum -a 256`.split.first + url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.11.0.tar.gz" + sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.11.0.tar.gz | shasum -a 256`.split.first license "MIT" depends_on "python@3.11" diff --git a/src/praisonai/praisonai/_dev/api_md.py b/src/praisonai/praisonai/_dev/api_md.py index bdada7af8..325b59602 100644 --- a/src/praisonai/praisonai/_dev/api_md.py +++ b/src/praisonai/praisonai/_dev/api_md.py @@ -219,8 +219,18 @@ class ApiMdGenerator: dict_sources = self._parse_lazy_imports_dict(tree) import_sources.update(dict_sources) + # Also include symbols from SECTION_GROUPS that exist in lazy imports + # This ensures lazy-loaded classes like Workflow appear in api.md + # even if they're not in __all__ (which is kept minimal for IDE experience) + section_symbols = set() + for symbol_list in self.SECTION_GROUPS.values(): + section_symbols.update(symbol_list) + + # Merge: __all__ exports + section group symbols that exist in lazy imports + exports_to_process = all_exports | (section_symbols & set(import_sources.keys())) + # For each export, find its definition - for export_name in sorted(all_exports): + for export_name in sorted(exports_to_process): if export_name in import_sources: module_path, original_name = import_sources[export_name] # Resolve to file diff --git a/src/praisonai/praisonai/bots/__init__.py b/src/praisonai/praisonai/bots/__init__.py new file mode 100644 index 000000000..147666436 --- /dev/null +++ b/src/praisonai/praisonai/bots/__init__.py @@ -0,0 +1,27 @@ +""" +Bot implementations for PraisonAI. + +Provides messaging bot runtimes for Telegram, Discord, and Slack. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .telegram import TelegramBot + from .discord import DiscordBot + from .slack import SlackBot + +def __getattr__(name: str): + """Lazy loading of bot components.""" + if name == "TelegramBot": + from .telegram import TelegramBot + return TelegramBot + if name == "DiscordBot": + from .discord import DiscordBot + return DiscordBot + if name == "SlackBot": + from .slack import SlackBot + return SlackBot + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = ["TelegramBot", "DiscordBot", "SlackBot"] diff --git a/src/praisonai/praisonai/bots/discord.py b/src/praisonai/praisonai/bots/discord.py new file mode 100644 index 000000000..33eb79247 --- /dev/null +++ b/src/praisonai/praisonai/bots/discord.py @@ -0,0 +1,378 @@ +""" +Discord Bot implementation for PraisonAI. + +Provides a full Discord bot runtime with slash commands, +message handling, and agent integration. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from praisonaiagents import Agent + +from praisonaiagents.bots import ( + BotConfig, + BotMessage, + BotUser, + BotChannel, + MessageType, +) + +logger = logging.getLogger(__name__) + + +class DiscordBot: + """Discord bot runtime for PraisonAI agents. + + Connects an agent to Discord, handling messages, slash commands, + and providing full bot functionality. + + Example: + from praisonai.bots import DiscordBot + from praisonaiagents import Agent + + agent = Agent(name="assistant") + bot = DiscordBot(token="YOUR_BOT_TOKEN", agent=agent) + + @bot.on_command("help") + async def help_command(message): + await bot.send_message(message.channel.channel_id, "Help text...") + + await bot.start() + + Requires: pip install discord.py + """ + + def __init__( + self, + token: str, + agent: Optional["Agent"] = None, + config: Optional[BotConfig] = None, + ): + """Initialize the Discord bot. + + Args: + token: Discord bot token + agent: Optional agent to handle messages + config: Optional bot configuration + """ + self._token = token + self._agent = agent + self.config = config or BotConfig(token=token) + + self._is_running = False + self._bot_user: Optional[BotUser] = None + self._client = None + + self._message_handlers: List[Callable] = [] + self._command_handlers: Dict[str, Callable] = {} + + @property + def is_running(self) -> bool: + return self._is_running + + @property + def platform(self) -> str: + return "discord" + + @property + def bot_user(self) -> Optional[BotUser]: + return self._bot_user + + async def start(self) -> None: + """Start the Discord bot.""" + if self._is_running: + logger.warning("Bot already running") + return + + try: + import discord + from discord.ext import commands + except ImportError: + raise ImportError( + "DiscordBot requires discord.py. " + "Install with: pip install discord.py" + ) + + intents = discord.Intents.default() + intents.message_content = True + intents.guilds = True + + self._client = commands.Bot( + command_prefix=self.config.command_prefix, + intents=intents, + ) + + @self._client.event + async def on_ready(): + self._bot_user = BotUser( + user_id=str(self._client.user.id), + username=self._client.user.name, + display_name=self._client.user.display_name, + is_bot=True, + ) + self._is_running = True + logger.info(f"Discord bot started: {self._client.user.name}") + + @self._client.event + async def on_message(message): + if message.author.bot: + return + + bot_message = self._convert_message(message) + + if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""): + return + if not self.config.is_channel_allowed(bot_message.channel.channel_id if bot_message.channel else ""): + return + + if bot_message.is_command: + command = bot_message.command + if command and command in self._command_handlers: + handler = self._command_handlers[command] + try: + if asyncio.iscoroutinefunction(handler): + await handler(bot_message) + else: + handler(bot_message) + except Exception as e: + logger.error(f"Command handler error: {e}") + return + + should_respond = False + if message.guild is None: + should_respond = True + elif self._client.user.mentioned_in(message) or not self.config.mention_required: + should_respond = True + + if should_respond: + for handler in self._message_handlers: + try: + if asyncio.iscoroutinefunction(handler): + await handler(bot_message) + else: + handler(bot_message) + except Exception as e: + logger.error(f"Message handler error: {e}") + + if self._agent: + if self.config.typing_indicator: + async with message.channel.typing(): + try: + response = self._agent.chat(bot_message.text) + await self._send_long_message(message.channel, response, reference=message) + except Exception as e: + logger.error(f"Agent error: {e}") + await message.reply(f"Error: {str(e)}") + else: + try: + response = self._agent.chat(bot_message.text) + await self._send_long_message(message.channel, response, reference=message) + except Exception as e: + logger.error(f"Agent error: {e}") + await message.reply(f"Error: {str(e)}") + + await self._client.start(self._token) + + async def stop(self) -> None: + """Stop the Discord bot.""" + if not self._is_running: + return + + self._is_running = False + + if self._client: + await self._client.close() + + logger.info("Discord bot stopped") + + def set_agent(self, agent: "Agent") -> None: + """Set the agent that handles messages.""" + self._agent = agent + + def get_agent(self) -> Optional["Agent"]: + """Get the current agent.""" + return self._agent + + async def send_message( + self, + channel_id: str, + content: Union[str, Dict[str, Any]], + reply_to: Optional[str] = None, + thread_id: Optional[str] = None, + ) -> BotMessage: + """Send a message to a channel.""" + if not self._client: + raise RuntimeError("Bot not started") + + text = content if isinstance(content, str) else str(content) + channel = self._client.get_channel(int(channel_id)) + + if not channel: + raise ValueError(f"Channel not found: {channel_id}") + + if thread_id: + thread = channel.get_thread(int(thread_id)) + if thread: + channel = thread + + sent = await channel.send(text) + + return BotMessage( + message_id=str(sent.id), + content=text, + message_type=MessageType.TEXT, + channel=BotChannel(channel_id=channel_id), + ) + + async def _send_long_message(self, channel, text: str, reference=None) -> None: + """Send a long message, splitting if necessary.""" + max_len = min(self.config.max_message_length, 2000) + + if len(text) <= max_len: + if reference: + await channel.send(text, reference=reference) + else: + await channel.send(text) + else: + chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)] + for i, chunk in enumerate(chunks): + if i == 0 and reference: + await channel.send(chunk, reference=reference) + else: + await channel.send(chunk) + + async def edit_message( + self, + channel_id: str, + message_id: str, + content: Union[str, Dict[str, Any]], + ) -> BotMessage: + """Edit an existing message.""" + if not self._client: + raise RuntimeError("Bot not started") + + text = content if isinstance(content, str) else str(content) + channel = self._client.get_channel(int(channel_id)) + + if not channel: + raise ValueError(f"Channel not found: {channel_id}") + + message = await channel.fetch_message(int(message_id)) + await message.edit(content=text) + + return BotMessage( + message_id=message_id, + content=text, + message_type=MessageType.EDIT, + channel=BotChannel(channel_id=channel_id), + ) + + async def delete_message( + self, + channel_id: str, + message_id: str, + ) -> bool: + """Delete a message.""" + if not self._client: + raise RuntimeError("Bot not started") + + try: + channel = self._client.get_channel(int(channel_id)) + if channel: + message = await channel.fetch_message(int(message_id)) + await message.delete() + return True + except Exception as e: + logger.error(f"Delete message error: {e}") + return False + + def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable: + """Register a message handler.""" + self._message_handlers.append(handler) + return handler + + def on_command(self, command: str) -> Callable: + """Decorator to register a command handler.""" + def decorator(func: Callable) -> Callable: + self._command_handlers[command] = func + return func + return decorator + + async def send_typing(self, channel_id: str) -> None: + """Send typing indicator.""" + if self._client: + channel = self._client.get_channel(int(channel_id)) + if channel: + await channel.trigger_typing() + + async def get_user(self, user_id: str) -> Optional[BotUser]: + """Get user information.""" + if not self._client: + return None + + try: + user = await self._client.fetch_user(int(user_id)) + return BotUser( + user_id=str(user.id), + username=user.name, + display_name=user.display_name, + is_bot=user.bot, + ) + except Exception: + return None + + async def get_channel(self, channel_id: str) -> Optional[BotChannel]: + """Get channel information.""" + if not self._client: + return None + + try: + channel = self._client.get_channel(int(channel_id)) + if channel: + channel_type = "dm" if hasattr(channel, "recipient") else "channel" + return BotChannel( + channel_id=str(channel.id), + name=getattr(channel, "name", None), + channel_type=channel_type, + ) + except Exception: + pass + return None + + def _convert_message(self, message) -> BotMessage: + """Convert Discord Message to BotMessage.""" + sender = BotUser( + user_id=str(message.author.id), + username=message.author.name, + display_name=message.author.display_name, + is_bot=message.author.bot, + ) + + channel_type = "dm" if message.guild is None else "channel" + channel = BotChannel( + channel_id=str(message.channel.id), + name=getattr(message.channel, "name", None), + channel_type=channel_type, + ) + + content = message.content + if self._client and self._client.user: + content = content.replace(f"<@{self._client.user.id}>", "").strip() + content = content.replace(f"<@!{self._client.user.id}>", "").strip() + + msg_type = MessageType.COMMAND if content.startswith(self.config.command_prefix) else MessageType.TEXT + + return BotMessage( + message_id=str(message.id), + content=content, + message_type=msg_type, + sender=sender, + channel=channel, + timestamp=message.created_at.timestamp() if message.created_at else 0, + thread_id=str(message.thread.id) if hasattr(message, "thread") and message.thread else None, + ) diff --git a/src/praisonai/praisonai/bots/slack.py b/src/praisonai/praisonai/bots/slack.py new file mode 100644 index 000000000..7d9990665 --- /dev/null +++ b/src/praisonai/praisonai/bots/slack.py @@ -0,0 +1,391 @@ +""" +Slack Bot implementation for PraisonAI. + +Provides a full Slack bot runtime with slash commands, +event handling, and agent integration. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from praisonaiagents import Agent + +from praisonaiagents.bots import ( + BotConfig, + BotMessage, + BotUser, + BotChannel, + MessageType, +) + +logger = logging.getLogger(__name__) + + +class SlackBot: + """Slack bot runtime for PraisonAI agents. + + Connects an agent to Slack, handling messages, slash commands, + and providing full bot functionality. + + Example: + from praisonai.bots import SlackBot + from praisonaiagents import Agent + + agent = Agent(name="assistant") + bot = SlackBot( + token="xoxb-YOUR-BOT-TOKEN", + app_token="xapp-YOUR-APP-TOKEN", + agent=agent, + ) + + @bot.on_command("help") + async def help_command(message): + await bot.send_message(message.channel.channel_id, "Help text...") + + await bot.start() + + Requires: pip install slack-bolt + """ + + def __init__( + self, + token: str, + app_token: Optional[str] = None, + signing_secret: Optional[str] = None, + agent: Optional["Agent"] = None, + config: Optional[BotConfig] = None, + ): + """Initialize the Slack bot. + + Args: + token: Slack bot token (xoxb-...) + app_token: Slack app token for Socket Mode (xapp-...) + signing_secret: Signing secret for webhook verification + agent: Optional agent to handle messages + config: Optional bot configuration + """ + self._token = token + self._app_token = app_token + self._signing_secret = signing_secret + self._agent = agent + self.config = config or BotConfig(token=token) + + self._is_running = False + self._bot_user: Optional[BotUser] = None + self._app = None + self._client = None + + self._message_handlers: List[Callable] = [] + self._command_handlers: Dict[str, Callable] = {} + + @property + def is_running(self) -> bool: + return self._is_running + + @property + def platform(self) -> str: + return "slack" + + @property + def bot_user(self) -> Optional[BotUser]: + return self._bot_user + + async def start(self) -> None: + """Start the Slack bot.""" + if self._is_running: + logger.warning("Bot already running") + return + + try: + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from slack_sdk.web.async_client import AsyncWebClient + except ImportError: + raise ImportError( + "SlackBot requires slack-bolt. " + "Install with: pip install slack-bolt" + ) + + self._app = AsyncApp( + token=self._token, + signing_secret=self._signing_secret, + ) + self._client = AsyncWebClient(token=self._token) + + auth_response = await self._client.auth_test() + self._bot_user = BotUser( + user_id=auth_response["user_id"], + username=auth_response["user"], + display_name=auth_response.get("bot_id", auth_response["user"]), + is_bot=True, + ) + + @self._app.event("message") + async def handle_message(event, say): + if event.get("bot_id"): + return + + bot_message = self._convert_event_to_message(event) + + if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""): + return + if not self.config.is_channel_allowed(bot_message.channel.channel_id if bot_message.channel else ""): + return + + for handler in self._message_handlers: + try: + if asyncio.iscoroutinefunction(handler): + await handler(bot_message) + else: + handler(bot_message) + except Exception as e: + logger.error(f"Message handler error: {e}") + + should_respond = False + text = event.get("text", "") + channel_type = event.get("channel_type", "") + + if channel_type == "im": + should_respond = True + elif self._bot_user and f"<@{self._bot_user.user_id}>" in text: + should_respond = True + text = text.replace(f"<@{self._bot_user.user_id}>", "").strip() + elif not self.config.mention_required: + should_respond = True + + if should_respond and self._agent: + try: + response = self._agent.chat(text) + await self._send_long_message(say, response, thread_ts=event.get("thread_ts") or event.get("ts")) + except Exception as e: + logger.error(f"Agent error: {e}") + await say(f"Error: {str(e)}") + + @self._app.event("app_mention") + async def handle_mention(event, say): + if event.get("bot_id"): + return + + text = event.get("text", "") + if self._bot_user: + text = text.replace(f"<@{self._bot_user.user_id}>", "").strip() + + if self._agent: + try: + response = self._agent.chat(text) + await self._send_long_message(say, response, thread_ts=event.get("thread_ts") or event.get("ts")) + except Exception as e: + logger.error(f"Agent error: {e}") + await say(f"Error: {str(e)}") + + for command, handler in self._command_handlers.items(): + @self._app.command(f"/{command}") + async def handle_command(ack, command_data, respond, cmd=command, hdlr=handler): + await ack() + bot_message = BotMessage( + message_id=command_data.get("trigger_id", ""), + content=command_data.get("text", ""), + message_type=MessageType.COMMAND, + sender=BotUser( + user_id=command_data.get("user_id", ""), + username=command_data.get("user_name", ""), + ), + channel=BotChannel( + channel_id=command_data.get("channel_id", ""), + name=command_data.get("channel_name", ""), + ), + ) + try: + if asyncio.iscoroutinefunction(hdlr): + await hdlr(bot_message) + else: + hdlr(bot_message) + except Exception as e: + logger.error(f"Command handler error: {e}") + await respond(f"Error: {str(e)}") + + self._is_running = True + logger.info(f"Slack bot started: {self._bot_user.username}") + + if self._app_token: + handler = AsyncSocketModeHandler(self._app, self._app_token) + await handler.start_async() + else: + logger.warning("No app_token provided, bot will not receive events via Socket Mode") + + async def stop(self) -> None: + """Stop the Slack bot.""" + if not self._is_running: + return + + self._is_running = False + logger.info("Slack bot stopped") + + def set_agent(self, agent: "Agent") -> None: + """Set the agent that handles messages.""" + self._agent = agent + + def get_agent(self) -> Optional["Agent"]: + """Get the current agent.""" + return self._agent + + async def send_message( + self, + channel_id: str, + content: Union[str, Dict[str, Any]], + reply_to: Optional[str] = None, + thread_id: Optional[str] = None, + ) -> BotMessage: + """Send a message to a channel.""" + if not self._client: + raise RuntimeError("Bot not started") + + text = content if isinstance(content, str) else str(content) + + kwargs = {"channel": channel_id, "text": text} + if thread_id: + kwargs["thread_ts"] = thread_id + + response = await self._client.chat_postMessage(**kwargs) + + return BotMessage( + message_id=response["ts"], + content=text, + message_type=MessageType.TEXT, + channel=BotChannel(channel_id=channel_id), + ) + + async def _send_long_message(self, say, text: str, thread_ts: Optional[str] = None) -> None: + """Send a long message, splitting if necessary.""" + max_len = min(self.config.max_message_length, 4000) + + if len(text) <= max_len: + await say(text=text, thread_ts=thread_ts) + else: + chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)] + for chunk in chunks: + await say(text=chunk, thread_ts=thread_ts) + + async def edit_message( + self, + channel_id: str, + message_id: str, + content: Union[str, Dict[str, Any]], + ) -> BotMessage: + """Edit an existing message.""" + if not self._client: + raise RuntimeError("Bot not started") + + text = content if isinstance(content, str) else str(content) + + await self._client.chat_update( + channel=channel_id, + ts=message_id, + text=text, + ) + + return BotMessage( + message_id=message_id, + content=text, + message_type=MessageType.EDIT, + channel=BotChannel(channel_id=channel_id), + ) + + async def delete_message( + self, + channel_id: str, + message_id: str, + ) -> bool: + """Delete a message.""" + if not self._client: + raise RuntimeError("Bot not started") + + try: + await self._client.chat_delete( + channel=channel_id, + ts=message_id, + ) + return True + except Exception as e: + logger.error(f"Delete message error: {e}") + return False + + def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable: + """Register a message handler.""" + self._message_handlers.append(handler) + return handler + + def on_command(self, command: str) -> Callable: + """Decorator to register a command handler.""" + def decorator(func: Callable) -> Callable: + self._command_handlers[command] = func + return func + return decorator + + async def send_typing(self, channel_id: str) -> None: + """Send typing indicator (not supported in Slack API).""" + pass + + async def get_user(self, user_id: str) -> Optional[BotUser]: + """Get user information.""" + if not self._client: + return None + + try: + response = await self._client.users_info(user=user_id) + user = response["user"] + return BotUser( + user_id=user["id"], + username=user.get("name"), + display_name=user.get("real_name") or user.get("name"), + is_bot=user.get("is_bot", False), + ) + except Exception: + return None + + async def get_channel(self, channel_id: str) -> Optional[BotChannel]: + """Get channel information.""" + if not self._client: + return None + + try: + response = await self._client.conversations_info(channel=channel_id) + channel = response["channel"] + channel_type = "dm" if channel.get("is_im") else "channel" + return BotChannel( + channel_id=channel["id"], + name=channel.get("name"), + channel_type=channel_type, + ) + except Exception: + return None + + def _convert_event_to_message(self, event: Dict[str, Any]) -> BotMessage: + """Convert Slack event to BotMessage.""" + sender = BotUser( + user_id=event.get("user", ""), + username=event.get("user", ""), + ) + + channel_type = "dm" if event.get("channel_type") == "im" else "channel" + channel = BotChannel( + channel_id=event.get("channel", ""), + channel_type=channel_type, + ) + + text = event.get("text", "") + msg_type = MessageType.TEXT + + return BotMessage( + message_id=event.get("ts", ""), + content=text, + message_type=msg_type, + sender=sender, + channel=channel, + timestamp=float(event.get("ts", "0").split(".")[0]) if event.get("ts") else 0, + thread_id=event.get("thread_ts"), + ) diff --git a/src/praisonai/praisonai/bots/telegram.py b/src/praisonai/praisonai/bots/telegram.py new file mode 100644 index 000000000..539fd23fc --- /dev/null +++ b/src/praisonai/praisonai/bots/telegram.py @@ -0,0 +1,386 @@ +""" +Telegram Bot implementation for PraisonAI. + +Provides a full Telegram bot runtime with webhook/polling support, +command handling, and agent integration. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from praisonaiagents import Agent + +from praisonaiagents.bots import ( + BotConfig, + BotMessage, + BotUser, + BotChannel, + MessageType, +) + +logger = logging.getLogger(__name__) + + +class TelegramBot: + """Telegram bot runtime for PraisonAI agents. + + Connects an agent to Telegram, handling messages, commands, + and providing full bot functionality. + + Example: + from praisonai.bots import TelegramBot + from praisonaiagents import Agent + + agent = Agent(name="assistant") + bot = TelegramBot(token="YOUR_BOT_TOKEN", agent=agent) + + @bot.on_command("help") + async def help_command(message): + await bot.send_message(message.channel.channel_id, "Help text...") + + await bot.start() + + Requires: pip install python-telegram-bot + """ + + def __init__( + self, + token: str, + agent: Optional["Agent"] = None, + config: Optional[BotConfig] = None, + ): + """Initialize the Telegram bot. + + Args: + token: Telegram bot token from @BotFather + agent: Optional agent to handle messages + config: Optional bot configuration + """ + self._token = token + self._agent = agent + self.config = config or BotConfig(token=token) + + self._is_running = False + self._bot_user: Optional[BotUser] = None + self._application = None + + self._message_handlers: List[Callable] = [] + self._command_handlers: Dict[str, Callable] = {} + + @property + def is_running(self) -> bool: + return self._is_running + + @property + def platform(self) -> str: + return "telegram" + + @property + def bot_user(self) -> Optional[BotUser]: + return self._bot_user + + async def start(self) -> None: + """Start the Telegram bot.""" + if self._is_running: + logger.warning("Bot already running") + return + + try: + from telegram import Update + from telegram.ext import ( + Application, + CommandHandler, + MessageHandler, + filters, + ContextTypes, + ) + except ImportError: + raise ImportError( + "TelegramBot requires python-telegram-bot. " + "Install with: pip install python-telegram-bot" + ) + + self._application = Application.builder().token(self._token).build() + + bot_info = await self._application.bot.get_me() + self._bot_user = BotUser( + user_id=str(bot_info.id), + username=bot_info.username, + display_name=bot_info.first_name, + is_bot=True, + ) + + async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not update.message or not update.message.text: + return + + message = self._convert_update_to_message(update) + + if not self.config.is_user_allowed(message.sender.user_id if message.sender else ""): + return + if not self.config.is_channel_allowed(message.channel.channel_id if message.channel else ""): + return + + for handler in self._message_handlers: + try: + if asyncio.iscoroutinefunction(handler): + await handler(message) + else: + handler(message) + except Exception as e: + logger.error(f"Message handler error: {e}") + + if self._agent and not message.is_command: + if self.config.typing_indicator: + await update.message.chat.send_action("typing") + + try: + response = self._agent.chat(message.text) + await self._send_long_message( + update.message.chat_id, + response, + reply_to=update.message.message_id, + ) + except Exception as e: + logger.error(f"Agent error: {e}") + await update.message.reply_text(f"Error: {str(e)}") + + async def handle_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not update.message or not update.message.text: + return + + message = self._convert_update_to_message(update) + command = message.command + + if command and command in self._command_handlers: + handler = self._command_handlers[command] + try: + if asyncio.iscoroutinefunction(handler): + await handler(message) + else: + handler(message) + except Exception as e: + logger.error(f"Command handler error: {e}") + + for command in self._command_handlers: + self._application.add_handler(CommandHandler(command, handle_command)) + + self._application.add_handler( + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message) + ) + + self._is_running = True + logger.info(f"Telegram bot started: @{self._bot_user.username}") + + if self.config.is_webhook_mode: + await self._application.run_webhook( + listen="0.0.0.0", + port=8443, + url_path=self.config.webhook_path, + webhook_url=f"{self.config.webhook_url}{self.config.webhook_path}", + ) + else: + await self._application.run_polling( + poll_interval=self.config.polling_interval, + ) + + async def stop(self) -> None: + """Stop the Telegram bot.""" + if not self._is_running: + return + + self._is_running = False + + if self._application: + await self._application.stop() + await self._application.shutdown() + + logger.info("Telegram bot stopped") + + def set_agent(self, agent: "Agent") -> None: + """Set the agent that handles messages.""" + self._agent = agent + + def get_agent(self) -> Optional["Agent"]: + """Get the current agent.""" + return self._agent + + async def send_message( + self, + channel_id: str, + content: Union[str, Dict[str, Any]], + reply_to: Optional[str] = None, + thread_id: Optional[str] = None, + ) -> BotMessage: + """Send a message to a channel.""" + if not self._application: + raise RuntimeError("Bot not started") + + text = content if isinstance(content, str) else str(content) + + kwargs = {"chat_id": int(channel_id), "text": text} + if reply_to: + kwargs["reply_to_message_id"] = int(reply_to) + if thread_id: + kwargs["message_thread_id"] = int(thread_id) + + sent = await self._application.bot.send_message(**kwargs) + + return BotMessage( + message_id=str(sent.message_id), + content=text, + message_type=MessageType.TEXT, + channel=BotChannel(channel_id=channel_id), + ) + + async def _send_long_message( + self, + chat_id: int, + text: str, + reply_to: Optional[int] = None, + ) -> None: + """Send a long message, splitting if necessary.""" + max_len = self.config.max_message_length + + if len(text) <= max_len: + kwargs = {"chat_id": chat_id, "text": text} + if reply_to: + kwargs["reply_to_message_id"] = reply_to + await self._application.bot.send_message(**kwargs) + else: + chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)] + for i, chunk in enumerate(chunks): + kwargs = {"chat_id": chat_id, "text": chunk} + if i == 0 and reply_to: + kwargs["reply_to_message_id"] = reply_to + await self._application.bot.send_message(**kwargs) + + async def edit_message( + self, + channel_id: str, + message_id: str, + content: Union[str, Dict[str, Any]], + ) -> BotMessage: + """Edit an existing message.""" + if not self._application: + raise RuntimeError("Bot not started") + + text = content if isinstance(content, str) else str(content) + + await self._application.bot.edit_message_text( + chat_id=int(channel_id), + message_id=int(message_id), + text=text, + ) + + return BotMessage( + message_id=message_id, + content=text, + message_type=MessageType.EDIT, + channel=BotChannel(channel_id=channel_id), + ) + + async def delete_message( + self, + channel_id: str, + message_id: str, + ) -> bool: + """Delete a message.""" + if not self._application: + raise RuntimeError("Bot not started") + + try: + await self._application.bot.delete_message( + chat_id=int(channel_id), + message_id=int(message_id), + ) + return True + except Exception as e: + logger.error(f"Delete message error: {e}") + return False + + def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable: + """Register a message handler.""" + self._message_handlers.append(handler) + return handler + + def on_command(self, command: str) -> Callable: + """Decorator to register a command handler.""" + def decorator(func: Callable) -> Callable: + self._command_handlers[command] = func + return func + return decorator + + async def send_typing(self, channel_id: str) -> None: + """Send typing indicator.""" + if self._application: + await self._application.bot.send_chat_action( + chat_id=int(channel_id), + action="typing", + ) + + async def get_user(self, user_id: str) -> Optional[BotUser]: + """Get user information.""" + if not self._application: + return None + + try: + chat = await self._application.bot.get_chat(int(user_id)) + return BotUser( + user_id=str(chat.id), + username=chat.username, + display_name=chat.first_name, + is_bot=False, + ) + except Exception: + return None + + async def get_channel(self, channel_id: str) -> Optional[BotChannel]: + """Get channel information.""" + if not self._application: + return None + + try: + chat = await self._application.bot.get_chat(int(channel_id)) + channel_type = "dm" if chat.type == "private" else chat.type + return BotChannel( + channel_id=str(chat.id), + name=chat.title or chat.username, + channel_type=channel_type, + ) + except Exception: + return None + + def _convert_update_to_message(self, update) -> BotMessage: + """Convert Telegram Update to BotMessage.""" + msg = update.message + + sender = BotUser( + user_id=str(msg.from_user.id), + username=msg.from_user.username, + display_name=msg.from_user.first_name, + is_bot=msg.from_user.is_bot, + ) if msg.from_user else None + + channel_type = "dm" if msg.chat.type == "private" else msg.chat.type + channel = BotChannel( + channel_id=str(msg.chat.id), + name=msg.chat.title or msg.chat.username, + channel_type=channel_type, + ) + + msg_type = MessageType.COMMAND if msg.text and msg.text.startswith("/") else MessageType.TEXT + + return BotMessage( + message_id=str(msg.message_id), + content=msg.text or "", + message_type=msg_type, + sender=sender, + channel=channel, + timestamp=msg.date.timestamp() if msg.date else 0, + thread_id=str(msg.message_thread_id) if msg.message_thread_id else None, + ) diff --git a/src/praisonai/praisonai/cli/app.py b/src/praisonai/praisonai/cli/app.py index aa459e3d4..cb5cd67b0 100644 --- a/src/praisonai/praisonai/cli/app.py +++ b/src/praisonai/praisonai/cli/app.py @@ -217,6 +217,7 @@ def register_commands(): from .commands.examples import app as examples_app from .commands.batch import app as batch_app from .commands.replay import app as replay_app + from .commands.loop import app as loop_app # Import TUI and queue commands from .features.tui.debug import create_debug_app as create_tui_debug_app @@ -276,6 +277,7 @@ def register_commands(): 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") app.add_typer(replay_app, name="replay", help="Context replay for debugging agent execution") + app.add_typer(loop_app, name="loop", help="Autonomous agent execution loops") # Register standardise command try: diff --git a/src/praisonai/praisonai/cli/commands/loop.py b/src/praisonai/praisonai/cli/commands/loop.py new file mode 100644 index 000000000..224ddedff --- /dev/null +++ b/src/praisonai/praisonai/cli/commands/loop.py @@ -0,0 +1,200 @@ +""" +Loop command for autonomous agent execution. + +Provides CLI interface for running agents in autonomous loops with: +- Completion promise detection +- Context clearing between iterations +- Iteration limits and timeouts +""" + +from typing import Optional + +import typer + +app = typer.Typer( + name="loop", + help="Run agents in autonomous execution loops", + no_args_is_help=False, +) + + +@app.callback(invoke_without_command=True) +def loop_main( + ctx: typer.Context, + prompt: Optional[str] = typer.Argument(None, help="Task prompt for the agent"), + max_iterations: int = typer.Option(10, "--max-iterations", "-n", help="Maximum iterations (default: 10)"), + completion_promise: Optional[str] = typer.Option(None, "--completion-promise", "-p", help="Promise text to signal completion"), + clear_context: bool = typer.Option(False, "--clear-context", "-c", help="Clear chat history between iterations"), + timeout: Optional[float] = typer.Option(None, "--timeout", "-t", help="Timeout in seconds"), + model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show verbose output"), +): + """Run an agent in an autonomous loop. + + The agent will execute the given PROMPT repeatedly until: + - It outputs TEXT matching --completion-promise + - It reaches --max-iterations + - It times out (if --timeout is set) + - It detects a doom loop (repeated actions) + + Examples: + + praisonai loop "Build a REST API" -n 5 + + praisonai loop "Refactor auth module" -p DONE + + praisonai loop "Implement feature X" -p COMPLETE -c + """ + if ctx.invoked_subcommand is not None: + return + + if not prompt: + typer.echo(ctx.get_help()) + return + + _run_loop( + prompt=prompt, + max_iterations=max_iterations, + completion_promise=completion_promise, + clear_context=clear_context, + timeout=timeout, + model=model, + verbose=verbose, + ) + + +def _run_loop( + prompt: str, + max_iterations: int, + completion_promise: Optional[str], + clear_context: bool, + timeout: Optional[float], + model: Optional[str], + verbose: bool, +): + """Execute the autonomous loop.""" + try: + from praisonaiagents import Agent + except ImportError: + typer.echo("Error: praisonaiagents not installed. Run: pip install praisonaiagents", err=True) + raise typer.Exit(1) + + # Build autonomy config + autonomy_config = { + "max_iterations": max_iterations, + "completion_promise": completion_promise, + "clear_context": clear_context, + } + + # Create agent + agent_kwargs = { + "name": "loop_agent", + "instructions": "You are an autonomous agent. Complete the given task. " + "When you have fully completed the task, signal completion.", + "autonomy": autonomy_config, + } + + if model: + agent_kwargs["llm"] = model + + if not verbose: + agent_kwargs["output"] = "silent" + + # Add completion promise instructions if provided + if completion_promise: + agent_kwargs["instructions"] += ( + f"\n\nIMPORTANT: When you have completed the task, you MUST include " + f"{completion_promise} in your response to signal completion." + ) + + agent = Agent(**agent_kwargs) + + # Show configuration + if verbose: + typer.echo("🔄 Starting autonomous loop") + typer.echo(f" Prompt: {prompt[:50]}{'...' if len(prompt) > 50 else ''}") + typer.echo(f" Max iterations: {max_iterations}") + if completion_promise: + typer.echo(f" Completion promise: {completion_promise}") + if clear_context: + typer.echo(" Context clearing: enabled") + if timeout: + typer.echo(f" Timeout: {timeout}s") + typer.echo("") + + # Run autonomous loop + try: + result = agent.run_autonomous( + prompt=prompt, + max_iterations=max_iterations, + timeout_seconds=timeout, + completion_promise=completion_promise, + clear_context=clear_context, + ) + + # Display result + if result.success: + typer.echo("✅ Task completed successfully") + typer.echo(f" Reason: {result.completion_reason}") + typer.echo(f" Iterations: {result.iterations}") + typer.echo(f" Duration: {result.duration_seconds:.2f}s") + if verbose: + typer.echo(f"\n📝 Final output:\n{result.output}") + else: + typer.echo("❌ Task did not complete") + typer.echo(f" Reason: {result.completion_reason}") + typer.echo(f" Iterations: {result.iterations}") + typer.echo(f" Duration: {result.duration_seconds:.2f}s") + if result.error: + typer.echo(f" Error: {result.error}") + raise typer.Exit(1) + + except KeyboardInterrupt: + typer.echo("\n⚠️ Loop cancelled by user") + raise typer.Exit(130) + except typer.Exit: + raise + except Exception as e: + typer.echo(f"❌ Error: {e}", err=True) + raise typer.Exit(1) + + +@app.command(name="help") +def loop_help(): + """Show detailed help for the loop command.""" + typer.echo(""" +🔄 Autonomous Loop Command + +The loop command runs an agent in an autonomous execution loop, +similar to Ralph's self-referential development loop. + +FEATURES: + • Completion Promise: Agent signals "done" with TEXT + • Context Clearing: Fresh memory each iteration (file-based state) + • Iteration Limits: Prevent runaway loops + • Doom Loop Detection: Stops on repeated actions + +USAGE: + praisonai loop "Your task here" [OPTIONS] + +OPTIONS: + -n, --max-iterations INT Maximum iterations (default: 10) + -p, --completion-promise STR Promise text to signal completion + -c, --clear-context Clear chat history between iterations + -t, --timeout FLOAT Timeout in seconds + -m, --model STR LLM model to use + -v, --verbose Show verbose output + +EXAMPLES: + # Basic loop + praisonai loop "Build a calculator" -n 5 + + # With completion promise + praisonai loop "Refactor the auth module" -p DONE -v + + # With context clearing (forces file-based state) + praisonai loop "Implement feature X" -p COMPLETE -c -n 20 + + # With timeout + praisonai loop "Debug the issue" -t 300 -v +""") diff --git a/src/praisonai/praisonai/cli/features/audit_cli.py b/src/praisonai/praisonai/cli/features/audit_cli.py new file mode 100644 index 000000000..4dad28718 --- /dev/null +++ b/src/praisonai/praisonai/cli/features/audit_cli.py @@ -0,0 +1,270 @@ +""" +Security Audit CLI for PraisonAI. + +Provides security scanning and audit capabilities. +""" + +from __future__ import annotations + +import os +import logging +from typing import Dict, Any, List + +logger = logging.getLogger(__name__) + + +class SecurityAudit: + """Security audit scanner for PraisonAI projects.""" + + def __init__(self): + self.findings: List[Dict[str, Any]] = [] + + def scan(self, path: str = ".") -> Dict[str, Any]: + """Scan a project for security issues. + + Args: + path: Path to scan + + Returns: + Audit results + """ + self.findings = [] + + self._check_env_files(path) + self._check_api_keys_in_code(path) + self._check_yaml_configs(path) + self._check_dependencies(path) + self._check_permissions(path) + + critical = sum(1 for f in self.findings if f["severity"] == "critical") + high = sum(1 for f in self.findings if f["severity"] == "high") + medium = sum(1 for f in self.findings if f["severity"] == "medium") + low = sum(1 for f in self.findings if f["severity"] == "low") + + return { + "path": path, + "findings": self.findings, + "summary": { + "total": len(self.findings), + "critical": critical, + "high": high, + "medium": medium, + "low": low, + }, + "passed": critical == 0 and high == 0, + } + + def _check_env_files(self, path: str) -> None: + """Check for exposed .env files.""" + env_files = [] + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".git")] + for f in files: + if f == ".env" or f.endswith(".env"): + env_files.append(os.path.join(root, f)) + + for env_file in env_files: + gitignore_path = os.path.join(os.path.dirname(env_file), ".gitignore") + if os.path.exists(gitignore_path): + with open(gitignore_path, "r") as f: + gitignore = f.read() + if ".env" not in gitignore: + self.findings.append({ + "type": "exposed_env", + "severity": "critical", + "file": env_file, + "message": ".env file not in .gitignore - secrets may be exposed", + "fix": "Add '.env' to .gitignore", + }) + else: + self.findings.append({ + "type": "missing_gitignore", + "severity": "high", + "file": env_file, + "message": "No .gitignore found - .env file may be committed", + "fix": "Create .gitignore with '.env' entry", + }) + + def _check_api_keys_in_code(self, path: str) -> None: + """Check for hardcoded API keys in code.""" + import re + + patterns = [ + (r'sk-[a-zA-Z0-9]{48}', "OpenAI API key"), + (r'sk-ant-[a-zA-Z0-9-]{95}', "Anthropic API key"), + (r'AIza[a-zA-Z0-9_-]{35}', "Google API key"), + (r'xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+', "Slack bot token"), + (r'xapp-[0-9]+-[a-zA-Z0-9]+', "Slack app token"), + ] + + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for f in files: + if f.endswith((".py", ".yaml", ".yml", ".json", ".js", ".ts")): + file_path = os.path.join(root, f) + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as fp: + content = fp.read() + + for pattern, key_type in patterns: + if re.search(pattern, content): + self.findings.append({ + "type": "hardcoded_key", + "severity": "critical", + "file": file_path, + "message": f"Possible {key_type} found in code", + "fix": "Move API key to environment variable", + }) + except Exception: + pass + + def _check_yaml_configs(self, path: str) -> None: + """Check YAML configurations for security issues.""" + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".")] + for f in files: + if f.endswith((".yaml", ".yml")): + file_path = os.path.join(root, f) + try: + import yaml + with open(file_path, "r") as fp: + config = yaml.safe_load(fp) + + if config and isinstance(config, dict): + if "api_key" in str(config).lower(): + self.findings.append({ + "type": "key_in_config", + "severity": "high", + "file": file_path, + "message": "API key reference found in YAML config", + "fix": "Use environment variables instead", + }) + except Exception: + pass + + def _check_dependencies(self, path: str) -> None: + """Check for known vulnerable dependencies.""" + requirements_files = ["requirements.txt", "pyproject.toml"] + + for req_file in requirements_files: + req_path = os.path.join(path, req_file) + if os.path.exists(req_path): + self.findings.append({ + "type": "dependency_check", + "severity": "low", + "file": req_path, + "message": "Run 'pip-audit' to check for vulnerable dependencies", + "fix": "pip install pip-audit && pip-audit", + }) + break + + def _check_permissions(self, path: str) -> None: + """Check file permissions.""" + sensitive_files = [".env", "credentials.json", "service-account.json"] + + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".git")] + for f in files: + if f in sensitive_files: + file_path = os.path.join(root, f) + try: + mode = os.stat(file_path).st_mode + if mode & 0o077: + self.findings.append({ + "type": "file_permissions", + "severity": "medium", + "file": file_path, + "message": "Sensitive file has overly permissive permissions", + "fix": f"chmod 600 {file_path}", + }) + except Exception: + pass + + +def handle_audit_command(args) -> None: + """Handle audit CLI command.""" + try: + from rich.console import Console + from rich.table import Table + from rich.panel import Panel + except ImportError: + print("Error: Rich library required for audit display") + return + + console = Console() + audit = SecurityAudit() + + path = getattr(args, "path", ".") + results = audit.scan(path) + + console.print(Panel.fit( + f"[bold]Security Audit Results[/bold]\n" + f"Path: {results['path']}", + title="PraisonAI Security Audit", + )) + console.print() + + summary = results["summary"] + if summary["total"] == 0: + console.print("[green]✓ No security issues found![/green]") + else: + table = Table(title="Summary") + table.add_column("Severity", style="bold") + table.add_column("Count", justify="right") + + if summary["critical"] > 0: + table.add_row("[red]Critical[/red]", str(summary["critical"])) + if summary["high"] > 0: + table.add_row("[orange1]High[/orange1]", str(summary["high"])) + if summary["medium"] > 0: + table.add_row("[yellow]Medium[/yellow]", str(summary["medium"])) + if summary["low"] > 0: + table.add_row("[blue]Low[/blue]", str(summary["low"])) + + console.print(table) + console.print() + + findings_table = Table(title="Findings") + findings_table.add_column("Severity") + findings_table.add_column("Type") + findings_table.add_column("File") + findings_table.add_column("Message") + + severity_colors = { + "critical": "red", + "high": "orange1", + "medium": "yellow", + "low": "blue", + } + + for finding in results["findings"]: + color = severity_colors.get(finding["severity"], "white") + findings_table.add_row( + f"[{color}]{finding['severity'].upper()}[/{color}]", + finding["type"], + finding["file"], + finding["message"], + ) + + console.print(findings_table) + console.print() + + if not results["passed"]: + console.print("[red]✗ Audit failed - critical/high issues found[/red]") + else: + console.print("[yellow]⚠ Audit passed with warnings[/yellow]") + + +def add_audit_parser(subparsers) -> None: + """Add audit subparser to CLI.""" + audit_parser = subparsers.add_parser( + "audit", + help="Run security audit on project", + ) + audit_parser.add_argument( + "path", + nargs="?", + default=".", + help="Path to audit (default: current directory)", + ) + audit_parser.set_defaults(func=handle_audit_command) diff --git a/src/praisonai/praisonai/cli/features/bots_cli.py b/src/praisonai/praisonai/cli/features/bots_cli.py new file mode 100644 index 000000000..0d7024094 --- /dev/null +++ b/src/praisonai/praisonai/cli/features/bots_cli.py @@ -0,0 +1,249 @@ +""" +Bot CLI commands for PraisonAI. + +Provides CLI commands for managing messaging bots. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +class BotHandler: + """Handler for bot CLI commands.""" + + def start_telegram( + self, + token: Optional[str] = None, + agent_file: Optional[str] = None, + ) -> None: + """Start a Telegram bot. + + Args: + token: Telegram bot token (or use TELEGRAM_BOT_TOKEN env var) + agent_file: Optional path to agent configuration file + """ + token = token or os.environ.get("TELEGRAM_BOT_TOKEN") + if not token: + print("Error: Telegram bot token required") + print("Provide via --token or TELEGRAM_BOT_TOKEN environment variable") + return + + try: + from praisonai.bots import TelegramBot + except ImportError as e: + print(f"Error: TelegramBot requires python-telegram-bot. {e}") + print("Install with: pip install python-telegram-bot") + return + + agent = self._load_agent(agent_file) + bot = TelegramBot(token=token, agent=agent) + + print("Starting Telegram bot...") + print("Press Ctrl+C to stop") + + try: + asyncio.run(bot.start()) + except KeyboardInterrupt: + print("\nStopping bot...") + asyncio.run(bot.stop()) + + def start_discord( + self, + token: Optional[str] = None, + agent_file: Optional[str] = None, + ) -> None: + """Start a Discord bot. + + Args: + token: Discord bot token (or use DISCORD_BOT_TOKEN env var) + agent_file: Optional path to agent configuration file + """ + token = token or os.environ.get("DISCORD_BOT_TOKEN") + if not token: + print("Error: Discord bot token required") + print("Provide via --token or DISCORD_BOT_TOKEN environment variable") + return + + try: + from praisonai.bots import DiscordBot + except ImportError as e: + print(f"Error: DiscordBot requires discord.py. {e}") + print("Install with: pip install discord.py") + return + + agent = self._load_agent(agent_file) + bot = DiscordBot(token=token, agent=agent) + + print("Starting Discord bot...") + print("Press Ctrl+C to stop") + + try: + asyncio.run(bot.start()) + except KeyboardInterrupt: + print("\nStopping bot...") + asyncio.run(bot.stop()) + + def start_slack( + self, + token: Optional[str] = None, + app_token: Optional[str] = None, + agent_file: Optional[str] = None, + ) -> None: + """Start a Slack bot. + + Args: + token: Slack bot token (or use SLACK_BOT_TOKEN env var) + app_token: Slack app token (or use SLACK_APP_TOKEN env var) + agent_file: Optional path to agent configuration file + """ + token = token or os.environ.get("SLACK_BOT_TOKEN") + app_token = app_token or os.environ.get("SLACK_APP_TOKEN") + + if not token: + print("Error: Slack bot token required") + print("Provide via --token or SLACK_BOT_TOKEN environment variable") + return + + try: + from praisonai.bots import SlackBot + except ImportError as e: + print(f"Error: SlackBot requires slack-bolt. {e}") + print("Install with: pip install slack-bolt") + return + + agent = self._load_agent(agent_file) + bot = SlackBot(token=token, app_token=app_token, agent=agent) + + print("Starting Slack bot...") + print("Press Ctrl+C to stop") + + try: + asyncio.run(bot.start()) + except KeyboardInterrupt: + print("\nStopping bot...") + asyncio.run(bot.stop()) + + def _load_agent(self, file_path: Optional[str]): + """Load agent from configuration file.""" + if not file_path: + from praisonaiagents import Agent + return Agent(name="assistant", instructions="You are a helpful assistant.") + + if not os.path.exists(file_path): + print(f"Warning: Agent file not found: {file_path}") + from praisonaiagents import Agent + return Agent(name="assistant", instructions="You are a helpful assistant.") + + try: + import yaml + with open(file_path, "r") as f: + config = yaml.safe_load(f) + + from praisonaiagents import Agent + + if "agents" in config and config["agents"]: + agent_config = config["agents"][0] + else: + agent_config = config + + return Agent( + name=agent_config.get("name", "assistant"), + instructions=agent_config.get("instructions", "You are a helpful assistant."), + llm=agent_config.get("llm"), + ) + except Exception as e: + print(f"Error loading agent: {e}") + from praisonaiagents import Agent + return Agent(name="assistant", instructions="You are a helpful assistant.") + + +def handle_bot_command(args) -> None: + """Handle bot CLI command.""" + handler = BotHandler() + + platform = getattr(args, "platform", None) + + if platform == "telegram": + handler.start_telegram( + token=getattr(args, "token", None), + agent_file=getattr(args, "agent", None), + ) + elif platform == "discord": + handler.start_discord( + token=getattr(args, "token", None), + agent_file=getattr(args, "agent", None), + ) + elif platform == "slack": + handler.start_slack( + token=getattr(args, "token", None), + app_token=getattr(args, "app_token", None), + agent_file=getattr(args, "agent", None), + ) + else: + print("Available platforms: telegram, discord, slack") + print("Usage: praisonai bot [options]") + + +def add_bot_parser(subparsers) -> None: + """Add bot subparser to CLI.""" + bot_parser = subparsers.add_parser( + "bot", + help="Start a messaging bot", + ) + + bot_subparsers = bot_parser.add_subparsers( + dest="platform", + help="Bot platform", + ) + + telegram_parser = bot_subparsers.add_parser( + "telegram", + help="Start a Telegram bot", + ) + telegram_parser.add_argument( + "--token", + help="Telegram bot token (or use TELEGRAM_BOT_TOKEN env var)", + ) + telegram_parser.add_argument( + "--agent", + help="Path to agent configuration file", + ) + + discord_parser = bot_subparsers.add_parser( + "discord", + help="Start a Discord bot", + ) + discord_parser.add_argument( + "--token", + help="Discord bot token (or use DISCORD_BOT_TOKEN env var)", + ) + discord_parser.add_argument( + "--agent", + help="Path to agent configuration file", + ) + + slack_parser = bot_subparsers.add_parser( + "slack", + help="Start a Slack bot", + ) + slack_parser.add_argument( + "--token", + help="Slack bot token (or use SLACK_BOT_TOKEN env var)", + ) + slack_parser.add_argument( + "--app-token", + dest="app_token", + help="Slack app token for Socket Mode (or use SLACK_APP_TOKEN env var)", + ) + slack_parser.add_argument( + "--agent", + help="Path to agent configuration file", + ) + + bot_parser.set_defaults(func=handle_bot_command) diff --git a/src/praisonai/praisonai/cli/features/gateway.py b/src/praisonai/praisonai/cli/features/gateway.py new file mode 100644 index 000000000..ca5e0bcfb --- /dev/null +++ b/src/praisonai/praisonai/cli/features/gateway.py @@ -0,0 +1,180 @@ +""" +Gateway CLI commands for PraisonAI. + +Provides CLI commands for managing the WebSocket gateway. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class GatewayHandler: + """Handler for gateway CLI commands.""" + + def __init__(self): + self._gateway = None + + def start( + self, + host: str = "127.0.0.1", + port: int = 8765, + agent_file: Optional[str] = None, + ) -> None: + """Start the gateway server. + + Args: + host: Host to bind to + port: Port to listen on + agent_file: Optional path to agent configuration file + """ + try: + from praisonai.gateway import WebSocketGateway + from praisonaiagents.gateway import GatewayConfig + except ImportError as e: + print(f"Error: Gateway requires additional dependencies. {e}") + print("Install with: pip install praisonai[api]") + return + + config = GatewayConfig(host=host, port=port) + self._gateway = WebSocketGateway(config=config) + + if agent_file: + self._load_agents_from_file(agent_file) + + print(f"Starting gateway on ws://{host}:{port}") + print("Press Ctrl+C to stop") + + try: + asyncio.run(self._gateway.start()) + except KeyboardInterrupt: + print("\nStopping gateway...") + asyncio.run(self._gateway.stop()) + + def _load_agents_from_file(self, file_path: str) -> None: + """Load agents from a configuration file.""" + import os + import yaml + + if not os.path.exists(file_path): + print(f"Warning: Agent file not found: {file_path}") + return + + try: + with open(file_path, "r") as f: + config = yaml.safe_load(f) + + if "agents" in config: + from praisonaiagents import Agent + + for agent_config in config["agents"]: + agent = Agent( + name=agent_config.get("name", "agent"), + instructions=agent_config.get("instructions", ""), + llm=agent_config.get("llm"), + ) + agent_id = self._gateway.register_agent(agent) + print(f"Registered agent: {agent_id}") + except Exception as e: + print(f"Error loading agents: {e}") + + def status(self, host: str = "127.0.0.1", port: int = 8765) -> None: + """Check gateway status. + + Args: + host: Gateway host + port: Gateway port + """ + import urllib.request + import json + + url = f"http://{host}:{port}/health" + + try: + with urllib.request.urlopen(url, timeout=5) as response: + data = json.loads(response.read().decode()) + print(f"Gateway Status: {data.get('status', 'unknown')}") + print(f" Uptime: {data.get('uptime', 0):.1f}s") + print(f" Agents: {data.get('agents', 0)}") + print(f" Sessions: {data.get('sessions', 0)}") + print(f" Clients: {data.get('clients', 0)}") + except Exception as e: + print(f"Gateway not reachable at {url}") + print(f"Error: {e}") + + +def handle_gateway_command(args) -> None: + """Handle gateway CLI command.""" + handler = GatewayHandler() + + subcommand = getattr(args, "gateway_command", None) or "start" + + if subcommand == "start": + handler.start( + host=getattr(args, "host", "127.0.0.1"), + port=getattr(args, "port", 8765), + agent_file=getattr(args, "agents", None), + ) + elif subcommand == "status": + handler.status( + host=getattr(args, "host", "127.0.0.1"), + port=getattr(args, "port", 8765), + ) + else: + print(f"Unknown gateway command: {subcommand}") + print("Available commands: start, status") + + +def add_gateway_parser(subparsers) -> None: + """Add gateway subparser to CLI.""" + gateway_parser = subparsers.add_parser( + "gateway", + help="Manage the WebSocket gateway", + ) + + gateway_subparsers = gateway_parser.add_subparsers( + dest="gateway_command", + help="Gateway commands", + ) + + start_parser = gateway_subparsers.add_parser( + "start", + help="Start the gateway server", + ) + start_parser.add_argument( + "--host", + default="127.0.0.1", + help="Host to bind to (default: 127.0.0.1)", + ) + start_parser.add_argument( + "--port", + type=int, + default=8765, + help="Port to listen on (default: 8765)", + ) + start_parser.add_argument( + "--agents", + help="Path to agent configuration file", + ) + + status_parser = gateway_subparsers.add_parser( + "status", + help="Check gateway status", + ) + status_parser.add_argument( + "--host", + default="127.0.0.1", + help="Gateway host (default: 127.0.0.1)", + ) + status_parser.add_argument( + "--port", + type=int, + default=8765, + help="Gateway port (default: 8765)", + ) + + gateway_parser.set_defaults(func=handle_gateway_command) diff --git a/src/praisonai/praisonai/cli/features/migrate.py b/src/praisonai/praisonai/cli/features/migrate.py new file mode 100644 index 000000000..35df8e1ce --- /dev/null +++ b/src/praisonai/praisonai/cli/features/migrate.py @@ -0,0 +1,284 @@ +""" +Config Migration CLI for PraisonAI. + +Provides migration tools for converting configurations between formats. +""" + +from __future__ import annotations + +import os +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class ConfigMigrator: + """Configuration migrator for PraisonAI.""" + + def migrate( + self, + source: str, + target: Optional[str] = None, + source_format: Optional[str] = None, + target_format: str = "praisonai", + ) -> Dict[str, Any]: + """Migrate configuration from one format to another. + + Args: + source: Source configuration file path + target: Target file path (optional) + source_format: Source format (auto-detected if not provided) + target_format: Target format (default: praisonai) + + Returns: + Migration result + """ + if not os.path.exists(source): + return {"success": False, "error": f"Source file not found: {source}"} + + if not source_format: + source_format = self._detect_format(source) + + try: + config = self._load_config(source, source_format) + except Exception as e: + return {"success": False, "error": f"Failed to load source: {e}"} + + try: + converted = self._convert_config(config, source_format, target_format) + except Exception as e: + return {"success": False, "error": f"Failed to convert: {e}"} + + if target: + try: + self._save_config(converted, target, target_format) + except Exception as e: + return {"success": False, "error": f"Failed to save: {e}"} + + return { + "success": True, + "source_format": source_format, + "target_format": target_format, + "config": converted, + "target_file": target, + } + + def _detect_format(self, path: str) -> str: + """Detect configuration format from file.""" + import yaml + + with open(path, "r") as f: + content = f.read() + + try: + config = yaml.safe_load(content) + except Exception: + return "unknown" + + if not isinstance(config, dict): + return "unknown" + + if "framework" in config and config.get("framework") == "praisonai": + return "praisonai" + if "agents" in config and "tasks" in config: + return "crewai" + if "config_list" in config or "llm_config" in config: + return "autogen" + if "agents" in config: + return "praisonai" + + return "unknown" + + def _load_config(self, path: str, format: str) -> Dict[str, Any]: + """Load configuration from file.""" + import yaml + + with open(path, "r") as f: + return yaml.safe_load(f) + + def _convert_config( + self, + config: Dict[str, Any], + source_format: str, + target_format: str, + ) -> Dict[str, Any]: + """Convert configuration between formats.""" + if source_format == target_format: + return config + + if source_format == "crewai" and target_format == "praisonai": + return self._convert_crewai_to_praisonai(config) + if source_format == "autogen" and target_format == "praisonai": + return self._convert_autogen_to_praisonai(config) + if source_format == "praisonai" and target_format == "crewai": + return self._convert_praisonai_to_crewai(config) + + return config + + def _convert_crewai_to_praisonai(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Convert CrewAI config to PraisonAI format.""" + result = { + "framework": "praisonai", + "topic": config.get("topic", "Converted from CrewAI"), + "agents": [], + "steps": [], + } + + for agent in config.get("agents", []): + result["agents"].append({ + "name": agent.get("role", "agent").lower().replace(" ", "_"), + "role": agent.get("role", "Agent"), + "goal": agent.get("goal", ""), + "backstory": agent.get("backstory", ""), + "tools": agent.get("tools", []), + }) + + for i, task in enumerate(config.get("tasks", [])): + agent_name = task.get("agent", "") + if isinstance(agent_name, dict): + agent_name = agent_name.get("role", "agent").lower().replace(" ", "_") + + result["steps"].append({ + "name": f"step_{i+1}", + "agent": agent_name, + "action": task.get("description", ""), + "expected_output": task.get("expected_output", ""), + }) + + return result + + def _convert_autogen_to_praisonai(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Convert AutoGen config to PraisonAI format.""" + result = { + "framework": "praisonai", + "topic": "Converted from AutoGen", + "agents": [], + "steps": [], + } + + llm_config = config.get("llm_config", {}) + model = "gpt-4o-mini" + if "config_list" in llm_config: + config_list = llm_config["config_list"] + if config_list: + model = config_list[0].get("model", model) + + for agent in config.get("agents", []): + result["agents"].append({ + "name": agent.get("name", "agent"), + "role": agent.get("name", "Agent"), + "goal": agent.get("system_message", ""), + "backstory": "", + "llm": model, + }) + + return result + + def _convert_praisonai_to_crewai(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Convert PraisonAI config to CrewAI format.""" + result = { + "agents": [], + "tasks": [], + } + + for agent in config.get("agents", []): + result["agents"].append({ + "role": agent.get("role", agent.get("name", "Agent")), + "goal": agent.get("goal", ""), + "backstory": agent.get("backstory", ""), + "tools": agent.get("tools", []), + }) + + for step in config.get("steps", []): + result["tasks"].append({ + "description": step.get("action", ""), + "expected_output": step.get("expected_output", ""), + "agent": step.get("agent", ""), + }) + + return result + + def _save_config(self, config: Dict[str, Any], path: str, format: str) -> None: + """Save configuration to file.""" + import yaml + + with open(path, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + + +def handle_migrate_command(args) -> None: + """Handle migrate CLI command.""" + try: + from rich.console import Console + from rich.panel import Panel + from rich.syntax import Syntax + except ImportError: + print("Error: Rich library required") + return + + console = Console() + migrator = ConfigMigrator() + + source = getattr(args, "source", None) + target = getattr(args, "target", None) + source_format = getattr(args, "from_format", None) + target_format = getattr(args, "to_format", "praisonai") + + if not source: + console.print("[red]Error: Source file required[/red]") + return + + result = migrator.migrate( + source=source, + target=target, + source_format=source_format, + target_format=target_format, + ) + + if not result["success"]: + console.print(f"[red]Migration failed: {result['error']}[/red]") + return + + console.print(Panel.fit( + f"[green]Migration successful![/green]\n\n" + f"Source format: {result['source_format']}\n" + f"Target format: {result['target_format']}", + title="Config Migration", + )) + + if target: + console.print(f"\nSaved to: {target}") + else: + import yaml + yaml_str = yaml.dump(result["config"], default_flow_style=False, sort_keys=False) + console.print("\nConverted configuration:") + console.print(Syntax(yaml_str, "yaml")) + + +def add_migrate_parser(subparsers) -> None: + """Add migrate subparser to CLI.""" + migrate_parser = subparsers.add_parser( + "migrate", + help="Migrate configuration between formats", + ) + migrate_parser.add_argument( + "source", + help="Source configuration file", + ) + migrate_parser.add_argument( + "--target", "-o", + help="Target output file", + ) + migrate_parser.add_argument( + "--from", dest="from_format", + choices=["praisonai", "crewai", "autogen"], + help="Source format (auto-detected if not provided)", + ) + migrate_parser.add_argument( + "--to", dest="to_format", + choices=["praisonai", "crewai"], + default="praisonai", + help="Target format (default: praisonai)", + ) + migrate_parser.set_defaults(func=handle_migrate_command) diff --git a/src/praisonai/praisonai/cli/features/recipe_creator.py b/src/praisonai/praisonai/cli/features/recipe_creator.py index 080e6c1b6..86aa286d5 100644 --- a/src/praisonai/praisonai/cli/features/recipe_creator.py +++ b/src/praisonai/praisonai/cli/features/recipe_creator.py @@ -407,22 +407,21 @@ BAD: "Use search_web to find {{{{topic}}}}" (variables don't work in actions!) - No explanations before or after ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -HIERARCHICAL PROCESS (MANDATORY - Use for all multi-agent recipes) +SEQUENTIAL PROCESS (DEFAULT - Use for all multi-agent recipes) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -**ALWAYS include these fields at the root level for multi-agent workflows:** +**ALWAYS include this field at the root level:** ```yaml -process: hierarchical -manager_llm: gpt-4o-mini +process: sequential ``` -**What hierarchical process does:** -- A manager agent validates each step's output before proceeding -- If a step fails validation, the workflow stops with a clear failure reason -- Ensures quality control and prevents cascading errors +**What sequential process does:** +- Executes steps in order, passing output from one step to the next +- Each step can reference previous step outputs via {{agent_name}}_output +- Simple and reliable for most workflows ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -EXAMPLE: SEARCH TASK (hierarchical with manager validation) +EXAMPLE: SEARCH TASK (sequential workflow) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ metadata: @@ -431,8 +430,7 @@ metadata: env: - OPENAI_API_KEY -process: hierarchical -manager_llm: gpt-4o-mini +process: sequential agents: researcher: @@ -449,14 +447,14 @@ agents: goal: Analyze and synthesize research findings backstory: Expert at identifying patterns, trends, and key insights from raw data. tools: [] - llm: gpt-4.1 + llm: gpt-4o-mini writer: role: Technical Writer goal: Create clear, well-structured reports backstory: Expert at transforming complex information into readable, actionable content. tools: [] - llm: gpt-4.1 + llm: gpt-4o-mini steps: - agent: researcher @@ -831,15 +829,29 @@ steps: Returns: Python code string + + Notes: + - web_crawl: Uses full path import since lazy loading returns module + - search_web: Works with standard import from praisonaiagents.tools + - tavily_*: Imported from praisonaiagents.tools (not praisonai_tools) """ # Map tools to their import sources tool_imports = [] tool_exports = [] for tool in tools: - if tool in ['tavily_search', 'tavily_extract']: + if tool in ['web_crawl', 'crawl_web']: + # web_crawl needs full path import (lazy loading returns module otherwise) + if 'web_crawl' not in str(tool_imports): + tool_imports.append("from praisonaiagents.tools.web_crawl import web_crawl") + tool_exports.append('web_crawl') + elif tool in ['search_web']: + if 'search_web' not in str(tool_imports): + tool_imports.append("from praisonaiagents.tools import search_web") + tool_exports.append('search_web') + elif tool in ['tavily_search', 'tavily_extract']: if 'tavily' not in str(tool_imports): - tool_imports.append("from praisonai_tools.tools import tavily_search, tavily_extract") + tool_imports.append("from praisonaiagents.tools import tavily_search, tavily_extract") tool_exports.append(tool) elif tool in ['read_file', 'write_file', 'list_files']: if 'file_tools' not in str(tool_imports): diff --git a/src/praisonai/praisonai/cli/features/sandbox_cli.py b/src/praisonai/praisonai/cli/features/sandbox_cli.py new file mode 100644 index 000000000..f83cdce76 --- /dev/null +++ b/src/praisonai/praisonai/cli/features/sandbox_cli.py @@ -0,0 +1,255 @@ +""" +Sandbox CLI commands for PraisonAI. + +Provides CLI commands for sandbox code execution. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from typing import Optional + +logger = logging.getLogger(__name__) + + +class SandboxHandler: + """Handler for sandbox CLI commands.""" + + def run( + self, + code: Optional[str] = None, + file: Optional[str] = None, + sandbox_type: str = "subprocess", + image: str = "python:3.11-slim", + timeout: int = 60, + ) -> None: + """Run code in a sandbox. + + Args: + code: Code to execute + file: Path to file to execute + sandbox_type: Type of sandbox (subprocess, docker) + image: Docker image to use + timeout: Execution timeout in seconds + """ + if not code and not file: + print("Error: Provide code via --code or --file") + return + + if file: + if not os.path.exists(file): + print(f"Error: File not found: {file}") + return + with open(file, "r") as f: + code = f.read() + + try: + from praisonaiagents.sandbox import ResourceLimits + + if sandbox_type == "docker": + from praisonai.sandbox import DockerSandbox + sandbox = DockerSandbox(image=image) + else: + from praisonai.sandbox import SubprocessSandbox + sandbox = SubprocessSandbox() + except ImportError as e: + print(f"Error: {e}") + return + + limits = ResourceLimits(timeout_seconds=timeout) + + print(f"Running in {sandbox_type} sandbox...") + + async def execute(): + await sandbox.start() + try: + result = await sandbox.execute(code, limits=limits) + + if result.stdout: + print(result.stdout, end="") + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + + if result.status.value != "completed": + print(f"\nStatus: {result.status.value}") + if result.error: + print(f"Error: {result.error}") + + return result.exit_code or 0 + finally: + await sandbox.stop() + + exit_code = asyncio.run(execute()) + sys.exit(exit_code) + + def shell( + self, + sandbox_type: str = "subprocess", + image: str = "python:3.11-slim", + ) -> None: + """Start an interactive sandbox shell. + + Args: + sandbox_type: Type of sandbox (subprocess, docker) + image: Docker image to use + """ + try: + from praisonaiagents.sandbox import ResourceLimits + + if sandbox_type == "docker": + from praisonai.sandbox import DockerSandbox + sandbox = DockerSandbox(image=image) + else: + from praisonai.sandbox import SubprocessSandbox + sandbox = SubprocessSandbox() + except ImportError as e: + print(f"Error: {e}") + return + + print(f"Starting {sandbox_type} sandbox shell...") + print("Type 'exit' or Ctrl+D to quit") + print() + + async def run_shell(): + await sandbox.start() + try: + while True: + try: + code = input(">>> ") + if code.strip().lower() == "exit": + break + if not code.strip(): + continue + + result = await sandbox.execute( + code, + limits=ResourceLimits(timeout_seconds=30), + ) + + if result.stdout: + print(result.stdout, end="") + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + if result.error: + print(f"Error: {result.error}") + except EOFError: + break + except KeyboardInterrupt: + print() + continue + finally: + await sandbox.stop() + + asyncio.run(run_shell()) + + def status(self) -> None: + """Check sandbox availability.""" + print("Sandbox Status:") + print() + + print("Subprocess sandbox: Available") + + try: + from praisonai.sandbox import DockerSandbox + sandbox = DockerSandbox() + if sandbox.is_available: + print("Docker sandbox: Available") + else: + print("Docker sandbox: Not available (Docker not running)") + except ImportError: + print("Docker sandbox: Not available (dependencies not installed)") + + +def handle_sandbox_command(args) -> None: + """Handle sandbox CLI command.""" + handler = SandboxHandler() + + subcommand = getattr(args, "sandbox_command", None) or "status" + + if subcommand == "run": + handler.run( + code=getattr(args, "code", None), + file=getattr(args, "file", None), + sandbox_type=getattr(args, "type", "subprocess"), + image=getattr(args, "image", "python:3.11-slim"), + timeout=getattr(args, "timeout", 60), + ) + elif subcommand == "shell": + handler.shell( + sandbox_type=getattr(args, "type", "subprocess"), + image=getattr(args, "image", "python:3.11-slim"), + ) + elif subcommand == "status": + handler.status() + else: + print(f"Unknown sandbox command: {subcommand}") + print("Available commands: run, shell, status") + + +def add_sandbox_parser(subparsers) -> None: + """Add sandbox subparser to CLI.""" + sandbox_parser = subparsers.add_parser( + "sandbox", + help="Run code in a sandbox", + ) + + sandbox_subparsers = sandbox_parser.add_subparsers( + dest="sandbox_command", + help="Sandbox commands", + ) + + run_parser = sandbox_subparsers.add_parser( + "run", + help="Run code in sandbox", + ) + run_parser.add_argument( + "--code", "-c", + help="Code to execute", + ) + run_parser.add_argument( + "--file", "-f", + help="File to execute", + ) + run_parser.add_argument( + "--type", "-t", + choices=["subprocess", "docker"], + default="subprocess", + help="Sandbox type (default: subprocess)", + ) + run_parser.add_argument( + "--image", + default="python:3.11-slim", + help="Docker image (default: python:3.11-slim)", + ) + run_parser.add_argument( + "--timeout", + type=int, + default=60, + help="Timeout in seconds (default: 60)", + ) + + shell_parser = sandbox_subparsers.add_parser( + "shell", + help="Start interactive sandbox shell", + ) + shell_parser.add_argument( + "--type", "-t", + choices=["subprocess", "docker"], + default="subprocess", + help="Sandbox type (default: subprocess)", + ) + shell_parser.add_argument( + "--image", + default="python:3.11-slim", + help="Docker image (default: python:3.11-slim)", + ) + + sandbox_subparsers.add_parser( + "status", + help="Check sandbox availability", + ) + + sandbox_parser.set_defaults(func=handle_sandbox_command) diff --git a/src/praisonai/praisonai/cli/features/wizard.py b/src/praisonai/praisonai/cli/features/wizard.py new file mode 100644 index 000000000..1add41c9b --- /dev/null +++ b/src/praisonai/praisonai/cli/features/wizard.py @@ -0,0 +1,289 @@ +""" +Onboarding Wizard for PraisonAI. + +Provides an interactive setup wizard for new users. +""" + +from __future__ import annotations + +import os +import logging +from typing import Dict, Any + +logger = logging.getLogger(__name__) + + +class OnboardingWizard: + """Interactive onboarding wizard for PraisonAI setup.""" + + def __init__(self): + self.config: Dict[str, Any] = {} + + def run(self, output_dir: str = ".") -> bool: + """Run the onboarding wizard. + + Args: + output_dir: Directory to create configuration files + + Returns: + True if setup completed successfully + """ + try: + from rich.console import Console + from rich.panel import Panel + from rich.prompt import Prompt, Confirm + except ImportError: + print("Error: Rich library required for wizard") + return False + + console = Console() + + console.print(Panel.fit( + "[bold blue]Welcome to PraisonAI![/bold blue]\n\n" + "This wizard will help you set up your first agent project.", + title="Onboarding Wizard", + )) + console.print() + + project_name = Prompt.ask( + "Project name", + default="my-agent-project", + ) + self.config["project_name"] = project_name + + console.print("\n[bold]Select your LLM provider:[/bold]") + console.print(" 1. OpenAI (GPT-4, GPT-3.5)") + console.print(" 2. Anthropic (Claude)") + console.print(" 3. Google (Gemini)") + console.print(" 4. Ollama (Local)") + console.print(" 5. Other (LiteLLM)") + + provider_choice = Prompt.ask( + "Provider", + choices=["1", "2", "3", "4", "5"], + default="1", + ) + + provider_map = { + "1": "openai", + "2": "anthropic", + "3": "google", + "4": "ollama", + "5": "litellm", + } + self.config["provider"] = provider_map[provider_choice] + + if self.config["provider"] != "ollama": + api_key = Prompt.ask( + f"API Key for {self.config['provider']}", + password=True, + ) + self.config["api_key"] = api_key + + model_defaults = { + "openai": "gpt-4o-mini", + "anthropic": "claude-3-sonnet-20240229", + "google": "gemini-1.5-flash", + "ollama": "llama3.2", + "litellm": "gpt-4o-mini", + } + + model = Prompt.ask( + "Model", + default=model_defaults.get(self.config["provider"], "gpt-4o-mini"), + ) + self.config["model"] = model + + console.print("\n[bold]Agent Configuration:[/bold]") + + agent_name = Prompt.ask( + "Agent name", + default="assistant", + ) + self.config["agent_name"] = agent_name + + agent_role = Prompt.ask( + "Agent role/description", + default="A helpful AI assistant", + ) + self.config["agent_role"] = agent_role + + use_tools = Confirm.ask( + "Enable tools (web search, file operations)?", + default=True, + ) + self.config["use_tools"] = use_tools + + use_memory = Confirm.ask( + "Enable memory (conversation history)?", + default=True, + ) + self.config["use_memory"] = use_memory + + console.print("\n[bold]Creating project files...[/bold]") + + project_dir = os.path.join(output_dir, project_name) + os.makedirs(project_dir, exist_ok=True) + + self._create_env_file(project_dir) + self._create_agent_yaml(project_dir) + self._create_main_py(project_dir) + self._create_readme(project_dir) + + console.print(f"\n[green]✓[/green] Project created at: {project_dir}") + console.print("\n[bold]Next steps:[/bold]") + console.print(f" 1. cd {project_name}") + console.print(" 2. pip install praisonaiagents") + console.print(" 3. python main.py") + + return True + + def _create_env_file(self, project_dir: str) -> None: + """Create .env file.""" + env_content = [] + + if self.config.get("api_key"): + provider = self.config["provider"] + key_names = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "litellm": "OPENAI_API_KEY", + } + key_name = key_names.get(provider, "API_KEY") + env_content.append(f"{key_name}={self.config['api_key']}") + + env_path = os.path.join(project_dir, ".env") + with open(env_path, "w") as f: + f.write("\n".join(env_content)) + + gitignore_path = os.path.join(project_dir, ".gitignore") + with open(gitignore_path, "w") as f: + f.write(".env\n__pycache__/\n*.pyc\n") + + def _create_agent_yaml(self, project_dir: str) -> None: + """Create agents.yaml file.""" + tools = [] + if self.config.get("use_tools"): + tools = ["search_web"] + + yaml_content = f"""# PraisonAI Agent Configuration +# Generated by praisonai init + +framework: praisonai +topic: {self.config['agent_role']} + +agents: + - name: {self.config['agent_name']} + role: {self.config['agent_role']} + goal: Help users with their tasks + backstory: You are a helpful AI assistant. + llm: {self.config['model']} + tools: {tools} + memory: {str(self.config.get('use_memory', True)).lower()} + +steps: + - name: main + agent: {self.config['agent_name']} + action: Respond to user input + expected_output: A helpful response +""" + + yaml_path = os.path.join(project_dir, "agents.yaml") + with open(yaml_path, "w") as f: + f.write(yaml_content) + + def _create_main_py(self, project_dir: str) -> None: + """Create main.py file.""" + main_content = f'''""" +{self.config['project_name']} - PraisonAI Agent + +Generated by praisonai init +""" + +from praisonaiagents import Agent + +def main(): + agent = Agent( + name="{self.config['agent_name']}", + instructions="{self.config['agent_role']}", + llm="{self.config['model']}", + memory={self.config.get('use_memory', True)}, + ) + + print("Agent ready! Type 'quit' to exit.") + print() + + while True: + user_input = input("You: ") + if user_input.lower() in ["quit", "exit", "q"]: + break + + response = agent.chat(user_input) + print(f"Agent: {{response}}") + print() + +if __name__ == "__main__": + main() +''' + + main_path = os.path.join(project_dir, "main.py") + with open(main_path, "w") as f: + f.write(main_content) + + def _create_readme(self, project_dir: str) -> None: + """Create README.md file.""" + readme_content = f"""# {self.config['project_name']} + +A PraisonAI agent project. + +## Setup + +1. Install dependencies: + ```bash + pip install praisonaiagents + ``` + +2. Set up your API key in `.env` + +3. Run the agent: + ```bash + python main.py + ``` + +## Configuration + +Edit `agents.yaml` to customize your agent's behavior. + +## Documentation + +- [PraisonAI Documentation](https://docs.praison.ai) +- [Agent Configuration](https://docs.praison.ai/agents) +- [Tools Reference](https://docs.praison.ai/tools) +""" + + readme_path = os.path.join(project_dir, "README.md") + with open(readme_path, "w") as f: + f.write(readme_content) + + +def handle_init_command(args) -> None: + """Handle init CLI command.""" + wizard = OnboardingWizard() + + output_dir = getattr(args, "output", ".") + wizard.run(output_dir=output_dir) + + +def add_init_parser(subparsers) -> None: + """Add init subparser to CLI.""" + init_parser = subparsers.add_parser( + "init", + help="Initialize a new PraisonAI project", + ) + init_parser.add_argument( + "--output", "-o", + default=".", + help="Output directory (default: current directory)", + ) + init_parser.set_defaults(func=handle_init_command) diff --git a/src/praisonai/praisonai/cli/main.py b/src/praisonai/praisonai/cli/main.py index f7e375057..469191f01 100644 --- a/src/praisonai/praisonai/cli/main.py +++ b/src/praisonai/praisonai/cli/main.py @@ -1640,6 +1640,41 @@ class PraisonAI: exit_code = handler.cmd_uninstall(unknown_args) sys.exit(exit_code) + elif args.command == 'gateway': + # Gateway command - WebSocket gateway for multi-agent coordination + from .features.gateway import handle_gateway_command + exit_code = handle_gateway_command(unknown_args) + sys.exit(exit_code) + + elif args.command == 'bot': + # Bot command - messaging bot runtimes (Telegram, Discord, Slack) + from .features.bots_cli import handle_bot_command + exit_code = handle_bot_command(unknown_args) + sys.exit(exit_code) + + elif args.command == 'sandbox': + # Sandbox command - secure code execution environment + from .features.sandbox_cli import handle_sandbox_command + exit_code = handle_sandbox_command(unknown_args) + sys.exit(exit_code) + + elif args.command == 'wizard': + # Wizard command - interactive project setup + from .features.wizard import handle_wizard_command + exit_code = handle_wizard_command(unknown_args) + sys.exit(exit_code) + + elif args.command == 'migrate': + # Migrate command - config migration between formats + from .features.migrate import handle_migrate_command + exit_code = handle_migrate_command(unknown_args) + sys.exit(exit_code) + + elif args.command == 'security': + # Security command - security audit and scanning + from .features.audit_cli import handle_audit_command + exit_code = handle_audit_command(unknown_args) + sys.exit(exit_code) # Only check framework availability for agent-related operations if not args.command and (args.init or args.auto or args.framework): diff --git a/src/praisonai/praisonai/deploy.py b/src/praisonai/praisonai/deploy.py index ef2b64c79..9b2a4f535 100644 --- a/src/praisonai/praisonai/deploy.py +++ b/src/praisonai/praisonai/deploy.py @@ -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.10.27 gunicorn markdown\n") + file.write("RUN pip install flask praisonai==3.11.0 gunicorn markdown\n") file.write("EXPOSE 8080\n") file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n') diff --git a/src/praisonai/praisonai/gateway/__init__.py b/src/praisonai/praisonai/gateway/__init__.py new file mode 100644 index 000000000..ed1a1b3fc --- /dev/null +++ b/src/praisonai/praisonai/gateway/__init__.py @@ -0,0 +1,22 @@ +""" +Gateway implementations for PraisonAI. + +Provides WebSocket gateway server for multi-agent coordination. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .server import WebSocketGateway, GatewaySession + +def __getattr__(name: str): + """Lazy loading of gateway components.""" + if name == "WebSocketGateway": + from .server import WebSocketGateway + return WebSocketGateway + if name == "GatewaySession": + from .server import GatewaySession + return GatewaySession + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = ["WebSocketGateway", "GatewaySession"] diff --git a/src/praisonai/praisonai/gateway/server.py b/src/praisonai/praisonai/gateway/server.py new file mode 100644 index 000000000..d6dc9d249 --- /dev/null +++ b/src/praisonai/praisonai/gateway/server.py @@ -0,0 +1,461 @@ +""" +WebSocket Gateway Server for PraisonAI. + +Provides a WebSocket-based gateway for multi-agent coordination, +session management, and real-time communication. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from praisonaiagents import Agent + +from praisonaiagents.gateway import ( + GatewayConfig, + GatewayEvent, + GatewayMessage, + EventType, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class GatewaySession: + """A gateway session tracking a conversation between client and agent.""" + + _session_id: str + _agent_id: str + _client_id: Optional[str] = None + _is_active: bool = True + _created_at: float = field(default_factory=time.time) + _last_activity: float = field(default_factory=time.time) + _state: Dict[str, Any] = field(default_factory=dict) + _messages: List[GatewayMessage] = field(default_factory=list) + _max_messages: int = 1000 + + @property + def session_id(self) -> str: + return self._session_id + + @property + def agent_id(self) -> Optional[str]: + return self._agent_id + + @property + def client_id(self) -> Optional[str]: + return self._client_id + + @property + def is_active(self) -> bool: + return self._is_active + + @property + def created_at(self) -> float: + return self._created_at + + @property + def last_activity(self) -> float: + return self._last_activity + + def get_state(self) -> Dict[str, Any]: + return dict(self._state) + + def set_state(self, key: str, value: Any) -> None: + self._state[key] = value + self._last_activity = time.time() + + def add_message(self, message: GatewayMessage) -> None: + self._messages.append(message) + self._last_activity = time.time() + if self._max_messages > 0 and len(self._messages) > self._max_messages: + self._messages = self._messages[-self._max_messages:] + + def get_messages(self, limit: Optional[int] = None) -> List[GatewayMessage]: + if limit: + return list(self._messages[-limit:]) + return list(self._messages) + + def close(self) -> None: + self._is_active = False + + +class WebSocketGateway: + """WebSocket gateway server for multi-agent coordination. + + Implements the GatewayProtocol for WebSocket-based communication. + + Example: + from praisonai.gateway import WebSocketGateway + from praisonaiagents import Agent + + # Create gateway + gateway = WebSocketGateway(port=8765) + + # Register agents + agent = Agent(name="assistant") + gateway.register_agent(agent) + + # Start gateway + await gateway.start() + """ + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8765, + config: Optional[GatewayConfig] = None, + ): + """Initialize the gateway. + + Args: + host: Host to bind to + port: Port to listen on + config: Optional gateway configuration + """ + self.config = config or GatewayConfig(host=host, port=port) + self._host = self.config.host + self._port = self.config.port + + self._is_running = False + self._started_at: Optional[float] = None + self._server = None + + self._agents: Dict[str, "Agent"] = {} + self._sessions: Dict[str, GatewaySession] = {} + self._clients: Dict[str, Any] = {} # WebSocket connections + self._client_sessions: Dict[str, str] = {} # client_id -> session_id + + self._event_handlers: Dict[str, List[Callable]] = {} + self._message_queue: asyncio.Queue = asyncio.Queue() + + @property + def is_running(self) -> bool: + return self._is_running + + @property + def port(self) -> int: + return self._port + + @property + def host(self) -> str: + return self._host + + async def start(self) -> None: + """Start the gateway server.""" + if self._is_running: + logger.warning("Gateway already running") + return + + try: + from starlette.applications import Starlette + from starlette.routing import Route, WebSocketRoute + from starlette.responses import JSONResponse + from starlette.websockets import WebSocket, WebSocketDisconnect + import uvicorn + except ImportError: + raise ImportError( + "Gateway requires starlette and uvicorn. " + "Install with: pip install praisonai[api]" + ) + + async def health(request): + return JSONResponse(self.health()) + + async def info(request): + return JSONResponse({ + "name": "PraisonAI Gateway", + "version": "1.0.0", + "agents": list(self._agents.keys()), + "sessions": len(self._sessions), + "clients": len(self._clients), + }) + + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + client_id = str(uuid.uuid4()) + self._clients[client_id] = websocket + + logger.info(f"Client connected: {client_id}") + + await self.emit(GatewayEvent( + type=EventType.CONNECT, + data={"client_id": client_id}, + source=client_id, + )) + + try: + while True: + data = await websocket.receive_json() + await self._handle_client_message(client_id, data) + except WebSocketDisconnect: + logger.info(f"Client disconnected: {client_id}") + except Exception as e: + logger.error(f"WebSocket error: {e}") + finally: + self._clients.pop(client_id, None) + session_id = self._client_sessions.pop(client_id, None) + if session_id: + self.close_session(session_id) + + await self.emit(GatewayEvent( + type=EventType.DISCONNECT, + data={"client_id": client_id}, + source=client_id, + )) + + routes = [ + Route("/health", health, methods=["GET"]), + Route("/info", info, methods=["GET"]), + WebSocketRoute("/ws", websocket_endpoint), + ] + + app = Starlette(routes=routes) + + config = uvicorn.Config( + app, + host=self._host, + port=self._port, + log_level="warning", + ) + self._server = uvicorn.Server(config) + + self._is_running = True + self._started_at = time.time() + + logger.info(f"Gateway started on ws://{self._host}:{self._port}") + + await self._server.serve() + + async def stop(self) -> None: + """Stop the gateway server.""" + if not self._is_running: + return + + self._is_running = False + + for client_id, ws in list(self._clients.items()): + try: + await ws.close() + except Exception: + pass + + self._clients.clear() + self._client_sessions.clear() + + for session in list(self._sessions.values()): + session.close() + + if self._server: + self._server.should_exit = True + + logger.info("Gateway stopped") + + async def _handle_client_message(self, client_id: str, data: Dict[str, Any]) -> None: + """Handle a message from a client.""" + msg_type = data.get("type", "message") + + if msg_type == "join": + agent_id = data.get("agent_id") + if agent_id and agent_id in self._agents: + session = self.create_session(agent_id, client_id) + self._client_sessions[client_id] = session.session_id + await self._send_to_client(client_id, { + "type": "joined", + "session_id": session.session_id, + "agent_id": agent_id, + }) + else: + await self._send_to_client(client_id, { + "type": "error", + "message": f"Agent not found: {agent_id}", + }) + + elif msg_type == "message": + session_id = self._client_sessions.get(client_id) + if session_id: + session = self._sessions.get(session_id) + if session: + content = data.get("content", "") + message = GatewayMessage( + content=content, + sender_id=client_id, + session_id=session_id, + ) + session.add_message(message) + + response = await self._process_agent_message(session, message) + + await self._send_to_client(client_id, { + "type": "response", + "content": response, + "session_id": session_id, + }) + else: + await self._send_to_client(client_id, { + "type": "error", + "message": "Not joined to any session", + }) + + elif msg_type == "leave": + session_id = self._client_sessions.pop(client_id, None) + if session_id: + self.close_session(session_id) + await self._send_to_client(client_id, { + "type": "left", + "session_id": session_id, + }) + + async def _process_agent_message( + self, + session: GatewaySession, + message: GatewayMessage, + ) -> str: + """Process a message through the agent.""" + agent = self._agents.get(session.agent_id) + if not agent: + return "Agent not available" + + try: + response = agent.chat(message.content if isinstance(message.content, str) else str(message.content)) + + response_message = GatewayMessage( + content=response, + sender_id=session.agent_id, + session_id=session.session_id, + reply_to=message.message_id, + ) + session.add_message(response_message) + + return response + except Exception as e: + logger.error(f"Agent error: {e}") + return f"Error: {str(e)}" + + async def _send_to_client(self, client_id: str, data: Dict[str, Any]) -> None: + """Send data to a specific client.""" + ws = self._clients.get(client_id) + if ws: + try: + await ws.send_json(data) + except Exception as e: + logger.error(f"Error sending to client {client_id}: {e}") + + def register_agent(self, agent: "Agent", agent_id: Optional[str] = None) -> str: + """Register an agent with the gateway.""" + aid = agent_id or getattr(agent, "agent_id", None) or str(uuid.uuid4()) + self._agents[aid] = agent + logger.info(f"Agent registered: {aid}") + return aid + + def unregister_agent(self, agent_id: str) -> bool: + """Unregister an agent from the gateway.""" + if agent_id in self._agents: + del self._agents[agent_id] + logger.info(f"Agent unregistered: {agent_id}") + return True + return False + + def get_agent(self, agent_id: str) -> Optional["Agent"]: + """Get a registered agent by ID.""" + return self._agents.get(agent_id) + + def list_agents(self) -> List[str]: + """List all registered agent IDs.""" + return list(self._agents.keys()) + + def create_session( + self, + agent_id: str, + client_id: Optional[str] = None, + session_id: Optional[str] = None, + ) -> GatewaySession: + """Create a new session.""" + sid = session_id or str(uuid.uuid4()) + session = GatewaySession( + _session_id=sid, + _agent_id=agent_id, + _client_id=client_id, + _max_messages=self.config.session_config.max_messages, + ) + self._sessions[sid] = session + logger.info(f"Session created: {sid} for agent {agent_id}") + return session + + def get_session(self, session_id: str) -> Optional[GatewaySession]: + """Get a session by ID.""" + return self._sessions.get(session_id) + + def close_session(self, session_id: str) -> bool: + """Close a session.""" + session = self._sessions.pop(session_id, None) + if session: + session.close() + logger.info(f"Session closed: {session_id}") + return True + return False + + def list_sessions(self, agent_id: Optional[str] = None) -> List[str]: + """List session IDs, optionally filtered by agent.""" + if agent_id: + return [ + sid for sid, session in self._sessions.items() + if session.agent_id == agent_id + ] + return list(self._sessions.keys()) + + def on_event(self, event_type: str) -> Callable: + """Decorator to register an event handler.""" + def decorator(func: Callable) -> Callable: + if event_type not in self._event_handlers: + self._event_handlers[event_type] = [] + self._event_handlers[event_type].append(func) + return func + return decorator + + async def emit(self, event: GatewayEvent) -> None: + """Emit an event to registered handlers.""" + event_type = event.type.value if hasattr(event.type, "value") else str(event.type) + handlers = self._event_handlers.get(event_type, []) + for handler in handlers: + try: + if asyncio.iscoroutinefunction(handler): + await handler(event) + else: + handler(event) + except Exception as e: + logger.error(f"Event handler error: {e}") + + async def broadcast( + self, + event: GatewayEvent, + exclude: Optional[List[str]] = None, + ) -> None: + """Broadcast an event to all connected clients.""" + exclude_set = set(exclude or []) + data = event.to_dict() + + for client_id, ws in list(self._clients.items()): + if client_id not in exclude_set: + try: + await ws.send_json(data) + except Exception as e: + logger.error(f"Broadcast error to {client_id}: {e}") + + def health(self) -> Dict[str, Any]: + """Get gateway health status.""" + uptime = time.time() - self._started_at if self._started_at else 0 + return { + "status": "healthy" if self._is_running else "stopped", + "uptime": uptime, + "agents": len(self._agents), + "sessions": len(self._sessions), + "clients": len(self._clients), + } diff --git a/src/praisonai/praisonai/sandbox/__init__.py b/src/praisonai/praisonai/sandbox/__init__.py new file mode 100644 index 000000000..b67dbae51 --- /dev/null +++ b/src/praisonai/praisonai/sandbox/__init__.py @@ -0,0 +1,23 @@ +""" +Sandbox implementations for PraisonAI. + +Provides Docker and subprocess sandbox for safe code execution. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .docker import DockerSandbox + from .subprocess import SubprocessSandbox + +def __getattr__(name: str): + """Lazy loading of sandbox components.""" + if name == "DockerSandbox": + from .docker import DockerSandbox + return DockerSandbox + if name == "SubprocessSandbox": + from .subprocess import SubprocessSandbox + return SubprocessSandbox + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = ["DockerSandbox", "SubprocessSandbox"] diff --git a/src/praisonai/praisonai/sandbox/docker.py b/src/praisonai/praisonai/sandbox/docker.py new file mode 100644 index 000000000..1850d118b --- /dev/null +++ b/src/praisonai/praisonai/sandbox/docker.py @@ -0,0 +1,437 @@ +""" +Docker Sandbox implementation for PraisonAI. + +Provides isolated code execution in Docker containers. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +import time +import uuid +from typing import Any, Dict, List, Optional, Union + +from praisonaiagents.sandbox import ( + SandboxConfig, + SandboxResult, + SandboxStatus, + ResourceLimits, +) + +logger = logging.getLogger(__name__) + + +class DockerSandbox: + """Docker-based sandbox for safe code execution. + + Executes code in isolated Docker containers with resource limits + and security policies. + + Example: + from praisonai.sandbox import DockerSandbox + + sandbox = DockerSandbox(image="python:3.11-slim") + result = await sandbox.execute("print('Hello, World!')") + print(result.stdout) # Hello, World! + + Requires: Docker installed and running + """ + + def __init__( + self, + image: str = "python:3.11-slim", + config: Optional[SandboxConfig] = None, + ): + """Initialize the Docker sandbox. + + Args: + image: Docker image to use + config: Optional sandbox configuration + """ + self.config = config or SandboxConfig.docker(image) + self._image = image + self._container_id: Optional[str] = None + self._is_running = False + self._temp_dir: Optional[str] = None + + @property + def is_available(self) -> bool: + """Check if Docker is available.""" + try: + import subprocess + result = subprocess.run( + ["docker", "version"], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + except Exception: + return False + + @property + def sandbox_type(self) -> str: + return "docker" + + async def start(self) -> None: + """Start/initialize the sandbox environment.""" + if self._is_running: + return + + if not self.is_available: + raise RuntimeError("Docker is not available") + + self._temp_dir = tempfile.mkdtemp(prefix="praisonai_sandbox_") + + try: + proc = await asyncio.create_subprocess_exec( + "docker", "pull", self._image, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() + except Exception as e: + logger.warning(f"Failed to pull image: {e}") + + self._is_running = True + logger.info(f"Docker sandbox initialized with image: {self._image}") + + async def stop(self) -> None: + """Stop/cleanup the sandbox environment.""" + if not self._is_running: + return + + if self._container_id: + try: + proc = await asyncio.create_subprocess_exec( + "docker", "rm", "-f", self._container_id, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() + except Exception as e: + logger.warning(f"Failed to remove container: {e}") + self._container_id = None + + if self._temp_dir and os.path.exists(self._temp_dir): + import shutil + shutil.rmtree(self._temp_dir, ignore_errors=True) + + self._is_running = False + logger.info("Docker sandbox stopped") + + async def execute( + self, + code: str, + language: str = "python", + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> SandboxResult: + """Execute code in the sandbox.""" + if not self._is_running: + await self.start() + + limits = limits or self.config.resource_limits + execution_id = str(uuid.uuid4()) + + code_file = os.path.join(self._temp_dir, f"code_{execution_id}.py") + with open(code_file, "w") as f: + f.write(code) + + docker_cmd = self._build_docker_command( + code_file=code_file, + language=language, + limits=limits, + env=env, + working_dir=working_dir, + ) + + started_at = time.time() + + try: + proc = await asyncio.create_subprocess_exec( + *docker_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=limits.timeout_seconds, + ) + + completed_at = time.time() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.COMPLETED, + exit_code=proc.returncode, + stdout=stdout.decode("utf-8", errors="replace"), + stderr=stderr.decode("utf-8", errors="replace"), + duration_seconds=completed_at - started_at, + started_at=started_at, + completed_at=completed_at, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.TIMEOUT, + error=f"Execution timed out after {limits.timeout_seconds}s", + duration_seconds=limits.timeout_seconds, + started_at=started_at, + completed_at=time.time(), + ) + except Exception as e: + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.FAILED, + error=str(e), + duration_seconds=time.time() - started_at, + started_at=started_at, + completed_at=time.time(), + ) + finally: + if os.path.exists(code_file): + os.remove(code_file) + + async def execute_file( + self, + file_path: str, + args: Optional[List[str]] = None, + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + ) -> SandboxResult: + """Execute a file in the sandbox.""" + if not os.path.exists(file_path): + return SandboxResult( + status=SandboxStatus.FAILED, + error=f"File not found: {file_path}", + ) + + with open(file_path, "r") as f: + code = f.read() + + return await self.execute(code, limits=limits, env=env) + + async def run_command( + self, + command: Union[str, List[str]], + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> SandboxResult: + """Run a shell command in the sandbox.""" + if not self._is_running: + await self.start() + + limits = limits or self.config.resource_limits + execution_id = str(uuid.uuid4()) + + if isinstance(command, list): + cmd_str = " ".join(command) + else: + cmd_str = command + + docker_cmd = [ + "docker", "run", "--rm", + "--memory", f"{limits.memory_mb}m", + "--cpus", str(limits.cpu_percent / 100), + ] + + if not limits.network_enabled: + docker_cmd.extend(["--network", "none"]) + + if env: + for key, value in env.items(): + docker_cmd.extend(["-e", f"{key}={value}"]) + + if working_dir: + docker_cmd.extend(["-w", working_dir]) + + docker_cmd.extend([self._image, "sh", "-c", cmd_str]) + + started_at = time.time() + + try: + proc = await asyncio.create_subprocess_exec( + *docker_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=limits.timeout_seconds, + ) + + completed_at = time.time() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.COMPLETED, + exit_code=proc.returncode, + stdout=stdout.decode("utf-8", errors="replace"), + stderr=stderr.decode("utf-8", errors="replace"), + duration_seconds=completed_at - started_at, + started_at=started_at, + completed_at=completed_at, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.TIMEOUT, + error=f"Command timed out after {limits.timeout_seconds}s", + duration_seconds=limits.timeout_seconds, + started_at=started_at, + completed_at=time.time(), + ) + except Exception as e: + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.FAILED, + error=str(e), + duration_seconds=time.time() - started_at, + started_at=started_at, + completed_at=time.time(), + ) + + async def write_file( + self, + path: str, + content: Union[str, bytes], + ) -> bool: + """Write a file to the sandbox.""" + if not self._temp_dir: + return False + + full_path = os.path.join(self._temp_dir, path.lstrip("/")) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + + try: + mode = "wb" if isinstance(content, bytes) else "w" + with open(full_path, mode) as f: + f.write(content) + return True + except Exception as e: + logger.error(f"Failed to write file: {e}") + return False + + async def read_file( + self, + path: str, + ) -> Optional[Union[str, bytes]]: + """Read a file from the sandbox.""" + if not self._temp_dir: + return None + + full_path = os.path.join(self._temp_dir, path.lstrip("/")) + + if not os.path.exists(full_path): + return None + + try: + with open(full_path, "r") as f: + return f.read() + except UnicodeDecodeError: + with open(full_path, "rb") as f: + return f.read() + except Exception: + return None + + async def list_files( + self, + path: str = "/", + ) -> List[str]: + """List files in a sandbox directory.""" + if not self._temp_dir: + return [] + + full_path = os.path.join(self._temp_dir, path.lstrip("/")) + + if not os.path.exists(full_path): + return [] + + try: + files = [] + for root, dirs, filenames in os.walk(full_path): + for filename in filenames: + rel_path = os.path.relpath( + os.path.join(root, filename), + self._temp_dir, + ) + files.append("/" + rel_path) + return files + except Exception: + return [] + + def get_status(self) -> Dict[str, Any]: + """Get sandbox status information.""" + return { + "available": self.is_available, + "type": self.sandbox_type, + "running": self._is_running, + "image": self._image, + "container_id": self._container_id, + "temp_dir": self._temp_dir, + } + + async def cleanup(self) -> None: + """Clean up sandbox resources.""" + if self._temp_dir and os.path.exists(self._temp_dir): + import shutil + shutil.rmtree(self._temp_dir, ignore_errors=True) + self._temp_dir = tempfile.mkdtemp(prefix="praisonai_sandbox_") + + async def reset(self) -> None: + """Reset sandbox to initial state.""" + await self.stop() + await self.start() + + def _build_docker_command( + self, + code_file: str, + language: str, + limits: ResourceLimits, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> List[str]: + """Build the Docker run command.""" + docker_cmd = [ + "docker", "run", "--rm", + "--memory", f"{limits.memory_mb}m", + "--cpus", str(limits.cpu_percent / 100), + "--pids-limit", str(limits.max_processes), + ] + + if not limits.network_enabled: + docker_cmd.extend(["--network", "none"]) + + docker_cmd.extend(["-v", f"{code_file}:/code/script.py:ro"]) + + if env: + for key, value in env.items(): + docker_cmd.extend(["-e", f"{key}={value}"]) + + docker_cmd.extend(["-w", working_dir or "/code"]) + + docker_cmd.append(self._image) + + if language == "python": + docker_cmd.extend(["python", "/code/script.py"]) + elif language == "bash": + docker_cmd.extend(["bash", "/code/script.py"]) + else: + docker_cmd.extend(["python", "/code/script.py"]) + + return docker_cmd diff --git a/src/praisonai/praisonai/sandbox/subprocess.py b/src/praisonai/praisonai/sandbox/subprocess.py new file mode 100644 index 000000000..ae99d2908 --- /dev/null +++ b/src/praisonai/praisonai/sandbox/subprocess.py @@ -0,0 +1,349 @@ +""" +Subprocess Sandbox implementation for PraisonAI. + +Provides isolated code execution using subprocess with resource limits. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +import time +import uuid +from typing import Any, Dict, List, Optional, Union + +from praisonaiagents.sandbox import ( + SandboxConfig, + SandboxResult, + SandboxStatus, + ResourceLimits, +) + +logger = logging.getLogger(__name__) + + +class SubprocessSandbox: + """Subprocess-based sandbox for code execution. + + Executes code in isolated subprocesses with timeout limits. + Less secure than Docker but works without Docker installation. + + Example: + from praisonai.sandbox import SubprocessSandbox + + sandbox = SubprocessSandbox() + result = await sandbox.execute("print('Hello, World!')") + print(result.stdout) # Hello, World! + """ + + def __init__( + self, + config: Optional[SandboxConfig] = None, + ): + """Initialize the subprocess sandbox. + + Args: + config: Optional sandbox configuration + """ + self.config = config or SandboxConfig.subprocess() + self._is_running = False + self._temp_dir: Optional[str] = None + + @property + def is_available(self) -> bool: + """Subprocess sandbox is always available.""" + return True + + @property + def sandbox_type(self) -> str: + return "subprocess" + + async def start(self) -> None: + """Start/initialize the sandbox environment.""" + if self._is_running: + return + + self._temp_dir = tempfile.mkdtemp(prefix="praisonai_sandbox_") + self._is_running = True + logger.info("Subprocess sandbox initialized") + + async def stop(self) -> None: + """Stop/cleanup the sandbox environment.""" + if not self._is_running: + return + + if self._temp_dir and os.path.exists(self._temp_dir): + import shutil + shutil.rmtree(self._temp_dir, ignore_errors=True) + + self._is_running = False + logger.info("Subprocess sandbox stopped") + + async def execute( + self, + code: str, + language: str = "python", + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> SandboxResult: + """Execute code in the sandbox.""" + if not self._is_running: + await self.start() + + limits = limits or self.config.resource_limits + execution_id = str(uuid.uuid4()) + + code_file = os.path.join(self._temp_dir, f"code_{execution_id}.py") + with open(code_file, "w") as f: + f.write(code) + + if language == "python": + cmd = ["python", code_file] + elif language == "bash": + cmd = ["bash", code_file] + else: + cmd = ["python", code_file] + + process_env = os.environ.copy() + if env: + process_env.update(env) + + started_at = time.time() + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_dir or self._temp_dir, + env=process_env, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=limits.timeout_seconds, + ) + + completed_at = time.time() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.COMPLETED, + exit_code=proc.returncode, + stdout=stdout.decode("utf-8", errors="replace"), + stderr=stderr.decode("utf-8", errors="replace"), + duration_seconds=completed_at - started_at, + started_at=started_at, + completed_at=completed_at, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.TIMEOUT, + error=f"Execution timed out after {limits.timeout_seconds}s", + duration_seconds=limits.timeout_seconds, + started_at=started_at, + completed_at=time.time(), + ) + except Exception as e: + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.FAILED, + error=str(e), + duration_seconds=time.time() - started_at, + started_at=started_at, + completed_at=time.time(), + ) + finally: + if os.path.exists(code_file): + os.remove(code_file) + + async def execute_file( + self, + file_path: str, + args: Optional[List[str]] = None, + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + ) -> SandboxResult: + """Execute a file in the sandbox.""" + if not os.path.exists(file_path): + return SandboxResult( + status=SandboxStatus.FAILED, + error=f"File not found: {file_path}", + ) + + with open(file_path, "r") as f: + code = f.read() + + return await self.execute(code, limits=limits, env=env) + + async def run_command( + self, + command: Union[str, List[str]], + limits: Optional[ResourceLimits] = None, + env: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + ) -> SandboxResult: + """Run a shell command in the sandbox.""" + if not self._is_running: + await self.start() + + limits = limits or self.config.resource_limits + execution_id = str(uuid.uuid4()) + + if isinstance(command, str): + cmd = ["sh", "-c", command] + else: + cmd = command + + process_env = os.environ.copy() + if env: + process_env.update(env) + + started_at = time.time() + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_dir or self._temp_dir, + env=process_env, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=limits.timeout_seconds, + ) + + completed_at = time.time() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.COMPLETED, + exit_code=proc.returncode, + stdout=stdout.decode("utf-8", errors="replace"), + stderr=stderr.decode("utf-8", errors="replace"), + duration_seconds=completed_at - started_at, + started_at=started_at, + completed_at=completed_at, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.TIMEOUT, + error=f"Command timed out after {limits.timeout_seconds}s", + duration_seconds=limits.timeout_seconds, + started_at=started_at, + completed_at=time.time(), + ) + except Exception as e: + return SandboxResult( + execution_id=execution_id, + status=SandboxStatus.FAILED, + error=str(e), + duration_seconds=time.time() - started_at, + started_at=started_at, + completed_at=time.time(), + ) + + async def write_file( + self, + path: str, + content: Union[str, bytes], + ) -> bool: + """Write a file to the sandbox.""" + if not self._temp_dir: + return False + + full_path = os.path.join(self._temp_dir, path.lstrip("/")) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + + try: + mode = "wb" if isinstance(content, bytes) else "w" + with open(full_path, mode) as f: + f.write(content) + return True + except Exception as e: + logger.error(f"Failed to write file: {e}") + return False + + async def read_file( + self, + path: str, + ) -> Optional[Union[str, bytes]]: + """Read a file from the sandbox.""" + if not self._temp_dir: + return None + + full_path = os.path.join(self._temp_dir, path.lstrip("/")) + + if not os.path.exists(full_path): + return None + + try: + with open(full_path, "r") as f: + return f.read() + except UnicodeDecodeError: + with open(full_path, "rb") as f: + return f.read() + except Exception: + return None + + async def list_files( + self, + path: str = "/", + ) -> List[str]: + """List files in a sandbox directory.""" + if not self._temp_dir: + return [] + + full_path = os.path.join(self._temp_dir, path.lstrip("/")) + + if not os.path.exists(full_path): + return [] + + try: + files = [] + for root, dirs, filenames in os.walk(full_path): + for filename in filenames: + rel_path = os.path.relpath( + os.path.join(root, filename), + self._temp_dir, + ) + files.append("/" + rel_path) + return files + except Exception: + return [] + + def get_status(self) -> Dict[str, Any]: + """Get sandbox status information.""" + return { + "available": self.is_available, + "type": self.sandbox_type, + "running": self._is_running, + "temp_dir": self._temp_dir, + } + + async def cleanup(self) -> None: + """Clean up sandbox resources.""" + if self._temp_dir and os.path.exists(self._temp_dir): + import shutil + shutil.rmtree(self._temp_dir, ignore_errors=True) + self._temp_dir = tempfile.mkdtemp(prefix="praisonai_sandbox_") + + async def reset(self) -> None: + """Reset sandbox to initial state.""" + await self.stop() + await self.start() diff --git a/src/praisonai/praisonai/version.py b/src/praisonai/praisonai/version.py index 2c174e195..0bec43bd6 100644 --- a/src/praisonai/praisonai/version.py +++ b/src/praisonai/praisonai/version.py @@ -1 +1 @@ -__version__ = "3.10.27" \ No newline at end of file +__version__ = "3.11.0" \ No newline at end of file diff --git a/src/praisonai/pyproject.toml b/src/praisonai/pyproject.toml index 6b634890b..75b45ce55 100644 --- a/src/praisonai/pyproject.toml +++ b/src/praisonai/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "rich>=13.7", "markdown>=3.5", "pyparsing>=3.0.0", - "praisonaiagents>=0.13.23", + "praisonaiagents>=0.14.0", "python-dotenv>=0.19.0", "litellm>=1.81.0", "PyYAML>=6.0", diff --git a/src/praisonai/uv.lock b/src/praisonai/uv.lock index 57273ab42..e98144100 100644 --- a/src/praisonai/uv.lock +++ b/src/praisonai/uv.lock @@ -3928,7 +3928,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.13.23" }, + { name = "praisonaiagents", specifier = ">=0.14.0" }, { name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" }, { name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" }, { name = "pyparsing", specifier = ">=3.0.0" }, @@ -3973,7 +3973,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "0.13.23" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3982,9 +3982,9 @@ dependencies = [ { name = "pydantic" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/99/c285084ea9680f12e8dd07c647014dcb2dd1fa0dac021ed0656b499b1682/praisonaiagents-0.13.23.tar.gz", hash = "sha256:58e86576a451af24f44059439cc851a317b433ef1a7770d5f3e7c97da64e05aa", size = 1132960, upload-time = "2026-01-28T13:15:37.788Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/12/2093300d257228b7886f53dc598a126aa963e178a1949c462dcd502e3d11/praisonaiagents-0.14.0.tar.gz", hash = "sha256:20190c57c9602bb7a4e98d2a2d4b91d47a4da66344a3f9d1bfbe5c015d52da13", size = 1148506, upload-time = "2026-01-29T10:41:42.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f3/9131343fe7163ca2d7f4d70a2764bd0b30d227c73c28bbc08cb722918b68/praisonaiagents-0.13.23-py3-none-any.whl", hash = "sha256:a5b35c19e4b945ad841e4d06172773953558d2e85eb60e17262912a8ef608352", size = 1114213, upload-time = "2026-01-28T13:15:35.688Z" }, + { url = "https://files.pythonhosted.org/packages/24/9a/28c99919c9a1f95644916aa2c831d173d95da142730d9fcb61563e63c4f0/praisonaiagents-0.14.0-py3-none-any.whl", hash = "sha256:b45c768b6240a5fcd6e4e7c82c9ab9f9ec544ed6fa615df42a95578b35699639", size = 1133604, upload-time = "2026-01-29T10:41:39.718Z" }, ] [[package]]