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]]