Compare commits

...
21 Commits
Author SHA1 Message Date
Mervin Praison a14ca673da Release v3.12.2 2026-01-30 10:24:14 +00:00
github-actions[bot] 02d9d1e04f docs: auto-update api.md [skip ci] 2026-01-30 08:31:24 +00:00
Mervin Praison d3ee549ccc Release v3.12.1 2026-01-30 08:31:09 +00:00
github-actions[bot] 63fbd04af3 docs: auto-update api.md [skip ci] 2026-01-30 08:00:39 +00:00
Mervin Praison 51f95ad904 Release v3.12.0 2026-01-30 08:00:22 +00:00
Mervin Praison 44b800df0e Release v3.11.14 2026-01-30 05:27:32 +00:00
Mervin Praison 6e3a99b8b6 Release v3.11.13 2026-01-29 20:52:07 +00:00
Mervin Praison b7d53b8f11 Release v3.11.12 2026-01-29 18:23:32 +00:00
Mervin Praison 643fac5762 Release v3.11.11 2026-01-29 18:07:10 +00:00
Mervin Praison 92b71cbc8f Release v3.11.10 2026-01-29 17:59:29 +00:00
Mervin Praison d7555ba1b2 Release v3.11.9 2026-01-29 17:53:32 +00:00
github-actions[bot] 22dd13ec66 docs: auto-update api.md [skip ci] 2026-01-29 17:40:47 +00:00
Mervin Praison ba40e818e4 Release v3.11.8 2026-01-29 17:40:31 +00:00
Mervin Praison 12f829321c update 2026-01-29 16:39:37 +00:00
Mervin Praison 149ed5bd94 refactor(docker): move bot services to dedicated docker/bots/ folder
- Create docker/bots/ with own Dockerfile, docker-compose.yml, README
- Remove bot services from main docker-compose.yml
- Add .env.template with bot tokens
- Add README with setup instructions for Slack/Discord/Telegram
2026-01-29 16:16:31 +00:00
Mervin Praison 15f2937dd5 feat(docker): add bot Dockerfile and services
- Add Dockerfile.bot for Slack/Discord/Telegram bots
- Add slack-bot, discord-bot, telegram-bot services to docker-compose
- Add bot tokens to .env.template
- Secrets passed at runtime via environment variables
2026-01-29 16:15:23 +00:00
Mervin Praison d28199a18d feat(bots): fix asyncio error and add thread configuration
- Fix asyncio.run() error by using run_in_executor() for sync agent.chat()
- Add reply_in_thread config option (default: False for inline replies)
- Add thread_threshold for auto-threading long responses (default: 500 chars)
- Add INFO logging for message received/sent flow
2026-01-29 16:11:21 +00:00
Mervin Praison 0d3d6bb852 Release v3.11.4 2026-01-29 15:18:30 +00:00
Mervin Praison 5f93f83c9f fix: register bot/gateway/sandbox commands in CLI
- Add 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence' to LEGACY_COMMANDS in legacy.py
- Add same commands to special_commands list in main.py:797 (argparse fix)
- Fix handle_bot_command to parse list args from main.py
- 'praisonai bot slack --token $TOKEN' now works correctly
2026-01-29 14:23:40 +00:00
Mervin Praison c9786fbd1e Release v3.11.3 2026-01-29 14:17:18 +00:00
github-actions[bot] 95e8e38998 docs: auto-update api.md [skip ci] 2026-01-29 13:45:28 +00:00
75 changed files with 7216 additions and 273 deletions

No files matched your search

+71 -59
View File
@@ -23,7 +23,7 @@ Methods:
Types:
```python
from praisonaiagents import Agent, Agents, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
```
Methods:
@@ -80,6 +80,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run</a>(prompt: str, **kwargs)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">session_id</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">start</a>(prompt: str = None, **kwargs)</code>
@@ -89,45 +90,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">switch_model</a>(new_model: str) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">add_task</a>(task)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aexecute_task</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">append_to_state</a>(key: str, value: Any, max_length: Optional[int] = None) -> List[Any]</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_all_tasks</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_task</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clean_json_output</a>(output: str) -> str</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">context_manager</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">current_plan</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">default_completion_checker</a>(task, agent_output)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">delete_state</a>(key: str) -> bool</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">display_token_usage</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">execute_task</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_agent_details</a>(agent_name)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_all_state</a>() -> Dict[str, Any]</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_all_tasks_status</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_detailed_token_report</a>() -> Dict[str, Any]</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_plan_markdown</a>() -> str</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_state</a>(key: str, default: Any = None) -> Any</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_details</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_result</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_status</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_todo_markdown</a>() -> str</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_task</a>(task_id)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_output_to_file</a>(task, task_output)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> None</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class Agents">Agents.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
* <code title="class AutoRagAgent">AutoRagAgent.<a href="./src/praisonai-agents/praisonaiagents/agents/auto_rag_agent.py">achat</a>(message: str, **kwargs) -> str</code>
@@ -240,7 +202,7 @@ Methods:
Types:
```python
from praisonaiagents import Loop, Parallel, Pipeline, Repeat, Route, Workflow, WorkflowStep, loop, parallel, repeat, route
from praisonaiagents import Loop, Parallel, Pipeline, Repeat, Route, Workflow, loop, parallel, repeat, route
```
Methods:
@@ -262,22 +224,6 @@ Methods:
* <code title="class Workflow">Workflow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class Workflow">Workflow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>() -> bool</code>
* <code title="class Workflow">Workflow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>(value: bool)</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">async_execution</a>() -> bool</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">branch_condition</a>() -> Optional[Dict[str, List[str]]]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">context_from</a>() -> Optional[List[str]]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">evaluator</a>()</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">executor</a>()</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">max_retries</a>() -> int</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">next_steps</a>() -> Optional[List[str]]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">on_error</a>() -> str</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">output_file</a>() -> Optional[str]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">output_json</a>() -> Optional[Any]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">output_pydantic</a>() -> Optional[Any]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">output_variable</a>() -> Optional[str]</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">quality_check</a>() -> bool</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">rerun</a>() -> bool</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">retain_full_context</a>() -> bool</code>
* <code title="class WorkflowStep">WorkflowStep.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(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</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List) -> Parallel</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">repeat</a>(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat</code>
@@ -734,11 +680,50 @@ Methods:
Types:
```python
from praisonaiagents import EmbeddingResult, Task, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentManager, EmbeddingResult, Task, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">add_task</a>(task)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aexecute_task</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">append_to_state</a>(key: str, value: Any, max_length: Optional[int] = None) -> List[Any]</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_all_tasks</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_task</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clean_json_output</a>(output: str) -> str</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">context_manager</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">current_plan</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">default_completion_checker</a>(task, agent_output)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">delete_state</a>(key: str) -> bool</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">display_token_usage</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">execute_task</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_agent_details</a>(agent_name)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_all_state</a>() -> Dict[str, Any]</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_all_tasks_status</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_detailed_token_report</a>() -> Dict[str, Any]</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_plan_markdown</a>() -> str</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_state</a>(key: str, default: Any = None) -> Any</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_details</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_result</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_task_status</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_todo_markdown</a>() -> str</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_task</a>(task_id)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_output_to_file</a>(task, task_output)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> None</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentManager">AgentManager.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
@@ -753,7 +738,7 @@ Methods:
Types:
```python
from praisonai import CloudProvider, Deploy, DeployConfig, DeployType, PraisonAI, __version__
from praisonai import AgentApp, CloudProvider, Deploy, DeployConfig, DeployType, PraisonAI, __version__
```
# CLI
@@ -765,6 +750,7 @@ Methods:
* <code title="cli">praisonai agents create <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
* <code title="cli">praisonai batch batch-run <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch list <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
@@ -778,6 +764,19 @@ Methods:
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai browser browser-callback <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser click <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser navigate <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser open <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser profiles <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser screenshot <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser snapshot <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser status <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser type <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
@@ -881,6 +880,12 @@ Methods:
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins doctor <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins enable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins info <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins list <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins plugins-callback <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai profile imports <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile optimize <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile profile-callback <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
@@ -921,6 +926,11 @@ Methods:
* <code title="cli">praisonai rules list <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
* <code title="cli">praisonai run <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai run run-main <a href="./src/praisonai/praisonai/cli/commands/run.py">--help</a></code>
* <code title="cli">praisonai sandbox explain <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox list <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -954,7 +964,9 @@ Methods:
* <code title="cli">praisonai session list <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session resume <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session show <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai skills check <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills create <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills eligible <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
+6
View File
@@ -13,6 +13,12 @@ GOOGLE_API_KEY=your_google_api_key_here
GROQ_API_KEY=your_groq_api_key_here
COHERE_API_KEY=your_cohere_api_key_here
# Bot Tokens (for messaging bot services)
SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
SLACK_APP_TOKEN=xapp-your-slack-app-token
DISCORD_BOT_TOKEN=your-discord-bot-token
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
# Optional: Service Configuration
FLASK_ENV=development
CHAINLIT_HOST=0.0.0.0
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.11.2" \
"praisonai>=3.12.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.11.2" \
"praisonai>=3.12.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.11.2" \
"praisonai>=3.12.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+15
View File
@@ -0,0 +1,15 @@
# Bot Environment Variables
# Copy to .env and fill in your values
# Required: LLM API Key
OPENAI_API_KEY=your_openai_api_key_here
# Slack Bot (Socket Mode)
SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
SLACK_APP_TOKEN=xapp-your-slack-app-token
# Discord Bot
DISCORD_BOT_TOKEN=your-discord-bot-token
# Telegram Bot
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
+42
View File
@@ -0,0 +1,42 @@
# Dockerfile for PraisonAI Slack Bot
# Secrets are passed at runtime via environment variables
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Create praison config directory
RUN mkdir -p /root/.praison
# Install praisonai with bot dependencies
RUN pip install --no-cache-dir \
praisonai \
slack-bolt \
slack-sdk \
"discord.py>=2.0.0" \
"python-telegram-bot>=20.0"
# Set environment variables for directory management
ENV PRAISON_CONFIG_DIR=/root/.praison
ENV DOCKER_CONTAINER=true
# Bot tokens should be passed at runtime (not baked into image)
# SLACK_BOT_TOKEN=xoxb-...
# SLACK_APP_TOKEN=xapp-...
# Optional: Copy custom agent configuration
# COPY agent.yaml /app/agent.yaml
# Expose no ports - Socket Mode doesn't need them
# For webhook mode, expose port 3000
# Default command - tokens from environment
CMD ["praisonai", "bot", "slack"]
+66
View File
@@ -0,0 +1,66 @@
# PraisonAI Bot Docker Deployment
Deploy Slack, Discord, or Telegram bots using Docker.
## Quick Start
```bash
# 1. Copy environment template
cp .env.template .env
# 2. Edit .env with your tokens
nano .env
# 3. Run specific bot
docker compose up slack-bot -d
# Or run all bots
docker compose up -d
```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENAI_API_KEY` | Yes | LLM API key |
| `SLACK_BOT_TOKEN` | For Slack | Bot token (xoxb-...) |
| `SLACK_APP_TOKEN` | For Slack | App token (xapp-...) |
| `DISCORD_BOT_TOKEN` | For Discord | Bot token |
| `TELEGRAM_BOT_TOKEN` | For Telegram | Bot token |
## Slack Setup
1. Create app at https://api.slack.com/apps
2. Enable **Socket Mode** → Generate App-Level Token with `connections:write`
3. Add **OAuth Scopes**: `chat:write`, `app_mentions:read`, `im:history`
4. Enable **Event Subscriptions** → Subscribe to `app_mention`, `message.im`
5. **Install to Workspace**
6. Copy Bot Token and App Token to `.env`
## Commands
```bash
# Start Slack bot
docker compose up slack-bot -d
# View logs
docker compose logs -f slack-bot
# Stop bot
docker compose down slack-bot
# Rebuild after updates
docker compose build --no-cache slack-bot
```
## Custom Agent Configuration
Mount your agent config:
```yaml
services:
slack-bot:
volumes:
- ./agent.yaml:/app/agent.yaml
command: ["praisonai", "bot", "slack", "--agent", "/app/agent.yaml"]
```
+53
View File
@@ -0,0 +1,53 @@
version: '3.8'
# PraisonAI Bot Services
# Run specific bots: docker compose up slack-bot
# Run all bots: docker compose up
services:
# Slack Bot
slack-bot:
build:
context: ../..
dockerfile: docker/bots/Dockerfile
volumes:
- praison_data:/root/.praison
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN}
SLACK_APP_TOKEN: ${SLACK_APP_TOKEN}
PRAISON_CONFIG_DIR: /root/.praison
command: [ "praisonai", "bot", "slack" ]
restart: unless-stopped
# Discord Bot
discord-bot:
build:
context: ../..
dockerfile: docker/bots/Dockerfile
volumes:
- praison_data:/root/.praison
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
PRAISON_CONFIG_DIR: /root/.praison
command: [ "praisonai", "bot", "discord" ]
restart: unless-stopped
# Telegram Bot
telegram-bot:
build:
context: ../..
dockerfile: docker/bots/Dockerfile
volumes:
- praison_data:/root/.praison
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
PRAISON_CONFIG_DIR: /root/.praison
command: [ "praisonai", "bot", "telegram" ]
restart: unless-stopped
volumes:
praison_data:
driver: local
+5 -4
View File
@@ -22,7 +22,7 @@ services:
CHAINLIT_DB_DIR: /root/.praison
command: praisonai ui --host 0.0.0.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8082"]
test: [ "CMD", "curl", "-f", "http://localhost:8082" ]
interval: 10s
timeout: 5s
retries: 3
@@ -49,7 +49,7 @@ services:
CHAINLIT_DB_DIR: /root/.praison
command: praisonai chat --host 0.0.0.0 --port 8083
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8083"]
test: [ "CMD", "curl", "-f", "http://localhost:8083" ]
interval: 10s
timeout: 5s
retries: 3
@@ -69,7 +69,7 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY}
PRAISON_CONFIG_DIR: /root/.praison
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
test: [ "CMD", "curl", "-f", "http://localhost:8080/health" ]
interval: 10s
timeout: 5s
retries: 3
@@ -89,6 +89,7 @@ services:
PRAISON_CONFIG_DIR: /root/.praison
networks:
- praison_network
# For bot services (Slack, Discord, Telegram), see docker/bots/
volumes:
praison_data:
@@ -96,4 +97,4 @@ volumes:
networks:
praison_network:
driver: bridge
driver: bridge
+5
View File
@@ -22,6 +22,8 @@ Simpler than competitors • More extensible • Faster • Agent-centric
| **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
Easy and developer frienldy for non developers
Easy and user friendly
---
## 2. Repository Structure
@@ -372,6 +374,9 @@ class MyTool(BaseTool):
- `praisonaiagents/tools/decorator.py` - @tool decorator
- `praisonaiagents/tools/registry.py` - Tool registry
> [!IMPORTANT]
> **Agents only recognize parameters.** When creating tools, always expose all options as function parameters—agents cannot discover env vars or config files.
### 6.2 Hooks & Middleware
```python
@@ -222,8 +222,9 @@ _LAZY_IMPORTS = {
'CodeAgent': ('praisonaiagents.agent.code_agent', 'CodeAgent'),
'CodeConfig': ('praisonaiagents.agent.code_agent', 'CodeConfig'),
# Agents
'Agents': ('praisonaiagents.agents.agents', 'Agents'),
# Agents / AgentManager
'AgentManager': ('praisonaiagents.agents.agents', 'AgentManager'),
# Note: 'Agents' is handled by _custom_handler for deprecation warning
'Task': ('praisonaiagents.task.task', 'Task'),
'AutoAgents': ('praisonaiagents.agents.autoagents', 'AutoAgents'),
'AutoRagAgent': ('praisonaiagents.agents.auto_rag_agent', 'AutoRagAgent'),
@@ -233,6 +234,10 @@ _LAZY_IMPORTS = {
# Session
'Session': ('praisonaiagents.session', 'Session'),
# App (AgentApp protocol and config)
'AgentAppProtocol': ('praisonaiagents.app.protocols', 'AgentAppProtocol'),
'AgentAppConfig': ('praisonaiagents.app.config', 'AgentAppConfig'),
# MCP (optional)
'MCP': ('praisonaiagents.mcp.mcp', 'MCP'),
@@ -420,19 +425,46 @@ def _custom_handler(name, cache):
"""Handle special cases that need custom logic."""
import warnings
# PraisonAIAgents deprecation warning
if name == "PraisonAIAgents":
# Agents deprecation warning (use AgentManager instead)
if name == "Agents":
warnings.warn(
"PraisonAIAgents is deprecated, use Agents instead. "
"Example: from praisonaiagents import Agents",
"Agents is deprecated, use AgentManager instead. "
"Example: from praisonaiagents import AgentManager",
DeprecationWarning,
stacklevel=4
)
value = lazy_import('praisonaiagents.agents.agents', 'Agents', cache)
value = lazy_import('praisonaiagents.agents.agents', 'AgentManager', cache)
cache['AgentManager'] = value
cache['Agents'] = value
return value
# PraisonAIAgents deprecation warning (use AgentManager instead)
if name == "PraisonAIAgents":
warnings.warn(
"PraisonAIAgents is deprecated, use AgentManager instead. "
"Example: from praisonaiagents import AgentManager",
DeprecationWarning,
stacklevel=4
)
value = lazy_import('praisonaiagents.agents.agents', 'AgentManager', cache)
cache['AgentManager'] = value
cache['PraisonAIAgents'] = value
return value
# WorkflowStep deprecation warning (use Task instead) - Phase 4 Consolidation
if name == "WorkflowStep":
warnings.warn(
"WorkflowStep is deprecated, use Task instead. "
"Task now supports all WorkflowStep features including action, handler, loop_over, etc. "
"Example: from praisonaiagents import Task",
DeprecationWarning,
stacklevel=4
)
value = lazy_import('praisonaiagents.task.task', 'Task', cache)
cache['Task'] = value
cache['WorkflowStep'] = value
return value
# Module imports (return the module itself)
if name == 'memory':
import importlib
@@ -567,7 +599,8 @@ def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
__all__ = [
# Core classes - the essentials
'Agent',
'Agents',
'AgentManager', # Primary class for multi-agent coordination (v0.14.16+)
'Agents', # Deprecated alias for AgentManager
'Task',
# Tool essentials
@@ -3841,33 +3841,38 @@ Your Goal: {self.goal}"""
"""Internal tool execution implementation."""
# Check if approval is required for this tool
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, get_approval_callback
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, get_approval_callback, is_env_auto_approve, is_yaml_approved
if is_approval_required(function_name):
risk_level = get_risk_level(function_name)
logging.debug(f"Tool {function_name} requires approval (risk level: {risk_level})")
# Use global approval callback or default console callback
callback = get_approval_callback() or console_approval_callback
try:
decision = callback(function_name, arguments, risk_level)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Mark as approved in context to prevent double approval in decorator
# Skip approval if auto-approve env var is set or tool is YAML-approved
if is_env_auto_approve() or is_yaml_approved(function_name):
logging.debug(f"Tool {function_name} auto-approved (env={is_env_auto_approve()}, yaml={is_yaml_approved(function_name)})")
mark_approved(function_name)
else:
risk_level = get_risk_level(function_name)
logging.debug(f"Tool {function_name} requires approval (risk level: {risk_level})")
# Use modified arguments if provided
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
# Use global approval callback or default console callback
callback = get_approval_callback() or console_approval_callback
try:
decision = callback(function_name, arguments, risk_level)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
except Exception as e:
error_msg = f"Error during approval process: {str(e)}"
logging.error(error_msg)
return {"error": error_msg, "approval_error": True}
# Mark as approved in context to prevent double approval in decorator
mark_approved(function_name)
# Use modified arguments if provided
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
except Exception as e:
error_msg = f"Error during approval process: {str(e)}"
logging.error(error_msg)
return {"error": error_msg, "approval_error": True}
# Special handling for MCP tools
# Check if tools is an MCP instance with the requested function name
@@ -6560,18 +6565,23 @@ Write the complete compiled report:"""
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
# Check if approval is required for this tool
from ..approval import is_approval_required, request_approval
from ..approval import is_approval_required, request_approval, is_env_auto_approve, is_yaml_approved, mark_approved
if is_approval_required(function_name):
decision = await request_approval(function_name, arguments)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Use modified arguments if provided
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
# Skip approval if auto-approve env var is set or tool is YAML-approved
if is_env_auto_approve() or is_yaml_approved(function_name):
logging.debug(f"Tool {function_name} auto-approved (env={is_env_auto_approve()}, yaml={is_yaml_approved(function_name)})")
mark_approved(function_name)
else:
decision = await request_approval(function_name, arguments)
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Use modified arguments if provided
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
# Try to find the function in the agent's tools list first
func = None
@@ -1,11 +1,18 @@
"""Agents module for managing multiple AI agents"""
from .agents import Agents, PraisonAIAgents
"""Agents module for managing multiple AI agents.
AgentManager is the primary class for multi-agent coordination.
Agents is a deprecated alias for backward compatibility.
"""
from .agents import AgentManager, Agents, PraisonAIAgents
from .autoagents import AutoAgents
from .auto_rag_agent import AutoRagAgent, AutoRagConfig, RetrievalPolicy
from .protocols import MergeStrategyProtocol, FirstWinsMerge, ConcatMerge, DictMerge
__all__ = [
'Agents', 'PraisonAIAgents', 'AutoAgents',
'AgentManager', # Primary class (v0.14.16+)
'Agents', # Deprecated alias for AgentManager
'PraisonAIAgents', # Deprecated alias for AgentManager
'AutoAgents',
'AutoRagAgent', 'AutoRagConfig', 'RetrievalPolicy',
'MergeStrategyProtocol', 'FirstWinsMerge', 'ConcatMerge', 'DictMerge',
]
@@ -169,7 +169,34 @@ def process_task_context(context_item, verbose=0, user_id=None):
else:
return str(context_item) # Fallback for unknown types
class Agents:
class AgentManager:
"""
Multi-agent coordinator that manages and delegates work to multiple agents.
AgentManager orchestrates the execution of tasks across multiple Agent instances,
supporting sequential, parallel, and hierarchical execution patterns.
Example:
from praisonaiagents import Agent, AgentManager, Task
researcher = Agent(role="Researcher", instructions="Research topics")
writer = Agent(role="Writer", instructions="Write content")
task1 = Task(description="Research AI trends", agent=researcher)
task2 = Task(description="Write article", agent=writer)
manager = AgentManager(
agents=[researcher, writer],
tasks=[task1, task2],
process="sequential"
)
result = manager.start()
Note:
The class was renamed from `Agents` to `AgentManager` in v0.14.16.
`Agents` remains as a deprecated alias for backward compatibility.
"""
def __init__(
self,
agents,
@@ -194,7 +221,7 @@ class Agents:
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
):
"""
Initialize PraisonAIAgents with consolidated feature parameters.
Initialize AgentManager with consolidated feature parameters.
Args:
agents: List of Agent instances
@@ -2373,6 +2400,7 @@ Context:
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
# Backward compatibility alias - Agents points to Agents
Agents = Agents
PraisonAIAgents = Agents
# Backward compatibility aliases
# Agents is deprecated in favor of AgentManager (v0.14.16+)
Agents = AgentManager
PraisonAIAgents = AgentManager
@@ -0,0 +1,30 @@
"""
App module for production deployment of AI agents.
This module provides the protocol and configuration for AgentApp,
a production platform for deploying agents as web services.
The protocol is defined here in the core SDK (lightweight).
The implementation lives in the praisonai wrapper (heavy deps like FastAPI).
Example:
# Protocol is importable from core SDK
from praisonaiagents import AgentAppProtocol, AgentAppConfig
# Implementation is in wrapper
from praisonai import AgentApp
app = AgentApp(
name="My App",
agents=[researcher, writer],
)
app.serve(port=8000)
"""
from .protocols import AgentAppProtocol
from .config import AgentAppConfig
__all__ = [
'AgentAppProtocol',
'AgentAppConfig',
]
@@ -0,0 +1,55 @@
"""
AgentApp configuration.
This module defines the configuration dataclass for AgentApp.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class AgentAppConfig:
"""
Configuration for AgentApp.
This dataclass holds all configuration options for an AgentApp instance.
It follows the PraisonAI principle of sensible defaults with explicit overrides.
Attributes:
name: Name of the application (default: "PraisonAI App")
host: Host address to bind to (default: "0.0.0.0")
port: Port number to listen on (default: 8000)
reload: Enable auto-reload for development (default: False)
cors_origins: List of allowed CORS origins (default: ["*"])
api_prefix: API route prefix (default: "/api")
docs_url: URL for API documentation (default: "/docs")
openapi_url: URL for OpenAPI schema (default: "/openapi.json")
debug: Enable debug mode (default: False)
log_level: Logging level (default: "info")
workers: Number of worker processes (default: 1)
timeout: Request timeout in seconds (default: 60)
metadata: Additional metadata for the app
Example:
config = AgentAppConfig(
name="My AI App",
port=9000,
reload=True,
debug=True
)
"""
name: str = "PraisonAI App"
host: str = "0.0.0.0"
port: int = 8000
reload: bool = False
cors_origins: List[str] = field(default_factory=lambda: ["*"])
api_prefix: str = "/api"
docs_url: str = "/docs"
openapi_url: str = "/openapi.json"
debug: bool = False
log_level: str = "info"
workers: int = 1
timeout: int = 60
metadata: Dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,76 @@
"""
AgentApp protocol definitions.
This module defines the protocol (interface) for AgentApp implementations.
The protocol is lightweight and lives in the core SDK.
Implementations live in the praisonai wrapper.
"""
from typing import TYPE_CHECKING, Any, List, Optional, Protocol, runtime_checkable
if TYPE_CHECKING:
from ..agent.agent import Agent
from ..agents.agents import AgentManager
from ..workflows.workflows import Workflow
@runtime_checkable
class AgentAppProtocol(Protocol):
"""
Protocol for AgentApp implementations.
AgentApp is a production platform for deploying agents as web services.
It wraps agents, managers, and workflows into a unified API server.
Implementations should:
- Provide FastAPI-based HTTP endpoints
- Support WebSocket connections for real-time communication
- Handle agent lifecycle management
- Provide health checks and monitoring endpoints
Example implementation (in praisonai wrapper):
class AgentApp:
def __init__(
self,
name: str = "PraisonAI App",
agents: List[Agent] = None,
managers: List[AgentManager] = None,
workflows: List[Workflow] = None,
):
...
def serve(self, host: str = "0.0.0.0", port: int = 8000):
'''Start the FastAPI server.'''
...
def get_app(self):
'''Get the FastAPI app instance for custom mounting.'''
...
"""
def serve(
self,
host: Optional[str] = None,
port: Optional[int] = None,
reload: bool = False,
**kwargs: Any
) -> None:
"""
Start the AgentApp server.
Args:
host: Host address to bind to (default: "0.0.0.0")
port: Port number to listen on (default: 8000)
reload: Enable auto-reload for development (default: False)
**kwargs: Additional server configuration
"""
...
def get_app(self) -> Any:
"""
Get the underlying web application instance.
Returns:
The FastAPI application instance for custom mounting or configuration.
"""
...
@@ -40,6 +40,15 @@ class BotConfig:
max_message_length: int = 4096
retry_attempts: int = 3
timeout: int = 30
reply_in_thread: bool = False # Default to inline, set True for thread replies
thread_threshold: int = 500 # Auto-thread responses longer than this (0 = disabled)
# Default tools enabled for all bots
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web"])
# Auto-approve tool calls (useful for trusted environments)
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
@@ -57,6 +66,10 @@ class BotConfig:
"max_message_length": self.max_message_length,
"retry_attempts": self.retry_attempts,
"timeout": self.timeout,
"reply_in_thread": self.reply_in_thread,
"thread_threshold": self.thread_threshold,
"default_tools": self.default_tools,
"auto_approve_tools": self.auto_approve_tools,
"metadata": self.metadata,
}
@@ -12,16 +12,47 @@ from typing import Optional, Dict, Any, List, Callable, Union, Literal
class HookEvent(str, Enum):
"""Event names for the hook system."""
# Tool lifecycle
BEFORE_TOOL = "before_tool"
AFTER_TOOL = "after_tool"
# Agent lifecycle
BEFORE_AGENT = "before_agent"
AFTER_AGENT = "after_agent"
# Session lifecycle
SESSION_START = "session_start"
SESSION_END = "session_end"
# LLM lifecycle
BEFORE_LLM = "before_llm"
AFTER_LLM = "after_llm"
# Error handling
ON_ERROR = "on_error"
ON_RETRY = "on_retry"
# Message lifecycle (for bot/channel integrations)
MESSAGE_RECEIVED = "message_received"
MESSAGE_SENDING = "message_sending"
MESSAGE_SENT = "message_sent"
# Gateway lifecycle
GATEWAY_START = "gateway_start"
GATEWAY_STOP = "gateway_stop"
# Compaction (memory management)
BEFORE_COMPACTION = "before_compaction"
AFTER_COMPACTION = "after_compaction"
# Tool result persistence (for modifying tool results before storage)
TOOL_RESULT_PERSIST = "tool_result_persist"
# Claude Code parity events
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
SUBAGENT_STOP = "subagent_stop" # When subagent completes
SETUP = "setup" # On initialization/maintenance
# Decision types for hook outputs
+96 -11
View File
@@ -799,15 +799,26 @@ Respond with ONLY a valid JSON tool call in this format:
# For Ollama, always generate summary when we have tool results
# This prevents infinite loops caused by empty/minimal responses
# Filter out error results first
# Filter out error results and collect error messages
valid_results = []
error_messages = []
for result in tool_results:
# Skip error responses
# Check for error responses
if isinstance(result, dict) and 'error' in result:
error_messages.append(result.get('error', 'Unknown error'))
continue
if isinstance(result, list) and len(result) > 0:
first_item = result[0]
if isinstance(first_item, dict) and 'error' in first_item:
error_messages.append(first_item.get('error', 'Unknown error'))
continue
valid_results.append(result)
# If no valid results, return None to continue
# If no valid results but we have errors, return a friendly error message
if not valid_results and error_messages:
return "I'm sorry, but I wasn't able to complete the search. The service encountered some issues. Please try again later or try a different search query."
# If no valid results at all, return None to let the loop continue
if not valid_results:
return None
@@ -818,6 +829,12 @@ Respond with ONLY a valid JSON tool call in this format:
# For simple numeric results, create a more natural response
if isinstance(result, (int, float)):
return f"The result is {result}."
# For search results (list of dicts with title/url/snippet), format nicely
elif isinstance(result, list) and len(result) > 0 and isinstance(result[0], dict):
first_item = result[0]
# Check if this looks like search results
if 'title' in first_item or 'url' in first_item or 'snippet' in first_item:
return self._format_search_results_summary(result)
return str(result)
else:
# Multiple tool results - create coherent summary
@@ -840,21 +857,71 @@ Respond with ONLY a valid JSON tool call in this format:
# Join the parts naturally
return " ".join(summary_parts)
def _format_search_results_summary(self, results: List[Dict]) -> str:
"""Format search results into a natural language summary."""
if not results:
return "No search results found."
lines = ["Here's what I found:\n"]
for i, item in enumerate(results[:5], 1): # Limit to 5 results
title = item.get('title', 'Untitled')
url = item.get('url', '')
snippet = item.get('snippet', item.get('content', ''))
lines.append(f"**{i}. {title}**")
if snippet:
# Truncate long snippets
if len(snippet) > 200:
snippet = snippet[:200] + "..."
lines.append(f" {snippet}")
if url:
lines.append(f" Link: {url}")
lines.append("") # Empty line between results
return "\n".join(lines).strip()
def _format_ollama_tool_result_message(self, function_name: str, tool_result: Any) -> Dict[str, str]:
"""
Format tool result message for Ollama provider.
Enhanced to instruct model to use the result for final answer.
Handles error results with specific instructions to provide user-friendly responses.
"""
tool_result_str = str(tool_result)
return {
"role": "user",
"content": f"""Tool execution complete.
# Check if the result is an error
is_error = False
error_message = None
if isinstance(tool_result, dict) and 'error' in tool_result:
is_error = True
error_message = tool_result.get('error', 'Unknown error')
elif isinstance(tool_result, list) and len(tool_result) > 0:
first_item = tool_result[0]
if isinstance(first_item, dict) and 'error' in first_item:
is_error = True
error_message = first_item.get('error', 'Unknown error')
if is_error:
# For errors, provide clear instructions to give a helpful response
return {
"role": "user",
"content": f"""The tool "{function_name}" encountered an error:
{error_message}
Please provide a helpful response to the user explaining that the operation could not be completed.
Be apologetic and suggest alternatives if possible. Do NOT repeat the raw error message.
Give a natural, conversational response."""
}
else:
# For successful results, format normally
tool_result_str = str(tool_result)
return {
"role": "user",
"content": f"""Tool execution complete.
Function: {function_name}
Result: {tool_result_str}
Now provide your final answer using this result."""
}
Now provide your final answer using this result. Summarize the information naturally for the user."""
}
def _get_tool_names_for_prompt(self, formatted_tools: Optional[List]) -> str:
"""Extract tool names from formatted tools for prompts."""
@@ -2254,10 +2321,19 @@ Now provide your final answer using this result."""
messages.append(self._format_ollama_tool_result_message(function_name, tool_result))
else:
# For other providers, use tool role with tool_call_id
# Format error results more clearly
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
elif isinstance(tool_result, list) and len(tool_result) > 0 and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": json.dumps(tool_result) if tool_result is not None else "Function returned an empty output"
"content": content
})
# Check if we should continue (for tools like sequential thinking)
@@ -3281,10 +3357,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
messages.append(self._format_ollama_tool_result_message(function_name, tool_result))
else:
# For other providers, use tool role with tool_call_id
# Format error results more clearly
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
elif isinstance(tool_result, list) and len(tool_result) > 0 and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": json.dumps(tool_result) if tool_result is not None else "Function returned an empty output"
"content": content
})
# For Ollama, add explicit prompt if we need a final answer
@@ -32,6 +32,7 @@ __all__ = [
"PermissionManager",
"PermissionRule",
"PermissionAction",
"PermissionMode",
"PermissionResult",
"DoomLoopDetector",
]
@@ -51,6 +52,10 @@ def __getattr__(name: str):
from .rules import PermissionAction
return PermissionAction
if name == "PermissionMode":
from .rules import PermissionMode
return PermissionMode
if name == "PermissionResult":
from .rules import PermissionResult
return PermissionResult
@@ -21,6 +21,25 @@ class PermissionAction(str, Enum):
ASK = "ask"
class PermissionMode(str, Enum):
"""
Permission modes for agent execution (Claude Code parity).
These modes control how permissions are handled during agent execution:
- DEFAULT: Standard permission checking with allow/deny/ask rules
- ACCEPT_EDITS: Auto-accept file edit operations
- DONT_ASK: Auto-deny any prompts that would require user input
- BYPASS: Skip all permission checks (dangerous, use with caution)
- PLAN: Read-only exploration mode, no write operations allowed
"""
DEFAULT = "default"
ACCEPT_EDITS = "accept_edits"
DONT_ASK = "dont_ask"
BYPASS = "bypass_permissions"
PLAN = "plan"
@dataclass
class PermissionRule:
"""
@@ -28,14 +28,32 @@ class PluginHook(str, Enum):
BEFORE_TOOL = "before_tool"
AFTER_TOOL = "after_tool"
# Message hooks
# Message hooks (for bot/channel integrations)
BEFORE_MESSAGE = "before_message"
AFTER_MESSAGE = "after_message"
MESSAGE_RECEIVED = "message_received"
MESSAGE_SENDING = "message_sending"
MESSAGE_SENT = "message_sent"
# LLM hooks
BEFORE_LLM = "before_llm"
AFTER_LLM = "after_llm"
# Session hooks
SESSION_START = "session_start"
SESSION_END = "session_end"
# Gateway hooks
GATEWAY_START = "gateway_start"
GATEWAY_STOP = "gateway_stop"
# Compaction hooks (memory management)
BEFORE_COMPACTION = "before_compaction"
AFTER_COMPACTION = "after_compaction"
# Tool result persistence (for modifying tool results before storage)
TOOL_RESULT_PERSIST = "tool_result_persist"
# Permission hooks
ON_PERMISSION_ASK = "on_permission_ask"
@@ -44,6 +62,16 @@ class PluginHook(str, Enum):
# Auth hooks
ON_AUTH = "on_auth"
# Error hooks
ON_ERROR = "on_error"
ON_RETRY = "on_retry"
# Claude Code parity hooks
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
SUBAGENT_STOP = "subagent_stop" # When subagent completes
SETUP = "setup" # On initialization/maintenance
@dataclass
@@ -13,9 +13,35 @@ import time
logger = logging.getLogger(__name__)
class Task:
"""
A unit of work that can be executed by an Agent or a custom handler function.
Task is the unified abstraction for both AgentManager tasks and Workflow steps.
It supports all features from the legacy WorkflowStep class.
Simple Usage:
task = Task(description="Research AI trends")
With action alias (from WorkflowStep):
task = Task(action="Write a blog post about {{topic}}")
With custom handler function:
task = Task(
name="process_data",
action="Process the input",
handler=my_custom_function
)
With loop iteration:
task = Task(
action="Process {{item}}",
loop_over="items",
loop_var="item"
)
"""
def __init__(
self,
description: str,
description: Optional[str] = None,
expected_output: Optional[str] = None,
agent: Optional[Agent] = None,
name: Optional[str] = None,
@@ -40,14 +66,43 @@ class Task:
memory=None,
quality_check=True,
input_file: Optional[str] = None,
rerun: bool = False, # Renamed from can_rerun and logic inverted, default True for backward compatibility
retain_full_context: bool = False, # By default, only use previous task output, not all previous tasks
rerun: bool = False,
retain_full_context: bool = False,
guardrail: Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]] = None,
guardrails: Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]] = None, # Alias for guardrail
guardrails: Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]] = None,
max_retries: int = 3,
retry_count: int = 0,
agent_config: Optional[Dict[str, Any]] = None,
variables: Optional[Dict[str, Any]] = None
variables: Optional[Dict[str, Any]] = None,
# ============================================================
# ROBUSTNESS PARAMS (graceful degradation & retry control)
# ============================================================
skip_on_failure: bool = False, # Allow workflow to continue if this task fails
retry_delay: float = 0.0, # Seconds between retries (supports exponential backoff)
# ============================================================
# NEW PARAMS FROM WORKFLOWSTEP (Phase 4 Consolidation)
# ============================================================
# Action alias - alternative to description (user-friendly)
action: Optional[str] = None,
# Handler function - custom function instead of agent
handler: Optional[Callable] = None,
# Condition function - check if task should run
should_run: Optional[Callable] = None,
# Loop support - iterate over a list
loop_over: Optional[str] = None,
loop_var: str = "item",
# Consolidated config objects (from WorkflowStep)
execution: Optional[Any] = None,
routing: Optional[Any] = None,
output_config: Optional[Any] = None,
# Feature configs (from WorkflowStep)
autonomy: Optional[Any] = None,
knowledge: Optional[Any] = None,
web: Optional[Any] = None,
reflection: Optional[Any] = None,
planning: Optional[Any] = None,
hooks: Optional[Any] = None,
caching: Optional[Any] = None,
):
# Add check if memory config is provided
if memory is not None or (config and config.get('memory_config')):
@@ -60,6 +115,17 @@ class Task:
MEMORY_AVAILABLE = False
# Don't raise - let it continue with limited functionality
# Handle action as alias for description (from WorkflowStep)
# If action provided but description not, use action as description
if action is not None and description is None:
description = action
# Store both - action is the user-friendly alias
self.action = action if action is not None else description
# Validate that we have either description or action
if description is None:
raise ValueError("Task requires either 'description' or 'action' parameter")
self.input_file = input_file
self.id = str(uuid.uuid4()) if id is None else str(id)
self.name = name
@@ -95,6 +161,35 @@ class Task:
self.agent_config = agent_config # Per-task agent configuration {role, goal, backstory, llm}
self.variables = variables if variables else {} # Variables for substitution in description
# ============================================================
# ROBUSTNESS PARAMS (graceful degradation & retry control)
# ============================================================
self.skip_on_failure = skip_on_failure
self.retry_delay = retry_delay
# ============================================================
# NEW PARAMS FROM WORKFLOWSTEP (Phase 4 Consolidation)
# ============================================================
# Handler function - custom function instead of agent
self.handler = handler
# Condition function - check if task should run
self.should_run = should_run
# Loop support - iterate over a list
self.loop_over = loop_over
self.loop_var = loop_var
# Consolidated config objects (from WorkflowStep)
self.execution = execution
self.routing = routing
self.output_config = output_config
# Feature configs (from WorkflowStep)
self.autonomy = autonomy
self.knowledge = knowledge
self.web = web
self.reflection = reflection
self.planning = planning
self.hooks = hooks
self.caching = caching
# Set logger level based on config verbose level
verbose = self.config.get("verbose", 0)
if verbose >= 5:
@@ -64,6 +64,15 @@ class ShellTools:
else:
command = shlex.split(command)
# Expand tilde and environment variables in command arguments
# (shell=False means the shell won't do this for us)
command = [os.path.expanduser(os.path.expandvars(arg)) for arg in command]
# Expand tilde in cwd (subprocess doesn't do this)
if cwd:
cwd = os.path.expanduser(cwd)
cwd = os.path.expandvars(cwd) # Also expand $HOME, $USER, etc.
# Set up process environment
process_env = os.environ.copy()
if env:
@@ -15,6 +15,8 @@ def create_subagent_tool(
agent_factory: Optional[Callable[..., Any]] = None,
allowed_agents: Optional[List[str]] = None,
max_depth: int = 3,
default_llm: Optional[str] = None,
default_permission_mode: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a subagent tool for task delegation.
@@ -26,6 +28,8 @@ def create_subagent_tool(
agent_factory: Optional factory function to create agents
allowed_agents: Optional list of allowed agent names
max_depth: Maximum nesting depth for subagents
default_llm: Default LLM model for subagents (Claude Code parity)
default_permission_mode: Default permission mode for subagents (Claude Code parity)
Returns:
Tool definition dictionary
@@ -38,6 +42,8 @@ def create_subagent_tool(
agent_name: Optional[str] = None,
context: Optional[str] = None,
tools: Optional[List[str]] = None,
llm: Optional[str] = None,
permission_mode: Optional[str] = None,
) -> Dict[str, Any]:
"""
Spawn a subagent to handle a specific task.
@@ -47,6 +53,8 @@ def create_subagent_tool(
agent_name: Optional specific agent to use
context: Optional additional context
tools: Optional list of tools to give the subagent
llm: Optional LLM model override (Claude Code parity)
permission_mode: Optional permission mode (Claude Code parity)
Returns:
Result from the subagent execution
@@ -72,11 +80,17 @@ def create_subagent_tool(
try:
_current_depth += 1
# Resolve LLM (parameter > default > None)
effective_llm = llm or default_llm
# Resolve permission mode (parameter > default > None)
effective_permission_mode = permission_mode or default_permission_mode
# If we have an agent factory, use it
if agent_factory:
subagent = agent_factory(
name=agent_name or "subagent",
tools=tools,
llm=effective_llm,
)
# Build prompt with context
@@ -92,6 +106,8 @@ def create_subagent_tool(
"output": result,
"agent_name": agent_name or "subagent",
"task": task,
"llm": effective_llm,
"permission_mode": effective_permission_mode,
}
else:
# Without factory, return a placeholder
@@ -100,6 +116,8 @@ def create_subagent_tool(
"output": f"[Subagent would execute: {task}]",
"agent_name": agent_name or "subagent",
"task": task,
"llm": effective_llm,
"permission_mode": effective_permission_mode,
"note": "No agent factory provided - simulation mode",
}
@@ -142,6 +160,15 @@ def create_subagent_tool(
"items": {"type": "string"},
"description": "Optional list of tool names to give the subagent",
},
"llm": {
"type": "string",
"description": "Optional LLM model to use for the subagent (e.g., 'gpt-4o-mini', 'claude-3-sonnet')",
},
"permission_mode": {
"type": "string",
"enum": ["default", "accept_edits", "dont_ask", "bypass_permissions", "plan"],
"description": "Optional permission mode for the subagent (default, accept_edits, dont_ask, bypass_permissions, plan)",
},
},
"required": ["task"],
},
@@ -5,10 +5,11 @@ multiple search providers in order, falling back to the next if one fails.
Search Provider Priority:
1. Tavily (requires TAVILY_API_KEY + tavily-python)
2. Exa (requires EXA_API_KEY + exa_py)
3. You.com (requires YDC_API_KEY + youdotcom)
4. DuckDuckGo (requires duckduckgo_search package, no API key)
5. SearxNG (requires requests + running SearxNG instance)
2. Brave (requires BRAVE_API_KEY + requests)
3. Exa (requires EXA_API_KEY + exa_py)
4. You.com (requires YDC_API_KEY + youdotcom)
5. DuckDuckGo (requires duckduckgo_search package, no API key)
6. SearxNG (requires requests + running SearxNG instance)
Usage:
from praisonaiagents.tools import search_web
@@ -62,6 +63,15 @@ def _check_duckduckgo() -> tuple[bool, Optional[str]]:
return True, None
def _check_brave() -> tuple[bool, Optional[str]]:
"""Check if Brave Search is available."""
if not os.environ.get("BRAVE_API_KEY"):
return False, "BRAVE_API_KEY not set"
if util.find_spec("requests") is None:
return False, "requests package not installed"
return True, None
def _check_searxng() -> tuple[bool, Optional[str]]:
"""Check if SearxNG is available."""
if util.find_spec("requests") is None:
@@ -69,37 +79,51 @@ def _check_searxng() -> tuple[bool, Optional[str]]:
return True, None
def _search_tavily(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
def _search_tavily(query: str, max_results: int = 5, search_depth: str = "basic", raw_content: bool = False) -> List[Dict[str, Any]]:
"""Search using Tavily."""
from tavily import TavilyClient
client = TavilyClient(api_key=os.environ.get("TAVILY_API_KEY"))
response = client.search(query=query, max_results=max_results)
depth = os.environ.get("TAVILY_SEARCH_DEPTH", search_depth).lower()
if depth not in ("basic", "advanced"):
depth = "basic"
response = client.search(
query=query,
max_results=max_results,
search_depth=depth,
include_raw_content=raw_content
)
results = []
for r in response.get("results", []):
snippet = r.get("raw_content", "") if raw_content else r.get("content", "")
results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"snippet": r.get("content", ""),
"snippet": snippet,
"provider": "tavily"
})
return results
def _search_exa(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
"""Search using Exa."""
def _search_exa(query: str, max_results: int = 5, full_text: bool = True) -> List[Dict[str, Any]]:
"""Search using Exa.
Args:
full_text: True for full page text (more tokens), False for highlights only.
"""
from exa_py import Exa
client = Exa(os.environ.get("EXA_API_KEY"))
response = client.search(query=query, num_results=max_results)
response = client.search(query=query, num_results=max_results, text=full_text)
results = []
for r in response.results:
snippet = getattr(r, "text", "") if full_text else getattr(r, "highlights", [""])[0] if getattr(r, "highlights", None) else ""
results.append({
"title": getattr(r, "title", "") or "",
"url": r.url,
"snippet": getattr(r, "text", "") or "",
"snippet": snippet or "",
"provider": "exa"
})
return results
@@ -117,24 +141,79 @@ def _search_youdotcom(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
if hasattr(response, 'results'):
web_results = getattr(response.results, 'web', []) or []
for r in web_results[:max_results]:
description = getattr(r, "description", "") or ""
snippets = getattr(r, "snippets", []) or []
full_snippet = description
if snippets:
full_snippet += "\n" + "\n".join(snippets)
results.append({
"title": getattr(r, "title", "") or "",
"url": getattr(r, "url", "") or "",
"snippet": getattr(r, "description", "") or "",
"snippet": full_snippet.strip(),
"provider": "youdotcom"
})
elif isinstance(response, dict):
web_results = response.get("results", {}).get("web", [])
for r in web_results[:max_results]:
description = r.get("description", "")
snippets = r.get("snippets", [])
full_snippet = description
if snippets:
full_snippet += "\n" + "\n".join(snippets)
results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"snippet": r.get("description", ""),
"snippet": full_snippet.strip(),
"provider": "youdotcom"
})
return results
def _search_brave(query: str, max_results: int = 5, extra_snippets: bool = True) -> List[Dict[str, Any]]:
"""Search using Brave Search.
Args:
extra_snippets: Include extra snippets for richer content (True by default).
"""
import requests
url = "https://api.search.brave.com/res/v1/web/search"
headers = {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": os.environ.get("BRAVE_API_KEY")
}
params = {
"q": query,
"count": max_results,
"result_filter": "web"
}
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
data = response.json()
results = []
for r in data.get("web", {}).get("results", []):
description = r.get("description", "")
full_snippet = description
if extra_snippets:
extra = r.get("extra_snippets", [])
if extra:
full_snippet += "\n" + "\n".join(extra)
results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"snippet": full_snippet.strip(),
"provider": "brave"
})
return results
def _search_duckduckgo(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
"""Search using DuckDuckGo with retry logic."""
import time
@@ -207,6 +286,7 @@ def _search_searxng(query: str, max_results: int = 5, searxng_url: Optional[str]
# Provider configuration: (name, check_func, search_func)
SEARCH_PROVIDERS = [
("tavily", _check_tavily, _search_tavily),
("brave", _check_brave, _search_brave),
("exa", _check_exa, _search_exa),
("youdotcom", _check_youdotcom, _search_youdotcom),
("duckduckgo", _check_duckduckgo, _search_duckduckgo),
@@ -219,52 +299,69 @@ def search_web(
max_results: int = 5,
providers: Optional[List[str]] = None,
searxng_url: Optional[str] = None,
search_depth: str = "basic",
tavily_raw_content: bool = False,
exa_full_text: bool = True,
brave_extra_snippets: bool = True,
) -> List[Dict[str, Any]]:
"""Search the web using multiple providers with automatic fallback.
"""Search the web using one or more providers with automatic fallback.
Tries each search provider in order until one succeeds. For each provider,
it first checks if the API key is set (if required) and if the package is
installed, then attempts the search. If the search fails, it moves to the
next provider.
VALID PROVIDER NAMES: tavily, brave, exa, youdotcom, duckduckgo, searxng
Args:
query: The search query string
max_results: Maximum number of results to return (default: 5)
providers: Optional list of provider names to try, in order.
If not specified, uses default order:
["tavily", "exa", "youdotcom", "duckduckgo", "searxng"]
searxng_url: Optional custom SearxNG instance URL
query: Search query string
max_results: Maximum results to return (default: 5)
providers: Which provider(s) to use. Accepts:
- Single string: "tavily" (uses just that provider)
- Comma-separated: "tavily, brave" (tries in order, falls back on failure)
- List: ["tavily", "brave"] (same as comma-separated)
If not specified, tries all providers in order until one succeeds.
Provider-specific options (only used by their respective provider):
- searxng_url: [SearxNG] Custom instance URL
- search_depth: [Tavily] "basic" (1 credit) or "advanced" (2 credits)
- tavily_raw_content: [Tavily] Include full page content (default: False for snippets)
- exa_full_text: [Exa] Full page text (True) vs highlights (False)
- brave_extra_snippets: [Brave] Include extra snippets (True) for richer content
Returns:
List of search results, each containing:
- title: Result title
- url: Result URL
- snippet: Result description/snippet
- provider: Name of the provider that returned the result
If all providers fail, returns a list with a single error dict.
Example:
# Use default provider order
results = search_web("AI news 2024")
# Specify providers to try
results = search_web("Python tutorials", providers=["duckduckgo", "tavily"])
# With custom SearxNG URL
results = search_web("tech news", searxng_url="http://my-searxng:8080/search")
List of dicts with: title, url, snippet, provider. Returns error dict if all fail.
"""
errors = []
# Valid provider names for reference
valid_provider_names = [p[0] for p in SEARCH_PROVIDERS]
# Check for WEB_SEARCH_PROVIDER env var (set by CLI --web-provider flag)
if providers is None:
env_provider = os.environ.get("WEB_SEARCH_PROVIDER")
if env_provider:
providers = env_provider
logger.debug(f"Using provider from WEB_SEARCH_PROVIDER env var: {env_provider}")
# Determine which providers to try
if providers:
# Handle string input (LLM might pass "provider1, provider2" instead of list)
if isinstance(providers, str):
providers = [p.strip() for p in providers.split(",")]
# Filter to only valid provider names
provider_order = []
for name in providers:
name_lower = name.lower().strip()
matched = False
for pname, check_fn, search_fn in SEARCH_PROVIDERS:
if pname == name.lower():
if pname == name_lower:
provider_order.append((pname, check_fn, search_fn))
matched = True
break
if not matched:
logger.warning(f"Invalid provider '{name}'. Valid providers: {valid_provider_names}")
# If no valid providers specified, fall back to default order
if not provider_order:
logger.warning(f"No valid providers in {providers}. Using default order: {valid_provider_names}")
provider_order = SEARCH_PROVIDERS
else:
provider_order = SEARCH_PROVIDERS
@@ -282,6 +379,12 @@ def search_web(
if provider_name == "searxng":
results = search_func(query, max_results, searxng_url)
elif provider_name == "tavily":
results = _search_tavily(query, max_results, search_depth, tavily_raw_content)
elif provider_name == "exa":
results = _search_exa(query, max_results, exa_full_text)
elif provider_name == "brave":
results = _search_brave(query, max_results, brave_extra_snippets)
else:
results = search_func(query, max_results)
@@ -8,7 +8,7 @@ from .workflows import (
# Core classes
Workflow,
Pipeline, # Alias for Workflow
WorkflowStep,
WorkflowStep as _OriginalWorkflowStep, # Keep original for internal use
WorkflowContext,
StepResult,
WorkflowManager,
@@ -27,6 +27,7 @@ from .workflows import (
loop,
repeat,
include,
when,
if_,
# Constants
@@ -86,6 +87,7 @@ __all__ = [
"loop",
"repeat",
"include",
"when",
"if_",
# Constants
@@ -130,7 +132,21 @@ _LAZY_IMPORTS = {
def __getattr__(name: str):
"""Lazy import mechanism for heavy modules."""
"""Lazy import mechanism for heavy modules and deprecation handling."""
import warnings
# WorkflowStep deprecation - return Task with warning (Phase 4 Consolidation)
if name == "WorkflowStep":
warnings.warn(
"WorkflowStep is deprecated, use Task instead. "
"Task now supports all WorkflowStep features including action, handler, loop_over, etc. "
"Example: from praisonaiagents import Task",
DeprecationWarning,
stacklevel=2
)
from ..task.task import Task
return Task
if name in _LAZY_IMPORTS:
module_name = _LAZY_IMPORTS[name]
import importlib
@@ -357,14 +357,15 @@ def repeat(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None,
@dataclass
class Include:
"""
Include another recipe as a workflow step.
Include another recipe or workflow as a workflow step.
Enables modular recipe composition - recipes can include other recipes.
Enables modular composition - recipes can include other recipes or workflows.
Usage:
workflow = Workflow(steps=[
content_writer,
include("wordpress-publisher"), # Include another recipe
include(workflow=my_other_workflow), # Include another workflow
])
YAML syntax:
@@ -377,17 +378,30 @@ class Include:
recipe: wordpress-publisher
input: "{{previous_output}}"
"""
recipe: str = "" # Recipe name or path
recipe: Optional[str] = None # Recipe name or path
workflow: Optional[Any] = None # Workflow instance for composition
input: Optional[str] = None # Input override (default: previous_output)
def __init__(self, recipe: str, input: Optional[str] = None):
def __init__(self, recipe: Optional[str] = None, workflow: Optional[Any] = None, input: Optional[str] = None):
if recipe is None and workflow is None:
raise ValueError("Either 'recipe' or 'workflow' must be provided")
self.recipe = recipe
self.workflow = workflow
self.input = input
def include(recipe: str, input: Optional[str] = None) -> Include:
"""Include another recipe as a workflow step."""
return Include(recipe=recipe, input=input)
def include(recipe: Optional[str] = None, workflow: Optional[Any] = None, input: Optional[str] = None) -> Include:
"""Include another recipe or workflow as a workflow step.
Args:
recipe: Recipe name or path to include
workflow: Workflow instance to include
input: Input override (default: previous_output)
Returns:
Include object for workflow composition
"""
return Include(recipe=recipe, workflow=workflow, input=input)
# Maximum nesting depth for patterns (prevents stack overflow)
@@ -440,7 +454,7 @@ class If:
self.else_steps = else_steps or []
def if_(
def when(
condition: str,
then_steps: List[Any],
else_steps: Optional[List[Any]] = None
@@ -448,6 +462,8 @@ def if_(
"""
Create a conditional branching step.
This is the preferred function for conditional branching (cleaner name than if_).
Args:
condition: Condition expression with {{var}} placeholders
then_steps: Steps to execute if condition is true
@@ -457,7 +473,7 @@ def if_(
If object for conditional execution
Example:
if_(
when(
condition="{{score}} > 80",
then_steps=[approve_agent],
else_steps=[reject_agent]
@@ -466,6 +482,34 @@ def if_(
return If(condition=condition, then_steps=then_steps, else_steps=else_steps)
def if_(
condition: str,
then_steps: List[Any],
else_steps: Optional[List[Any]] = None
) -> If:
"""
Create a conditional branching step.
.. deprecated::
Use :func:`when` instead. `if_` will be removed in a future version.
Args:
condition: Condition expression with {{var}} placeholders
then_steps: Steps to execute if condition is true
else_steps: Steps to execute if condition is false (optional)
Returns:
If object for conditional execution
"""
import warnings
warnings.warn(
"if_() is deprecated, use when() instead for cleaner syntax",
DeprecationWarning,
stacklevel=2
)
return If(condition=condition, then_steps=then_steps, else_steps=else_steps)
@dataclass
class WorkflowStep:
"""
@@ -858,6 +902,11 @@ class Workflow:
execution: Optional[Any] = None # Union[str, ExecutionConfig] - execution control
caching: Optional[Any] = None # Union[bool, CachingConfig] - caching
# ============================================================
# ROBUSTNESS PARAMS (debugging & audit trail)
# ============================================================
history: bool = False # Enable execution history tracking for debugging
# Status tracking
status: str = "not_started" # not_started, running, completed, failed
step_statuses: Dict[str, str] = field(default_factory=dict) # {step_name: status}
@@ -880,6 +929,8 @@ class Workflow:
_guardrails_config: Optional[Any] = field(default=None, repr=False)
_web_config: Optional[Any] = field(default=None, repr=False)
_reflection_config: Optional[Any] = field(default=None, repr=False)
# Execution history for debugging (only populated when history=True)
_execution_history: List[Dict[str, Any]] = field(default_factory=list, repr=False)
def __post_init__(self):
"""Resolve consolidated params to internal values."""
@@ -1005,6 +1056,20 @@ class Workflow:
except ImportError:
pass
def get_history(self) -> List[Dict[str, Any]]:
"""Return execution trace for debugging.
Returns a list of step execution records, each containing:
- step: Step name
- timestamp: ISO format timestamp
- success: Whether the step succeeded
- output: Truncated output (first 500 chars)
- error: Error message if failed
Only populated when history=True is set on the workflow.
"""
return self._execution_history
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.14.2"
version = "0.15.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,118 @@
"""
Tests for AgentManager rename (Agents → AgentManager).
TDD: These tests define the expected behavior for the API consolidation.
"""
import pytest
import warnings
class TestAgentManagerRename:
"""Test that AgentManager is the primary class and Agents is deprecated alias."""
def test_agent_manager_importable(self):
"""AgentManager should be importable from praisonaiagents."""
from praisonaiagents import AgentManager
assert AgentManager is not None
def test_agents_still_importable(self):
"""Agents should still be importable for backward compatibility."""
from praisonaiagents import Agents
assert Agents is not None
def test_agent_manager_is_agents(self):
"""AgentManager and Agents should be the same class."""
from praisonaiagents import AgentManager, Agents
assert AgentManager is Agents
def test_agents_emits_deprecation_warning(self):
"""Importing Agents should emit a DeprecationWarning."""
# Clear any cached imports
import sys
# Remove from cache to trigger fresh import
modules_to_remove = [k for k in sys.modules.keys() if 'praisonaiagents' in k]
for mod in modules_to_remove:
del sys.modules[mod]
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
# Import fresh
import praisonaiagents
# Access Agents to trigger lazy loading
_ = praisonaiagents.Agents
# Check for deprecation warning
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
agents_warnings = [x for x in deprecation_warnings if 'Agents' in str(x.message)]
assert len(agents_warnings) >= 1, f"Expected deprecation warning for Agents, got: {[str(x.message) for x in w]}"
def test_agent_manager_no_deprecation_warning(self):
"""Importing AgentManager should NOT emit a deprecation warning."""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
from praisonaiagents import AgentManager
_ = AgentManager # Use it
# Check no deprecation warning for AgentManager
agent_manager_warnings = [x for x in w if 'AgentManager' in str(x.message) and issubclass(x.category, DeprecationWarning)]
assert len(agent_manager_warnings) == 0, f"Unexpected deprecation warning for AgentManager: {agent_manager_warnings}"
def test_agent_manager_in_all(self):
"""AgentManager should be in __all__ for IDE autocomplete."""
import praisonaiagents
assert 'AgentManager' in praisonaiagents.__all__
def test_agent_manager_class_name(self):
"""The class should be named AgentManager."""
from praisonaiagents import AgentManager
assert AgentManager.__name__ == 'AgentManager'
class TestAgentManagerFunctionality:
"""Test that AgentManager works correctly with agents and tasks."""
def test_agent_manager_instantiation(self):
"""AgentManager should be instantiable with agents list."""
from praisonaiagents import AgentManager, Agent
agent = Agent(name="test", instructions="Test agent")
manager = AgentManager(agents=[agent])
assert manager is not None
assert len(manager.agents) == 1
def test_agents_alias_instantiation(self):
"""Agents alias should work identically to AgentManager."""
from praisonaiagents import Agents, Agent
with warnings.catch_warnings():
warnings.simplefilter("ignore") # Ignore deprecation for this test
agent = Agent(name="test", instructions="Test agent")
manager = Agents(agents=[agent])
assert manager is not None
assert len(manager.agents) == 1
class TestBackwardCompatibility:
"""Test backward compatibility with existing code patterns."""
def test_from_agents_import_pattern(self):
"""Common import pattern should still work."""
from praisonaiagents.agents import Agents
assert Agents is not None
def test_from_agents_import_agent_manager(self):
"""New import pattern should work."""
from praisonaiagents.agents import AgentManager
assert AgentManager is not None
def test_praison_ai_agents_deprecated(self):
"""PraisonAIAgents should still work as deprecated alias."""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
from praisonaiagents import PraisonAIAgents
# Should emit deprecation warning
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) >= 1
@@ -0,0 +1,66 @@
"""
Tests for AgentApp protocol and configuration.
TDD: These tests define the expected behavior for the AgentApp feature.
"""
import warnings
class TestAgentAppProtocol:
"""Test that AgentAppProtocol is importable from core SDK."""
def test_agent_app_protocol_importable(self):
"""AgentAppProtocol should be importable from praisonaiagents."""
from praisonaiagents import AgentAppProtocol
assert AgentAppProtocol is not None
def test_agent_app_config_importable(self):
"""AgentAppConfig should be importable from praisonaiagents."""
from praisonaiagents import AgentAppConfig
assert AgentAppConfig is not None
class TestAgentAppProtocolInterface:
"""Test the AgentAppProtocol interface definition."""
def test_protocol_has_serve_method(self):
"""AgentAppProtocol should define a serve method."""
from praisonaiagents import AgentAppProtocol
import inspect
# Check that serve is defined in the protocol
assert hasattr(AgentAppProtocol, 'serve')
def test_protocol_has_get_app_method(self):
"""AgentAppProtocol should define a get_app method."""
from praisonaiagents import AgentAppProtocol
assert hasattr(AgentAppProtocol, 'get_app')
class TestAgentAppConfig:
"""Test AgentAppConfig dataclass."""
def test_config_default_values(self):
"""AgentAppConfig should have sensible defaults."""
from praisonaiagents import AgentAppConfig
config = AgentAppConfig()
assert config.host == "0.0.0.0"
assert config.port == 8000
assert config.reload is False
def test_config_custom_values(self):
"""AgentAppConfig should accept custom values."""
from praisonaiagents import AgentAppConfig
config = AgentAppConfig(
name="My App",
host="127.0.0.1",
port=9000,
reload=True
)
assert config.name == "My App"
assert config.host == "127.0.0.1"
assert config.port == 9000
assert config.reload is True
@@ -0,0 +1,274 @@
"""
Tests for bot CLI commands.
Tests the enhanced bot command with full capability options.
"""
from unittest.mock import patch, MagicMock
class TestBotCapabilities:
"""Tests for BotCapabilities dataclass."""
def test_bot_capabilities_defaults(self):
"""Test default values for BotCapabilities."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities()
assert caps.browser is False
assert caps.browser_profile == "default"
assert caps.browser_headless is False
assert caps.tools == []
assert caps.skills == []
assert caps.memory is False
assert caps.knowledge is False
assert caps.web_search is False
assert caps.sandbox is False
assert caps.exec_enabled is False
assert caps.model is None
def test_bot_capabilities_custom_values(self):
"""Test custom values for BotCapabilities."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities(
browser=True,
browser_profile="chrome",
tools=["DuckDuckGoTool", "WikipediaTool"],
memory=True,
web_search=True,
web_search_provider="tavily",
model="gpt-4o",
)
assert caps.browser is True
assert caps.browser_profile == "chrome"
assert caps.tools == ["DuckDuckGoTool", "WikipediaTool"]
assert caps.memory is True
assert caps.web_search is True
assert caps.web_search_provider == "tavily"
assert caps.model == "gpt-4o"
def test_bot_capabilities_to_dict(self):
"""Test to_dict method."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities(browser=True, model="gpt-4o")
result = caps.to_dict()
assert isinstance(result, dict)
assert result["browser"] is True
assert result["model"] == "gpt-4o"
assert "tools" in result
assert "memory" in result
class TestBotHandler:
"""Tests for BotHandler class."""
def test_bot_handler_init(self):
"""Test BotHandler initialization."""
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
assert handler is not None
@patch('praisonai.cli.features.bots_cli.BotHandler._load_agent')
def test_build_tools_browser(self, mock_load_agent):
"""Test _build_tools with browser enabled."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
caps = BotCapabilities(browser=True)
# Mock the browser tool import
with patch.dict('sys.modules', {'praisonai_tools': MagicMock()}):
tools = handler._build_tools(caps)
# Should attempt to add browser tool
assert isinstance(tools, list)
@patch('praisonai.cli.features.bots_cli.BotHandler._load_agent')
def test_build_tools_web_search(self, mock_load_agent):
"""Test _build_tools with web search enabled."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
caps = BotCapabilities(web_search=True, web_search_provider="duckduckgo")
with patch.dict('sys.modules', {'praisonai_tools': MagicMock()}):
tools = handler._build_tools(caps)
assert isinstance(tools, list)
def test_resolve_tool_by_name_not_found(self):
"""Test _resolve_tool_by_name with non-existent tool."""
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
result = handler._resolve_tool_by_name("NonExistentTool12345")
assert result is None
def test_print_startup_info_no_capabilities(self, capsys):
"""Test _print_startup_info without capabilities."""
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
handler._print_startup_info("Telegram", None)
captured = capsys.readouterr()
assert "Starting Telegram bot" in captured.out
assert "Press Ctrl+C to stop" in captured.out
def test_print_startup_info_with_capabilities(self, capsys):
"""Test _print_startup_info with capabilities."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
caps = BotCapabilities(
browser=True,
memory=True,
web_search=True,
)
handler._print_startup_info("Slack", caps)
captured = capsys.readouterr()
assert "Starting Slack bot" in captured.out
assert "Capabilities:" in captured.out
assert "browser" in captured.out
assert "memory" in captured.out
class TestHandleBotCommand:
"""Tests for handle_bot_command function."""
def test_handle_bot_command_no_platform(self, capsys):
"""Test handle_bot_command with no platform."""
from praisonai.cli.features.bots_cli import handle_bot_command
result = handle_bot_command([])
assert result == 1
captured = capsys.readouterr()
assert "Available platforms" in captured.out
def test_handle_bot_command_help_output(self, capsys):
"""Test handle_bot_command shows help."""
from praisonai.cli.features.bots_cli import handle_bot_command
handle_bot_command([])
captured = capsys.readouterr()
assert "telegram" in captured.out.lower()
assert "discord" in captured.out.lower()
assert "slack" in captured.out.lower()
class TestBuildCapabilitiesFromArgs:
"""Tests for _build_capabilities_from_args function."""
def test_build_capabilities_from_args_defaults(self):
"""Test building capabilities from args with defaults."""
from praisonai.cli.features.bots_cli import _build_capabilities_from_args
class MockArgs:
browser = False
browser_profile = "default"
browser_headless = False
tools = []
skills = []
skills_dir = None
memory = False
memory_provider = "default"
knowledge = False
knowledge_sources = []
web_search = False
web_provider = "duckduckgo"
sandbox = False
exec_enabled = False
model = None
thinking = None
session_id = None
user_id = None
caps = _build_capabilities_from_args(MockArgs())
assert caps.browser is False
assert caps.memory is False
assert caps.web_search is False
def test_build_capabilities_from_args_custom(self):
"""Test building capabilities from args with custom values."""
from praisonai.cli.features.bots_cli import _build_capabilities_from_args
class MockArgs:
browser = True
browser_profile = "chrome"
browser_headless = True
tools = ["DuckDuckGoTool"]
skills = []
skills_dir = None
memory = True
memory_provider = "redis"
knowledge = False
knowledge_sources = []
web_search = True
web_provider = "tavily"
sandbox = False
exec_enabled = False
model = "gpt-4o"
thinking = "medium"
session_id = "test-session"
user_id = "user-123"
caps = _build_capabilities_from_args(MockArgs())
assert caps.browser is True
assert caps.browser_profile == "chrome"
assert caps.tools == ["DuckDuckGoTool"]
assert caps.memory is True
assert caps.web_search is True
assert caps.model == "gpt-4o"
class TestAddCapabilityArgs:
"""Tests for _add_capability_args function."""
def test_add_capability_args(self):
"""Test that capability args are added to parser."""
import argparse
from praisonai.cli.features.bots_cli import _add_capability_args
parser = argparse.ArgumentParser()
_add_capability_args(parser)
# Parse with defaults
args = parser.parse_args([])
assert hasattr(args, 'browser')
assert hasattr(args, 'memory')
assert hasattr(args, 'web_search')
assert hasattr(args, 'tools')
assert hasattr(args, 'model')
def test_add_capability_args_with_values(self):
"""Test parsing capability args with values."""
import argparse
from praisonai.cli.features.bots_cli import _add_capability_args
parser = argparse.ArgumentParser()
_add_capability_args(parser)
args = parser.parse_args([
'--browser',
'--memory',
'--web',
'--model', 'gpt-4o',
'--tools', 'DuckDuckGoTool', 'WikipediaTool',
])
assert args.browser is True
assert args.memory is True
assert args.web_search is True
assert args.model == "gpt-4o"
assert args.tools == ["DuckDuckGoTool", "WikipediaTool"]
@@ -33,6 +33,34 @@ from praisonaiagents.hooks.runner import HookRunner
# HookResult Tests
# =============================================================================
class TestHookEvent:
"""Tests for HookEvent enum - Claude Code parity."""
def test_claude_code_parity_events(self):
"""Test Claude Code parity hook events."""
# USER_PROMPT_SUBMIT - when user submits a prompt
assert HookEvent.USER_PROMPT_SUBMIT.value == "user_prompt_submit"
# NOTIFICATION - when notification is sent
assert HookEvent.NOTIFICATION.value == "notification"
# SUBAGENT_STOP - when subagent completes
assert HookEvent.SUBAGENT_STOP.value == "subagent_stop"
# SETUP - on initialization/maintenance
assert HookEvent.SETUP.value == "setup"
def test_existing_events_unchanged(self):
"""Verify existing events are unchanged (backward compatibility)."""
assert HookEvent.BEFORE_TOOL.value == "before_tool"
assert HookEvent.AFTER_TOOL.value == "after_tool"
assert HookEvent.BEFORE_AGENT.value == "before_agent"
assert HookEvent.AFTER_AGENT.value == "after_agent"
assert HookEvent.SESSION_START.value == "session_start"
assert HookEvent.SESSION_END.value == "session_end"
assert HookEvent.BEFORE_LLM.value == "before_llm"
assert HookEvent.AFTER_LLM.value == "after_llm"
assert HookEvent.ON_ERROR.value == "on_error"
assert HookEvent.ON_RETRY.value == "on_retry"
class TestHookResult:
"""Tests for HookResult class."""
@@ -29,6 +29,25 @@ class TestPermissionAction:
assert PermissionAction.ASK.value == "ask"
class TestPermissionMode:
"""Tests for PermissionMode enum - Claude Code parity."""
def test_mode_values(self):
"""Test permission mode enum values."""
from praisonaiagents.permissions.rules import PermissionMode
# DEFAULT - Standard permission checking
assert PermissionMode.DEFAULT.value == "default"
# ACCEPT_EDITS - Auto-accept file edits
assert PermissionMode.ACCEPT_EDITS.value == "accept_edits"
# DONT_ASK - Auto-deny prompts
assert PermissionMode.DONT_ASK.value == "dont_ask"
# BYPASS - Skip all checks (dangerous)
assert PermissionMode.BYPASS.value == "bypass_permissions"
# PLAN - Read-only exploration
assert PermissionMode.PLAN.value == "plan"
class TestPermissionRule:
"""Tests for PermissionRule."""
@@ -27,6 +27,47 @@ class TestPluginHook:
assert PluginHook.ON_INIT.value == "on_init"
assert PluginHook.BEFORE_TOOL.value == "before_tool"
assert PluginHook.AFTER_AGENT.value == "after_agent"
def test_message_hooks(self):
"""Test message lifecycle hooks (moltbot parity)."""
assert PluginHook.MESSAGE_RECEIVED.value == "message_received"
assert PluginHook.MESSAGE_SENDING.value == "message_sending"
assert PluginHook.MESSAGE_SENT.value == "message_sent"
def test_gateway_hooks(self):
"""Test gateway lifecycle hooks (moltbot parity)."""
assert PluginHook.GATEWAY_START.value == "gateway_start"
assert PluginHook.GATEWAY_STOP.value == "gateway_stop"
def test_session_hooks(self):
"""Test session lifecycle hooks."""
assert PluginHook.SESSION_START.value == "session_start"
assert PluginHook.SESSION_END.value == "session_end"
def test_compaction_hooks(self):
"""Test compaction hooks (memory management)."""
assert PluginHook.BEFORE_COMPACTION.value == "before_compaction"
assert PluginHook.AFTER_COMPACTION.value == "after_compaction"
def test_error_hooks(self):
"""Test error handling hooks."""
assert PluginHook.ON_ERROR.value == "on_error"
assert PluginHook.ON_RETRY.value == "on_retry"
def test_tool_result_persist_hook(self):
"""Test tool_result_persist hook (moltbot parity)."""
assert PluginHook.TOOL_RESULT_PERSIST.value == "tool_result_persist"
def test_claude_code_parity_hooks(self):
"""Test Claude Code parity hooks (new additions)."""
# USER_PROMPT_SUBMIT - when user submits a prompt
assert PluginHook.USER_PROMPT_SUBMIT.value == "user_prompt_submit"
# NOTIFICATION - when notification is sent
assert PluginHook.NOTIFICATION.value == "notification"
# SUBAGENT_STOP - when subagent completes
assert PluginHook.SUBAGENT_STOP.value == "subagent_stop"
# SETUP - on initialization/maintenance
assert PluginHook.SETUP.value == "setup"
class TestPluginInfo:
@@ -0,0 +1 @@
"""Unit tests for Task module."""
@@ -0,0 +1,199 @@
"""
Tests for PraisonAI Robustness Improvements.
Tests the following features:
1. Task.skip_on_failure - Allow workflows to continue when one step fails
2. Task.retry_delay - Exponential backoff for retries
3. Workflow.history - Execution trace for debugging
4. Include(workflow=) - Workflow composition
5. when() - Alias for if_() with cleaner name
TDD: These tests are written FIRST before implementation.
"""
import warnings
import pytest
class TestTaskSkipOnFailure:
"""Tests for Task.skip_on_failure parameter."""
def test_task_skip_on_failure_default_false(self):
"""skip_on_failure should default to False."""
from praisonaiagents import Task
task = Task(description="Test task")
assert task.skip_on_failure is False
def test_task_skip_on_failure_true(self):
"""Task should accept skip_on_failure=True."""
from praisonaiagents import Task
task = Task(description="Optional step", skip_on_failure=True)
assert task.skip_on_failure is True
def test_task_skip_on_failure_false_explicit(self):
"""Task should accept skip_on_failure=False explicitly."""
from praisonaiagents import Task
task = Task(description="Critical step", skip_on_failure=False)
assert task.skip_on_failure is False
class TestTaskRetryDelay:
"""Tests for Task.retry_delay parameter."""
def test_task_retry_delay_default_zero(self):
"""retry_delay should default to 0.0."""
from praisonaiagents import Task
task = Task(description="Test task")
assert task.retry_delay == 0.0
def test_task_retry_delay_custom_value(self):
"""Task should accept custom retry_delay."""
from praisonaiagents import Task
task = Task(description="Test task", retry_delay=1.0)
assert task.retry_delay == 1.0
def test_task_retry_delay_float(self):
"""retry_delay should accept float values."""
from praisonaiagents import Task
task = Task(description="Test task", retry_delay=0.5)
assert task.retry_delay == 0.5
def test_task_retry_delay_with_max_retries(self):
"""retry_delay should work alongside max_retries."""
from praisonaiagents import Task
task = Task(description="Test task", retry_delay=2.0, max_retries=5)
assert task.retry_delay == 2.0
assert task.max_retries == 5
class TestWorkflowHistory:
"""Tests for Workflow.history parameter."""
def test_workflow_history_default_false(self):
"""history should default to False."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[])
assert workflow.history is False
def test_workflow_history_true(self):
"""Workflow should accept history=True."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[], history=True)
assert workflow.history is True
def test_workflow_execution_history_initialized(self):
"""_execution_history should be initialized as empty list."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[], history=True)
assert hasattr(workflow, '_execution_history')
assert workflow._execution_history == []
def test_workflow_get_history_method_exists(self):
"""Workflow should have get_history() method."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[], history=True)
assert hasattr(workflow, 'get_history')
assert callable(workflow.get_history)
def test_workflow_get_history_returns_list(self):
"""get_history() should return a list."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[], history=True)
history = workflow.get_history()
assert isinstance(history, list)
class TestIncludeWorkflow:
"""Tests for Include with workflow parameter."""
def test_include_with_recipe(self):
"""Include should work with recipe parameter (existing behavior)."""
from praisonaiagents.workflows import include
inc = include(recipe="wordpress-publisher")
assert inc.recipe == "wordpress-publisher"
def test_include_with_workflow(self):
"""Include should accept workflow parameter."""
from praisonaiagents.workflows import include, Workflow
sub_workflow = Workflow(name="sub", steps=[])
inc = include(workflow=sub_workflow)
assert inc.workflow is sub_workflow
def test_include_requires_recipe_or_workflow(self):
"""Include should raise error if neither recipe nor workflow provided."""
from praisonaiagents.workflows import Include
with pytest.raises(ValueError, match="Either 'recipe' or 'workflow' must be provided"):
Include()
def test_include_with_workflow_and_input(self):
"""Include should accept workflow with input override."""
from praisonaiagents.workflows import include, Workflow
sub_workflow = Workflow(name="sub", steps=[])
inc = include(workflow=sub_workflow, input="custom input")
assert inc.workflow is sub_workflow
assert inc.input == "custom input"
class TestWhenAlias:
"""Tests for when() alias for if_()."""
def test_when_function_exists(self):
"""when() function should exist."""
from praisonaiagents.workflows import when
assert callable(when)
def test_when_returns_if_object(self):
"""when() should return an If object."""
from praisonaiagents.workflows import when, If
result = when(condition="True", then_steps=[])
assert isinstance(result, If)
def test_when_with_condition_and_then_steps(self):
"""when() should accept condition and then_steps."""
from praisonaiagents.workflows import when
result = when(
condition="{{score}} > 80",
then_steps=["step1", "step2"]
)
assert result.condition == "{{score}} > 80"
assert result.then_steps == ["step1", "step2"]
def test_when_with_else_steps(self):
"""when() should accept optional else_steps."""
from praisonaiagents.workflows import when
result = when(
condition="{{approved}}",
then_steps=["approve"],
else_steps=["reject"]
)
assert result.then_steps == ["approve"]
assert result.else_steps == ["reject"]
def test_if_deprecated_warning(self):
"""if_() should show deprecation warning."""
from praisonaiagents.workflows import if_
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
_ = if_(condition="True", then_steps=[])
# Check deprecation warning was issued
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) >= 1
assert "when()" in str(deprecation_warnings[0].message)
class TestExportsAndImports:
"""Tests for proper exports in __init__.py files."""
def test_when_exported_from_workflows(self):
"""when should be exported from praisonaiagents.workflows."""
from praisonaiagents.workflows import when
assert callable(when)
def test_when_in_workflows_all(self):
"""when should be in workflows __all__."""
import praisonaiagents.workflows as workflows_module
assert 'when' in workflows_module.__all__
@@ -0,0 +1,382 @@
"""
Tests for Task + WorkflowStep Consolidation (Phase 4).
Tests the new parameters added to Task from WorkflowStep:
- action (alias for description)
- handler (custom function instead of agent)
- should_run (condition function)
- loop_over / loop_var (iteration support)
- execution, routing, output (consolidated configs)
- autonomy, knowledge, web, reflection, planning, hooks, caching (feature configs)
TDD: These tests are written FIRST before implementation.
"""
import warnings
class TestTaskActionAlias:
"""Test that 'action' works as an alias for 'description'."""
def test_action_as_description_alias(self):
"""When action is provided without description, action becomes description."""
from praisonaiagents import Task
task = Task(action="Write a blog post about AI")
assert task.description == "Write a blog post about AI"
assert task.action == "Write a blog post about AI"
def test_description_takes_precedence(self):
"""When both action and description provided, description takes precedence."""
from praisonaiagents import Task
task = Task(
description="Primary description",
action="Secondary action"
)
assert task.description == "Primary description"
assert task.action == "Secondary action"
def test_action_attribute_always_set(self):
"""Task.action should always be set (from action or description)."""
from praisonaiagents import Task
task = Task(description="Research AI trends")
assert task.action == "Research AI trends"
class TestTaskHandler:
"""Test that Task supports handler functions (from WorkflowStep)."""
def test_handler_param_exists(self):
"""Task should accept a handler parameter."""
from praisonaiagents import Task
def my_handler(context):
return {"output": "processed"}
task = Task(
description="Process data",
handler=my_handler
)
assert task.handler is my_handler
def test_handler_default_none(self):
"""Handler should default to None."""
from praisonaiagents import Task
task = Task(description="Simple task")
assert task.handler is None
def test_handler_callable_check(self):
"""Handler must be callable if provided."""
from praisonaiagents import Task
def valid_handler(ctx):
return "result"
task = Task(description="Test", handler=valid_handler)
assert callable(task.handler)
class TestTaskShouldRun:
"""Test that Task supports should_run condition (from WorkflowStep)."""
def test_should_run_param_exists(self):
"""Task should accept a should_run parameter."""
from praisonaiagents import Task
def check_condition(context):
return context.get("enabled", False)
task = Task(
description="Conditional task",
should_run=check_condition
)
assert task.should_run is check_condition
def test_should_run_default_none(self):
"""should_run should default to None (always run)."""
from praisonaiagents import Task
task = Task(description="Always runs")
assert task.should_run is None
class TestTaskLoopSupport:
"""Test that Task supports loop_over and loop_var (from WorkflowStep)."""
def test_loop_over_param_exists(self):
"""Task should accept loop_over parameter."""
from praisonaiagents import Task
task = Task(
description="Process {{item}}",
loop_over="items"
)
assert task.loop_over == "items"
def test_loop_var_param_exists(self):
"""Task should accept loop_var parameter with default 'item'."""
from praisonaiagents import Task
task = Task(
description="Process {{item}}",
loop_over="items"
)
assert task.loop_var == "item"
def test_loop_var_custom_value(self):
"""Task should accept custom loop_var value."""
from praisonaiagents import Task
task = Task(
description="Process {{doc}}",
loop_over="documents",
loop_var="doc"
)
assert task.loop_var == "doc"
def test_loop_defaults_none(self):
"""loop_over should default to None."""
from praisonaiagents import Task
task = Task(description="No loop")
assert task.loop_over is None
class TestTaskConsolidatedConfigs:
"""Test that Task supports consolidated config objects (from WorkflowStep)."""
def test_execution_config_dict(self):
"""Task should accept execution config as dict."""
from praisonaiagents import Task
task = Task(
description="Execute task",
execution={"max_retries": 5, "async_exec": True}
)
assert task.execution is not None
def test_routing_config_dict(self):
"""Task should accept routing config as dict."""
from praisonaiagents import Task
task = Task(
description="Route task",
routing={"next": ["task_b"], "on_error": "stop"}
)
assert task.routing is not None
def test_output_config_dict(self):
"""Task should accept output config as dict (alternative to output_file/json)."""
from praisonaiagents import Task
task = Task(
description="Output task",
output_config={"file": "result.txt", "variable": "result"}
)
assert task.output_config is not None
class TestTaskFeatureConfigs:
"""Test that Task supports feature configs (from WorkflowStep)."""
def test_autonomy_param(self):
"""Task should accept autonomy parameter."""
from praisonaiagents import Task
task = Task(description="Autonomous task", autonomy=True)
assert task.autonomy is True
def test_knowledge_param(self):
"""Task should accept knowledge parameter."""
from praisonaiagents import Task
task = Task(description="Knowledge task", knowledge=["docs/"])
assert task.knowledge == ["docs/"]
def test_web_param(self):
"""Task should accept web parameter."""
from praisonaiagents import Task
task = Task(description="Web task", web=True)
assert task.web is True
def test_reflection_param(self):
"""Task should accept reflection parameter."""
from praisonaiagents import Task
task = Task(description="Reflective task", reflection=True)
assert task.reflection is True
def test_planning_param(self):
"""Task should accept planning parameter."""
from praisonaiagents import Task
task = Task(description="Planning task", planning=True)
assert task.planning is True
def test_hooks_param(self):
"""Task should accept hooks parameter."""
from praisonaiagents import Task
def my_hook(ctx):
pass
task = Task(description="Hooked task", hooks=[my_hook])
assert task.hooks == [my_hook]
def test_caching_param(self):
"""Task should accept caching parameter."""
from praisonaiagents import Task
task = Task(description="Cached task", caching=True)
assert task.caching is True
class TestTaskBackwardCompatibility:
"""Ensure existing Task API still works unchanged."""
def test_basic_task_creation(self):
"""Basic Task creation should work as before."""
from praisonaiagents import Task
task = Task(description="Simple task")
assert task.description == "Simple task"
assert task.status == "not started"
def test_task_with_expected_output(self):
"""Task with expected_output should work."""
from praisonaiagents import Task
task = Task(
description="Research task",
expected_output="A detailed report"
)
assert task.expected_output == "A detailed report"
def test_task_with_tools(self):
"""Task with tools should work."""
from praisonaiagents import Task
def my_tool():
pass
task = Task(description="Tool task", tools=[my_tool])
assert my_tool in task.tools
def test_task_with_output_file(self):
"""Task with output_file should still work."""
from praisonaiagents import Task
task = Task(
description="File output task",
output_file="output.txt"
)
assert task.output_file == "output.txt"
def test_task_with_guardrails(self):
"""Task with guardrails should still work."""
from praisonaiagents import Task
def my_guardrail(output):
return (True, output)
task = Task(
description="Guarded task",
guardrails=my_guardrail
)
assert task.guardrail is my_guardrail
class TestWorkflowStepDeprecation:
"""Test that WorkflowStep shows deprecation warning and creates Task."""
def test_workflowstep_import_shows_warning(self):
"""Importing WorkflowStep should show deprecation warning."""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
from praisonaiagents import WorkflowStep
# Check that a deprecation warning was issued
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) >= 1
assert "WorkflowStep" in str(deprecation_warnings[0].message)
assert "Task" in str(deprecation_warnings[0].message)
def test_workflowstep_creates_task_instance(self):
"""WorkflowStep(...) should create a Task instance."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from praisonaiagents import WorkflowStep, Task
step = WorkflowStep(
name="writer",
action="Write content"
)
# Should be a Task instance
assert isinstance(step, Task)
def test_workflowstep_params_pass_through(self):
"""WorkflowStep params should pass through to Task."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from praisonaiagents import WorkflowStep
def my_handler(ctx):
return "result"
step = WorkflowStep(
name="processor",
action="Process data",
handler=my_handler,
loop_over="items"
)
assert step.name == "processor"
assert step.action == "Process data"
assert step.handler is my_handler
assert step.loop_over == "items"
class TestWorkflowWithTask:
"""Test that Workflow works with Task instances."""
def test_workflow_accepts_task_in_steps(self):
"""Workflow should accept Task instances in steps parameter."""
from praisonaiagents import Task
from praisonaiagents.workflows import Workflow
task1 = Task(name="step1", action="First step")
task2 = Task(name="step2", action="Second step")
workflow = Workflow(steps=[task1, task2])
assert len(workflow.steps) == 2
@@ -35,9 +35,10 @@ class TestSubagentTool:
def test_subagent_with_factory(self):
"""Test subagent execution with factory."""
class MockAgent:
def __init__(self, name, tools=None):
def __init__(self, name, tools=None, llm=None):
self.name = name
self.tools = tools
self.llm = llm
def chat(self, prompt):
return f"Executed: {prompt}"
@@ -87,6 +88,48 @@ class TestSubagentTool:
)
assert result["success"] is True
def test_subagent_with_llm_selection(self):
"""Test subagent with custom LLM model selection (Claude Code parity)."""
class MockAgent:
def __init__(self, name, tools=None, llm=None):
self.name = name
self.tools = tools
self.llm = llm
def chat(self, prompt):
return f"Model: {self.llm}, Task: {prompt}"
tool = create_subagent_tool(
agent_factory=lambda **kwargs: MockAgent(**kwargs),
default_llm="gpt-4o-mini"
)
func = tool["function"]
# Test with default LLM
result = func(task="Test task")
assert result["success"] is True
assert "gpt-4o-mini" in result["output"]
# Test with custom LLM override
result2 = func(task="Test task", llm="claude-3-sonnet")
assert result2["success"] is True
assert "claude-3-sonnet" in result2["output"]
def test_subagent_with_permission_mode(self):
"""Test subagent with permission mode (Claude Code parity)."""
tool = create_subagent_tool()
func = tool["function"]
# Test with permission_mode parameter
result = func(
task="Analyze code",
permission_mode="plan" # Read-only mode
)
assert result["success"] is True
# Permission mode should be passed through
assert result.get("permission_mode") == "plan"
class TestBatchTool:
+1 -1
View File
@@ -2549,7 +2549,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.14.2"
version = "0.15.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.11.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.11.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.12.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.12.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+4
View File
@@ -18,6 +18,7 @@ __all__ = [
'DeployConfig',
'DeployType',
'CloudProvider',
'AgentApp', # Production deployment platform (v0.14.16+)
'recipe',
'embed',
'embedding',
@@ -62,6 +63,9 @@ def __getattr__(name):
elif name == 'EmbeddingResult':
from praisonaiagents.embedding import EmbeddingResult
return EmbeddingResult
elif name == 'AgentApp':
from .app import AgentApp
return AgentApp
# Try praisonaiagents exports
try:
+23
View File
@@ -0,0 +1,23 @@
"""
AgentApp module for production deployment of AI agents.
This module provides the AgentApp class which implements the AgentAppProtocol
from the core SDK. It wraps agents, managers, and workflows into a unified
FastAPI-based web service.
Example:
from praisonai import AgentApp
from praisonaiagents import Agent
assistant = Agent(name="assistant", instructions="Be helpful")
app = AgentApp(
name="My AI App",
agents=[assistant],
)
app.serve(port=8000)
"""
from .app import AgentApp
__all__ = ['AgentApp']
+237
View File
@@ -0,0 +1,237 @@
"""
AgentApp implementation for production deployment.
This module provides the AgentApp class which implements the AgentAppProtocol.
It creates a FastAPI-based web service for deploying agents.
"""
from typing import Any, Dict, List, Optional, Union
from praisonaiagents import AgentAppConfig, AgentAppProtocol
class AgentApp:
"""
Production platform for deploying AI agents as web services.
AgentApp wraps agents, managers, and workflows into a unified FastAPI
application with REST and WebSocket endpoints.
Example:
from praisonai import AgentApp
from praisonaiagents import Agent
assistant = Agent(name="assistant", instructions="Be helpful")
# Simple usage
app = AgentApp(agents=[assistant])
app.serve(port=8000)
# With configuration
app = AgentApp(
name="My AI App",
agents=[assistant],
config=AgentAppConfig(port=9000, reload=True)
)
app.serve()
Attributes:
name: Name of the application
agents: List of Agent instances
managers: List of AgentManager instances
workflows: List of Workflow instances
config: AgentAppConfig instance
"""
def __init__(
self,
name: str = "PraisonAI App",
agents: Optional[List[Any]] = None,
managers: Optional[List[Any]] = None,
workflows: Optional[List[Any]] = None,
config: Optional[AgentAppConfig] = None,
**kwargs: Any
):
"""
Initialize AgentApp.
Args:
name: Name of the application
agents: List of Agent instances to serve
managers: List of AgentManager instances to serve
workflows: List of Workflow instances to serve
config: AgentAppConfig for server configuration
**kwargs: Additional configuration passed to AgentAppConfig
"""
self.name = name
self.agents = agents or []
self.managers = managers or []
self.workflows = workflows or []
# Merge kwargs into config
if config is None:
config = AgentAppConfig(name=name, **kwargs)
self.config = config
# FastAPI app instance (lazy initialized)
self._app = None
def _create_app(self) -> Any:
"""Create the FastAPI application."""
try:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
except ImportError:
raise ImportError(
"FastAPI is required for AgentApp. "
"Install with: pip install praisonai[api]"
)
app = FastAPI(
title=self.name,
description="PraisonAI Agent Application",
version="1.0.0",
docs_url=self.config.docs_url,
openapi_url=self.config.openapi_url,
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=self.config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routes
self._register_routes(app)
return app
def _register_routes(self, app: Any) -> None:
"""Register API routes."""
from fastapi import HTTPException
from pydantic import BaseModel
class ChatRequest(BaseModel):
message: str
agent_name: Optional[str] = None
session_id: Optional[str] = None
class ChatResponse(BaseModel):
response: str
agent_name: str
session_id: Optional[str] = None
@app.get("/")
async def root():
return {
"name": self.name,
"status": "running",
"agents": [getattr(a, 'name', str(a)) for a in self.agents],
"managers": len(self.managers),
"workflows": len(self.workflows),
}
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.get(f"{self.config.api_prefix}/agents")
async def list_agents():
return {
"agents": [
{
"name": getattr(a, 'name', f'agent_{i}'),
"role": getattr(a, 'role', None),
"instructions": getattr(a, 'instructions', None)[:100] + "..."
if getattr(a, 'instructions', None) and len(getattr(a, 'instructions', '')) > 100
else getattr(a, 'instructions', None),
}
for i, a in enumerate(self.agents)
]
}
@app.post(f"{self.config.api_prefix}/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
# Find the agent
agent = None
if request.agent_name:
for a in self.agents:
if getattr(a, 'name', None) == request.agent_name:
agent = a
break
if agent is None:
raise HTTPException(status_code=404, detail=f"Agent '{request.agent_name}' not found")
elif self.agents:
agent = self.agents[0]
else:
raise HTTPException(status_code=400, detail="No agents available")
# Call the agent
try:
response = agent.chat(request.message)
return ChatResponse(
response=str(response),
agent_name=getattr(agent, 'name', 'unknown'),
session_id=request.session_id,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def get_app(self) -> Any:
"""
Get the FastAPI application instance.
Returns:
The FastAPI application instance for custom mounting or configuration.
"""
if self._app is None:
self._app = self._create_app()
return self._app
def serve(
self,
host: Optional[str] = None,
port: Optional[int] = None,
reload: bool = False,
**kwargs: Any
) -> None:
"""
Start the AgentApp server.
Args:
host: Host address to bind to (default from config)
port: Port number to listen on (default from config)
reload: Enable auto-reload for development
**kwargs: Additional uvicorn configuration
"""
try:
import uvicorn
except ImportError:
raise ImportError(
"Uvicorn is required for AgentApp. "
"Install with: pip install praisonai[api]"
)
app = self.get_app()
uvicorn.run(
app,
host=host or self.config.host,
port=port or self.config.port,
reload=reload or self.config.reload,
log_level=self.config.log_level,
**kwargs
)
# Verify protocol compliance
def _verify_protocol():
"""Verify that AgentApp implements AgentAppProtocol."""
assert isinstance(AgentApp(agents=[]), AgentAppProtocol), \
"AgentApp must implement AgentAppProtocol"
# Run verification at import time (only in debug mode)
# _verify_protocol()
+78
View File
@@ -0,0 +1,78 @@
"""
Media parsing utilities for bot responses.
Parses MEDIA: protocol from agent responses to extract audio/image paths.
Similar to moltbot's splitMediaFromOutput() pattern.
"""
import os
import re
from typing import Dict, List, Any
# Regex to match MEDIA:/path lines
MEDIA_REGEX = re.compile(r"^MEDIA:(.+)$", re.MULTILINE)
# Voice bubble control tag (Telegram)
VOICE_TAG = "[[audio_as_voice]]"
def split_media_from_output(text: str) -> Dict[str, Any]:
"""
Extract MEDIA: paths from agent response text.
Parses lines like:
MEDIA:/tmp/tts_abc123.mp3
[[audio_as_voice]]
Args:
text: Raw agent response text
Returns:
Dict with:
- text: Cleaned text without MEDIA: lines
- media_urls: List of extracted file paths
- audio_as_voice: True if [[audio_as_voice]] tag present
Example:
>>> split_media_from_output("Hello\\nMEDIA:/tmp/audio.mp3")
{"text": "Hello", "media_urls": ["/tmp/audio.mp3"], "audio_as_voice": False}
"""
if not text:
return {"text": "", "media_urls": [], "audio_as_voice": False}
# Check for voice tag
audio_as_voice = VOICE_TAG in text
clean = text.replace(VOICE_TAG, "")
# Extract media paths
media_urls: List[str] = []
for match in MEDIA_REGEX.finditer(clean):
path = match.group(1).strip()
# Remove quotes if present
if path.startswith('"') and path.endswith('"'):
path = path[1:-1]
elif path.startswith("'") and path.endswith("'"):
path = path[1:-1]
# Validate path exists or is URL
if path and (os.path.exists(path) or path.startswith("http")):
media_urls.append(path)
# Remove MEDIA: lines from text
clean = MEDIA_REGEX.sub("", clean)
# Clean up whitespace
clean = "\n".join(line for line in clean.split("\n") if line.strip())
clean = clean.strip()
return {
"text": clean,
"media_urls": media_urls,
"audio_as_voice": audio_as_voice,
}
def is_audio_file(path: str) -> bool:
"""Check if file path is an audio file by extension."""
audio_extensions = {".mp3", ".wav", ".ogg", ".opus", ".m4a", ".aac", ".flac"}
return os.path.splitext(path.lower())[1] in audio_extensions
+93 -8
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
@@ -22,6 +24,8 @@ from praisonaiagents.bots import (
MessageType,
)
from .media import split_media_from_output, is_audio_file
logger = logging.getLogger(__name__)
@@ -81,6 +85,13 @@ class SlackBot:
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
# Audio capabilities
self._stt_enabled: bool = False
def enable_stt(self, enabled: bool = True) -> None:
"""Enable STT for audio file transcription."""
self._stt_enabled = enabled
@property
def is_running(self) -> bool:
@@ -159,11 +170,28 @@ class SlackBot:
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"))
logger.info(f"Message received: {text[:100]}...")
# Run sync agent.chat() in executor to avoid asyncio.run() conflicts
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self._agent.chat, text)
logger.info(f"Response sent: {response[:100]}...")
# Determine if we should reply in thread
thread_ts = None
if self.config.reply_in_thread:
thread_ts = event.get("thread_ts") or event.get("ts")
elif self.config.thread_threshold > 0 and len(response) > self.config.thread_threshold:
# Auto-thread long responses
thread_ts = event.get("thread_ts") or event.get("ts")
await self._send_response_with_media(
event.get("channel"), say, response, thread_ts=thread_ts
)
except Exception as e:
logger.error(f"Agent error: {e}")
await say(f"Error: {str(e)}")
error_msg = str(e)
if error_msg:
await say(text=f"Error: {error_msg}", thread_ts=event.get("ts"))
@self._app.event("app_mention")
async def handle_mention(event, say):
@@ -176,11 +204,28 @@ class SlackBot:
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"))
logger.info(f"@mention received: {text[:100]}...")
# Run sync agent.chat() in executor to avoid asyncio.run() conflicts
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self._agent.chat, text)
logger.info(f"Response sent: {response[:100]}...")
# Determine if we should reply in thread
thread_ts = None
if self.config.reply_in_thread:
thread_ts = event.get("thread_ts") or event.get("ts")
elif self.config.thread_threshold > 0 and len(response) > self.config.thread_threshold:
# Auto-thread long responses
thread_ts = event.get("thread_ts") or event.get("ts")
await self._send_response_with_media(
event.get("channel"), say, response, thread_ts=thread_ts
)
except Exception as e:
logger.error(f"Agent error: {e}")
await say(f"Error: {str(e)}")
error_msg = str(e)
if error_msg:
await say(text=f"Error: {error_msg}", thread_ts=event.get("ts"))
for command, handler in self._command_handlers.items():
@self._app.command(f"/{command}")
@@ -263,12 +308,52 @@ class SlackBot:
"""Send a long message, splitting if necessary."""
max_len = min(self.config.max_message_length, 4000)
if not text or not text.strip():
logger.warning("Attempted to send empty message")
return
if len(text) <= max_len:
await say(text=text, thread_ts=thread_ts)
await say(text=text.strip(), 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)
if chunk and chunk.strip():
await say(text=chunk.strip(), thread_ts=thread_ts)
async def _send_response_with_media(
self,
channel_id: str,
say,
response: str,
thread_ts: Optional[str] = None,
) -> None:
"""Send response, extracting and uploading any MEDIA: files."""
# Parse response for media
parsed = split_media_from_output(response)
text = parsed["text"]
media_urls = parsed.get("media_urls", [])
# Send text first if present
if text:
await self._send_long_message(say, text, thread_ts=thread_ts)
# Upload audio files to Slack
for media_path in media_urls:
if not os.path.exists(media_path):
logger.warning(f"Media file not found: {media_path}")
continue
if is_audio_file(media_path) and self._client:
try:
# Upload file to Slack
await self._client.files_upload_v2(
channel=channel_id,
file=media_path,
title=os.path.basename(media_path),
thread_ts=thread_ts,
)
except Exception as e:
logger.error(f"Failed to upload audio to Slack: {e}")
async def edit_message(
self,
+121 -7
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
@@ -22,6 +24,8 @@ from praisonaiagents.bots import (
MessageType,
)
from .media import split_media_from_output, is_audio_file
logger = logging.getLogger(__name__)
@@ -70,6 +74,18 @@ class TelegramBot:
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
# Audio capabilities (set by BotCapabilities)
self._stt_enabled: bool = False
self._auto_tts: bool = False
def enable_stt(self, enabled: bool = True) -> None:
"""Enable STT for voice message transcription."""
self._stt_enabled = enabled
def enable_auto_tts(self, enabled: bool = True) -> None:
"""Enable auto-TTS for responses."""
self._auto_tts = enabled
@property
def is_running(self) -> bool:
@@ -115,10 +131,20 @@ class TelegramBot:
)
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message or not update.message.text:
# Handle text OR audio messages
message_text = None
if update.message:
# Check for voice/audio first
if update.message.voice or update.message.audio:
message_text = await self._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text
if not update.message or not message_text:
return
message = self._convert_update_to_message(update)
message = self._convert_update_to_message(update, override_text=message_text)
if not self.config.is_user_allowed(message.sender.user_id if message.sender else ""):
return
@@ -139,8 +165,8 @@ class TelegramBot:
await update.message.chat.send_action("typing")
try:
response = self._agent.chat(message.text)
await self._send_long_message(
response = self._agent.chat(message_text)
await self._send_response_with_media(
update.message.chat_id,
response,
reply_to=update.message.message_id,
@@ -149,6 +175,10 @@ class TelegramBot:
logger.error(f"Agent error: {e}")
await update.message.reply_text(f"Error: {str(e)}")
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle voice messages."""
await handle_message(update, context)
async def handle_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message or not update.message.text:
return
@@ -173,6 +203,11 @@ class TelegramBot:
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)
)
# Add voice/audio handlers
self._application.add_handler(
MessageHandler(filters.VOICE | filters.AUDIO, handle_voice)
)
self._is_running = True
logger.info(f"Telegram bot started: @{self._bot_user.username}")
@@ -259,6 +294,82 @@ class TelegramBot:
kwargs["reply_to_message_id"] = reply_to
await self._application.bot.send_message(**kwargs)
async def _transcribe_audio(self, update) -> Optional[str]:
"""Download and transcribe voice/audio message."""
if not self._stt_enabled:
return None
try:
# Get file info
if update.message.voice:
file = await update.message.voice.get_file()
elif update.message.audio:
file = await update.message.audio.get_file()
else:
return None
# Download to temp file
temp_path = os.path.join(tempfile.gettempdir(), f"voice_{file.file_id}.ogg")
await file.download_to_drive(temp_path)
# Transcribe using existing stt_tool
try:
from praisonai.tools.audio import stt_tool
result = stt_tool(temp_path)
if result.get("success"):
text = result.get("text", "")
logger.info(f"Transcribed voice message: {text[:50]}...")
return f"[Voice message]: {text}"
else:
logger.warning(f"STT failed: {result.get('error')}")
return None
finally:
# Clean up temp file
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
logger.error(f"Audio transcription error: {e}")
return None
async def _send_response_with_media(
self,
chat_id: int,
response: str,
reply_to: Optional[int] = None,
) -> None:
"""Send response, extracting and sending any MEDIA: files."""
# Parse response for media
parsed = split_media_from_output(response)
text = parsed["text"]
media_urls = parsed.get("media_urls", [])
audio_as_voice = parsed.get("audio_as_voice", False)
# Send text first if present
if text:
await self._send_long_message(chat_id, text, reply_to=reply_to)
# Send audio files
for media_path in media_urls:
if not os.path.exists(media_path):
logger.warning(f"Media file not found: {media_path}")
continue
if is_audio_file(media_path):
try:
with open(media_path, "rb") as f:
if audio_as_voice:
await self._application.bot.send_voice(
chat_id=chat_id, voice=f
)
else:
await self._application.bot.send_audio(
chat_id=chat_id, audio=f
)
except Exception as e:
logger.error(f"Failed to send audio: {e}")
async def edit_message(
self,
channel_id: str,
@@ -355,7 +466,7 @@ class TelegramBot:
except Exception:
return None
def _convert_update_to_message(self, update) -> BotMessage:
def _convert_update_to_message(self, update, override_text: Optional[str] = None) -> BotMessage:
"""Convert Telegram Update to BotMessage."""
msg = update.message
@@ -373,14 +484,17 @@ class TelegramBot:
channel_type=channel_type,
)
msg_type = MessageType.COMMAND if msg.text and msg.text.startswith("/") else MessageType.TEXT
# Use override text for transcribed audio, otherwise message text
content = override_text if override_text else (msg.text or "")
msg_type = MessageType.COMMAND if content and content.startswith("/") else MessageType.TEXT
return BotMessage(
message_id=str(msg.message_id),
content=msg.text or "",
content=content,
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,
)
+118
View File
@@ -219,6 +219,12 @@ def register_commands():
from .commands.replay import app as replay_app
from .commands.loop import app as loop_app
# Import new moltbot-inspired commands
from .commands.bot import app as bot_app
from .commands.browser import app as browser_app
from .commands.plugins import app as plugins_app
from .commands.sandbox import app as sandbox_app
# Import TUI and queue commands
from .features.tui.debug import create_debug_app as create_tui_debug_app
from .features.tui.cli import create_queue_app
@@ -279,6 +285,118 @@ def register_commands():
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")
# Helper function for loading agents from config
def _load_agents_from_config_file(config_path: str, console) -> list:
"""Load agents from a YAML config file."""
import yaml
try:
with open(config_path, 'r') as f:
config_data = yaml.safe_load(f)
except Exception as e:
console.print(f"[red]Error loading config: {e}[/red]")
return []
if not config_data:
return []
agents = []
# Try to load agents from config
agents_config = config_data.get('agents', [])
if not agents_config and 'agent' in config_data:
agents_config = [config_data['agent']]
if agents_config:
try:
from praisonaiagents import Agent
for agent_data in agents_config:
if isinstance(agent_data, dict):
agent = Agent(
name=agent_data.get('name', 'Agent'),
role=agent_data.get('role'),
instructions=agent_data.get('instructions', agent_data.get('goal', '')),
llm=agent_data.get('llm'),
)
agents.append(agent)
console.print(f"[green]✓ Loaded agent: {agent.name}[/green]")
except Exception as e:
console.print(f"[yellow]Warning: Could not load agents from config: {e}[/yellow]")
return agents
# Register app command directly using Typer
@app.command(name="app")
def app_cmd(
port: int = typer.Option(8000, "--port", "-p", help="Port to listen on"),
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
config: str = typer.Option(None, "--config", "-c", help="Path to config file (YAML)"),
reload: bool = typer.Option(False, "--reload", "-r", help="Enable auto-reload for development"),
debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug mode"),
name: str = typer.Option("PraisonAI App", "--name", "-n", help="Application name"),
):
"""
Start an AgentApp server for production deployment.
AgentApp provides a FastAPI-based web service for deploying AI agents
with REST and WebSocket endpoints.
"""
from rich.console import Console
console = Console()
try:
from praisonai import AgentApp
from praisonaiagents import AgentAppConfig
except ImportError as e:
console.print(f"[red]Error importing AgentApp: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise typer.Abort()
# Load agents from config file if provided
agents = []
if config:
agents = _load_agents_from_config_file(config, console)
# Create config
app_config = AgentAppConfig(
name=name,
host=host,
port=port,
reload=reload,
debug=debug,
)
# Create and start app
console.print(f"\n[bold green]🚀 Starting {name}[/bold green]")
console.print(f"[dim]Host: {host}:{port}[/dim]")
if agents:
console.print(f"[dim]Agents: {len(agents)}[/dim]")
if reload:
console.print("[yellow]Auto-reload enabled (development mode)[/yellow]")
console.print()
try:
agent_app = AgentApp(
name=name,
agents=agents,
config=app_config,
)
agent_app.serve()
except ImportError as e:
console.print(f"[red]Missing dependency: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error starting server: {e}[/red]")
raise typer.Abort()
# Register moltbot-inspired commands
app.add_typer(bot_app, name="bot", help="Messaging bots with full agent capabilities")
app.add_typer(browser_app, name="browser", help="Browser control for agent automation")
app.add_typer(plugins_app, name="plugins", help="Plugin management and inspection")
app.add_typer(sandbox_app, name="sandbox", help="Sandbox container management")
# Register standardise command
try:
standardise_app = typer.Typer(name="standardise", help="Documentation and examples standardisation (FDEP)")
+139
View File
@@ -0,0 +1,139 @@
"""
CLI command for AgentApp - production deployment of AI agents.
Usage:
praisonai app # Start server with default config
praisonai app --port 9000 # Start on custom port
praisonai app --config app.yaml # Load from config file
praisonai app --reload # Enable auto-reload for development
"""
import click
from typing import Optional
@click.command("app")
@click.option("--port", "-p", type=int, default=8000, help="Port to listen on")
@click.option("--host", "-h", type=str, default="0.0.0.0", help="Host to bind to")
@click.option("--config", "-c", type=click.Path(exists=True), help="Path to config file (YAML)")
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload for development")
@click.option("--debug", "-d", is_flag=True, help="Enable debug mode")
@click.option("--name", "-n", type=str, default="PraisonAI App", help="Application name")
def app_command(
port: int,
host: str,
config: Optional[str],
reload: bool,
debug: bool,
name: str,
):
"""
Start an AgentApp server for production deployment.
AgentApp provides a FastAPI-based web service for deploying AI agents
with REST and WebSocket endpoints.
Examples:
# Start with defaults (port 8000)
praisonai app
# Start on custom port
praisonai app --port 9000
# Enable auto-reload for development
praisonai app --reload
# Load agents from config file
praisonai app --config agents.yaml
"""
from rich.console import Console
console = Console()
try:
from praisonai import AgentApp
from praisonaiagents import AgentAppConfig
except ImportError as e:
console.print(f"[red]Error importing AgentApp: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise click.Abort()
# Load agents from config file if provided
agents = []
if config:
agents = _load_agents_from_config(config, console)
# Create config
app_config = AgentAppConfig(
name=name,
host=host,
port=port,
reload=reload,
debug=debug,
)
# Create and start app
console.print(f"\n[bold green]🚀 Starting {name}[/bold green]")
console.print(f"[dim]Host: {host}:{port}[/dim]")
if agents:
console.print(f"[dim]Agents: {len(agents)}[/dim]")
if reload:
console.print("[yellow]Auto-reload enabled (development mode)[/yellow]")
console.print()
try:
agent_app = AgentApp(
name=name,
agents=agents,
config=app_config,
)
agent_app.serve()
except ImportError as e:
console.print(f"[red]Missing dependency: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise click.Abort()
except Exception as e:
console.print(f"[red]Error starting server: {e}[/red]")
raise click.Abort()
def _load_agents_from_config(config_path: str, console) -> list:
"""Load agents from a YAML config file."""
import yaml
from pathlib import Path
try:
with open(config_path, 'r') as f:
config_data = yaml.safe_load(f)
except Exception as e:
console.print(f"[red]Error loading config: {e}[/red]")
return []
if not config_data:
return []
agents = []
# Try to load agents from config
agents_config = config_data.get('agents', [])
if not agents_config and 'agent' in config_data:
agents_config = [config_data['agent']]
if agents_config:
try:
from praisonaiagents import Agent
for agent_data in agents_config:
if isinstance(agent_data, dict):
agent = Agent(
name=agent_data.get('name', 'Agent'),
role=agent_data.get('role'),
instructions=agent_data.get('instructions', agent_data.get('goal', '')),
llm=agent_data.get('llm'),
)
agents.append(agent)
console.print(f"[green]✓ Loaded agent: {agent.name}[/green]")
except Exception as e:
console.print(f"[yellow]Warning: Could not load agents from config: {e}[/yellow]")
return agents
+275
View File
@@ -0,0 +1,275 @@
"""
Bot command group for PraisonAI CLI.
Provides commands for starting messaging bots with full agent capabilities.
Inspired by moltbot's comprehensive CLI options.
"""
from typing import List, Optional
import typer
app = typer.Typer(
help="Start messaging bots with full agent capabilities",
no_args_is_help=True,
)
def _common_options():
"""Common options for all bot commands."""
pass
@app.command("telegram")
def bot_telegram(
token: Optional[str] = typer.Option(None, "--token", "-t", help="Telegram bot token", envvar="TELEGRAM_BOT_TOKEN"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
browser: bool = typer.Option(False, "--browser", help="Enable browser control"),
browser_profile: str = typer.Option("default", "--browser-profile", help="Browser profile name"),
browser_headless: bool = typer.Option(False, "--browser-headless", help="Run browser headless"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
skills: Optional[List[str]] = typer.Option(None, "--skills", help="Skills to enable"),
skills_dir: Optional[str] = typer.Option(None, "--skills-dir", help="Custom skills directory"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
memory_provider: str = typer.Option("default", "--memory-provider", help="Memory provider"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
knowledge_sources: Optional[List[str]] = typer.Option(None, "--knowledge-sources", help="Knowledge sources"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
web_provider: str = typer.Option("duckduckgo", "--web-provider", help="Web search provider"),
sandbox: bool = typer.Option(False, "--sandbox", help="Enable sandbox mode"),
exec_enabled: bool = typer.Option(False, "--exec", help="Enable exec tool"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
session_id: Optional[str] = typer.Option(None, "--session-id", help="Session ID"),
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
thinking: Optional[str] = typer.Option(None, "--thinking", help="Thinking mode (off, minimal, low, medium, high)"),
tts: bool = typer.Option(False, "--tts", help="Enable TTS tool for text-to-speech"),
tts_voice: str = typer.Option("alloy", "--tts-voice", help="TTS voice (alloy, echo, fable, onyx, nova, shimmer)"),
tts_model: Optional[str] = typer.Option(None, "--tts-model", help="TTS model (default: openai/tts-1)"),
auto_tts: bool = typer.Option(False, "--auto-tts", help="Auto-convert all responses to speech"),
stt: bool = typer.Option(False, "--stt", help="Enable STT tool for speech-to-text"),
stt_model: Optional[str] = typer.Option(None, "--stt-model", help="STT model (default: openai/whisper-1)"),
):
"""Start a Telegram bot with full agent capabilities.
Examples:
praisonai bot telegram --token $TELEGRAM_BOT_TOKEN
praisonai bot telegram --agent agents.yaml --browser --web
praisonai bot telegram --tools DuckDuckGoTool WikipediaTool --memory
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
browser=browser,
browser_profile=browser_profile,
browser_headless=browser_headless,
tools=tools or [],
skills=skills or [],
skills_dir=skills_dir,
memory=memory,
memory_provider=memory_provider,
knowledge=knowledge,
knowledge_sources=knowledge_sources or [],
web_search=web_search,
web_search_provider=web_provider,
sandbox=sandbox,
exec_enabled=exec_enabled,
auto_approve=auto_approve,
model=model,
thinking=thinking,
tts=tts,
tts_voice=tts_voice,
tts_model=tts_model,
auto_tts=auto_tts,
stt=stt,
stt_model=stt_model,
session_id=session_id,
user_id=user_id,
)
handler = BotHandler()
handler.start_telegram(token=token, agent_file=agent, capabilities=capabilities)
@app.command("discord")
def bot_discord(
token: Optional[str] = typer.Option(None, "--token", "-t", help="Discord bot token", envvar="DISCORD_BOT_TOKEN"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
browser: bool = typer.Option(False, "--browser", help="Enable browser control"),
browser_profile: str = typer.Option("default", "--browser-profile", help="Browser profile name"),
browser_headless: bool = typer.Option(False, "--browser-headless", help="Run browser headless"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
skills: Optional[List[str]] = typer.Option(None, "--skills", help="Skills to enable"),
skills_dir: Optional[str] = typer.Option(None, "--skills-dir", help="Custom skills directory"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
memory_provider: str = typer.Option("default", "--memory-provider", help="Memory provider"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
knowledge_sources: Optional[List[str]] = typer.Option(None, "--knowledge-sources", help="Knowledge sources"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
web_provider: str = typer.Option("duckduckgo", "--web-provider", help="Web search provider"),
sandbox: bool = typer.Option(False, "--sandbox", help="Enable sandbox mode"),
exec_enabled: bool = typer.Option(False, "--exec", help="Enable exec tool"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
session_id: Optional[str] = typer.Option(None, "--session-id", help="Session ID"),
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
thinking: Optional[str] = typer.Option(None, "--thinking", help="Thinking mode (off, minimal, low, medium, high)"),
tts: bool = typer.Option(False, "--tts", help="Enable TTS tool for text-to-speech"),
tts_voice: str = typer.Option("alloy", "--tts-voice", help="TTS voice (alloy, echo, fable, onyx, nova, shimmer)"),
tts_model: Optional[str] = typer.Option(None, "--tts-model", help="TTS model (default: openai/tts-1)"),
auto_tts: bool = typer.Option(False, "--auto-tts", help="Auto-convert all responses to speech"),
stt: bool = typer.Option(False, "--stt", help="Enable STT tool for speech-to-text"),
stt_model: Optional[str] = typer.Option(None, "--stt-model", help="STT model (default: openai/whisper-1)"),
):
"""Start a Discord bot with full agent capabilities.
Examples:
praisonai bot discord --token $DISCORD_BOT_TOKEN
praisonai bot discord --agent agents.yaml --browser --web
praisonai bot discord --tools DuckDuckGoTool --memory
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
browser=browser,
browser_profile=browser_profile,
browser_headless=browser_headless,
tools=tools or [],
skills=skills or [],
skills_dir=skills_dir,
memory=memory,
memory_provider=memory_provider,
knowledge=knowledge,
knowledge_sources=knowledge_sources or [],
web_search=web_search,
web_search_provider=web_provider,
sandbox=sandbox,
exec_enabled=exec_enabled,
auto_approve=auto_approve,
model=model,
thinking=thinking,
tts=tts,
tts_voice=tts_voice,
tts_model=tts_model,
auto_tts=auto_tts,
stt=stt,
stt_model=stt_model,
session_id=session_id,
user_id=user_id,
)
handler = BotHandler()
handler.start_discord(token=token, agent_file=agent, capabilities=capabilities)
@app.command("slack")
def bot_slack(
token: Optional[str] = typer.Option(None, "--token", "-t", help="Slack bot token", envvar="SLACK_BOT_TOKEN"),
app_token: Optional[str] = typer.Option(None, "--app-token", help="Slack app token for Socket Mode", envvar="SLACK_APP_TOKEN"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
browser: bool = typer.Option(False, "--browser", help="Enable browser control"),
browser_profile: str = typer.Option("default", "--browser-profile", help="Browser profile name"),
browser_headless: bool = typer.Option(False, "--browser-headless", help="Run browser headless"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
skills: Optional[List[str]] = typer.Option(None, "--skills", help="Skills to enable"),
skills_dir: Optional[str] = typer.Option(None, "--skills-dir", help="Custom skills directory"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
memory_provider: str = typer.Option("default", "--memory-provider", help="Memory provider"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
knowledge_sources: Optional[List[str]] = typer.Option(None, "--knowledge-sources", help="Knowledge sources"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
web_provider: str = typer.Option("duckduckgo", "--web-provider", help="Web search provider"),
sandbox: bool = typer.Option(False, "--sandbox", help="Enable sandbox mode"),
exec_enabled: bool = typer.Option(False, "--exec", help="Enable exec tool"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
session_id: Optional[str] = typer.Option(None, "--session-id", help="Session ID"),
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
thinking: Optional[str] = typer.Option(None, "--thinking", help="Thinking mode (off, minimal, low, medium, high)"),
tts: bool = typer.Option(False, "--tts", help="Enable TTS tool for text-to-speech"),
tts_voice: str = typer.Option("alloy", "--tts-voice", help="TTS voice (alloy, echo, fable, onyx, nova, shimmer)"),
tts_model: Optional[str] = typer.Option(None, "--tts-model", help="TTS model (default: openai/tts-1)"),
auto_tts: bool = typer.Option(False, "--auto-tts", help="Auto-convert all responses to speech"),
stt: bool = typer.Option(False, "--stt", help="Enable STT tool for speech-to-text"),
stt_model: Optional[str] = typer.Option(None, "--stt-model", help="STT model (default: openai/whisper-1)"),
):
"""Start a Slack bot with full agent capabilities.
Examples:
praisonai bot slack --token $SLACK_BOT_TOKEN
praisonai bot slack --agent agents.yaml --browser --web
praisonai bot slack --tools DuckDuckGoTool --memory --app-token $SLACK_APP_TOKEN
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
browser=browser,
browser_profile=browser_profile,
browser_headless=browser_headless,
tools=tools or [],
skills=skills or [],
skills_dir=skills_dir,
memory=memory,
memory_provider=memory_provider,
knowledge=knowledge,
knowledge_sources=knowledge_sources or [],
web_search=web_search,
web_search_provider=web_provider,
sandbox=sandbox,
exec_enabled=exec_enabled,
auto_approve=auto_approve,
model=model,
thinking=thinking,
tts=tts,
tts_voice=tts_voice,
tts_model=tts_model,
auto_tts=auto_tts,
stt=stt,
stt_model=stt_model,
session_id=session_id,
user_id=user_id,
)
handler = BotHandler()
handler.start_slack(token=token, app_token=app_token, agent_file=agent, capabilities=capabilities)
@app.callback(invoke_without_command=True)
def bot_callback(ctx: typer.Context):
"""Show bot help if no subcommand provided."""
if ctx.invoked_subcommand is None:
help_text = """
[bold cyan]PraisonAI Bot - Messaging Bots with Full Agent Capabilities[/bold cyan]
Start a bot on any platform with: praisonai bot <platform>
[bold]Platforms:[/bold]
[green]telegram[/green] Telegram Bot API
[green]discord[/green] Discord Bot API
[green]slack[/green] Slack Socket Mode
[bold]Capability Options (all platforms):[/bold]
[yellow]--agent FILE[/yellow] Agent YAML configuration
[yellow]--model MODEL[/yellow] LLM model to use
[yellow]--browser[/yellow] Enable browser control
[yellow]--web[/yellow] Enable web search
[yellow]--memory[/yellow] Enable memory
[yellow]--knowledge[/yellow] Enable knowledge/RAG
[yellow]--tools TOOL...[/yellow] Enable specific tools
[yellow]--sandbox[/yellow] Enable sandbox mode
[yellow]--tts[/yellow] Enable TTS tool
[yellow]--stt[/yellow] Enable STT tool
[yellow]--auto-tts[/yellow] Auto-convert responses to speech
[bold]Examples:[/bold]
praisonai bot telegram --token $TELEGRAM_BOT_TOKEN
praisonai bot slack --agent agents.yaml --browser --web
praisonai bot discord --tools DuckDuckGoTool --memory --model gpt-4o
"""
try:
from rich import print as rprint
rprint(help_text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', help_text)
print(plain)
@@ -0,0 +1,299 @@
"""
Browser command group for PraisonAI CLI.
Provides browser control commands for agent automation.
Inspired by moltbot's browser CLI.
"""
from typing import Optional
import typer
app = typer.Typer(
help="Browser control for agent automation",
no_args_is_help=True,
)
@app.command("status")
def browser_status(
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check browser status.
Examples:
praisonai browser status
praisonai browser --profile chrome status
"""
try:
from rich.console import Console
console = Console()
# Check if browser tools are available
try:
from praisonai_tools import BrowserBaseTool
console.print(f"[green]✓[/green] Browser tools available")
console.print(f" Profile: {profile}")
console.print(f" Status: Ready")
except ImportError:
console.print("[yellow]![/yellow] Browser tools not installed")
console.print(" Install with: pip install praisonai-tools")
except ImportError:
print(f"Browser status: Profile={profile}")
print("Install rich for better output: pip install rich")
@app.command("open")
def browser_open(
url: str = typer.Argument(..., help="URL to open"),
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
headless: bool = typer.Option(False, "--headless", help="Run in headless mode"),
):
"""Open a URL in the browser.
Examples:
praisonai browser open https://example.com
praisonai browser open https://example.com --headless
"""
try:
from praisonai_tools import BrowserBaseTool
print(f"Opening {url} in browser (profile: {profile})...")
browser = BrowserBaseTool()
result = browser.run(action="navigate", url=url)
print(f"Result: {result}")
except ImportError:
print("Error: Browser tools not installed")
print("Install with: pip install praisonai-tools")
raise typer.Exit(1)
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.command("snapshot")
def browser_snapshot(
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
format: str = typer.Option("aria", "--format", "-f", help="Snapshot format (aria, ai)"),
):
"""Take a snapshot of the current page.
Examples:
praisonai browser snapshot
praisonai browser snapshot --output snapshot.txt
"""
try:
from praisonai_tools import BrowserBaseTool
print(f"Taking snapshot (profile: {profile})...")
browser = BrowserBaseTool()
result = browser.run(action="snapshot")
if output:
with open(output, "w") as f:
f.write(str(result))
print(f"Snapshot saved to: {output}")
else:
print(result)
except ImportError:
print("Error: Browser tools not installed")
print("Install with: pip install praisonai-tools")
raise typer.Exit(1)
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.command("screenshot")
def browser_screenshot(
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
full_page: bool = typer.Option(False, "--full-page", help="Capture full page"),
format: str = typer.Option("png", "--format", "-f", help="Image format (png, jpeg)"),
):
"""Take a screenshot of the current page.
Examples:
praisonai browser screenshot
praisonai browser screenshot --output page.png --full-page
"""
try:
from praisonai_tools import BrowserBaseTool
print(f"Taking screenshot (profile: {profile})...")
browser = BrowserBaseTool()
result = browser.run(action="screenshot", full_page=full_page)
if output:
# Save screenshot to file
print(f"Screenshot saved to: {output}")
else:
print(f"Screenshot captured: {result}")
except ImportError:
print("Error: Browser tools not installed")
print("Install with: pip install praisonai-tools")
raise typer.Exit(1)
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.command("click")
def browser_click(
selector: str = typer.Argument(..., help="Element selector or ref"),
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
double: bool = typer.Option(False, "--double", help="Double click"),
):
"""Click an element on the page.
Examples:
praisonai browser click "#submit-button"
praisonai browser click "ref:123" --double
"""
try:
from praisonai_tools import BrowserBaseTool
print(f"Clicking element: {selector}")
browser = BrowserBaseTool()
result = browser.run(action="click", selector=selector)
print(f"Result: {result}")
except ImportError:
print("Error: Browser tools not installed")
raise typer.Exit(1)
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.command("type")
def browser_type(
selector: str = typer.Argument(..., help="Element selector or ref"),
text: str = typer.Argument(..., help="Text to type"),
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
submit: bool = typer.Option(False, "--submit", help="Submit after typing"),
):
"""Type text into an element.
Examples:
praisonai browser type "#search-input" "hello world"
praisonai browser type "#search-input" "hello world" --submit
"""
try:
from praisonai_tools import BrowserBaseTool
print(f"Typing into element: {selector}")
browser = BrowserBaseTool()
result = browser.run(action="type", selector=selector, text=text)
print(f"Result: {result}")
except ImportError:
print("Error: Browser tools not installed")
raise typer.Exit(1)
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.command("navigate")
def browser_navigate(
url: str = typer.Argument(..., help="URL to navigate to"),
profile: str = typer.Option("default", "--profile", "-p", help="Browser profile name"),
):
"""Navigate to a URL.
Examples:
praisonai browser navigate https://example.com
"""
try:
from praisonai_tools import BrowserBaseTool
print(f"Navigating to: {url}")
browser = BrowserBaseTool()
result = browser.run(action="navigate", url=url)
print(f"Result: {result}")
except ImportError:
print("Error: Browser tools not installed")
raise typer.Exit(1)
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.command("profiles")
def browser_profiles(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List available browser profiles.
Examples:
praisonai browser profiles
praisonai browser profiles --json
"""
profiles = [
{"name": "default", "description": "Default browser profile"},
{"name": "chrome", "description": "Chrome browser via extension relay"},
{"name": "headless", "description": "Headless browser for automation"},
]
if json_output:
import json
print(json.dumps(profiles, indent=2))
else:
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Browser Profiles")
table.add_column("Name", style="cyan")
table.add_column("Description")
for p in profiles:
table.add_row(p["name"], p["description"])
console.print(table)
except ImportError:
for p in profiles:
print(f" {p['name']}: {p['description']}")
@app.callback(invoke_without_command=True)
def browser_callback(ctx: typer.Context):
"""Show browser help if no subcommand provided."""
if ctx.invoked_subcommand is None:
help_text = """
[bold cyan]PraisonAI Browser - Browser Control for Agent Automation[/bold cyan]
Control browsers for agent automation with: praisonai browser <command>
[bold]Commands:[/bold]
[green]status[/green] Check browser status
[green]open[/green] Open a URL
[green]navigate[/green] Navigate to a URL
[green]snapshot[/green] Take a page snapshot
[green]screenshot[/green] Take a screenshot
[green]click[/green] Click an element
[green]type[/green] Type text into an element
[green]profiles[/green] List browser profiles
[bold]Examples:[/bold]
praisonai browser status
praisonai browser open https://example.com
praisonai browser snapshot --output page.txt
praisonai browser click "#submit-button"
"""
try:
from rich import print as rprint
rprint(help_text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', help_text)
print(plain)
@@ -0,0 +1,343 @@
"""
Plugins command group for PraisonAI CLI.
Provides plugin management commands inspired by moltbot's plugins CLI.
Supports listing, enabling, disabling, and inspecting plugins.
"""
import typer
app = typer.Typer(
help="Plugin management and inspection",
no_args_is_help=True,
)
@app.command("list")
def plugins_list(
enabled_only: bool = typer.Option(False, "--enabled", help="Show only enabled plugins"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List available plugins.
Examples:
praisonai plugins list
praisonai plugins list --enabled
praisonai plugins list --json
"""
try:
# Try to get plugins from the plugin system
plugins = _get_available_plugins()
if enabled_only:
plugins = [p for p in plugins if p.get("enabled", False)]
if json_output:
import json
print(json.dumps(plugins, indent=2))
else:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Plugins ({len(plugins)} available)")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Status")
table.add_column("Description")
for plugin in plugins:
status = "[green]enabled[/green]" if plugin.get("enabled") else "[dim]disabled[/dim]"
table.add_row(
plugin.get("id", "-"),
plugin.get("name", "-"),
status,
plugin.get("description", "-")[:40] + "..." if len(plugin.get("description", "")) > 40 else plugin.get("description", "-"),
)
console.print(table)
except Exception as e:
typer.echo(f"Error listing plugins: {e}", err=True)
raise typer.Exit(1)
@app.command("info")
def plugins_info(
plugin_id: str = typer.Argument(..., help="Plugin ID"),
):
"""Show detailed information about a plugin.
Examples:
praisonai plugins info memory-core
praisonai plugins info browser-tool
"""
try:
plugins = _get_available_plugins()
plugin = next((p for p in plugins if p.get("id") == plugin_id), None)
if not plugin:
typer.echo(f"Plugin not found: {plugin_id}", err=True)
raise typer.Exit(1)
from rich.console import Console
from rich.panel import Panel
console = Console()
console.print(f"\n[bold cyan]{plugin.get('name', plugin_id)}[/bold cyan]")
console.print(f"ID: {plugin.get('id', '-')}")
console.print(f"Status: {'[green]enabled[/green]' if plugin.get('enabled') else '[dim]disabled[/dim]'}")
console.print(f"Description: {plugin.get('description', '-')}")
if plugin.get("version"):
console.print(f"Version: {plugin.get('version')}")
if plugin.get("author"):
console.print(f"Author: {plugin.get('author')}")
if plugin.get("hooks"):
console.print(f"Hooks: {', '.join(plugin.get('hooks', []))}")
if plugin.get("config_schema"):
console.print("\n[bold]Config Schema:[/bold]")
import json
console.print(Panel(json.dumps(plugin.get("config_schema"), indent=2)))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("enable")
def plugins_enable(
plugin_id: str = typer.Argument(..., help="Plugin ID to enable"),
):
"""Enable a plugin.
Examples:
praisonai plugins enable memory-core
praisonai plugins enable browser-tool
"""
try:
# Update config to enable plugin
config_path = _get_config_path()
config = _load_config(config_path)
if "plugins" not in config:
config["plugins"] = {}
if "enabled" not in config["plugins"]:
config["plugins"]["enabled"] = []
if plugin_id not in config["plugins"]["enabled"]:
config["plugins"]["enabled"].append(plugin_id)
_save_config(config_path, config)
typer.echo(f"[green]✓[/green] Plugin enabled: {plugin_id}")
else:
typer.echo(f"Plugin already enabled: {plugin_id}")
except Exception as e:
typer.echo(f"Error enabling plugin: {e}", err=True)
raise typer.Exit(1)
@app.command("disable")
def plugins_disable(
plugin_id: str = typer.Argument(..., help="Plugin ID to disable"),
):
"""Disable a plugin.
Examples:
praisonai plugins disable memory-core
praisonai plugins disable browser-tool
"""
try:
config_path = _get_config_path()
config = _load_config(config_path)
if "plugins" in config and "enabled" in config["plugins"]:
if plugin_id in config["plugins"]["enabled"]:
config["plugins"]["enabled"].remove(plugin_id)
_save_config(config_path, config)
typer.echo(f"[yellow]![/yellow] Plugin disabled: {plugin_id}")
else:
typer.echo(f"Plugin not enabled: {plugin_id}")
else:
typer.echo(f"Plugin not enabled: {plugin_id}")
except Exception as e:
typer.echo(f"Error disabling plugin: {e}", err=True)
raise typer.Exit(1)
@app.command("doctor")
def plugins_doctor():
"""Check plugin health and configuration.
Verifies that all enabled plugins are properly configured and
their dependencies are met.
Examples:
praisonai plugins doctor
"""
try:
from rich.console import Console
from rich.table import Table
console = Console()
plugins = _get_available_plugins()
enabled_plugins = [p for p in plugins if p.get("enabled")]
console.print("[bold]Plugin Health Check[/bold]\n")
if not enabled_plugins:
console.print("[yellow]No plugins enabled[/yellow]")
return
table = Table()
table.add_column("Plugin", style="cyan")
table.add_column("Status")
table.add_column("Issues")
issues_found = 0
for plugin in enabled_plugins:
issues = []
# Check if plugin module exists
if plugin.get("module"):
try:
__import__(plugin["module"])
except ImportError:
issues.append("Module not found")
# Check required config
if plugin.get("required_config"):
for key in plugin["required_config"]:
# Check if config key exists
pass # Would check actual config
if issues:
status = "[red]✗ Issues[/red]"
issues_found += len(issues)
else:
status = "[green]✓ OK[/green]"
table.add_row(
plugin.get("name", plugin.get("id")),
status,
", ".join(issues) if issues else "-",
)
console.print(table)
if issues_found:
console.print(f"\n[red]Found {issues_found} issue(s)[/red]")
else:
console.print(f"\n[green]All {len(enabled_plugins)} plugins healthy[/green]")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
def _get_available_plugins():
"""Get list of available plugins."""
# Built-in plugins
plugins = [
{
"id": "memory-core",
"name": "Memory Core",
"description": "Semantic memory indexing and search",
"enabled": True,
"hooks": ["on_message", "on_session_start"],
},
{
"id": "browser-tool",
"name": "Browser Tool",
"description": "Browser automation and control",
"enabled": False,
"hooks": ["on_tool_call"],
},
{
"id": "knowledge-rag",
"name": "Knowledge RAG",
"description": "Retrieval-augmented generation",
"enabled": False,
"hooks": ["on_message", "on_context_build"],
},
{
"id": "telemetry",
"name": "Telemetry",
"description": "Usage tracking and analytics",
"enabled": False,
"hooks": ["on_request", "on_response"],
},
]
# Try to load actual plugin registry
try:
from praisonaiagents.plugins import get_plugin_registry
registry = get_plugin_registry()
if registry:
# Merge with actual plugins
pass
except ImportError:
pass
return plugins
def _get_config_path():
"""Get config file path."""
from pathlib import Path
config_dir = Path.home() / ".praisonai"
config_dir.mkdir(exist_ok=True)
return config_dir / "config.json"
def _load_config(path):
"""Load config from file."""
import json
if path.exists():
with open(path) as f:
return json.load(f)
return {}
def _save_config(path, config):
"""Save config to file."""
import json
with open(path, "w") as f:
json.dump(config, f, indent=2)
@app.callback(invoke_without_command=True)
def plugins_callback(ctx: typer.Context):
"""Show plugins help if no subcommand provided."""
if ctx.invoked_subcommand is None:
help_text = """
[bold cyan]PraisonAI Plugins - Plugin Management[/bold cyan]
Manage plugins with: praisonai plugins <command>
[bold]Commands:[/bold]
[green]list[/green] List available plugins
[green]info[/green] Show plugin details
[green]enable[/green] Enable a plugin
[green]disable[/green] Disable a plugin
[green]doctor[/green] Check plugin health
[bold]Examples:[/bold]
praisonai plugins list
praisonai plugins info memory-core
praisonai plugins enable browser-tool
praisonai plugins doctor
"""
try:
from rich import print as rprint
rprint(help_text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', help_text)
print(plain)
@@ -0,0 +1,371 @@
"""
Sandbox command group for PraisonAI CLI.
Provides sandbox management commands inspired by moltbot's sandbox CLI.
Supports listing, explaining, and recreating sandbox containers.
"""
from typing import Optional
import typer
app = typer.Typer(
help="Sandbox container management",
no_args_is_help=True,
)
@app.command("status")
def sandbox_status(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show sandbox status and configuration.
Examples:
praisonai sandbox status
praisonai sandbox status --json
"""
try:
from rich.console import Console
console = Console()
# Check sandbox availability
sandbox_info = _get_sandbox_info()
if json_output:
import json
print(json.dumps(sandbox_info, indent=2))
else:
console.print("[bold cyan]Sandbox Status[/bold cyan]\n")
status = "[green]Available[/green]" if sandbox_info["available"] else "[red]Not Available[/red]"
console.print(f"Status: {status}")
console.print(f"Mode: {sandbox_info.get('mode', 'off')}")
console.print(f"Scope: {sandbox_info.get('scope', 'session')}")
if sandbox_info.get("docker_available"):
console.print("Docker: [green]Available[/green]")
console.print(f"Image: {sandbox_info.get('image', 'default')}")
else:
console.print("Docker: [yellow]Not Available[/yellow]")
if sandbox_info.get("containers"):
console.print(f"\n[bold]Active Containers:[/bold] {len(sandbox_info['containers'])}")
for container in sandbox_info["containers"]:
console.print(f" - {container['name']} ({container['status']})")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("explain")
def sandbox_explain(
session: Optional[str] = typer.Option(None, "--session", "-s", help="Session key"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Explain effective sandbox policy and tool access.
Shows the effective sandbox mode, scope, workspace access,
and tool policy for a session or agent.
Examples:
praisonai sandbox explain
praisonai sandbox explain --session main
praisonai sandbox explain --agent work
"""
try:
from rich.console import Console
from rich.table import Table
console = Console()
policy = _get_sandbox_policy(session, agent)
if json_output:
import json
print(json.dumps(policy, indent=2))
else:
console.print("[bold cyan]Sandbox Policy[/bold cyan]\n")
if session:
console.print(f"Session: {session}")
if agent:
console.print(f"Agent: {agent}")
console.print("\n[bold]Effective Settings:[/bold]")
console.print(f" Mode: {policy.get('mode', 'off')}")
console.print(f" Scope: {policy.get('scope', 'session')}")
console.print(f" Workspace Access: {policy.get('workspace_access', 'full')}")
console.print("\n[bold]Tool Policy:[/bold]")
table = Table()
table.add_column("Tool", style="cyan")
table.add_column("Access")
table.add_column("Notes")
for tool, access in policy.get("tools", {}).items():
status = "[green]allowed[/green]" if access.get("allowed") else "[red]denied[/red]"
table.add_row(tool, status, access.get("notes", "-"))
console.print(table)
if policy.get("elevated_gates"):
console.print("\n[bold]Elevated Gates:[/bold]")
for gate in policy["elevated_gates"]:
console.print(f" - {gate}")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("list")
def sandbox_list(
browser: bool = typer.Option(False, "--browser", help="List only browser containers"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List sandbox containers.
Examples:
praisonai sandbox list
praisonai sandbox list --browser
praisonai sandbox list --json
"""
try:
containers = _get_sandbox_containers(browser_only=browser)
if json_output:
import json
print(json.dumps(containers, indent=2))
else:
from rich.console import Console
from rich.table import Table
console = Console()
title = "Browser Containers" if browser else "Sandbox Containers"
table = Table(title=f"{title} ({len(containers)} found)")
table.add_column("Name", style="cyan")
table.add_column("Status")
table.add_column("Image")
table.add_column("Age")
table.add_column("Session/Agent")
for container in containers:
status = "[green]running[/green]" if container.get("running") else "[dim]stopped[/dim]"
table.add_row(
container.get("name", "-"),
status,
container.get("image", "-"),
container.get("age", "-"),
container.get("session", "-"),
)
console.print(table)
if not containers:
console.print("[dim]No containers found[/dim]")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("recreate")
def sandbox_recreate(
all_containers: bool = typer.Option(False, "--all", help="Recreate all containers"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Recreate for specific session"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Recreate for specific agent"),
browser: bool = typer.Option(False, "--browser", help="Only recreate browser containers"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
):
"""Recreate sandbox containers.
Removes sandbox containers to force recreation with updated
images or configuration. Containers are automatically recreated
when the agent is next used.
Examples:
praisonai sandbox recreate --all
praisonai sandbox recreate --session main
praisonai sandbox recreate --agent mybot
praisonai sandbox recreate --browser --force
"""
if not any([all_containers, session, agent]):
typer.echo("Error: Specify --all, --session, or --agent", err=True)
raise typer.Exit(1)
try:
containers = _get_sandbox_containers(browser_only=browser)
# Filter containers
if session:
containers = [c for c in containers if c.get("session") == session]
if agent:
containers = [c for c in containers if c.get("agent") == agent]
if not containers:
typer.echo("No matching containers found")
return
# Confirm
if not force:
typer.echo(f"Will recreate {len(containers)} container(s):")
for c in containers:
typer.echo(f" - {c.get('name')}")
if not typer.confirm("Continue?"):
typer.echo("Cancelled")
return
# Remove containers
removed = 0
for container in containers:
try:
_remove_container(container.get("name"))
removed += 1
typer.echo(f"[green]✓[/green] Removed: {container.get('name')}")
except Exception as e:
typer.echo(f"[red]✗[/red] Failed to remove {container.get('name')}: {e}")
typer.echo(f"\nRemoved {removed}/{len(containers)} containers")
typer.echo("Containers will be recreated on next use")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
def _get_sandbox_info():
"""Get sandbox status information."""
import shutil
info = {
"available": False,
"mode": "off",
"scope": "session",
"docker_available": shutil.which("docker") is not None,
"image": "praisonai-sandbox:latest",
"containers": [],
}
# Check if Docker is available
if info["docker_available"]:
info["available"] = True
# Try to list containers
try:
import subprocess
result = subprocess.run(
["docker", "ps", "--filter", "name=praisonai-sbx-", "--format", "{{.Names}}\t{{.Status}}"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
for line in result.stdout.strip().split("\n"):
parts = line.split("\t")
if len(parts) >= 2:
info["containers"].append({
"name": parts[0],
"status": parts[1],
})
except Exception:
pass
return info
def _get_sandbox_policy(session: Optional[str], agent: Optional[str]):
"""Get effective sandbox policy."""
return {
"mode": "off",
"scope": "session",
"workspace_access": "full",
"tools": {
"exec": {"allowed": True, "notes": "Host execution"},
"browser": {"allowed": True, "notes": "Browser automation"},
"file_read": {"allowed": True, "notes": "Read workspace files"},
"file_write": {"allowed": True, "notes": "Write workspace files"},
},
"elevated_gates": [],
}
def _get_sandbox_containers(browser_only: bool = False):
"""Get list of sandbox containers."""
containers = []
try:
import subprocess
filter_name = "praisonai-browser-" if browser_only else "praisonai-sbx-"
result = subprocess.run(
["docker", "ps", "-a", "--filter", f"name={filter_name}", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.CreatedAt}}"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
for line in result.stdout.strip().split("\n"):
parts = line.split("\t")
if len(parts) >= 4:
containers.append({
"name": parts[0],
"running": "Up" in parts[1],
"status": parts[1],
"image": parts[2],
"age": parts[3],
"session": parts[0].split("-")[-1] if "-" in parts[0] else "-",
})
except Exception:
pass
return containers
def _remove_container(name: str):
"""Remove a Docker container."""
import subprocess
# Stop if running
subprocess.run(["docker", "stop", name], capture_output=True, timeout=10)
# Remove
result = subprocess.run(["docker", "rm", name], capture_output=True, timeout=10)
if result.returncode != 0:
raise Exception(result.stderr.decode() if result.stderr else "Unknown error")
@app.callback(invoke_without_command=True)
def sandbox_callback(ctx: typer.Context):
"""Show sandbox help if no subcommand provided."""
if ctx.invoked_subcommand is None:
help_text = """
[bold cyan]PraisonAI Sandbox - Container Management[/bold cyan]
Manage sandbox containers with: praisonai sandbox <command>
[bold]Commands:[/bold]
[green]status[/green] Show sandbox status
[green]explain[/green] Explain effective sandbox policy
[green]list[/green] List sandbox containers
[green]recreate[/green] Recreate containers
[bold]Examples:[/bold]
praisonai sandbox status
praisonai sandbox explain --agent work
praisonai sandbox list --browser
praisonai sandbox recreate --all --force
"""
try:
from rich import print as rprint
rprint(help_text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', help_text)
print(plain)
+155 -2
View File
@@ -1,12 +1,13 @@
"""
Skills command group for PraisonAI CLI.
Provides skill management commands.
Provides skill management commands inspired by moltbot's skills CLI.
Supports listing, checking eligibility, and managing skills.
"""
import typer
app = typer.Typer(help="Skill management")
app = typer.Typer(help="Skill management and inspection")
@app.command("list")
@@ -215,3 +216,155 @@ def skills_info(
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("check")
def skills_check(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed check results"),
):
"""Check skill requirements and eligibility.
Verifies that all required binaries, environment variables, and
configurations are present for skills to be eligible.
Examples:
praisonai skills check
praisonai skills check --verbose
"""
try:
from praisonaiagents.skills import discover_skills
import os
import shutil
skills = discover_skills(include_defaults=True)
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Skill Requirements Check")
table.add_column("Skill", style="cyan")
table.add_column("Status")
table.add_column("Missing", style="yellow")
total = 0
eligible = 0
for skill in skills:
total += 1
missing = []
# Check metadata requirements if available
metadata = getattr(skill, 'metadata', {}) or {}
requires = metadata.get('requires', {}) if isinstance(metadata, dict) else {}
# Check required binaries
bins = requires.get('bins', []) if isinstance(requires, dict) else []
for bin_name in bins:
if not shutil.which(bin_name):
missing.append(f"bin:{bin_name}")
# Check required env vars
env_vars = requires.get('env', []) if isinstance(requires, dict) else []
for env_var in env_vars:
if not os.environ.get(env_var):
missing.append(f"env:{env_var}")
if missing:
status = "[red]✗ Missing requirements[/red]"
else:
status = "[green]✓ Eligible[/green]"
eligible += 1
if verbose or missing:
table.add_row(
skill.name,
status,
", ".join(missing) if missing else "-",
)
console.print(table)
console.print(f"\n[bold]Summary:[/bold] {eligible}/{total} skills eligible")
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("eligible")
def skills_eligible(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List only eligible skills (requirements met).
Shows skills that have all their requirements satisfied and are
ready to use.
Examples:
praisonai skills eligible
praisonai skills eligible --json
"""
try:
from praisonaiagents.skills import discover_skills
import os
import shutil
skills = discover_skills(include_defaults=True)
eligible_skills = []
for skill in skills:
# Check metadata requirements if available
metadata = getattr(skill, 'metadata', {}) or {}
requires = metadata.get('requires', {}) if isinstance(metadata, dict) else {}
is_eligible = True
# Check required binaries
bins = requires.get('bins', []) if isinstance(requires, dict) else []
for bin_name in bins:
if not shutil.which(bin_name):
is_eligible = False
break
# Check required env vars
if is_eligible:
env_vars = requires.get('env', []) if isinstance(requires, dict) else []
for env_var in env_vars:
if not os.environ.get(env_var):
is_eligible = False
break
if is_eligible:
eligible_skills.append(skill)
if json_output:
import json
output = [
{
"name": s.name,
"description": s.description,
"path": str(s.path) if s.path else None,
}
for s in eligible_skills
]
print(json.dumps(output, indent=2))
else:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Eligible Skills ({len(eligible_skills)} ready)")
table.add_column("Name", style="cyan")
table.add_column("Description")
for skill in eligible_skills:
table.add_row(
skill.name,
skill.description[:60] + "..." if len(skill.description) > 60 else skill.description,
)
console.print(table)
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
+18 -10
View File
@@ -160,17 +160,25 @@ def _launch_chainlit_ui(ui_type: str, port: int, host: str, public: bool):
ui_script = ui_scripts.get(ui_type, "chainlit_ui.py")
ui_path = os.path.join(os.path.dirname(praisonai.__file__), ui_script)
# Get chainlit run function
from chainlit.cli import run as chainlit_run
# Build args
args = [ui_path]
if host:
args.extend(["--host", host])
args.extend(["--port", str(port)])
print(f"Starting {ui_type} UI at http://{host}:{port}")
chainlit_run(args)
# Use subprocess to run chainlit with proper CLI args
import subprocess
cmd = ["chainlit", "run", ui_path, "--host", host, "--port", str(port)]
if public:
cmd.append("--public")
try:
subprocess.run(cmd, check=True)
except FileNotFoundError:
# Fallback: try running with python -m chainlit
cmd = [sys.executable, "-m", "chainlit", "run", ui_path, "--host", host, "--port", str(port)]
if public:
cmd.append("--public")
subprocess.run(cmd, check=True)
except KeyboardInterrupt:
print("\nUI stopped.")
def _launch_gradio_ui(port: int, host: str, public: bool):
+550 -45
View File
@@ -1,7 +1,16 @@
"""
Bot CLI commands for PraisonAI.
Provides CLI commands for managing messaging bots.
Provides CLI commands for managing messaging bots with full tool support.
Inspired by moltbot's comprehensive CLI options.
Features:
- Browser control integration
- Skills/tools enabling
- YAML agent configuration
- Memory support
- Knowledge/RAG integration
- Web search capability
"""
from __future__ import annotations
@@ -9,24 +18,112 @@ from __future__ import annotations
import asyncio
import logging
import os
from typing import Optional
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class BotCapabilities:
"""Capabilities configuration for bot instances.
Enables various agent capabilities when running bots.
All capabilities are opt-in for zero performance impact.
"""
# Browser control
browser: bool = False
browser_profile: str = "default"
browser_headless: bool = False
# Tools and skills
tools: List[str] = field(default_factory=list)
skills: List[str] = field(default_factory=list)
skills_dir: Optional[str] = None
# Memory
memory: bool = False
memory_provider: str = "default"
# Knowledge/RAG
knowledge: bool = False
knowledge_sources: List[str] = field(default_factory=list)
# Web search
web_search: bool = False
web_search_provider: str = "duckduckgo"
# Execution
sandbox: bool = False
exec_enabled: bool = False
auto_approve: bool = False # Auto-approve CLI tool execution
# Model settings
model: Optional[str] = None
thinking: Optional[str] = None # off, minimal, low, medium, high
# Audio (TTS/STT)
tts: bool = False # Enable TTS tool
tts_voice: str = "alloy" # TTS voice
tts_model: Optional[str] = None # TTS model (default: openai/tts-1)
auto_tts: bool = False # Auto-convert responses to speech
stt: bool = False # Enable STT tool
stt_model: Optional[str] = None # STT model (default: openai/whisper-1)
# Session
session_id: Optional[str] = None
user_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"browser": self.browser,
"browser_profile": self.browser_profile,
"browser_headless": self.browser_headless,
"tools": self.tools,
"skills": self.skills,
"skills_dir": self.skills_dir,
"memory": self.memory,
"memory_provider": self.memory_provider,
"knowledge": self.knowledge,
"knowledge_sources": self.knowledge_sources,
"web_search": self.web_search,
"web_search_provider": self.web_search_provider,
"sandbox": self.sandbox,
"exec_enabled": self.exec_enabled,
"auto_approve": self.auto_approve,
"model": self.model,
"thinking": self.thinking,
"tts": self.tts,
"tts_voice": self.tts_voice,
"tts_model": self.tts_model,
"auto_tts": self.auto_tts,
"stt": self.stt,
"stt_model": self.stt_model,
"session_id": self.session_id,
"user_id": self.user_id,
}
class BotHandler:
"""Handler for bot CLI commands."""
"""Handler for bot CLI commands.
Supports comprehensive tool/capability configuration inspired by moltbot.
"""
def start_telegram(
self,
token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = 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
capabilities: Optional capabilities configuration
"""
token = token or os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
@@ -34,6 +131,11 @@ class BotHandler:
print("Provide via --token or TELEGRAM_BOT_TOKEN environment variable")
return
# Set auto-approve if enabled - use environment variable (persists across async contexts)
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
logger.info("Auto-approve enabled for all tool executions")
try:
from praisonai.bots import TelegramBot
except ImportError as e:
@@ -41,11 +143,10 @@ class BotHandler:
print("Install with: pip install python-telegram-bot")
return
agent = self._load_agent(agent_file)
agent = self._load_agent(agent_file, capabilities)
bot = TelegramBot(token=token, agent=agent)
print("Starting Telegram bot...")
print("Press Ctrl+C to stop")
self._print_startup_info("Telegram", capabilities)
try:
asyncio.run(bot.start())
@@ -57,12 +158,14 @@ class BotHandler:
self,
token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = 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
capabilities: Optional capabilities configuration
"""
token = token or os.environ.get("DISCORD_BOT_TOKEN")
if not token:
@@ -70,6 +173,11 @@ class BotHandler:
print("Provide via --token or DISCORD_BOT_TOKEN environment variable")
return
# Set auto-approve if enabled - use environment variable (persists across async contexts)
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
logger.info("Auto-approve enabled for all tool executions")
try:
from praisonai.bots import DiscordBot
except ImportError as e:
@@ -77,11 +185,10 @@ class BotHandler:
print("Install with: pip install discord.py")
return
agent = self._load_agent(agent_file)
agent = self._load_agent(agent_file, capabilities)
bot = DiscordBot(token=token, agent=agent)
print("Starting Discord bot...")
print("Press Ctrl+C to stop")
self._print_startup_info("Discord", capabilities)
try:
asyncio.run(bot.start())
@@ -94,6 +201,7 @@ class BotHandler:
token: Optional[str] = None,
app_token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = None,
) -> None:
"""Start a Slack bot.
@@ -101,6 +209,7 @@ class BotHandler:
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
capabilities: Optional capabilities configuration
"""
token = token or os.environ.get("SLACK_BOT_TOKEN")
app_token = app_token or os.environ.get("SLACK_APP_TOKEN")
@@ -110,6 +219,11 @@ class BotHandler:
print("Provide via --token or SLACK_BOT_TOKEN environment variable")
return
# Set auto-approve if enabled - use environment variable (persists across async contexts)
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
logger.info("Auto-approve enabled for all tool executions")
try:
from praisonai.bots import SlackBot
except ImportError as e:
@@ -117,11 +231,10 @@ class BotHandler:
print("Install with: pip install slack-bolt")
return
agent = self._load_agent(agent_file)
agent = self._load_agent(agent_file, capabilities)
bot = SlackBot(token=token, app_token=app_token, agent=agent)
print("Starting Slack bot...")
print("Press Ctrl+C to stop")
self._print_startup_info("Slack", capabilities)
try:
asyncio.run(bot.start())
@@ -129,43 +242,418 @@ class BotHandler:
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.")
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
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.")
Follows progressive disclosure pattern from agents.md:
- bool True enables defaults
- List/Config provides custom configuration
"""
if not capabilities:
return {}
kwargs: Dict[str, Any] = {}
# LLM model
if capabilities.model:
kwargs["llm"] = capabilities.model
# Memory: True enables defaults (agents.md pattern)
if capabilities.memory:
kwargs["memory"] = True
# Knowledge: List of sources or True
if capabilities.knowledge and capabilities.knowledge_sources:
kwargs["knowledge"] = capabilities.knowledge_sources
elif capabilities.knowledge:
kwargs["knowledge"] = True
# Skills: List of skill names
if capabilities.skills:
kwargs["skills"] = capabilities.skills
# Thinking mode → reflection (maps off/minimal/low/medium/high)
if capabilities.thinking:
# Map thinking mode to reflection config
# medium/high enables extended thinking, off/minimal/low disables
if capabilities.thinking in ("medium", "high"):
kwargs["reflection"] = True
return kwargs
def _print_startup_info(self, platform: str, capabilities: Optional[BotCapabilities]) -> None:
"""Print startup information with enabled capabilities."""
print(f"Starting {platform} bot...")
if capabilities:
enabled = []
if capabilities.browser:
enabled.append(f"browser ({capabilities.browser_profile})")
if capabilities.tools:
enabled.append(f"tools ({len(capabilities.tools)})")
if capabilities.skills:
enabled.append(f"skills ({len(capabilities.skills)})")
if capabilities.memory:
enabled.append("memory")
if capabilities.knowledge:
enabled.append("knowledge")
if capabilities.web_search:
enabled.append(f"web ({capabilities.web_search_provider})")
if capabilities.sandbox:
enabled.append("sandbox")
if capabilities.exec_enabled:
enabled.append("exec")
if enabled:
print(f"Capabilities: {', '.join(enabled)}")
print("Press Ctrl+C to stop")
def _load_agent(
self,
file_path: Optional[str],
capabilities: Optional[BotCapabilities] = None,
):
"""Load agent from configuration file with capabilities.
Args:
file_path: Path to agent YAML configuration
capabilities: Bot capabilities to apply
Returns:
Configured Agent instance
"""
from praisonaiagents import Agent
# Build tools list from capabilities
tools = self._build_tools(capabilities) if capabilities else []
# Get agent kwargs from capabilities (DRY)
agent_kwargs = self._get_agent_kwargs(capabilities)
# Default agent if no file
if not file_path:
return Agent(
name="assistant",
instructions="You are a helpful assistant.",
tools=tools if tools else None,
**agent_kwargs,
)
# Resolve file path - try current dir first, then check if it's absolute
resolved_path = file_path
if not os.path.isabs(file_path):
# Try relative to current working directory
cwd_path = os.path.join(os.getcwd(), file_path)
if os.path.exists(cwd_path):
resolved_path = cwd_path
if not os.path.exists(resolved_path):
print(f"Warning: Agent file not found: {file_path} (resolved: {resolved_path})")
return Agent(
name="assistant",
instructions="You are a helpful assistant.",
tools=tools if tools else None,
**agent_kwargs,
)
try:
import yaml
with open(file_path, "r") as f:
with open(resolved_path, "r") as f:
config = yaml.safe_load(f)
from praisonaiagents import Agent
# Support both list-style and dict-style YAML agents
agent_config = None
if "agents" in config and config["agents"]:
agent_config = config["agents"][0]
else:
agents = config["agents"]
if isinstance(agents, list):
# List style: agents: - name: foo
agent_config = agents[0]
elif isinstance(agents, dict):
# Dict style: agents: searcher: name: foo
first_key = next(iter(agents))
agent_config = agents[first_key]
if not agent_config:
agent_config = config
# Merge tools from YAML and capabilities
yaml_tools = agent_config.get("tools", [])
all_tools = self._resolve_tools(yaml_tools) + tools
# Override llm from YAML if not provided in capabilities
if "llm" not in agent_kwargs and agent_config.get("llm"):
agent_kwargs["llm"] = agent_config.get("llm")
return Agent(
name=agent_config.get("name", "assistant"),
instructions=agent_config.get("instructions", "You are a helpful assistant."),
llm=agent_config.get("llm"),
tools=all_tools if all_tools else None,
**agent_kwargs,
)
except Exception as e:
import traceback
print(f"Error loading agent: {e}")
from praisonaiagents import Agent
return Agent(name="assistant", instructions="You are a helpful assistant.")
traceback.print_exc()
return Agent(
name="assistant",
instructions="You are a helpful assistant.",
tools=tools if tools else None,
**agent_kwargs,
)
def _build_tools(self, capabilities: BotCapabilities) -> List:
"""Build tools list from capabilities."""
import os
tools = []
# Set WEB_SEARCH_PROVIDER env var to respect --web-provider CLI flag
# This must be done BEFORE importing search_web
if capabilities.web_search_provider:
os.environ["WEB_SEARCH_PROVIDER"] = capabilities.web_search_provider
logger.info(f"Web search provider set to: {capabilities.web_search_provider}")
# Default tools: execute_command and search_web (always enabled)
try:
from praisonaiagents.tools import execute_command, search_web
tools.append(execute_command)
tools.append(search_web)
logger.info("Default tools enabled: execute_command, search_web")
except ImportError:
logger.warning("Default tools not available from praisonaiagents.tools")
# Browser tool
if capabilities.browser:
try:
from praisonai_tools import BrowserBaseTool
tools.append(BrowserBaseTool())
logger.info("Browser tool enabled")
except ImportError:
logger.warning("Browser tool not available. Install praisonai-tools.")
# Web search (additional provider-specific tool)
if capabilities.web_search:
try:
if capabilities.web_search_provider == "duckduckgo":
from praisonai_tools import DuckDuckGoTool
tools.append(DuckDuckGoTool())
elif capabilities.web_search_provider == "tavily":
from praisonai_tools import TavilyTool
tools.append(TavilyTool())
elif capabilities.web_search_provider == "serper":
from praisonai_tools import SerperTool
tools.append(SerperTool())
else:
from praisonai_tools import DuckDuckGoTool
tools.append(DuckDuckGoTool())
logger.info(f"Web search provider enabled: {capabilities.web_search_provider}")
except ImportError:
logger.warning("Web search tool not available. Install praisonai-tools.")
# Exec tool (code execution) - already included in default via execute_command
if capabilities.exec_enabled:
logger.info("Exec mode enabled (execute_command already included)")
# TTS tool
if capabilities.tts:
try:
from praisonai.tools.audio import create_tts_tool
tts_tool = create_tts_tool()
tools.append(tts_tool)
logger.info("TTS tool enabled")
except ImportError as e:
logger.warning(f"TTS tool not available: {e}")
# STT tool
if capabilities.stt:
try:
from praisonai.tools.audio import create_stt_tool
stt_tool = create_stt_tool()
tools.append(stt_tool)
logger.info("STT tool enabled")
except ImportError as e:
logger.warning(f"STT tool not available: {e}")
# Named tools
for tool_name in capabilities.tools:
tool = self._resolve_tool_by_name(tool_name)
if tool:
tools.append(tool)
return tools
def _resolve_tool_by_name(self, name: str):
"""Resolve a tool by name."""
try:
import praisonai_tools
tool_class = getattr(praisonai_tools, name, None)
if tool_class:
return tool_class()
except ImportError:
pass
try:
from praisonaiagents import tools as agent_tools
tool_func = getattr(agent_tools, name, None)
if tool_func:
return tool_func
except ImportError:
pass
logger.warning(f"Tool not found: {name}")
return None
def _resolve_tools(self, tool_names: List[str]) -> List:
"""Resolve a list of tool names to tool instances."""
tools = []
for name in tool_names:
tool = self._resolve_tool_by_name(name)
if tool:
tools.append(tool)
return tools
def handle_bot_command(args) -> None:
"""Handle bot CLI command."""
def _add_capability_args(parser) -> None:
"""Add capability arguments to a parser."""
# Agent configuration
parser.add_argument("--agent", help="Path to agent YAML configuration file")
parser.add_argument("--model", "-m", help="LLM model to use")
# Browser
parser.add_argument("--browser", action="store_true", help="Enable browser control")
parser.add_argument("--browser-profile", default="default", help="Browser profile name")
parser.add_argument("--browser-headless", action="store_true", help="Run browser in headless mode")
# Tools
parser.add_argument("--tools", "-t", nargs="*", default=[], help="Tools to enable (e.g., DuckDuckGoTool)")
parser.add_argument("--skills", nargs="*", default=[], help="Skills to enable")
parser.add_argument("--skills-dir", help="Custom skills directory")
# Memory
parser.add_argument("--memory", action="store_true", help="Enable memory")
parser.add_argument("--memory-provider", default="default", help="Memory provider")
# Knowledge/RAG
parser.add_argument("--knowledge", action="store_true", help="Enable knowledge/RAG")
parser.add_argument("--knowledge-sources", nargs="*", default=[], help="Knowledge sources")
# Web search
parser.add_argument("--web", "--web-search", dest="web_search", action="store_true", help="Enable web search")
parser.add_argument("--web-provider", default="duckduckgo", help="Web search provider (duckduckgo, tavily, serper)")
# Execution
parser.add_argument("--sandbox", action="store_true", help="Enable sandbox mode")
parser.add_argument("--exec", dest="exec_enabled", action="store_true", help="Enable exec tool")
parser.add_argument("--auto-approve", dest="auto_approve", action="store_true", help="Auto-approve all tool executions")
# Session
parser.add_argument("--session-id", help="Session ID")
parser.add_argument("--user-id", help="User ID for memory isolation")
# Thinking mode
parser.add_argument("--thinking", choices=["off", "minimal", "low", "medium", "high"], help="Thinking mode")
# Audio (TTS/STT)
parser.add_argument("--tts", action="store_true", help="Enable TTS tool for text-to-speech")
parser.add_argument("--tts-voice", default="alloy", help="TTS voice (alloy, echo, fable, onyx, nova, shimmer)")
parser.add_argument("--tts-model", help="TTS model (default: openai/tts-1)")
parser.add_argument("--auto-tts", action="store_true", help="Auto-convert all responses to speech")
parser.add_argument("--stt", action="store_true", help="Enable STT tool for speech-to-text")
parser.add_argument("--stt-model", help="STT model (default: openai/whisper-1)")
def _build_capabilities_from_args(args) -> BotCapabilities:
"""Build BotCapabilities from parsed arguments."""
return BotCapabilities(
browser=getattr(args, "browser", False),
browser_profile=getattr(args, "browser_profile", "default"),
browser_headless=getattr(args, "browser_headless", False),
tools=getattr(args, "tools", []) or [],
skills=getattr(args, "skills", []) or [],
skills_dir=getattr(args, "skills_dir", None),
memory=getattr(args, "memory", False),
memory_provider=getattr(args, "memory_provider", "default"),
knowledge=getattr(args, "knowledge", False),
knowledge_sources=getattr(args, "knowledge_sources", []) or [],
web_search=getattr(args, "web_search", False),
web_search_provider=getattr(args, "web_provider", "duckduckgo"),
sandbox=getattr(args, "sandbox", False),
exec_enabled=getattr(args, "exec_enabled", False),
auto_approve=getattr(args, "auto_approve", False),
model=getattr(args, "model", None),
thinking=getattr(args, "thinking", None),
tts=getattr(args, "tts", False),
tts_voice=getattr(args, "tts_voice", "alloy"),
tts_model=getattr(args, "tts_model", None),
auto_tts=getattr(args, "auto_tts", False),
stt=getattr(args, "stt", False),
stt_model=getattr(args, "stt_model", None),
session_id=getattr(args, "session_id", None),
user_id=getattr(args, "user_id", None),
)
def handle_bot_command(args) -> int:
"""Handle bot CLI command.
Args:
args: Either a list of command-line arguments (from main.py unknown_args)
or an argparse.Namespace object (from legacy add_bot_parser).
Returns:
Exit code (0 for success, 1 for error)
"""
import argparse
# If args is a list, parse it using argparse
if isinstance(args, list):
parser = argparse.ArgumentParser(
prog="praisonai bot",
description="Start a messaging bot with full agent capabilities",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic bot
praisonai bot telegram --token $TELEGRAM_BOT_TOKEN
# Bot with agent YAML
praisonai bot slack --token $SLACK_BOT_TOKEN --agent agents.yaml
# Bot with browser and web search
praisonai bot discord --token $DISCORD_BOT_TOKEN --browser --web
# Bot with tools
praisonai bot telegram --token $TELEGRAM_BOT_TOKEN --tools DuckDuckGoTool WikipediaTool
# Full-featured bot
praisonai bot slack --token $SLACK_BOT_TOKEN --agent agents.yaml --browser --web --memory --model gpt-4o
""",
)
subparsers = parser.add_subparsers(dest="platform", help="Bot platform")
# Telegram
telegram_parser = subparsers.add_parser("telegram", help="Start a Telegram bot")
telegram_parser.add_argument("--token", help="Telegram bot token")
_add_capability_args(telegram_parser)
# Discord
discord_parser = subparsers.add_parser("discord", help="Start a Discord bot")
discord_parser.add_argument("--token", help="Discord bot token")
_add_capability_args(discord_parser)
# Slack
slack_parser = subparsers.add_parser("slack", help="Start a Slack bot")
slack_parser.add_argument("--token", help="Slack bot token")
slack_parser.add_argument("--app-token", dest="app_token", help="Slack app token for Socket Mode")
_add_capability_args(slack_parser)
try:
args = parser.parse_args(args)
except SystemExit:
return 1
handler = BotHandler()
capabilities = _build_capabilities_from_args(args)
platform = getattr(args, "platform", None)
@@ -173,28 +661,51 @@ def handle_bot_command(args) -> None:
handler.start_telegram(
token=getattr(args, "token", None),
agent_file=getattr(args, "agent", None),
capabilities=capabilities,
)
return 0
elif platform == "discord":
handler.start_discord(
token=getattr(args, "token", None),
agent_file=getattr(args, "agent", None),
capabilities=capabilities,
)
return 0
elif platform == "slack":
handler.start_slack(
token=getattr(args, "token", None),
app_token=getattr(args, "app_token", None),
agent_file=getattr(args, "agent", None),
capabilities=capabilities,
)
return 0
else:
print("Available platforms: telegram, discord, slack")
print("")
print("Usage: praisonai bot <platform> [options]")
print("")
print("Capability Options:")
print(" --agent FILE Agent YAML configuration file")
print(" --model MODEL LLM model to use")
print(" --browser Enable browser control")
print(" --web Enable web search")
print(" --memory Enable memory")
print(" --knowledge Enable knowledge/RAG")
print(" --tools TOOL... Enable specific tools")
print(" --sandbox Enable sandbox mode")
print("")
print("Examples:")
print(" praisonai bot telegram --token $TELEGRAM_BOT_TOKEN")
print(" praisonai bot slack --token $SLACK_BOT_TOKEN --agent agents.yaml --browser --web")
print(" praisonai bot discord --token $DISCORD_BOT_TOKEN --tools DuckDuckGoTool --memory")
return 1
def add_bot_parser(subparsers) -> None:
"""Add bot subparser to CLI."""
"""Add bot subparser to CLI with full capability options."""
bot_parser = subparsers.add_parser(
"bot",
help="Start a messaging bot",
help="Start a messaging bot with full agent capabilities",
)
bot_subparsers = bot_parser.add_subparsers(
@@ -202,6 +713,7 @@ def add_bot_parser(subparsers) -> None:
help="Bot platform",
)
# Telegram
telegram_parser = bot_subparsers.add_parser(
"telegram",
help="Start a Telegram bot",
@@ -210,11 +722,9 @@ def add_bot_parser(subparsers) -> None:
"--token",
help="Telegram bot token (or use TELEGRAM_BOT_TOKEN env var)",
)
telegram_parser.add_argument(
"--agent",
help="Path to agent configuration file",
)
_add_capability_args(telegram_parser)
# Discord
discord_parser = bot_subparsers.add_parser(
"discord",
help="Start a Discord bot",
@@ -223,11 +733,9 @@ def add_bot_parser(subparsers) -> None:
"--token",
help="Discord bot token (or use DISCORD_BOT_TOKEN env var)",
)
discord_parser.add_argument(
"--agent",
help="Path to agent configuration file",
)
_add_capability_args(discord_parser)
# Slack
slack_parser = bot_subparsers.add_parser(
"slack",
help="Start a Slack bot",
@@ -241,9 +749,6 @@ def add_bot_parser(subparsers) -> None:
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",
)
_add_capability_args(slack_parser)
bot_parser.set_defaults(func=handle_bot_command)
+7
View File
@@ -21,6 +21,8 @@ LEGACY_COMMANDS = {
'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api',
'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug',
'lsp', 'diag', 'persistence', 'browser',
# Bot/Gateway/Sandbox commands (added for resident agent features)
'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security',
}
# Typer commands that have been implemented
@@ -38,9 +40,14 @@ TYPER_COMMANDS = {
'replay',
# Standardisation commands
'standardise', 'standardize',
# Moltbot-inspired commands (bots, browser, plugins, sandbox)
'bot', 'browser', 'plugins', 'sandbox', 'loop',
# RAG commands
'rag', 'index', 'query', 'search',
}
def is_legacy_invocation(argv: List[str]) -> bool:
"""
Check if the command line represents a legacy invocation.
+7 -3
View File
@@ -794,7 +794,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -832,7 +832,7 @@ class PraisonAI:
parser.add_argument("--no-lsp", action="store_true", help="Disable LSP tools (code intelligence: symbols, definitions, references)")
parser.add_argument("--save", "-s", action="store_true", help="Save research output to file (output/research/)")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output for research")
parser.add_argument("--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
parser.add_argument("--web", "--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
@@ -1648,8 +1648,12 @@ class PraisonAI:
elif args.command == 'bot':
# Bot command - messaging bot runtimes (Telegram, Discord, Slack)
# Re-inject flags consumed by main parser into unknown_args for bot handler
bot_args = list(unknown_args)
if getattr(args, 'model', None) and '--model' not in bot_args and '-m' not in bot_args:
bot_args.extend(['--model', args.model])
from .features.bots_cli import handle_bot_command
exit_code = handle_bot_command(unknown_args)
exit_code = handle_bot_command(bot_args)
sys.exit(exit_code)
elif args.command == 'sandbox':
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==3.11.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.12.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+24
View File
@@ -9,6 +9,10 @@ __all__ = [
"multiedit",
"glob_files",
"grep_search",
"tts_tool",
"stt_tool",
"create_tts_tool",
"create_stt_tool",
]
_lazy_cache = {}
@@ -34,4 +38,24 @@ def __getattr__(name: str):
_lazy_cache[name] = grep_search
return grep_search
if name == "tts_tool":
from .audio import tts_tool
_lazy_cache[name] = tts_tool
return tts_tool
if name == "stt_tool":
from .audio import stt_tool
_lazy_cache[name] = stt_tool
return stt_tool
if name == "create_tts_tool":
from .audio import create_tts_tool
_lazy_cache[name] = create_tts_tool
return create_tts_tool
if name == "create_stt_tool":
from .audio import create_stt_tool
_lazy_cache[name] = create_stt_tool
return create_stt_tool
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+268
View File
@@ -0,0 +1,268 @@
"""
Audio Tools for PraisonAI Bots.
Provides TTS and STT tools that wrap the core AudioAgent for use by agents.
These tools enable agents to convert text to speech and transcribe audio.
Following moltbot patterns:
- tts_tool: Convert text to speech, return audio file path
- stt_tool: Transcribe audio file to text
"""
from __future__ import annotations
import os
import tempfile
import logging
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
# Lazy-loaded AudioAgent instance (shared for efficiency)
_audio_agent = None
def _get_audio_agent():
"""Lazy load AudioAgent from core SDK."""
global _audio_agent
if _audio_agent is None:
try:
from praisonaiagents import AudioAgent
_audio_agent = AudioAgent(verbose=False)
except ImportError:
raise ImportError(
"AudioAgent requires litellm. "
"Install with: pip install praisonaiagents[llm]"
)
return _audio_agent
def tts_tool(
text: str,
voice: Optional[str] = None,
model: Optional[str] = None,
output_dir: Optional[str] = None,
output_format: str = "mp3",
) -> Dict[str, Any]:
"""
Convert text to speech and return the audio file path.
This tool wraps the core AudioAgent.speech() method for use by agents.
Similar to moltbot's tts-tool, it returns a MEDIA: path that can be
sent to messaging platforms.
Args:
text: Text to convert to speech
voice: Voice to use (e.g., "alloy", "echo", "fable", "onyx", "nova", "shimmer")
model: TTS model (default: "openai/tts-1")
output_dir: Directory to save audio (default: temp directory)
output_format: Audio format (mp3, opus, aac, flac, wav)
Returns:
Dict with:
- success: bool
- audio_path: str (path to audio file)
- media_line: str (MEDIA:path for bot responses)
- error: str (if failed)
Example:
result = tts_tool("Hello world!")
if result["success"]:
print(result["media_line"]) # MEDIA:/tmp/tts_abc123.mp3
"""
try:
agent = _get_audio_agent()
# Determine output path
if output_dir:
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"tts_{os.urandom(8).hex()}.{output_format}")
else:
# Use temp directory
output_path = os.path.join(
tempfile.gettempdir(),
f"tts_{os.urandom(8).hex()}.{output_format}"
)
# Build kwargs
kwargs = {"response_format": output_format}
if voice:
kwargs["voice"] = voice
if model:
kwargs["model"] = model
# Generate speech
agent.speech(text, output=output_path, **kwargs)
# Check if file was created
if os.path.exists(output_path):
# Determine if voice-compatible (opus for Telegram)
voice_compatible = output_format.lower() in ("opus", "ogg")
return {
"success": True,
"audio_path": output_path,
"media_line": f"MEDIA:{output_path}",
"voice_compatible": voice_compatible,
"format": output_format,
}
else:
return {
"success": False,
"error": "Audio file was not created",
}
except Exception as e:
logger.error(f"TTS error: {e}")
return {
"success": False,
"error": str(e),
}
def stt_tool(
audio_path: str,
language: Optional[str] = None,
model: Optional[str] = None,
) -> Dict[str, Any]:
"""
Transcribe audio file to text.
This tool wraps the core AudioAgent.transcribe() method for use by agents.
Args:
audio_path: Path to audio file to transcribe
language: Language code (e.g., "en", "es", "fr")
model: STT model (default: "openai/whisper-1")
Returns:
Dict with:
- success: bool
- text: str (transcribed text)
- error: str (if failed)
Example:
result = stt_tool("audio.mp3")
if result["success"]:
print(result["text"])
"""
try:
if not os.path.exists(audio_path):
return {
"success": False,
"error": f"Audio file not found: {audio_path}",
}
agent = _get_audio_agent()
# Build kwargs
kwargs = {}
if language:
kwargs["language"] = language
if model:
kwargs["model"] = model
# Transcribe
text = agent.transcribe(audio_path, **kwargs)
return {
"success": True,
"text": text,
}
except Exception as e:
logger.error(f"STT error: {e}")
return {
"success": False,
"error": str(e),
}
# Tool functions decorated for agent use
def create_tts_tool():
"""
Create a TTS tool function for use with agents.
Returns a function that can be added to an agent's tools list.
Example:
from praisonai.tools.audio import create_tts_tool
from praisonaiagents import Agent
agent = Agent(
name="assistant",
tools=[create_tts_tool()]
)
"""
try:
from praisonaiagents import tool
except ImportError:
raise ImportError("praisonaiagents is required")
@tool
def tts(text: str, voice: str = "alloy") -> str:
"""
Convert text to speech and return the audio file path.
Args:
text: Text to convert to speech
voice: Voice to use (alloy, echo, fable, onyx, nova, shimmer)
Returns:
MEDIA: path to the generated audio file, or error message
"""
result = tts_tool(text, voice=voice)
if result["success"]:
return result["media_line"]
return f"Error: {result['error']}"
return tts
def create_stt_tool():
"""
Create an STT tool function for use with agents.
Returns a function that can be added to an agent's tools list.
Example:
from praisonai.tools.audio import create_stt_tool
from praisonaiagents import Agent
agent = Agent(
name="assistant",
tools=[create_stt_tool()]
)
"""
try:
from praisonaiagents import tool
except ImportError:
raise ImportError("praisonaiagents is required")
@tool
def stt(audio_path: str, language: str = None) -> str:
"""
Transcribe audio file to text.
Args:
audio_path: Path to audio file to transcribe
language: Optional language code (en, es, fr, etc.)
Returns:
Transcribed text, or error message
"""
result = stt_tool(audio_path, language=language)
if result["success"]:
return result["text"]
return f"Error: {result['error']}"
return stt
# Convenience exports
__all__ = [
"tts_tool",
"stt_tool",
"create_tts_tool",
"create_stt_tool",
]
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.11.2"
__version__ = "3.12.2"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.14.2",
"praisonaiagents>=0.15.2",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
@@ -0,0 +1,15 @@
FROM python:3.12-slim-bookworm
RUN set -eux; \
apt-get update && \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY run.sh /usr/local/bin/praisonai-install-smoke
RUN chmod +x /usr/local/bin/praisonai-install-smoke
ENTRYPOINT ["/usr/local/bin/praisonai-install-smoke"]
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_URL="${PRAISONAI_INSTALL_URL:-https://praison.ai/install.sh}"
EXPECTED_VERSION="${PRAISONAI_EXPECTED_VERSION:-}"
EXTRAS="${PRAISONAI_EXTRAS:-}"
echo "==> PraisonAI Install Smoke Test"
echo "install_url=$INSTALL_URL"
echo "expected_version=$EXPECTED_VERSION"
echo "extras=$EXTRAS"
# Get latest version from PyPI if not specified
if [[ -z "$EXPECTED_VERSION" ]]; then
echo "==> Fetching latest version from PyPI..."
EXPECTED_VERSION=$(pip index versions praisonaiagents 2>/dev/null | head -1 | grep -oP '\(\K[^)]+' || echo "")
if [[ -z "$EXPECTED_VERSION" ]]; then
# Fallback: try pip show after install
echo "==> Could not determine version from PyPI, will verify after install"
fi
fi
echo "==> Running official installer one-liner"
if [[ -n "$EXTRAS" ]]; then
PRAISONAI_EXTRAS="$EXTRAS" curl -fsSL "$INSTALL_URL" | bash
else
curl -fsSL "$INSTALL_URL" | bash
fi
echo "==> Verify installation"
# Source the venv if it exists
VENV_DIR="$HOME/.praisonai/venv"
if [[ -d "$VENV_DIR" ]]; then
source "$VENV_DIR/bin/activate"
fi
# Check import works
python3 -c "import praisonaiagents; print(f'Installed version: {praisonaiagents.__version__}')"
INSTALLED_VERSION=$(python3 -c "import praisonaiagents; print(praisonaiagents.__version__)" 2>/dev/null)
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
if [[ -n "$EXPECTED_VERSION" && "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
echo "WARNING: Version mismatch (expected $EXPECTED_VERSION, got $INSTALLED_VERSION)"
# Don't fail on version mismatch for now, as PyPI version detection may be flaky
fi
echo "==> Sanity check: Basic agent creation"
python3 -c "
from praisonaiagents import Agent
agent = Agent(name='test', instructions='You are a test agent')
print(f'Agent created: {agent.name}')
print('Basic sanity check passed!')
"
echo "==> Smoke test PASSED"
+459
View File
@@ -0,0 +1,459 @@
# PraisonAI Installer for Windows
# Usage: iwr -useb https://praison.ai/install.ps1 | iex
#
# Parameters:
# -Version Specific version to install (default: latest)
# -Extras Comma-separated extras (e.g., "ui,chat,code")
# -NoVenv Skip virtual environment creation
# -Python Path to Python executable
# -DryRun Print what would happen without making changes
# -Help Show help message
param(
[string]$Version = "latest",
[string]$Extras = "",
[switch]$NoVenv,
[string]$Python = "",
[switch]$DryRun,
[switch]$Help
)
# Environment variable overrides
if ($env:PRAISONAI_VERSION) { $Version = $env:PRAISONAI_VERSION }
if ($env:PRAISONAI_EXTRAS) { $Extras = $env:PRAISONAI_EXTRAS }
if ($env:PRAISONAI_SKIP_VENV -eq "1") { $NoVenv = $true }
if ($env:PRAISONAI_PYTHON) { $Python = $env:PRAISONAI_PYTHON }
if ($env:PRAISONAI_DRY_RUN -eq "1") { $DryRun = $true }
$MinPythonVersion = [version]"3.10"
$VenvDir = "$env:USERPROFILE\.praisonai\venv"
function Write-Banner {
Write-Host ""
Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "║ PraisonAI - AI Agents Made Simple ║" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "║ Works everywhere. Installs everything. You're welcome. ║" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
}
function Write-Info {
param([string]$Message)
Write-Host "==> " -ForegroundColor Blue -NoNewline
Write-Host $Message
}
function Write-Success {
param([string]$Message)
Write-Host "✓ " -ForegroundColor Green -NoNewline
Write-Host $Message
}
function Write-Warning {
param([string]$Message)
Write-Host "⚠ " -ForegroundColor Yellow -NoNewline
Write-Host $Message
}
function Write-Error {
param([string]$Message)
Write-Host "✗ " -ForegroundColor Red -NoNewline
Write-Host $Message
}
function Write-Step {
param([string]$Message)
Write-Host "→ " -ForegroundColor Cyan -NoNewline
Write-Host $Message
}
function Show-Help {
Write-Host "PraisonAI Installer for Windows"
Write-Host ""
Write-Host "Usage: iwr -useb https://praison.ai/install.ps1 | iex"
Write-Host " & ([scriptblock]::Create((iwr -useb https://praison.ai/install.ps1))) [OPTIONS]"
Write-Host ""
Write-Host "Options:"
Write-Host " -Version VERSION Install specific version (default: latest)"
Write-Host " -Extras EXTRAS Install with extras (e.g., ui,chat,code)"
Write-Host " -NoVenv Skip virtual environment creation"
Write-Host " -Python PATH Use specific Python executable"
Write-Host " -DryRun Print what would happen without making changes"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Environment variables:"
Write-Host " PRAISONAI_VERSION Specific version to install"
Write-Host " PRAISONAI_EXTRAS Comma-separated extras"
Write-Host " PRAISONAI_SKIP_VENV Skip virtual environment (1 to enable)"
Write-Host " PRAISONAI_PYTHON Path to Python executable"
Write-Host " PRAISONAI_DRY_RUN Print what would happen (1 to enable)"
Write-Host ""
Write-Host "Examples:"
Write-Host " # Basic install"
Write-Host " iwr -useb https://praison.ai/install.ps1 | iex"
Write-Host ""
Write-Host " # Install with extras"
Write-Host ' & ([scriptblock]::Create((iwr -useb https://praison.ai/install.ps1))) -Extras "ui,chat"'
Write-Host ""
}
function Test-PythonVersion {
param([string]$PythonPath)
try {
$versionOutput = & $PythonPath -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null
if ($versionOutput) {
$version = [version]$versionOutput
return $version -ge $MinPythonVersion
}
} catch {
return $false
}
return $false
}
function Find-Python {
# Check user-specified Python first
if ($Python -and (Test-Path $Python)) {
if (Test-PythonVersion $Python) {
return $Python
}
}
# Try common Python commands
$pythonCommands = @("python", "python3", "py -3.12", "py -3.11", "py -3.10", "py")
foreach ($cmd in $pythonCommands) {
try {
$cmdParts = $cmd -split " "
$exe = $cmdParts[0]
$args = if ($cmdParts.Length -gt 1) { $cmdParts[1..($cmdParts.Length-1)] } else { @() }
if (Get-Command $exe -ErrorAction SilentlyContinue) {
$fullCmd = if ($args) { "$exe $($args -join ' ')" } else { $exe }
if (Test-PythonVersion $fullCmd) {
return $fullCmd
}
}
} catch {
continue
}
}
return $null
}
function Install-Python {
Write-Info "Python $MinPythonVersion+ not found. Installing..."
# Try winget first
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Step "Installing Python via winget..."
if ($DryRun) {
Write-Info "[DRY RUN] Would run: winget install Python.Python.3.12"
} else {
winget install Python.Python.3.12 --accept-source-agreements --accept-package-agreements
}
return
}
# Try Chocolatey
if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Step "Installing Python via Chocolatey..."
if ($DryRun) {
Write-Info "[DRY RUN] Would run: choco install python -y"
} else {
choco install python -y
}
return
}
# Try Scoop
if (Get-Command scoop -ErrorAction SilentlyContinue) {
Write-Step "Installing Python via Scoop..."
if ($DryRun) {
Write-Info "[DRY RUN] Would run: scoop install python"
} else {
scoop install python
}
return
}
Write-Error "Could not find a package manager (winget, choco, or scoop)."
Write-Host "Please install Python $MinPythonVersion+ manually from: https://www.python.org/downloads/"
exit 1
}
function Ensure-Python {
Write-Info "Checking Python installation..."
$pythonCmd = Find-Python
if ($pythonCmd) {
$version = & $pythonCmd -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" 2>$null
Write-Success "Python $version found: $pythonCmd"
return $pythonCmd
}
Install-Python
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
$pythonCmd = Find-Python
if ($pythonCmd) {
Write-Success "Python installed successfully"
return $pythonCmd
}
Write-Error "Failed to install Python. Please install Python $MinPythonVersion+ manually."
exit 1
}
function Ensure-Pip {
param([string]$PythonCmd)
Write-Info "Checking pip..."
try {
& $PythonCmd -m pip --version 2>$null | Out-Null
Write-Success "pip is available"
return
} catch {}
Write-Step "Installing pip..."
if ($DryRun) {
Write-Info "[DRY RUN] Would install pip"
return
}
# Try ensurepip
try {
& $PythonCmd -m ensurepip --upgrade 2>$null
Write-Success "pip installed via ensurepip"
return
} catch {}
# Fall back to get-pip.py
$getPipUrl = "https://bootstrap.pypa.io/get-pip.py"
$getPipPath = "$env:TEMP\get-pip.py"
Invoke-WebRequest -Uri $getPipUrl -OutFile $getPipPath
& $PythonCmd $getPipPath
Remove-Item $getPipPath -ErrorAction SilentlyContinue
Write-Success "pip installed via get-pip.py"
}
function Create-Venv {
param([string]$PythonCmd)
if ($NoVenv) {
Write-Info "Skipping virtual environment (NoVenv specified)"
return $null
}
Write-Info "Creating virtual environment..."
if ($DryRun) {
Write-Info "[DRY RUN] Would create venv at: $VenvDir"
return $VenvDir
}
$parentDir = Split-Path $VenvDir -Parent
if (-not (Test-Path $parentDir)) {
New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
}
if (Test-Path $VenvDir) {
Write-Step "Virtual environment already exists at $VenvDir"
} else {
& $PythonCmd -m venv $VenvDir
Write-Success "Virtual environment created at $VenvDir"
}
return $VenvDir
}
function Install-PraisonAI {
param(
[string]$PythonCmd,
[string]$VenvPath
)
Write-Info "Installing PraisonAI..."
$pipCmd = "$PythonCmd -m pip"
# Use venv pip if available
if ($VenvPath -and (Test-Path "$VenvPath\Scripts\pip.exe")) {
$pipCmd = "$VenvPath\Scripts\pip.exe"
}
# Build install package name
$installPkg = "praisonaiagents"
if ($Version -ne "latest") {
$installPkg = "praisonaiagents==$Version"
}
# Add extras if specified
if ($Extras) {
$installPkg = "praisonaiagents[$Extras]"
if ($Version -ne "latest") {
$installPkg = "praisonaiagents[$Extras]==$Version"
}
}
if ($DryRun) {
Write-Info "[DRY RUN] Would run: $pipCmd install --upgrade $installPkg"
return
}
# Upgrade pip first
Invoke-Expression "$pipCmd install --upgrade pip"
# Install PraisonAI
Invoke-Expression "$pipCmd install --upgrade $installPkg"
# Also install wrapper if extras include ui/chat/code
if ($Extras -match "ui|chat|code") {
$wrapperPkg = "praisonai[$Extras]"
if ($Version -ne "latest") {
$wrapperPkg = "praisonai[$Extras]==$Version"
}
Invoke-Expression "$pipCmd install --upgrade $wrapperPkg"
}
Write-Success "PraisonAI installed successfully!"
}
function Setup-Path {
param([string]$VenvPath)
if ($NoVenv -or -not $VenvPath) {
return
}
Write-Info "Setting up PATH..."
$binDir = "$VenvPath\Scripts"
if ($DryRun) {
Write-Info "[DRY RUN] Would add to PATH: $binDir"
return
}
# Check if already in PATH
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$binDir*") {
$newPath = "$binDir;$currentPath"
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
Write-Success "Added PraisonAI to user PATH"
} else {
Write-Step "PATH already configured"
}
# Update current session
$env:Path = "$binDir;$env:Path"
}
function Test-Installation {
param([string]$VenvPath)
Write-Info "Verifying installation..."
if ($DryRun) {
Write-Info "[DRY RUN] Would verify installation"
return
}
$pythonCmd = "python"
if ($VenvPath -and (Test-Path "$VenvPath\Scripts\python.exe")) {
$pythonCmd = "$VenvPath\Scripts\python.exe"
}
try {
$version = & $pythonCmd -c "import praisonaiagents; print(f'Version: {praisonaiagents.__version__}')" 2>$null
if ($version) {
Write-Success "PraisonAI agents package verified: $version"
}
} catch {
Write-Error "Failed to import praisonaiagents"
exit 1
}
# Check CLI
if (Get-Command praisonai -ErrorAction SilentlyContinue) {
$cliVersion = & praisonai --version 2>$null | Select-Object -First 1
Write-Success "CLI available: praisonai $cliVersion"
}
}
function Show-NextSteps {
param([string]$VenvPath)
Write-Host ""
Write-Host "Installation complete!" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor White
Write-Host ""
if ($VenvPath -and -not $NoVenv) {
Write-Host " 1. Activate the virtual environment:"
Write-Host " $VenvPath\Scripts\Activate.ps1" -ForegroundColor Cyan
Write-Host ""
Write-Host " 2. Or restart your terminal to use PraisonAI globally"
Write-Host ""
}
Write-Host " Quick start:"
Write-Host ' python -c "from praisonaiagents import Agent; Agent(name=''test'').start(''Hello!'')"' -ForegroundColor Cyan
Write-Host ""
Write-Host " Documentation:"
Write-Host " https://docs.praison.ai" -ForegroundColor Cyan
Write-Host ""
Write-Host " Set your API key:"
Write-Host ' $env:OPENAI_API_KEY = "your_key"' -ForegroundColor Cyan
Write-Host ""
}
# Main
function Main {
if ($Help) {
Show-Help
return
}
Write-Banner
Write-Info "Detected OS: Windows"
if ($DryRun) {
Write-Warning "Running in DRY RUN mode - no changes will be made"
}
# Ensure Python
$pythonCmd = Ensure-Python
# Ensure pip
Ensure-Pip $pythonCmd
# Create virtual environment
$venvPath = Create-Venv $pythonCmd
# Install PraisonAI
Install-PraisonAI $pythonCmd $venvPath
# Setup PATH
Setup-Path $venvPath
# Verify installation
Test-Installation $venvPath
# Show next steps
Show-NextSteps $venvPath
}
Main
+636
View File
@@ -0,0 +1,636 @@
#!/usr/bin/env bash
# PraisonAI Installer
# Works on macOS, Linux, and Windows (WSL)
# Usage: curl -fsSL https://praison.ai/install.sh | bash
#
# Environment variables:
# PRAISONAI_VERSION - Specific version to install (default: latest)
# PRAISONAI_EXTRAS - Comma-separated extras (e.g., "ui,chat,code")
# PRAISONAI_NO_PROMPT - Skip interactive prompts (1 to enable)
# PRAISONAI_DRY_RUN - Print what would happen without making changes
# PRAISONAI_PYTHON - Path to Python executable
# PRAISONAI_SKIP_VENV - Skip virtual environment creation (1 to enable)
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Configuration
VERSION="${PRAISONAI_VERSION:-latest}"
EXTRAS="${PRAISONAI_EXTRAS:-}"
NO_PROMPT="${PRAISONAI_NO_PROMPT:-0}"
DRY_RUN="${PRAISONAI_DRY_RUN:-0}"
PYTHON_CMD="${PRAISONAI_PYTHON:-}"
SKIP_VENV="${PRAISONAI_SKIP_VENV:-0}"
MIN_PYTHON_VERSION="3.10"
# Logging functions
log_info() {
echo -e "${BLUE}==>${NC} $1"
}
log_success() {
echo -e "${GREEN}✓${NC} $1"
}
log_warn() {
echo -e "${YELLOW}⚠${NC} $1"
}
log_error() {
echo -e "${RED}✗${NC} $1" >&2
}
log_step() {
echo -e "${CYAN}→${NC} $1"
}
# Print banner
print_banner() {
echo -e "${BOLD}"
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ${CYAN}PraisonAI${NC}${BOLD} - AI Agents Made Simple ║"
echo "║ ║"
echo "║ Works everywhere. Installs everything. You're welcome. ║"
echo "║ ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
}
# Detect OS
detect_os() {
case "$(uname -s)" in
Darwin*)
OS="macos"
;;
Linux*)
if grep -qi microsoft /proc/version 2>/dev/null; then
OS="wsl"
else
OS="linux"
fi
;;
MINGW*|MSYS*|CYGWIN*)
OS="windows"
;;
*)
OS="unknown"
;;
esac
echo "$OS"
}
# Detect package manager
detect_package_manager() {
if command -v brew &>/dev/null; then
echo "brew"
elif command -v apt-get &>/dev/null; then
echo "apt"
elif command -v dnf &>/dev/null; then
echo "dnf"
elif command -v yum &>/dev/null; then
echo "yum"
elif command -v pacman &>/dev/null; then
echo "pacman"
elif command -v apk &>/dev/null; then
echo "apk"
else
echo "unknown"
fi
}
# Check if command exists
command_exists() {
command -v "$1" &>/dev/null
}
# Compare Python versions
version_gte() {
# Returns 0 if $1 >= $2
printf '%s\n%s\n' "$2" "$1" | sort -V -C
}
# Find suitable Python
find_python() {
local python_cmd=""
# Check user-specified Python first
if [[ -n "$PYTHON_CMD" ]]; then
if command_exists "$PYTHON_CMD"; then
python_cmd="$PYTHON_CMD"
else
log_error "Specified Python not found: $PYTHON_CMD"
return 1
fi
else
# Try common Python commands
for cmd in python3.12 python3.11 python3.10 python3 python; do
if command_exists "$cmd"; then
python_cmd="$cmd"
break
fi
done
fi
if [[ -z "$python_cmd" ]]; then
return 1
fi
# Verify version
local version
version=$("$python_cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
if version_gte "$version" "$MIN_PYTHON_VERSION"; then
echo "$python_cmd"
return 0
fi
return 1
}
# Install Python on macOS
install_python_macos() {
log_step "Installing Python via Homebrew..."
if ! command_exists brew; then
log_step "Installing Homebrew first..."
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would install Homebrew"
else
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Add Homebrew to PATH for this session
if [[ -f "/opt/homebrew/bin/brew" ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f "/usr/local/bin/brew" ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
fi
fi
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: brew install [email protected]"
else
brew install [email protected] || brew upgrade [email protected] || true
fi
}
# Install Python on Linux
install_python_linux() {
local pkg_manager
pkg_manager=$(detect_package_manager)
log_step "Installing Python via $pkg_manager..."
case "$pkg_manager" in
apt)
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: apt-get update && apt-get install -y python3 python3-pip python3-venv"
else
sudo apt-get update
sudo apt-get install -y python3 python3-pip python3-venv
fi
;;
dnf)
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: dnf install -y python3 python3-pip"
else
sudo dnf install -y python3 python3-pip
fi
;;
yum)
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: yum install -y python3 python3-pip"
else
sudo yum install -y python3 python3-pip
fi
;;
pacman)
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: pacman -Sy --noconfirm python python-pip"
else
sudo pacman -Sy --noconfirm python python-pip
fi
;;
apk)
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: apk add python3 py3-pip"
else
sudo apk add python3 py3-pip
fi
;;
*)
log_error "Unsupported package manager. Please install Python $MIN_PYTHON_VERSION+ manually."
return 1
;;
esac
}
# Ensure Python is installed
ensure_python() {
log_info "Checking Python installation..."
local python_cmd
if python_cmd=$(find_python); then
local version
version=$("$python_cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')")
log_success "Python $version found: $python_cmd"
echo "$python_cmd"
return 0
fi
log_warn "Python $MIN_PYTHON_VERSION+ not found. Installing..."
local os
os=$(detect_os)
case "$os" in
macos)
install_python_macos
;;
linux|wsl)
install_python_linux
;;
*)
log_error "Cannot auto-install Python on this OS. Please install Python $MIN_PYTHON_VERSION+ manually."
return 1
;;
esac
# Try to find Python again
if python_cmd=$(find_python); then
log_success "Python installed successfully"
echo "$python_cmd"
return 0
fi
log_error "Failed to install Python. Please install Python $MIN_PYTHON_VERSION+ manually."
return 1
}
# Ensure pip is available
ensure_pip() {
local python_cmd="$1"
log_info "Checking pip..."
if "$python_cmd" -m pip --version &>/dev/null; then
log_success "pip is available"
return 0
fi
log_step "Installing pip..."
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would install pip"
return 0
fi
# Try ensurepip first
if "$python_cmd" -m ensurepip --upgrade &>/dev/null; then
log_success "pip installed via ensurepip"
return 0
fi
# Fall back to get-pip.py
curl -fsSL https://bootstrap.pypa.io/get-pip.py | "$python_cmd"
log_success "pip installed via get-pip.py"
}
# Create virtual environment
create_venv() {
local python_cmd="$1"
local venv_dir="${2:-$HOME/.praisonai/venv}"
if [[ "$SKIP_VENV" == "1" ]]; then
log_info "Skipping virtual environment (PRAISONAI_SKIP_VENV=1)"
return 0
fi
log_info "Creating virtual environment..."
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would create venv at: $venv_dir"
return 0
fi
mkdir -p "$(dirname "$venv_dir")"
if [[ -d "$venv_dir" ]]; then
log_step "Virtual environment already exists at $venv_dir"
else
"$python_cmd" -m venv "$venv_dir"
log_success "Virtual environment created at $venv_dir"
fi
echo "$venv_dir"
}
# Install PraisonAI
install_praisonai() {
local python_cmd="$1"
local venv_dir="$2"
log_info "Installing PraisonAI..."
local pip_cmd="$python_cmd -m pip"
# Use venv pip if available
if [[ -n "$venv_dir" && -f "$venv_dir/bin/pip" ]]; then
pip_cmd="$venv_dir/bin/pip"
elif [[ -n "$venv_dir" && -f "$venv_dir/Scripts/pip.exe" ]]; then
pip_cmd="$venv_dir/Scripts/pip.exe"
fi
# Build install command
local install_pkg="praisonaiagents"
if [[ "$VERSION" != "latest" ]]; then
install_pkg="praisonaiagents==$VERSION"
fi
# Add extras if specified
if [[ -n "$EXTRAS" ]]; then
install_pkg="praisonaiagents[$EXTRAS]"
if [[ "$VERSION" != "latest" ]]; then
install_pkg="praisonaiagents[$EXTRAS]==$VERSION"
fi
fi
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would run: $pip_cmd install --upgrade $install_pkg"
return 0
fi
# Upgrade pip first
$pip_cmd install --upgrade pip
# Install PraisonAI
$pip_cmd install --upgrade "$install_pkg"
# Also install the wrapper if extras include ui/chat/code
if [[ "$EXTRAS" == *"ui"* ]] || [[ "$EXTRAS" == *"chat"* ]] || [[ "$EXTRAS" == *"code"* ]]; then
local wrapper_pkg="praisonai[$EXTRAS]"
if [[ "$VERSION" != "latest" ]]; then
wrapper_pkg="praisonai[$EXTRAS]==$VERSION"
fi
$pip_cmd install --upgrade "$wrapper_pkg"
fi
log_success "PraisonAI installed successfully!"
}
# Setup shell PATH
setup_shell_path() {
local venv_dir="$1"
if [[ "$SKIP_VENV" == "1" ]] || [[ -z "$venv_dir" ]]; then
return 0
fi
log_info "Setting up shell PATH..."
local bin_dir="$venv_dir/bin"
if [[ ! -d "$bin_dir" ]]; then
bin_dir="$venv_dir/Scripts"
fi
local shell_rc=""
case "$SHELL" in
*/zsh)
shell_rc="$HOME/.zshrc"
;;
*/bash)
if [[ -f "$HOME/.bash_profile" ]]; then
shell_rc="$HOME/.bash_profile"
else
shell_rc="$HOME/.bashrc"
fi
;;
*/fish)
shell_rc="$HOME/.config/fish/config.fish"
;;
*)
shell_rc="$HOME/.profile"
;;
esac
local path_line="export PATH=\"$bin_dir:\$PATH\""
if [[ "$SHELL" == */fish ]]; then
path_line="set -gx PATH $bin_dir \$PATH"
fi
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would add to $shell_rc: $path_line"
return 0
fi
# Check if already in rc file
if grep -q "$bin_dir" "$shell_rc" 2>/dev/null; then
log_step "PATH already configured in $shell_rc"
else
echo "" >> "$shell_rc"
echo "# PraisonAI" >> "$shell_rc"
echo "$path_line" >> "$shell_rc"
log_success "Added PraisonAI to PATH in $shell_rc"
fi
# Export for current session
export PATH="$bin_dir:$PATH"
}
# Verify installation
verify_installation() {
local venv_dir="$1"
log_info "Verifying installation..."
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would verify installation"
return 0
fi
local python_cmd="python3"
if [[ -n "$venv_dir" && -f "$venv_dir/bin/python" ]]; then
python_cmd="$venv_dir/bin/python"
elif [[ -n "$venv_dir" && -f "$venv_dir/Scripts/python.exe" ]]; then
python_cmd="$venv_dir/Scripts/python.exe"
fi
# Check import
if $python_cmd -c "import praisonaiagents; print(f'Version: {praisonaiagents.__version__}')" 2>/dev/null; then
log_success "PraisonAI agents package verified"
else
log_error "Failed to import praisonaiagents"
return 1
fi
# Check CLI if wrapper installed
if command_exists praisonai; then
local cli_version
cli_version=$(praisonai --version 2>/dev/null | head -n1 || echo "unknown")
log_success "CLI available: praisonai $cli_version"
fi
}
# Print next steps
print_next_steps() {
local venv_dir="$1"
echo ""
echo -e "${BOLD}${GREEN}Installation complete!${NC}"
echo ""
echo -e "${BOLD}Next steps:${NC}"
echo ""
if [[ -n "$venv_dir" ]] && [[ "$SKIP_VENV" != "1" ]]; then
echo " 1. Activate the virtual environment:"
if [[ "$SHELL" == */fish ]]; then
echo -e " ${CYAN}source $venv_dir/bin/activate.fish${NC}"
else
echo -e " ${CYAN}source $venv_dir/bin/activate${NC}"
fi
echo ""
echo " 2. Or restart your terminal to use PraisonAI globally"
echo ""
fi
echo " Quick start:"
echo -e " ${CYAN}python -c \"from praisonaiagents import Agent; Agent(name='test').start('Hello!')\"${NC}"
echo ""
echo " Documentation:"
echo -e " ${CYAN}https://docs.praison.ai${NC}"
echo ""
echo " Set your API key:"
echo -e " ${CYAN}export OPENAI_API_KEY=your_key${NC}"
echo ""
}
# Print help
print_help() {
echo "PraisonAI Installer"
echo ""
echo "Usage: curl -fsSL https://praison.ai/install.sh | bash"
echo " curl -fsSL https://praison.ai/install.sh | bash -s -- [OPTIONS]"
echo ""
echo "Options:"
echo " --version VERSION Install specific version (default: latest)"
echo " --extras EXTRAS Install with extras (e.g., ui,chat,code)"
echo " --no-venv Skip virtual environment creation"
echo " --python PATH Use specific Python executable"
echo " --dry-run Print what would happen without making changes"
echo " --no-prompt Skip interactive prompts"
echo " -h, --help Show this help message"
echo ""
echo "Environment variables:"
echo " PRAISONAI_VERSION Specific version to install"
echo " PRAISONAI_EXTRAS Comma-separated extras"
echo " PRAISONAI_NO_PROMPT Skip interactive prompts (1 to enable)"
echo " PRAISONAI_DRY_RUN Print what would happen (1 to enable)"
echo " PRAISONAI_PYTHON Path to Python executable"
echo " PRAISONAI_SKIP_VENV Skip virtual environment (1 to enable)"
echo ""
echo "Examples:"
echo " # Basic install"
echo " curl -fsSL https://praison.ai/install.sh | bash"
echo ""
echo " # Install with UI extras"
echo " curl -fsSL https://praison.ai/install.sh | bash -s -- --extras ui,chat"
echo ""
echo " # Install specific version"
echo " curl -fsSL https://praison.ai/install.sh | bash -s -- --version 0.14.0"
echo ""
echo " # Dry run"
echo " curl -fsSL https://praison.ai/install.sh | bash -s -- --dry-run"
}
# Parse arguments
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
VERSION="$2"
shift 2
;;
--extras)
EXTRAS="$2"
shift 2
;;
--no-venv)
SKIP_VENV="1"
shift
;;
--python)
PYTHON_CMD="$2"
shift 2
;;
--dry-run)
DRY_RUN="1"
shift
;;
--no-prompt)
NO_PROMPT="1"
shift
;;
-h|--help)
print_help
exit 0
;;
*)
log_error "Unknown option: $1"
print_help
exit 1
;;
esac
done
}
# Main installation function
main() {
parse_args "$@"
print_banner
local os
os=$(detect_os)
log_info "Detected OS: $os"
if [[ "$DRY_RUN" == "1" ]]; then
log_warn "Running in DRY RUN mode - no changes will be made"
fi
# Ensure Python
local python_cmd
python_cmd=$(ensure_python)
# Ensure pip
ensure_pip "$python_cmd"
# Create virtual environment
local venv_dir=""
if [[ "$SKIP_VENV" != "1" ]]; then
venv_dir=$(create_venv "$python_cmd")
fi
# Install PraisonAI
install_praisonai "$python_cmd" "$venv_dir"
# Setup PATH
setup_shell_path "$venv_dir"
# Verify installation
verify_installation "$venv_dir"
# Print next steps
print_next_steps "$venv_dir"
}
# Run main
main "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Test the PraisonAI installer in Docker
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SMOKE_IMAGE="${PRAISONAI_SMOKE_IMAGE:-praisonai-install-smoke:local}"
INSTALL_URL="${PRAISONAI_INSTALL_URL:-file:///install.sh}"
echo "==> Build smoke test image: $SMOKE_IMAGE"
docker build \
-t "$SMOKE_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-smoke/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-smoke"
echo "==> Run installer smoke test"
docker run --rm -t \
-v "$ROOT_DIR/scripts/install.sh:/install.sh:ro" \
-e PRAISONAI_INSTALL_URL="$INSTALL_URL" \
-e PRAISONAI_EXPECTED_VERSION="${PRAISONAI_EXPECTED_VERSION:-}" \
-e PRAISONAI_EXTRAS="${PRAISONAI_EXTRAS:-}" \
"$SMOKE_IMAGE"
echo "==> Smoke test completed successfully!"
@@ -0,0 +1,280 @@
"""
Unit tests for audio tools (TTS/STT).
Tests the wrapper-level audio tools that use the core AudioAgent.
"""
from unittest.mock import Mock, patch, MagicMock
import os
import tempfile
class TestTTSTool:
"""Test TTS tool functionality."""
def test_tts_tool_success(self):
"""Test successful TTS conversion."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import tts_tool
# Create a temp file to simulate output
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
temp_path = f.name
# Mock speech to create the file
def mock_speech(text, output, **kwargs):
with open(output, 'w') as f:
f.write('fake audio')
mock_agent.speech.side_effect = mock_speech
result = tts_tool("Hello world", output_dir=os.path.dirname(temp_path))
assert result["success"] is True
assert "audio_path" in result
assert result["media_line"].startswith("MEDIA:")
# Cleanup
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_tts_tool_with_voice(self):
"""Test TTS with custom voice."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import tts_tool
# Mock speech to create a file
def mock_speech(text, output, **kwargs):
with open(output, 'w') as f:
f.write('fake audio')
mock_agent.speech.side_effect = mock_speech
result = tts_tool("Hello", voice="echo")
# Verify voice was passed
mock_agent.speech.assert_called_once()
call_kwargs = mock_agent.speech.call_args[1]
assert call_kwargs.get("voice") == "echo"
def test_tts_tool_error_handling(self):
"""Test TTS error handling."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_agent.speech.side_effect = Exception("API error")
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import tts_tool
result = tts_tool("Hello world")
assert result["success"] is False
assert "error" in result
assert "API error" in result["error"]
def test_tts_tool_opus_voice_compatible(self):
"""Test that opus format is marked as voice compatible."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import tts_tool
def mock_speech(text, output, **kwargs):
with open(output, 'w') as f:
f.write('fake audio')
mock_agent.speech.side_effect = mock_speech
result = tts_tool("Hello", output_format="opus")
assert result["success"] is True
assert result["voice_compatible"] is True
class TestSTTTool:
"""Test STT tool functionality."""
def test_stt_tool_success(self):
"""Test successful STT transcription."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_agent.transcribe.return_value = "Hello world"
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import stt_tool
# Create a temp audio file
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
f.write(b'fake audio data')
temp_path = f.name
try:
result = stt_tool(temp_path)
assert result["success"] is True
assert result["text"] == "Hello world"
finally:
os.unlink(temp_path)
def test_stt_tool_file_not_found(self):
"""Test STT with non-existent file."""
from praisonai.tools.audio import stt_tool
result = stt_tool("/nonexistent/path/audio.mp3")
assert result["success"] is False
assert "not found" in result["error"]
def test_stt_tool_with_language(self):
"""Test STT with language parameter."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_agent.transcribe.return_value = "Hola mundo"
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import stt_tool
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
f.write(b'fake audio')
temp_path = f.name
try:
result = stt_tool(temp_path, language="es")
# Verify language was passed
mock_agent.transcribe.assert_called_once()
call_kwargs = mock_agent.transcribe.call_args[1]
assert call_kwargs.get("language") == "es"
finally:
os.unlink(temp_path)
def test_stt_tool_error_handling(self):
"""Test STT error handling."""
with patch('praisonai.tools.audio._get_audio_agent') as mock_get_agent:
mock_agent = Mock()
mock_agent.transcribe.side_effect = Exception("Transcription failed")
mock_get_agent.return_value = mock_agent
from praisonai.tools.audio import stt_tool
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
f.write(b'fake audio')
temp_path = f.name
try:
result = stt_tool(temp_path)
assert result["success"] is False
assert "Transcription failed" in result["error"]
finally:
os.unlink(temp_path)
class TestCreateToolFunctions:
"""Test the create_*_tool factory functions."""
def test_create_tts_tool(self):
"""Test create_tts_tool returns a callable."""
with patch('praisonai.tools.audio._get_audio_agent'):
from praisonai.tools.audio import create_tts_tool
tool = create_tts_tool()
assert callable(tool)
assert hasattr(tool, '__name__') or hasattr(tool, 'name')
def test_create_stt_tool(self):
"""Test create_stt_tool returns a callable."""
with patch('praisonai.tools.audio._get_audio_agent'):
from praisonai.tools.audio import create_stt_tool
tool = create_stt_tool()
assert callable(tool)
assert hasattr(tool, '__name__') or hasattr(tool, 'name')
class TestBotCapabilitiesTTS:
"""Test TTS/STT in BotCapabilities."""
def test_bot_capabilities_tts_fields(self):
"""Test BotCapabilities has TTS/STT fields."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities(
tts=True,
tts_voice="echo",
tts_model="openai/tts-1-hd",
auto_tts=True,
stt=True,
stt_model="openai/whisper-1",
)
assert caps.tts is True
assert caps.tts_voice == "echo"
assert caps.tts_model == "openai/tts-1-hd"
assert caps.auto_tts is True
assert caps.stt is True
assert caps.stt_model == "openai/whisper-1"
def test_bot_capabilities_to_dict_includes_tts(self):
"""Test to_dict includes TTS/STT fields."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities(tts=True, stt=True)
d = caps.to_dict()
assert "tts" in d
assert "stt" in d
assert "auto_tts" in d
assert d["tts"] is True
assert d["stt"] is True
def test_build_capabilities_from_args_tts(self):
"""Test _build_capabilities_from_args includes TTS/STT."""
from praisonai.cli.features.bots_cli import _build_capabilities_from_args
from argparse import Namespace
args = Namespace(
browser=False,
browser_profile="default",
browser_headless=False,
tools=[],
skills=[],
skills_dir=None,
memory=False,
memory_provider="default",
knowledge=False,
knowledge_sources=[],
web_search=False,
web_provider="duckduckgo",
sandbox=False,
exec_enabled=False,
auto_approve=False,
model=None,
thinking=None,
tts=True,
tts_voice="nova",
tts_model="openai/tts-1-hd",
auto_tts=True,
stt=True,
stt_model="openai/whisper-1",
session_id=None,
user_id=None,
)
caps = _build_capabilities_from_args(args)
assert caps.tts is True
assert caps.tts_voice == "nova"
assert caps.tts_model == "openai/tts-1-hd"
assert caps.auto_tts is True
assert caps.stt is True
assert caps.stt_model == "openai/whisper-1"
+223
View File
@@ -0,0 +1,223 @@
"""
Unit tests for bot CLI capabilities wiring.
Tests that BotCapabilities are correctly passed to Agent constructor.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
class TestBotCapabilitiesWiring:
"""Test that bot capabilities are wired to Agent constructor."""
def test_get_agent_kwargs_memory(self):
"""Test that --memory flag is converted to Agent kwargs."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(memory=True)
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("memory") is True
def test_get_agent_kwargs_knowledge(self):
"""Test that --knowledge flag with sources is converted to Agent kwargs."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(
knowledge=True,
knowledge_sources=["docs.pdf", "data.txt"]
)
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("knowledge") == ["docs.pdf", "data.txt"]
def test_get_agent_kwargs_knowledge_no_sources(self):
"""Test that --knowledge flag without sources sets knowledge=True."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(knowledge=True)
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("knowledge") is True
def test_get_agent_kwargs_skills(self):
"""Test that --skills flag is converted to Agent kwargs."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(skills=["web_search", "code_exec"])
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("skills") == ["web_search", "code_exec"]
def test_get_agent_kwargs_thinking_medium(self):
"""Test that --thinking medium enables reflection."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(thinking="medium")
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("reflection") is True
def test_get_agent_kwargs_thinking_high(self):
"""Test that --thinking high enables reflection."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(thinking="high")
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("reflection") is True
def test_get_agent_kwargs_thinking_low(self):
"""Test that --thinking low does NOT enable reflection."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(thinking="low")
kwargs = handler._get_agent_kwargs(capabilities)
assert "reflection" not in kwargs
def test_get_agent_kwargs_model(self):
"""Test that --model is converted to Agent kwargs."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities(model="gpt-4o")
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs.get("llm") == "gpt-4o"
def test_get_agent_kwargs_empty_capabilities(self):
"""Test that empty capabilities returns empty kwargs."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
handler = BotHandler()
capabilities = BotCapabilities()
kwargs = handler._get_agent_kwargs(capabilities)
assert kwargs == {}
def test_get_agent_kwargs_none_capabilities(self):
"""Test that None capabilities returns empty kwargs."""
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
kwargs = handler._get_agent_kwargs(None)
assert kwargs == {}
@patch('praisonaiagents.Agent')
def test_load_agent_passes_capabilities(self, mock_agent_class):
"""Test that _load_agent passes capabilities to Agent constructor."""
from praisonai.cli.features.bots_cli import BotHandler, BotCapabilities
mock_agent = Mock()
mock_agent_class.return_value = mock_agent
handler = BotHandler()
capabilities = BotCapabilities(
memory=True,
knowledge=True,
knowledge_sources=["docs.pdf"],
skills=["web_search"],
thinking="medium",
model="gpt-4o",
)
# Call _load_agent without a file (uses default agent)
with patch.object(handler, '_build_tools', return_value=[]):
_ = handler._load_agent(None, capabilities)
# Verify Agent was called with the right kwargs
call_kwargs = mock_agent_class.call_args[1]
assert call_kwargs.get("memory") is True
assert call_kwargs.get("knowledge") == ["docs.pdf"]
assert call_kwargs.get("skills") == ["web_search"]
assert call_kwargs.get("reflection") is True
assert call_kwargs.get("llm") == "gpt-4o"
class TestBotCapabilitiesDataclass:
"""Test BotCapabilities dataclass."""
def test_default_values(self):
"""Test default values are correct."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities()
assert caps.memory is False
assert caps.knowledge is False
assert caps.knowledge_sources == []
assert caps.skills == []
assert caps.thinking is None
assert caps.model is None
assert caps.sandbox is False
def test_to_dict(self):
"""Test to_dict method."""
from praisonai.cli.features.bots_cli import BotCapabilities
caps = BotCapabilities(memory=True, model="gpt-4o")
d = caps.to_dict()
assert d["memory"] is True
assert d["model"] == "gpt-4o"
class TestBuildCapabilitiesFromArgs:
"""Test _build_capabilities_from_args function."""
def test_builds_from_args(self):
"""Test building capabilities from argparse namespace."""
from praisonai.cli.features.bots_cli import _build_capabilities_from_args
from argparse import Namespace
args = Namespace(
memory=True,
knowledge=True,
knowledge_sources=["docs.pdf"],
skills=["web_search"],
thinking="medium",
model="gpt-4o",
browser=False,
browser_profile="default",
browser_headless=False,
tools=[],
skills_dir=None,
memory_provider="default",
web_search=False,
web_provider="duckduckgo",
sandbox=False,
exec_enabled=False,
auto_approve=False,
session_id=None,
user_id=None,
)
caps = _build_capabilities_from_args(args)
assert caps.memory is True
assert caps.knowledge is True
assert caps.knowledge_sources == ["docs.pdf"]
assert caps.skills == ["web_search"]
assert caps.thinking == "medium"
assert caps.model == "gpt-4o"
@@ -0,0 +1,88 @@
"""
Unit tests for bot media parsing utilities.
"""
import os
import tempfile
import pytest
from praisonai.bots.media import split_media_from_output, is_audio_file
class TestSplitMediaFromOutput:
"""Tests for split_media_from_output function."""
def test_empty_text(self):
"""Empty text returns empty result."""
result = split_media_from_output("")
assert result["text"] == ""
assert result["media_urls"] == []
assert result["audio_as_voice"] is False
def test_text_only(self):
"""Text without MEDIA: is preserved."""
result = split_media_from_output("Hello world!")
assert result["text"] == "Hello world!"
assert result["media_urls"] == []
assert result["audio_as_voice"] is False
def test_media_with_existing_file(self):
"""MEDIA: with existing file is extracted."""
# Create temp file
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
temp_path = f.name
try:
result = split_media_from_output(f"Hello\nMEDIA:{temp_path}\nWorld")
assert "Hello" in result["text"]
assert "World" in result["text"]
assert temp_path in result["media_urls"]
finally:
os.unlink(temp_path)
def test_voice_tag_detection(self):
"""[[audio_as_voice]] tag is detected and removed."""
result = split_media_from_output("[[audio_as_voice]] Hello!")
assert result["audio_as_voice"] is True
assert "[[audio_as_voice]]" not in result["text"]
assert "Hello" in result["text"]
def test_multiple_media_urls(self):
"""Multiple MEDIA: lines are all extracted."""
# Create temp files
temps = []
for i in range(2):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
temps.append(f.name)
try:
text = f"Start\nMEDIA:{temps[0]}\nMiddle\nMEDIA:{temps[1]}\nEnd"
result = split_media_from_output(text)
assert len(result["media_urls"]) == 2
assert temps[0] in result["media_urls"]
assert temps[1] in result["media_urls"]
finally:
for t in temps:
os.unlink(t)
def test_http_url(self):
"""HTTP URLs are accepted as media."""
result = split_media_from_output("MEDIA:https://example.com/audio.mp3")
assert "https://example.com/audio.mp3" in result["media_urls"]
class TestIsAudioFile:
"""Tests for is_audio_file function."""
def test_audio_extensions(self):
"""Audio extensions are recognized."""
assert is_audio_file("/path/to/file.mp3") is True
assert is_audio_file("/path/to/file.wav") is True
assert is_audio_file("/path/to/file.ogg") is True
assert is_audio_file("/path/to/file.opus") is True
assert is_audio_file("/path/to/file.m4a") is True
def test_non_audio_extensions(self):
"""Non-audio extensions are rejected."""
assert is_audio_file("/path/to/file.txt") is False
assert is_audio_file("/path/to/file.pdf") is False
assert is_audio_file("/path/to/file.png") is False
+4 -4
View File
@@ -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.14.2" },
{ name = "praisonaiagents", specifier = ">=0.15.2" },
{ 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.14.2"
version = "0.15.2"
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/d3/15/206a9f99edad0e89f0be2d438d5c545e829971cc119d137eec74d87aae10/praisonaiagents-0.14.2.tar.gz", hash = "sha256:416efe7a9bfa13edd502bedf6046ceb50f790c0954dd6a56fff99716831fcddc", size = 1152873, upload-time = "2026-01-29T13:38:54.142Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/d8/6d815f3dda3295c7f59e1ddf5e9057fd2ee608fce3c816a49e176b66f73d/praisonaiagents-0.15.2.tar.gz", hash = "sha256:fc3b36108a03d047bf913fbca8a6987125506948668e91cfd3f34cc87a541f92", size = 1160924, upload-time = "2026-01-30T10:21:04.375Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/20/8efd51b9a4700d644df858bca3d80aabc7f4511d9000acb110cd08304bf3/praisonaiagents-0.14.2-py3-none-any.whl", hash = "sha256:12f4a339fab6648afddcc4acb1cfcd1cc0e99bbeead3d0ddccfb886b9cfe6a46", size = 1140931, upload-time = "2026-01-29T13:38:51.674Z" },
{ url = "https://files.pythonhosted.org/packages/d0/82/9d842def26267ed0be7c9d17adc43a5624909610a6f0add79c06e852ac84/praisonaiagents-0.15.2-py3-none-any.whl", hash = "sha256:19b1b80c79182264b4ed0490ee72d418fa6003375c16b1ac81c52f6ad8bf2b69", size = 1149931, upload-time = "2026-01-30T10:21:02.508Z" },
]
[[package]]
+1
View File
@@ -0,0 +1 @@
# Add your custom tools here