Compare commits

...
13 Commits
Author SHA1 Message Date
Mervin Praison 8ddbb4ee71 Release v4.5.2 2026-02-14 00:06:53 +00:00
Mervin Praison 371ec987ac Release v4.5.1 2026-02-13 15:00:40 +00:00
github-actions[bot] 0885a9b65d docs: auto-update api.md [skip ci] 2026-02-13 14:11:15 +00:00
Mervin Praison fe8f52c1b4 Release v4.5.0 2026-02-13 14:10:58 +00:00
github-actions[bot] 951dfd36c8 docs: auto-update api.md [skip ci] 2026-02-13 07:59:22 +00:00
Mervin Praison 7d0657632f Release v4.4.12 2026-02-13 07:59:05 +00:00
Mervin Praison ab80807b34 Release v4.4.11 2026-02-12 14:26:25 +00:00
Mervin Praison 38888c745e Release v4.4.10 2026-02-12 12:44:16 +00:00
github-actions[bot] d905a25833 docs: auto-update api.md [skip ci] 2026-02-12 11:33:34 +00:00
Mervin Praison ec0b435ab3 Release v4.4.9 2026-02-12 11:33:17 +00:00
github-actions[bot] 4833b86863 docs: auto-update api.md [skip ci] 2026-02-12 08:03:44 +00:00
Mervin Praison 42af95d9ab Release v4.4.8 2026-02-12 08:03:28 +00:00
github-actions[bot] 0ec7cec870 docs: auto-update api.md [skip ci] 2026-02-12 07:41:46 +00:00
119 changed files with 17737 additions and 974 deletions

No files matched your search

+6 -1
View File
@@ -672,7 +672,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -736,6 +736,8 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
@@ -777,6 +779,7 @@ Methods:
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <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 bot whatsapp <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>
@@ -889,6 +892,7 @@ Methods:
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai obs obs-fallback <a href="./src/praisonai/praisonai/cli/commands/obs.py">--help</a></code>
* <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>
@@ -1014,6 +1018,7 @@ Methods:
* <code title="cli">praisonai train llm <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train show <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train train-callback <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai ui bot <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai ui chat <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai ui code <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai ui gradio <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.4.7" \
"praisonai>=4.5.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>=4.4.7" \
"praisonai>=4.5.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>=4.4.7" \
"praisonai>=4.5.2" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,24 @@
"""Agent Approval — delegate tool approvals to another AI agent."""
from praisonaiagents import Agent
from praisonaiagents.approval import AgentApproval
# Create a security reviewer agent
reviewer = Agent(
name="security-reviewer",
instructions=(
"You are a security reviewer. Only approve low-risk read operations. "
"Deny anything destructive like delete, remove, or write operations. "
"Respond with exactly one word: APPROVE or DENY"
),
)
# Create a worker agent that uses the reviewer for approvals
worker = Agent(
name="assistant",
instructions="Help users with system tasks",
tools=["execute_command"],
approval=AgentApproval(approver_agent=reviewer),
)
worker.start("List the files in the current directory")
@@ -0,0 +1,13 @@
"""Discord Approval — route tool approvals to a Discord channel."""
from praisonaiagents import Agent
from praisonai.bots import DiscordApproval
agent = Agent(
name="assistant",
instructions="Help users with system tasks",
tools=["execute_command"],
approval=DiscordApproval(channel_id="YOUR_CHANNEL_ID"), # token from DISCORD_BOT_TOKEN env
)
agent.start("List the files in the current directory")
@@ -0,0 +1,15 @@
"""HTTP Approval — serve a local web dashboard for tool approvals."""
from praisonaiagents import Agent
from praisonai.bots import HTTPApproval
agent = Agent(
name="assistant",
instructions="Help users with system tasks",
tools=["execute_command"],
approval=HTTPApproval(host="127.0.0.1", port=8899),
)
# When a tool needs approval, open http://127.0.0.1:8899/approve/<request_id>
# in your browser and click Approve or Deny.
agent.start("List the files in the current directory")
@@ -0,0 +1,13 @@
"""Slack Approval — route tool approvals to a Slack channel."""
from praisonaiagents import Agent
from praisonai.bots import SlackApproval
agent = Agent(
name="assistant",
instructions="Help users with system tasks",
tools=["execute_command"],
approval=SlackApproval(channel="#approvals"), # token from SLACK_BOT_TOKEN env
)
agent.start("List the files in the current directory")
@@ -0,0 +1,13 @@
"""Telegram Approval — route tool approvals to a Telegram chat."""
from praisonaiagents import Agent
from praisonai.bots import TelegramApproval
agent = Agent(
name="assistant",
instructions="Help users with system tasks",
tools=["execute_command"],
approval=TelegramApproval(chat_id="YOUR_CHAT_ID"), # token from TELEGRAM_BOT_TOKEN env
)
agent.start("List the files in the current directory")
@@ -0,0 +1,16 @@
"""Webhook Approval — route tool approvals to an external HTTP endpoint."""
from praisonaiagents import Agent
from praisonai.bots import WebhookApproval
agent = Agent(
name="assistant",
instructions="Help users with system tasks",
tools=["execute_command"],
approval=WebhookApproval(
webhook_url="https://your-app.com/api/approvals",
headers={"Authorization": "Bearer YOUR_TOKEN"},
),
)
agent.start("List the files in the current directory")
+8
View File
@@ -47,3 +47,11 @@ channels:
routing:
dm: "personal"
default: "personal"
# WhatsApp Web mode (experimental, no tokens needed):
# whatsapp:
# mode: web
# creds_dir: "~/.praisonai/whatsapp"
# routing:
# dm: "personal"
# default: "personal"
+13
View File
@@ -0,0 +1,13 @@
# WhatsApp Web Mode Bot (Experimental)
# Token-free: connect via QR code scan
# ⚠️ Uses reverse-engineered protocol — your number may be banned
platform: whatsapp
mode: web
# creds_dir: "~/.praisonai/whatsapp" # optional, default shown
agent:
name: "WhatsApp Web Assistant"
instructions: "You are a helpful AI assistant on WhatsApp."
llm: "gpt-4o-mini"
memory: true
+226 -2
View File
@@ -111,6 +111,7 @@ Easy and user friendly
| `bus/` | 48K | Event bus |
| `streaming/` | 56K | Streaming events, callbacks |
| `trace/` | 152K | Trace protocols, context events |
| `scheduler/` | 48K | Schedule models, store, parser, runner |
### 3.2 Protocol-First Design
@@ -343,6 +344,56 @@ agent = Agent(
)
```
### 5.3 Parameter Consolidation
Agent parameters are consolidated into Config objects following the pattern:
`False=disabled, True=defaults, Config=custom`
**Consolidated Parameters (use these):**
| Config Object | Replaces (Deprecated) |
|--------------|----------------------|
| `execution=ExecutionConfig(code_execution=True, code_mode="safe", rate_limiter=obj)` | `allow_code_execution`, `code_execution_mode`, `rate_limiter` |
| `memory=MemoryConfig(auto_save="session_name")` | `auto_save` |
| `autonomy=AutonomyConfig(verification_hooks=[...])` | `verification_hooks` |
| `handoffs=[other_agent]` | `allow_delegation` |
| `output=OutputConfig(...)` | `verbose`, `markdown`, `stream`, `metrics` |
| `reflection=ReflectionConfig(...)` | `self_reflect`, `max_reflect`, `min_reflect` |
| `templates=TemplateConfig(...)` | `system_template`, `prompt_template`, `response_template` |
| `caching=CachingConfig(...)` | `cache`, `prompt_caching` |
| `web=WebConfig(...)` | `web_search`, `web_fetch` |
**Deprecated standalone params still work** (backward compatible) but emit `DeprecationWarning`.
```python
# ❌ Old way (deprecated)
agent = Agent(
name="coder",
allow_code_execution=True,
code_execution_mode="safe",
auto_save="my_session",
rate_limiter=my_limiter,
allow_delegation=True,
verification_hooks=[my_hook],
)
# ✅ New way (consolidated)
from praisonaiagents import Agent, ExecutionConfig, MemoryConfig
from praisonaiagents.agent.autonomy import AutonomyConfig
agent = Agent(
name="coder",
handoffs=[reviewer_agent],
execution=ExecutionConfig(
code_execution=True,
code_mode="safe",
rate_limiter=my_limiter,
),
memory=MemoryConfig(auto_save="my_session"),
autonomy=AutonomyConfig(verification_hooks=[my_hook]),
)
```
---
## 6. Extension Points
@@ -406,7 +457,37 @@ class MyMemoryAdapter: # Implements MemoryProtocol
...
```
### 6.4 Database Adapters
### 6.4 Schedule Tools
Agent-centric scheduling via standalone `@tool` functions (no Agent class bloat):
```python
from praisonaiagents import Agent
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
agent = Agent(
name="assistant",
tools=[schedule_add, schedule_list, schedule_remove],
)
```
**Schedule expression formats:**
- Keywords: `"hourly"`, `"daily"`, `"weekly"`
- Intervals: `"*/30m"`, `"*/6h"`, `"*/10s"`
- Cron: `"cron:0 7 * * *"` (requires optional `croniter`)
- One-shot: `"at:2026-03-01T09:00:00"`
- Relative: `"in 20 minutes"`
**Core components:**
- `scheduler/models.py` — `Schedule` and `ScheduleJob` dataclasses
- `scheduler/store.py` — `FileScheduleStore` (thread-safe JSON persistence at `~/.praisonai/schedules/jobs.json`)
- `scheduler/parser.py` — `parse_schedule()` for human-friendly expressions
- `scheduler/runner.py` — `ScheduleRunner` (stateless due-job checker)
- `tools/schedule_tools.py` — `schedule_add`, `schedule_list`, `schedule_remove`
**Hook events:** `SCHEDULE_ADD`, `SCHEDULE_REMOVE`, `SCHEDULE_TRIGGER` (in `hooks/types.py`)
### 6.5 Database Adapters
```python
# Base files in praisonaiagents/db/
@@ -602,10 +683,153 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
| Event bus | `praisonaiagents/bus/bus.py` |
| Workflow engine | `praisonaiagents/workflows/workflows.py` |
| Policy engine | `praisonaiagents/policy/engine.py` |
| Scheduler module | `praisonaiagents/scheduler/` |
| Schedule tools | `praisonaiagents/tools/schedule_tools.py` |
---
## 12. Design Goals
## 12. BotOS — Multi-Platform Bot Orchestration
### 12.1 Architecture
BotOS is the multi-platform bot orchestration layer for PraisonAI. It follows the same protocol-driven, agent-centric philosophy as the rest of the SDK.
```
┌──────────────────────────────────────────────────┐
│ BotOS │
│ Multi-platform orchestrator (runs all bots) │
├──────────┬──────────┬──────────┬─────────────────┤
│ Bot │ Bot │ Bot │ Bot │
│ telegram │ discord │ slack │ custom_platform │
├──────────┴──────────┴──────────┴─────────────────┤
│ Agent / AgentTeam / AgentFlow │
│ (AI brain) │
└──────────────────────────────────────────────────┘
```
### 12.2 Layer Separation
| Layer | Package | Purpose |
|-------|---------|---------|
| `BotOSProtocol` | `praisonaiagents` (Core SDK) | Interface contract — lightweight |
| `BotOSConfig` | `praisonaiagents` (Core SDK) | Configuration dataclass |
| `Bot` | `praisonai` (Wrapper) | Single-platform bot wrapper |
| `BotOS` | `praisonai` (Wrapper) | Multi-platform orchestrator |
| `_registry` | `praisonai` (Wrapper) | Platform adapter registry |
### 12.3 Usage Patterns
```python
# === Level 1: Single bot, 3 lines ===
from praisonai.bots import Bot
from praisonaiagents import Agent
bot = Bot("telegram", agent=Agent(name="assistant", instructions="Be helpful"))
bot.run()
# === Level 2: Multi-platform, shared agent ===
from praisonai.bots import BotOS
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful")
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
botos.run()
# === Level 3: AgentTeam in BotOS ===
from praisonai.bots import BotOS, Bot
from praisonaiagents import Agent, AgentTeam, Task
researcher = Agent(name="researcher", instructions="Research topics")
writer = Agent(name="writer", instructions="Write content")
t1 = Task(name="research", description="Research AI", agent=researcher)
t2 = Task(name="write", description="Write about AI", agent=writer)
team = AgentTeam(agents=[researcher, writer], tasks=[t1, t2])
botos = BotOS(bots=[Bot("telegram", agent=team)])
botos.run()
# === Level 4: YAML config ===
botos = BotOS.from_config("botos.yaml")
botos.run()
# === Level 5: Custom platform extension ===
from praisonai.bots._registry import register_platform
class MyCustomBot:
def __init__(self, **kwargs): ...
async def start(self): ...
async def stop(self): ...
register_platform("mychat", MyCustomBot)
bot = Bot("mychat", agent=agent, token="my-token")
bot.run()
```
### 12.4 Token Resolution
Tokens are resolved in order: `explicit token=` > env var > empty string.
| Platform | Env Var |
|----------|---------|
| Telegram | `TELEGRAM_BOT_TOKEN` |
| Discord | `DISCORD_BOT_TOKEN` |
| Slack | `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` |
| WhatsApp | `WHATSAPP_ACCESS_TOKEN` + `WHATSAPP_PHONE_NUMBER_ID` |
| Custom | `{PLATFORM}_BOT_TOKEN` |
### 12.5 File Locations
| What | Where |
|------|-------|
| BotOSProtocol | `praisonaiagents/bots/protocols.py` |
| BotOSConfig | `praisonaiagents/bots/config.py` |
| Bot class | `praisonai/bots/bot.py` |
| BotOS class | `praisonai/bots/botos.py` |
| Platform registry | `praisonai/bots/_registry.py` |
| Unit tests | `tests/unit/test_botos_protocol.py` |
| Integration tests | `tests/unit/test_botos_integration.py` |
### 12.6 Extending Platforms
Third-party platforms can be registered at runtime:
```python
from praisonai.bots._registry import register_platform, list_platforms
# Register
register_platform("line", LineBot)
register_platform("viber", ViberBot)
# Verify
print(list_platforms()) # [..., "line", "viber", ...]
# Use
bot = Bot("line", agent=agent, token="my-line-token")
```
**Adapter contract**: Custom adapters must implement `async start()` and `async stop()`. Optional: `send_message()`, `on_message()`, `on_command()`, `is_running` property.
### 12.7 YAML Config Format
```yaml
# botos.yaml
agent:
name: assistant
instructions: You are a helpful assistant.
llm: gpt-4o-mini
platforms:
telegram:
token: ${TELEGRAM_BOT_TOKEN}
discord:
token: ${DISCORD_BOT_TOKEN}
slack:
token: ${SLACK_BOT_TOKEN}
app_token: ${SLACK_APP_TOKEN}
```
---
## 13. Design Goals
> Make PraisonAI the **best agent framework in the world**
@@ -193,6 +193,10 @@ _LAZY_IMPORTS = {
'GuardrailResult': ('praisonaiagents.guardrails', 'GuardrailResult'),
'LLMGuardrail': ('praisonaiagents.guardrails', 'LLMGuardrail'),
# Approval (agent-centric approval backends)
'AutoApproveBackend': ('praisonaiagents.approval.backends', 'AutoApproveBackend'),
'ConsoleBackend': ('praisonaiagents.approval.backends', 'ConsoleBackend'),
# Flow display
'FlowDisplay': ('praisonaiagents.flow_display', 'FlowDisplay'),
'track_workflow': ('praisonaiagents.flow_display', 'track_workflow'),
@@ -416,6 +420,8 @@ _LAZY_IMPORTS = {
'BotChannel': ('praisonaiagents.bots.protocols', 'BotChannel'),
'MessageType': ('praisonaiagents.bots.protocols', 'MessageType'),
'BotConfig': ('praisonaiagents.bots.config', 'BotConfig'),
'BotOSProtocol': ('praisonaiagents.bots.protocols', 'BotOSProtocol'),
'BotOSConfig': ('praisonaiagents.bots.config', 'BotOSConfig'),
# Sandbox protocols and config (implementations in praisonai wrapper)
'SandboxProtocol': ('praisonaiagents.sandbox.protocols', 'SandboxProtocol'),
@@ -646,6 +652,9 @@ __all__ = [
'tool',
'Tools',
# Approval (agent-centric approval backends)
'AutoApproveBackend',
# Embedding API - simplified imports
# Usage: from praisonaiagents import embedding, EmbeddingResult
'embedding',
@@ -411,13 +411,13 @@ class Agent:
api_key: Optional[str] = None, # Kept separate (connection/auth)
# Tools
tools: Optional[List[Any]] = None,
allow_delegation: bool = False,
allow_code_execution: Optional[bool] = False,
code_execution_mode: Literal["safe", "unsafe"] = "safe",
allow_delegation: bool = False, # Deprecated: use handoffs= instead
allow_code_execution: Optional[bool] = False, # Deprecated: use execution=ExecutionConfig(code_execution=True)
code_execution_mode: Literal["safe", "unsafe"] = "safe", # Deprecated: use execution=ExecutionConfig(code_mode="safe")
handoffs: Optional[List[Union['Agent', 'Handoff']]] = None,
# Session management
auto_save: Optional[str] = None,
rate_limiter: Optional[Any] = None,
# Session management (deprecated standalone params - use config objects)
auto_save: Optional[str] = None, # Deprecated: use memory=MemoryConfig(auto_save="name")
rate_limiter: Optional[Any] = None, # Deprecated: use execution=ExecutionConfig(rate_limiter=obj)
# ============================================================
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
# Each follows: False=disabled, True=defaults, Config=custom
@@ -430,13 +430,14 @@ class Agent:
web: Optional[Union[bool, Any]] = None, # Union[bool, WebConfig]
context: Optional[Union[bool, Any]] = None, # Union[bool, ManagerConfig, ContextManager] - None=smart default
autonomy: Optional[Union[bool, Dict[str, Any], Any]] = None, # Union[bool, dict, AutonomyConfig]
verification_hooks: Optional[List[Any]] = None, # List of VerificationHook instances
verification_hooks: Optional[List[Any]] = None, # Deprecated: use autonomy=AutonomyConfig(verification_hooks=[...])
output: Optional[Union[str, Any]] = None, # Union[str preset, OutputConfig]
execution: Optional[Union[str, Any]] = None, # Union[str preset, ExecutionConfig]
templates: Optional[Any] = None, # TemplateConfig
caching: Optional[Union[bool, Any]] = None, # Union[bool, CachingConfig]
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
):
"""Initialize an Agent instance.
@@ -452,12 +453,12 @@ class Agent:
base_url: Custom LLM endpoint URL (e.g., for Ollama). Kept separate for auth.
api_key: API key for LLM provider. Kept separate for auth.
tools: List of tools, functions, callables, or MCP instances.
allow_delegation: Allow task delegation to other agents. Defaults to False.
allow_code_execution: Enable code execution during tasks. Defaults to False.
code_execution_mode: "safe" (restricted) or "unsafe" (full access). Defaults to "safe".
allow_delegation: **Deprecated** — use ``handoffs=`` instead.
allow_code_execution: **Deprecated** — use ``execution=ExecutionConfig(code_execution=True)``.
code_execution_mode: **Deprecated** — use ``execution=ExecutionConfig(code_mode="safe")``.
handoffs: List of Agent or Handoff objects for agent-to-agent collaboration.
auto_save: Session name for automatic session saving.
rate_limiter: Rate limiter instance for API call throttling.
auto_save: **Deprecated** — use ``memory=MemoryConfig(auto_save="name")``.
rate_limiter: **Deprecated** — use ``execution=ExecutionConfig(rate_limiter=obj)``.
memory: Memory system configuration. Accepts:
- bool: True enables defaults, False disables
- MemoryConfig: Custom configuration
@@ -486,7 +487,8 @@ class Agent:
- bool: True enables with defaults
- Dict: Configuration dict
- AutonomyConfig: Custom configuration
verification_hooks: List of VerificationHook instances for output verification.
verification_hooks: **Deprecated** — use ``autonomy=AutonomyConfig(verification_hooks=[...])``.
Still works for backward compatibility.
output: Output configuration. Accepts:
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
- OutputConfig: Custom configuration
@@ -520,6 +522,11 @@ class Agent:
- system_template, prompt_template, response_template → templates=
- cache, prompt_caching → caching=
- web_search, web_fetch → web=
- allow_delegation → handoffs=
- allow_code_execution, code_execution_mode → execution=
- auto_save → memory=MemoryConfig(auto_save=)
- rate_limiter → execution=ExecutionConfig(rate_limiter=)
- verification_hooks → autonomy=AutonomyConfig(verification_hooks=)
"""
# Add check at start if memory is requested
if memory is not None:
@@ -584,6 +591,48 @@ class Agent:
# AutonomyConfig is in agent/autonomy.py - use dict for config defaults
autonomy = apply_config_defaults("autonomy", autonomy, None)
# ============================================================
# DEPRECATION WARNINGS for params consolidated into configs
# Old params still work but emit warnings pointing to new API
# ============================================================
import warnings as _warnings
if allow_delegation:
_warnings.warn(
"Parameter 'allow_delegation' is deprecated. Use 'handoffs=[other_agent]' instead.",
DeprecationWarning, stacklevel=2,
)
if allow_code_execution:
_warnings.warn(
"Parameter 'allow_code_execution' is deprecated. "
"Use 'execution=ExecutionConfig(code_execution=True)' instead.",
DeprecationWarning, stacklevel=2,
)
if code_execution_mode != "safe":
_warnings.warn(
"Parameter 'code_execution_mode' is deprecated. "
"Use 'execution=ExecutionConfig(code_mode=\"unsafe\")' instead.",
DeprecationWarning, stacklevel=2,
)
if auto_save is not None:
_warnings.warn(
"Parameter 'auto_save' is deprecated. "
"Use 'memory=MemoryConfig(auto_save=\"name\")' instead.",
DeprecationWarning, stacklevel=2,
)
if rate_limiter is not None:
_warnings.warn(
"Parameter 'rate_limiter' is deprecated. "
"Use 'execution=ExecutionConfig(rate_limiter=obj)' instead.",
DeprecationWarning, stacklevel=2,
)
if verification_hooks is not None:
_warnings.warn(
"Parameter 'verification_hooks' is deprecated. "
"Use 'autonomy=AutonomyConfig(verification_hooks=[...])' instead.",
DeprecationWarning, stacklevel=2,
)
# ============================================================
# CONSOLIDATED PARAMS EXTRACTION (agent-centric API)
# Uses unified resolver: Instance > Config > Array > String > Bool > Default
@@ -730,6 +779,13 @@ class Agent:
max_rpm = _exec_config.max_rpm
max_execution_time = _exec_config.max_execution_time
max_retry_limit = _exec_config.max_retry_limit
# Extract consolidated fields (config takes precedence over deprecated standalone params)
if _exec_config.rate_limiter is not None:
rate_limiter = _exec_config.rate_limiter
if _exec_config.code_execution:
allow_code_execution = True
if _exec_config.code_mode != "safe":
code_execution_mode = _exec_config.code_mode
else:
max_iter, max_rpm, max_execution_time, max_retry_limit = 20, None, None, 2
@@ -879,6 +935,9 @@ class Agent:
db = _memory_config.db
auto_memory = _memory_config.auto_memory
claude_memory = _memory_config.claude_memory
# Extract auto_save from MemoryConfig (takes precedence over standalone param)
if _memory_config.auto_save is not None:
auto_save = _memory_config.auto_save
# Convert to internal format
backend = _memory_config.backend
if hasattr(backend, 'value'):
@@ -911,11 +970,24 @@ class Agent:
if _memory_config.history:
_history_enabled = True
_history_limit = _memory_config.history_limit
# Use explicit session_id from MemoryConfig if provided
if _memory_config.session_id:
_history_session_id = _memory_config.session_id
# Use auto_save session if no explicit session and history is enabled
if _history_enabled and _history_session_id is None and auto_save:
_history_session_id = auto_save
# Auto-generate session_id when history=True but no session_id set
# This ensures _init_session_store() can create the store
if _history_enabled and session_id is None and _history_session_id is not None:
session_id = _history_session_id
elif _history_enabled and session_id is None and _history_session_id is None:
import hashlib as _hl
_agent_hash = _hl.md5(name.encode()).hexdigest()[:8]
session_id = f"history_{_agent_hash}"
_history_session_id = session_id
# ─────────────────────────────────────────────────────────────────────
# Resolve KNOWLEDGE param - FAST PATH
# ─────────────────────────────────────────────────────────────────────
@@ -1357,6 +1429,16 @@ Your Goal: {self.goal}
self._guardrail_fn = None
self._setup_guardrail()
# Initialize approval backend (agent-centric approval)
# True = AutoApproveBackend, False/None = registry fallback, object = custom backend
if approval is True:
from ..approval.backends import AutoApproveBackend
self._approval_backend = AutoApproveBackend()
elif approval is False or approval is None:
self._approval_backend = None
else:
self._approval_backend = approval
# Cache for system prompts and formatted tools with lazy thread-safe lock
self._system_prompt_cache = {}
self._formatted_tools_cache = {}
@@ -1835,6 +1917,9 @@ Summary:"""
"auto_escalate": autonomy.auto_escalate,
}
config = autonomy
# Extract verification_hooks from AutonomyConfig if provided
if autonomy.verification_hooks and not verification_hooks:
self._verification_hooks = autonomy.verification_hooks
else:
self.autonomy_enabled = False
self.autonomy_config = {}
@@ -2746,7 +2831,7 @@ Summary:"""
user_id=mem_user_id,
verbose=1 if getattr(self, 'verbose', False) else 0
)
elif isinstance(memory, str) and memory in ("sqlite", "chromadb", "mem0", "mongodb"):
elif isinstance(memory, str) and memory in ("sqlite", "chromadb", "mem0", "mongodb", "redis", "postgres"):
# Use full Memory class with specific provider
try:
from ..memory.memory import Memory
@@ -2784,6 +2869,11 @@ Summary:"""
logging.warning("Full Memory class requires additional dependencies. Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=mem_user_id)
elif isinstance(memory, str):
# Unknown string backend - fall back to FileMemory with warning
logging.warning(f"Unknown memory backend '{memory}'. Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=mem_user_id)
else:
# Assume it's already a memory instance
self._memory_instance = memory
@@ -3974,6 +4064,12 @@ Your Goal: {self.goal}"""
execution_time_ms=(time.time() - start_time) * 1000
)
self._hook_runner.execute_sync(HookEvent.AFTER_AGENT, after_agent_input)
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
if response:
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._process_auto_memory(prompt_str, str(response))
return response
@@ -4041,39 +4137,46 @@ Your Goal: {self.goal}"""
def _execute_tool_impl(self, function_name, arguments):
"""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, is_env_auto_approve, is_yaml_approved
if is_approval_required(function_name):
# 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)
# Check if approval is required for this tool (protocol-driven)
# Priority: self._approval_backend (agent param) > registry (global/per-agent)
from ..approval import get_approval_registry
try:
backend = getattr(self, '_approval_backend', None)
if backend is not None:
# Agent-level approval backend — bypass registry, call directly
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
if function_name in DEFAULT_DANGEROUS_TOOLS:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
if hasattr(backend, 'request_approval_sync'):
decision = backend.request_approval_sync(request)
else:
import asyncio
decision = asyncio.run(backend.request_approval(request))
else:
from ..approval.protocols import ApprovalDecision
decision = ApprovalDecision(approved=True, reason="No approval required")
else:
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
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}
# Fallback to global registry
decision = get_approval_registry().approve_sync(
getattr(self, 'name', None), 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}
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
@@ -6416,6 +6519,36 @@ Write the complete compiled report:"""
"""
pass
def _process_auto_memory(self, user_message: str, assistant_response: str):
"""Process auto-memory extraction after agent response.
Called after each agent response when auto_memory=True in MemoryConfig.
Uses AutoMemory to extract and store memorable content (names, preferences,
facts) from the conversation. No-op when auto_memory is disabled.
Args:
user_message: The user's input message
assistant_response: The agent's response
"""
if not self._auto_memory or not self._memory_instance:
return
try:
from ..memory.auto_memory import AutoMemory
# Lazy-create AutoMemory wrapper on first use
if not hasattr(self, '_auto_memory_instance') or self._auto_memory_instance is None:
self._auto_memory_instance = AutoMemory(
self._memory_instance,
enabled=True,
verbose=1 if getattr(self, 'verbose', False) else 0
)
self._auto_memory_instance.process_interaction(
user_message=str(user_message),
assistant_response=str(assistant_response),
)
except Exception as e:
logging.debug(f"Auto-memory extraction failed: {e}")
def _auto_save_session(self):
"""Auto-save session if auto_save is enabled."""
if not self.auto_save or not self._memory_instance:
@@ -6765,24 +6898,40 @@ Write the complete compiled report:"""
try:
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, is_env_auto_approve, is_yaml_approved, mark_approved
if is_approval_required(function_name):
# 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)
# Check if approval is required for this tool (protocol-driven)
# Priority: self._approval_backend (agent param) > registry (global/per-agent)
from ..approval import get_approval_registry
try:
backend = getattr(self, '_approval_backend', None)
if backend is not None:
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
if function_name in DEFAULT_DANGEROUS_TOOLS:
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
)
decision = await backend.request_approval(request)
else:
from ..approval.protocols import ApprovalDecision
decision = ApprovalDecision(approved=True, reason="No approval required")
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}")
decision = await get_approval_registry().approve_async(
getattr(self, 'name', None), 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}
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}
# Try to find the function in the agent's tools list first
func = None
@@ -55,6 +55,7 @@ class AutonomyConfig:
observe: Whether to emit observability events
completion_promise: Optional string that signals completion when wrapped in <promise>TEXT</promise>
clear_context: Whether to clear chat history between iterations
verification_hooks: List of VerificationHook instances for output verification
"""
enabled: bool = True
max_iterations: int = 20
@@ -63,6 +64,7 @@ class AutonomyConfig:
observe: bool = False
completion_promise: Optional[str] = None
clear_context: bool = False
verification_hooks: Optional[List[Any]] = None
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AutonomyConfig":
@@ -75,6 +77,7 @@ class AutonomyConfig:
observe=data.get("observe", False),
completion_promise=data.get("completion_promise"),
clear_context=data.get("clear_context", False),
verification_hooks=data.get("verification_hooks"),
)
@@ -462,17 +462,23 @@ DO NOT use strings for tasks. Each task MUST be a complete object with all four
# Use consolidated knowledge if provided, else fall back to legacy knowledge_sources
effective_knowledge = self._knowledge if self._knowledge is not None else self.knowledge_sources
# Build execution config merging legacy code_execution params
_effective_execution = self._execution
if _effective_execution is None and (self.allow_code_execution or self.code_execution_mode != "safe"):
from ..config.feature_configs import ExecutionConfig
_effective_execution = ExecutionConfig(
code_execution=bool(self.allow_code_execution),
code_mode=self.code_execution_mode,
)
agent = Agent(
name=agent_config.name,
role=agent_config.role,
goal=agent_config.goal,
backstory=agent_config.backstory,
tools=agent_tools, # Use assigned tools
allow_code_execution=self.allow_code_execution,
memory=self.memory,
llm=self.llm, # Consolidated LLM param
code_execution_mode=self.code_execution_mode,
allow_delegation=self.allow_delegation,
base_url=self.base_url,
api_key=self.api_key,
# Consolidated params (new SDK contract)
@@ -480,7 +486,7 @@ DO NOT use strings for tasks. Each task MUST be a complete object with all four
reflection=self._reflection, # Overrides self_reflect/max_reflect/min_reflect/reflect_llm
caching=self._caching, # Overrides cache
knowledge=effective_knowledge, # Overrides knowledge_sources/embedder_config
execution=self._execution, # Overrides max_iter
execution=_effective_execution, # Overrides max_iter + code execution
guardrails=self._guardrails,
web=self._web,
hooks=self._hooks,
@@ -1,529 +0,0 @@
"""
Human Approval Framework for PraisonAI Agents
This module provides a minimal human-in-the-loop approval system for dangerous tool operations.
It extends the existing callback system to require human approval before executing high-risk tools.
"""
import logging
import asyncio
import json
import os
from typing import Dict, Set, Optional, Callable, Any, Literal, List
from functools import wraps
import contextvars
from contextvars import ContextVar
from dataclasses import dataclass, field
# Lazy import for rich components
_rich_console = None
_rich_panel = None
_rich_confirm = None
def _get_rich_console():
"""Lazy import rich Console."""
global _rich_console
if _rich_console is None:
from rich.console import Console
_rich_console = Console
return _rich_console
def _get_rich_panel():
"""Lazy import rich Panel."""
global _rich_panel
if _rich_panel is None:
from rich.panel import Panel
_rich_panel = Panel
return _rich_panel
def _get_rich_confirm():
"""Lazy import rich Confirm."""
global _rich_confirm
if _rich_confirm is None:
from rich.prompt import Confirm
_rich_confirm = Confirm
return _rich_confirm
# Global registries for approval requirements
APPROVAL_REQUIRED_TOOLS: Set[str] = set()
TOOL_RISK_LEVELS: Dict[str, str] = {}
# Risk levels
RiskLevel = Literal["critical", "high", "medium", "low"]
# Global approval callback
approval_callback: Optional[Callable] = None
# Context variable to track if we're in an approved execution context
_approved_context: ContextVar[Set[str]] = ContextVar('approved_context', default=set())
# Context variable for YAML-defined auto-approved tools (from agents.yaml approve field)
_yaml_approved_tools: ContextVar[Set[str]] = ContextVar('yaml_approved_tools', default=set())
# Global permission allowlist
_permission_allowlist: Optional["PermissionAllowlist"] = None
@dataclass
class ToolPermission:
"""Permission entry for a tool."""
tool_name: str
allowed_paths: List[str] = field(default_factory=list)
session_only: bool = False # If True, permission expires with session
class PermissionAllowlist:
"""
Persistent permission allowlist for tools.
Allows pre-approving tools and paths to skip interactive approval.
Can be saved/loaded from JSON for persistence across sessions.
Usage:
allowlist = PermissionAllowlist()
allowlist.add_tool("read_file")
allowlist.add_tool("write_file", paths=["./src", "./tests"])
if allowlist.is_allowed("read_file"):
# Skip approval prompt
pass
"""
def __init__(self):
self._tools: Dict[str, ToolPermission] = {}
self._session_tools: Set[str] = set()
def add_tool(
self,
tool_name: str,
paths: Optional[List[str]] = None,
session_only: bool = False
) -> None:
"""
Add a tool to the allowlist.
Args:
tool_name: Name of the tool to allow
paths: Optional list of allowed paths (empty = all paths)
session_only: If True, permission expires with session
"""
self._tools[tool_name] = ToolPermission(
tool_name=tool_name,
allowed_paths=paths or [],
session_only=session_only
)
if session_only:
self._session_tools.add(tool_name)
def remove_tool(self, tool_name: str) -> bool:
"""Remove a tool from the allowlist."""
if tool_name in self._tools:
del self._tools[tool_name]
self._session_tools.discard(tool_name)
return True
return False
def is_allowed(self, tool_name: str, path: Optional[str] = None) -> bool:
"""
Check if a tool is allowed.
Args:
tool_name: Name of the tool
path: Optional path to check against allowed paths
Returns:
True if tool is allowed (optionally for the given path)
"""
if tool_name not in self._tools:
return False
permission = self._tools[tool_name]
# If no paths specified, tool is allowed for all paths
if not permission.allowed_paths:
return True
# If path not provided, check if tool has any permissions
if path is None:
return True
# Check if path matches any allowed path
for allowed_path in permission.allowed_paths:
# Normalize paths for comparison
norm_allowed = os.path.normpath(allowed_path)
norm_path = os.path.normpath(path)
# Check if path starts with allowed path
if norm_path.startswith(norm_allowed):
return True
return False
def is_empty(self) -> bool:
"""Check if allowlist is empty."""
return len(self._tools) == 0
def list_tools(self) -> List[str]:
"""List all allowed tools."""
return list(self._tools.keys())
def clear_session_permissions(self) -> None:
"""Clear session-only permissions."""
for tool_name in list(self._session_tools):
if tool_name in self._tools:
del self._tools[tool_name]
self._session_tools.clear()
def save(self, filepath: str) -> None:
"""Save allowlist to JSON file."""
data = {
"tools": {
name: {
"allowed_paths": perm.allowed_paths,
"session_only": perm.session_only
}
for name, perm in self._tools.items()
if not perm.session_only # Don't persist session-only permissions
}
}
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
with open(filepath, "w") as f:
json.dump(data, f, indent=2)
@classmethod
def load(cls, filepath: str) -> "PermissionAllowlist":
"""Load allowlist from JSON file."""
allowlist = cls()
if not os.path.exists(filepath):
return allowlist
with open(filepath, "r") as f:
data = json.load(f)
for tool_name, tool_data in data.get("tools", {}).items():
allowlist.add_tool(
tool_name,
paths=tool_data.get("allowed_paths", []),
session_only=tool_data.get("session_only", False)
)
return allowlist
def get_permission_allowlist() -> PermissionAllowlist:
"""Get the global permission allowlist."""
global _permission_allowlist
if _permission_allowlist is None:
_permission_allowlist = PermissionAllowlist()
return _permission_allowlist
def set_permission_allowlist(allowlist: PermissionAllowlist) -> None:
"""Set the global permission allowlist."""
global _permission_allowlist
_permission_allowlist = allowlist
class ApprovalDecision:
"""Result of an approval request"""
def __init__(self, approved: bool, modified_args: Optional[Dict[str, Any]] = None, reason: str = ""):
self.approved = approved
self.modified_args = modified_args or {}
self.reason = reason
def set_approval_callback(callback_fn: Callable):
"""Set a custom approval callback function.
The callback should accept (function_name, arguments, risk_level) and return ApprovalDecision.
"""
global approval_callback
approval_callback = callback_fn
def get_approval_callback() -> Optional[Callable]:
"""Get the current approval callback function.
Returns the custom callback if set, otherwise None.
This should be used instead of directly accessing approval_callback
to ensure the latest callback is always used.
"""
return approval_callback
def mark_approved(tool_name: str):
"""Mark a tool as approved in the current context."""
approved = _approved_context.get(set())
approved.add(tool_name)
_approved_context.set(approved)
def is_already_approved(tool_name: str) -> bool:
"""Check if a tool is already approved in the current context."""
approved = _approved_context.get(set())
return tool_name in approved
def is_yaml_approved(tool_name: str) -> bool:
"""Check if a tool is auto-approved via YAML approve field."""
try:
yaml_approved = _yaml_approved_tools.get()
return tool_name in yaml_approved
except LookupError:
return False
def is_env_auto_approve() -> bool:
"""Check if PRAISONAI_AUTO_APPROVE environment variable is set."""
return os.environ.get("PRAISONAI_AUTO_APPROVE", "").lower() in ("true", "1", "yes")
def set_yaml_approved_tools(tools: List[str]) -> contextvars.Token:
"""
Set the list of YAML-approved tools for the current context.
This is called by the workflow runner when parsing agents.yaml with approve field.
Args:
tools: List of tool names to auto-approve
Returns:
Token that can be used to reset the context
"""
return _yaml_approved_tools.set(set(tools))
def reset_yaml_approved_tools(token: contextvars.Token) -> None:
"""Reset YAML-approved tools to previous state."""
_yaml_approved_tools.reset(token)
def clear_approval_context():
"""Clear the approval context."""
_approved_context.set(set())
def require_approval(risk_level: RiskLevel = "high"):
"""Decorator to mark a tool as requiring human approval.
Args:
risk_level: The risk level of the tool ("critical", "high", "medium", "low")
"""
def decorator(func):
tool_name = getattr(func, '__name__', str(func))
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
@wraps(func)
def wrapper(*args, **kwargs):
# Skip approval if already approved in current context
if is_already_approved(tool_name):
return func(*args, **kwargs)
# Skip approval if tool is auto-approved via YAML approve field (primary)
if is_yaml_approved(tool_name):
mark_approved(tool_name)
return func(*args, **kwargs)
# Skip approval if PRAISONAI_AUTO_APPROVE env var is set (secondary)
if is_env_auto_approve():
mark_approved(tool_name)
return func(*args, **kwargs)
# Request approval before executing the function
try:
# Try to check if we're in an async context
try:
asyncio.get_running_loop()
# We're in an async context, but this is a sync function
# Fall back to sync approval to avoid loop conflicts
raise RuntimeError("Use sync fallback in async context")
except RuntimeError:
# Either no running loop or we want sync fallback
# Use asyncio.run for clean async execution
decision = asyncio.run(request_approval(tool_name, kwargs))
except Exception as e:
# Fallback to sync approval if async fails
logging.warning(f"Async approval failed, using sync fallback: {e}")
callback = get_approval_callback() or console_approval_callback
decision = callback(tool_name, kwargs, risk_level)
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
# Mark as approved and merge modified args
mark_approved(tool_name)
kwargs.update(decision.modified_args)
return func(*args, **kwargs)
@wraps(func)
async def async_wrapper(*args, **kwargs):
# Skip approval if already approved in current context
if is_already_approved(tool_name):
return await func(*args, **kwargs)
# Skip approval if tool is auto-approved via YAML approve field (primary)
if is_yaml_approved(tool_name):
mark_approved(tool_name)
return await func(*args, **kwargs)
# Skip approval if PRAISONAI_AUTO_APPROVE env var is set (secondary)
if is_env_auto_approve():
mark_approved(tool_name)
return await func(*args, **kwargs)
# Request approval before executing the function
decision = await request_approval(tool_name, kwargs)
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
# Mark as approved and merge modified args
mark_approved(tool_name)
kwargs.update(decision.modified_args)
return await func(*args, **kwargs)
# Return the appropriate wrapper based on function type
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return wrapper
return decorator
def console_approval_callback(function_name: str, arguments: Dict[str, Any], risk_level: str) -> ApprovalDecision:
"""Default console-based approval callback.
Displays tool information and prompts user for approval via console.
"""
Console = _get_rich_console()
Panel = _get_rich_panel()
Confirm = _get_rich_confirm()
console = Console()
# Create risk level styling
risk_colors = {
"critical": "bold red",
"high": "red",
"medium": "yellow",
"low": "blue"
}
risk_color = risk_colors.get(risk_level, "white")
# Display tool information
tool_info = f"[bold]Function:[/] {function_name}\n"
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{risk_level.upper()}[/{risk_color}]\n"
tool_info += f"[bold]Arguments:[/]\n"
for key, value in arguments.items():
# Truncate long values for display
str_value = str(value)
if len(str_value) > 100:
str_value = str_value[:97] + "..."
tool_info += f" {key}: {str_value}\n"
console.print(Panel(
tool_info.strip(),
title="🔒 Tool Approval Required",
border_style=risk_color,
title_align="left"
))
# Get user approval
try:
approved = Confirm.ask(
f"[{risk_color}]Do you want to execute this {risk_level} risk tool?[/{risk_color}]",
default=False
)
if approved:
console.print("[green]✅ Tool execution approved[/green]")
return ApprovalDecision(approved=True, reason="User approved")
else:
console.print("[red]❌ Tool execution denied[/red]")
return ApprovalDecision(approved=False, reason="User denied")
except KeyboardInterrupt:
console.print("\n[red]❌ Tool execution cancelled by user[/red]")
return ApprovalDecision(approved=False, reason="User cancelled")
except Exception as e:
console.print(f"[red]Error during approval: {e}[/red]")
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
async def request_approval(function_name: str, arguments: Dict[str, Any]) -> ApprovalDecision:
"""Request approval for a tool execution.
Args:
function_name: Name of the function to execute
arguments: Arguments to pass to the function
Returns:
ApprovalDecision with approval status and any modifications
"""
# Check if approval is required
if function_name not in APPROVAL_REQUIRED_TOOLS:
return ApprovalDecision(approved=True, reason="No approval required")
risk_level = TOOL_RISK_LEVELS.get(function_name, "medium")
# Use custom callback if set, otherwise use console callback
callback = get_approval_callback() or console_approval_callback
try:
# Handle async callbacks
if asyncio.iscoroutinefunction(callback):
decision = await callback(function_name, arguments, risk_level)
else:
# Run sync callback in executor to avoid blocking
loop = asyncio.get_event_loop()
decision = await loop.run_in_executor(None, callback, function_name, arguments, risk_level)
return decision
except Exception as e:
logging.error(f"Error in approval callback: {e}")
return ApprovalDecision(approved=False, reason=f"Approval callback error: {e}")
# Default dangerous tools - can be configured at runtime
DEFAULT_DANGEROUS_TOOLS = {
# Critical risk tools
"execute_command": "critical",
"kill_process": "critical",
"execute_code": "critical",
# High risk tools
"write_file": "high",
"delete_file": "high",
"move_file": "high",
"copy_file": "high",
"execute_query": "high",
# Medium risk tools
"evaluate": "medium",
"crawl": "medium",
"scrape_page": "medium",
}
def configure_default_approvals():
"""Configure default dangerous tools to require approval."""
for tool_name, risk_level in DEFAULT_DANGEROUS_TOOLS.items():
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
def add_approval_requirement(tool_name: str, risk_level: RiskLevel = "high"):
"""Dynamically add approval requirement for a tool."""
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
def remove_approval_requirement(tool_name: str):
"""Remove approval requirement for a tool."""
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
TOOL_RISK_LEVELS.pop(tool_name, None)
def is_approval_required(tool_name: str) -> bool:
"""Check if a tool requires approval."""
return tool_name in APPROVAL_REQUIRED_TOOLS
def get_risk_level(tool_name: str) -> Optional[str]:
"""Get the risk level of a tool."""
return TOOL_RISK_LEVELS.get(tool_name)
# Initialize with defaults
configure_default_approvals()
@@ -0,0 +1,325 @@
"""
Extensible Approval Protocol for PraisonAI Agents.
This package provides a protocol-driven, per-agent approval system for
tool executions. It is fully backward-compatible with the old
``approval.py`` module — all existing imports continue to work.
Quick start (no Agent class changes needed)::
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
# Global auto-approve for all agents
get_approval_registry().set_backend(AutoApproveBackend())
# Per-agent backend
get_approval_registry().set_backend(AutoApproveBackend(), agent_name="bot")
Custom backend::
from praisonaiagents.approval import ApprovalProtocol, ApprovalRequest, ApprovalDecision
class WebhookBackend:
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
# POST to external service, wait for response
return ApprovalDecision(approved=True)
get_approval_registry().set_backend(WebhookBackend())
"""
from __future__ import annotations
import asyncio
import contextvars
import json
import logging
import os
from dataclasses import dataclass, field
from functools import wraps
from typing import Callable, Dict, List, Literal, Optional, Set
# ── New protocol-driven exports ──────────────────────────────────────────────
from .protocols import ApprovalProtocol, ApprovalRequest # noqa: F401
from .protocols import ApprovalDecision # noqa: F401
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
logger = logging.getLogger(__name__)
# ── Singleton registry ───────────────────────────────────────────────────────
_registry: Optional[ApprovalRegistry] = None
def get_approval_registry() -> ApprovalRegistry:
"""Return the global singleton :class:`ApprovalRegistry`."""
global _registry
if _registry is None:
_registry = ApprovalRegistry()
return _registry
# ── Backward-compatible API (delegates to registry) ─────────────────────────
# These globals are kept for code that imports them directly.
APPROVAL_REQUIRED_TOOLS: Set[str] = get_approval_registry()._required_tools
TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
# Legacy global callback holder — set_approval_callback wraps it into a backend
approval_callback: Optional[Callable] = None
def set_approval_callback(callback_fn: Optional[Callable]) -> None:
"""Set a custom approval callback (legacy API).
The callback is wrapped into a :class:`CallbackBackend` and set as the
global backend on the registry.
"""
global approval_callback
approval_callback = callback_fn
reg = get_approval_registry()
if callback_fn is not None:
reg.set_backend(CallbackBackend(callback_fn))
else:
reg.remove_backend()
def get_approval_callback() -> Optional[Callable]:
"""Get the current approval callback function (legacy API)."""
return approval_callback
def mark_approved(tool_name: str) -> None:
get_approval_registry().mark_approved(tool_name)
def is_already_approved(tool_name: str) -> bool:
return get_approval_registry().is_already_approved(tool_name)
def is_yaml_approved(tool_name: str) -> bool:
return get_approval_registry().is_yaml_approved(tool_name)
def is_env_auto_approve() -> bool:
return ApprovalRegistry.is_env_auto_approve()
def set_yaml_approved_tools(tools: List[str]) -> contextvars.Token:
return get_approval_registry().set_yaml_approved_tools(tools)
def reset_yaml_approved_tools(token: contextvars.Token) -> None:
get_approval_registry().reset_yaml_approved_tools(token)
def clear_approval_context() -> None:
get_approval_registry().clear_approved()
def configure_default_approvals() -> None:
"""Re-configure default dangerous tools (idempotent)."""
reg = get_approval_registry()
for tool_name, risk_level in DEFAULT_DANGEROUS_TOOLS.items():
reg.add_requirement(tool_name, risk_level)
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
get_approval_registry().add_requirement(tool_name, risk_level)
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
def remove_approval_requirement(tool_name: str) -> None:
get_approval_registry().remove_requirement(tool_name)
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
TOOL_RISK_LEVELS.pop(tool_name, None)
def is_approval_required(tool_name: str) -> bool:
return get_approval_registry().is_required(tool_name)
def get_risk_level(tool_name: str) -> Optional[str]:
return get_approval_registry().get_risk_level(tool_name)
# ── Legacy console callback (kept for direct callers) ───────────────────────
def console_approval_callback(function_name: str, arguments: Dict, risk_level: str) -> ApprovalDecision:
"""Default console-based approval callback (legacy API)."""
backend = ConsoleBackend()
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=risk_level,
)
return backend.request_approval_sync(request)
# ── Legacy async request_approval ────────────────────────────────────────────
async def request_approval(function_name: str, arguments: Dict) -> ApprovalDecision:
"""Request approval for a tool execution (legacy async API)."""
return await get_approval_registry().approve_async(None, function_name, arguments)
# ── require_approval decorator (unchanged semantics) ─────────────────────────
RiskLevel = Literal["critical", "high", "medium", "low"]
def require_approval(risk_level: RiskLevel = "high"):
"""Decorator to mark a tool as requiring human approval."""
def decorator(func):
tool_name = getattr(func, '__name__', str(func))
reg = get_approval_registry()
reg.add_requirement(tool_name, risk_level)
APPROVAL_REQUIRED_TOOLS.add(tool_name)
TOOL_RISK_LEVELS[tool_name] = risk_level
@wraps(func)
def wrapper(*args, **kwargs):
if is_already_approved(tool_name):
return func(*args, **kwargs)
if is_yaml_approved(tool_name):
mark_approved(tool_name)
return func(*args, **kwargs)
if is_env_auto_approve():
mark_approved(tool_name)
return func(*args, **kwargs)
try:
try:
asyncio.get_running_loop()
raise RuntimeError("Use sync fallback in async context")
except RuntimeError:
decision = asyncio.run(request_approval(tool_name, kwargs))
except Exception as e:
logging.warning(f"Async approval failed, using sync fallback: {e}")
decision = console_approval_callback(tool_name, kwargs, risk_level)
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
mark_approved(tool_name)
kwargs.update(decision.modified_args)
return func(*args, **kwargs)
@wraps(func)
async def async_wrapper(*args, **kwargs):
if is_already_approved(tool_name):
return await func(*args, **kwargs)
if is_yaml_approved(tool_name):
mark_approved(tool_name)
return await func(*args, **kwargs)
if is_env_auto_approve():
mark_approved(tool_name)
return await func(*args, **kwargs)
decision = await request_approval(tool_name, kwargs)
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
mark_approved(tool_name)
kwargs.update(decision.modified_args)
return await func(*args, **kwargs)
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return wrapper
return decorator
# ── PermissionAllowlist (kept as-is) ─────────────────────────────────────────
@dataclass
class ToolPermission:
"""Permission entry for a tool."""
tool_name: str
allowed_paths: List[str] = field(default_factory=list)
session_only: bool = False
class PermissionAllowlist:
"""Persistent permission allowlist for tools."""
def __init__(self):
self._tools: Dict[str, ToolPermission] = {}
self._session_tools: Set[str] = set()
def add_tool(self, tool_name: str, paths: Optional[List[str]] = None, session_only: bool = False) -> None:
self._tools[tool_name] = ToolPermission(tool_name=tool_name, allowed_paths=paths or [], session_only=session_only)
if session_only:
self._session_tools.add(tool_name)
def remove_tool(self, tool_name: str) -> bool:
if tool_name in self._tools:
del self._tools[tool_name]
self._session_tools.discard(tool_name)
return True
return False
def is_allowed(self, tool_name: str, path: Optional[str] = None) -> bool:
if tool_name not in self._tools:
return False
permission = self._tools[tool_name]
if not permission.allowed_paths:
return True
if path is None:
return True
for allowed_path in permission.allowed_paths:
norm_allowed = os.path.normpath(allowed_path)
norm_path = os.path.normpath(path)
if norm_path.startswith(norm_allowed):
return True
return False
def is_empty(self) -> bool:
return len(self._tools) == 0
def list_tools(self) -> List[str]:
return list(self._tools.keys())
def clear_session_permissions(self) -> None:
for tool_name in list(self._session_tools):
if tool_name in self._tools:
del self._tools[tool_name]
self._session_tools.clear()
def save(self, filepath: str) -> None:
data = {
"tools": {
name: {"allowed_paths": perm.allowed_paths, "session_only": perm.session_only}
for name, perm in self._tools.items()
if not perm.session_only
}
}
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
with open(filepath, "w") as f:
json.dump(data, f, indent=2)
@classmethod
def load(cls, filepath: str) -> "PermissionAllowlist":
allowlist = cls()
if not os.path.exists(filepath):
return allowlist
with open(filepath, "r") as f:
data = json.load(f)
for tool_name, tool_data in data.get("tools", {}).items():
allowlist.add_tool(tool_name, paths=tool_data.get("allowed_paths", []), session_only=tool_data.get("session_only", False))
return allowlist
_permission_allowlist: Optional[PermissionAllowlist] = None
def get_permission_allowlist() -> PermissionAllowlist:
global _permission_allowlist
if _permission_allowlist is None:
_permission_allowlist = PermissionAllowlist()
return _permission_allowlist
def set_permission_allowlist(allowlist: PermissionAllowlist) -> None:
global _permission_allowlist
_permission_allowlist = allowlist
@@ -0,0 +1,285 @@
"""
Built-in approval backends for PraisonAI Agents.
Provides lightweight backends that ship with the core SDK:
- **AutoApproveBackend** — always approves (bots, trusted envs).
- **ConsoleBackend** — interactive Rich terminal prompt (CLI default).
- **AgentApproval** — delegates approval decision to another AI agent.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .protocols import ApprovalDecision, ApprovalRequest
logger = logging.getLogger(__name__)
# Lazy Rich imports (same pattern as old approval.py)
_rich_console = None
_rich_panel = None
_rich_confirm = None
def _get_rich_console():
global _rich_console
if _rich_console is None:
from rich.console import Console
_rich_console = Console
return _rich_console
def _get_rich_panel():
global _rich_panel
if _rich_panel is None:
from rich.panel import Panel
_rich_panel = Panel
return _rich_panel
def _get_rich_confirm():
global _rich_confirm
if _rich_confirm is None:
from rich.prompt import Confirm
_rich_confirm = Confirm
return _rich_confirm
class AutoApproveBackend:
"""Always approves. Use for bots or trusted unattended environments."""
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
class ConsoleBackend:
"""Interactive Rich terminal prompt. Default for CLI usage."""
def _prompt_user(self, request: ApprovalRequest) -> bool:
"""Show Rich panel and ask yes/no. Returns True if approved."""
Console = _get_rich_console()
Panel = _get_rich_panel()
Confirm = _get_rich_confirm()
console = Console()
risk_colors = {
"critical": "bold red",
"high": "red",
"medium": "yellow",
"low": "blue",
}
risk_color = risk_colors.get(request.risk_level, "white")
tool_info = f"[bold]Function:[/] {request.tool_name}\n"
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{request.risk_level.upper()}[/{risk_color}]\n"
if request.agent_name:
tool_info += f"[bold]Agent:[/] {request.agent_name}\n"
tool_info += "[bold]Arguments:[/]\n"
for key, value in request.arguments.items():
str_value = str(value)
if len(str_value) > 100:
str_value = str_value[:97] + "..."
tool_info += f" {key}: {str_value}\n"
console.print(Panel(
tool_info.strip(),
title="🔒 Tool Approval Required",
border_style=risk_color,
title_align="left",
))
try:
return Confirm.ask(
f"[{risk_color}]Do you want to execute this {request.risk_level} risk tool?[/{risk_color}]",
default=False,
)
except (KeyboardInterrupt, EOFError):
return False
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
"""Synchronous approval via Rich console prompt."""
try:
approved = self._prompt_user(request)
if approved:
return ApprovalDecision(approved=True, reason="User approved", approver="console")
return ApprovalDecision(approved=False, reason="User denied", approver="console")
except Exception as e:
logger.error("Console approval error: %s", e)
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
"""Async wrapper — runs the sync prompt in an executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.request_approval_sync, request)
class AgentApproval:
"""Delegates approval decisions to another AI agent.
The approver agent receives a structured prompt describing the tool call
and responds with ``APPROVE`` or ``DENY``. This enables autonomous
multi-agent approval workflows without human intervention.
Lives in the core SDK because it only depends on the Agent class which
is already in core — no external dependencies.
Args:
approver_agent: An Agent instance that will evaluate approval requests.
If ``None``, a default approver agent is created with sensible
instructions.
llm: LLM model to use for the default approver agent (default ``gpt-4o-mini``).
Example::
from praisonaiagents import Agent
from praisonaiagents.approval import AgentApproval
approver = Agent(
name="security-reviewer",
instructions="Only approve low-risk read operations. Deny anything destructive.",
)
worker = Agent(
name="worker",
tools=[execute_command],
approval=AgentApproval(approver_agent=approver),
)
"""
def __init__(
self,
approver_agent: Any = None,
llm: str = "gpt-4o-mini",
):
self._approver_agent = approver_agent
self._llm = llm
def __repr__(self) -> str:
name = getattr(self._approver_agent, "name", None) or "default"
return f"AgentApproval(approver={name!r})"
def _get_approver(self) -> Any:
"""Lazily create or return the approver agent."""
if self._approver_agent is not None:
return self._approver_agent
# Lazy import to avoid circular dependency at module level
from praisonaiagents.agent.agent import Agent
self._approver_agent = Agent(
name="approval-reviewer",
instructions=(
"You are a security reviewer for tool execution requests. "
"Evaluate each request and respond with exactly one word: "
"APPROVE or DENY. Consider the tool name, arguments, and risk level. "
"Deny anything that looks destructive, dangerous, or unauthorized. "
"Approve safe read-only operations."
),
llm=self._llm,
)
return self._approver_agent
def _build_prompt(self, request: ApprovalRequest) -> str:
"""Build the evaluation prompt for the approver agent."""
args_str = "\n".join(
f" {k}: {v}" for k, v in request.arguments.items()
) or " (none)"
return (
f"Tool Approval Request:\n"
f" Tool: {request.tool_name}\n"
f" Risk Level: {request.risk_level.upper()}\n"
f" Agent: {request.agent_name or 'unknown'}\n"
f" Arguments:\n{args_str}\n\n"
f"Respond with exactly one word: APPROVE or DENY"
)
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
"""Ask the approver agent to evaluate the request."""
try:
approver = self._get_approver()
prompt = self._build_prompt(request)
# Use the agent's chat method
if hasattr(approver, "achat"):
response = await approver.achat(prompt)
elif hasattr(approver, "chat"):
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, approver.chat, prompt)
else:
return ApprovalDecision(
approved=False,
reason="Approver agent has no chat method",
)
response_text = str(response).strip().upper()
approved = "APPROVE" in response_text and "DENY" not in response_text
return ApprovalDecision(
approved=approved,
reason=f"Agent {'approved' if approved else 'denied'}: {str(response).strip()[:200]}",
approver=getattr(approver, "name", "agent"),
metadata={"platform": "agent", "response": str(response).strip()[:500]},
)
except Exception as e:
logger.error(f"AgentApproval error: {e}")
return ApprovalDecision(
approved=False,
reason=f"Agent approval error: {e}",
)
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
"""Synchronous wrapper."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, self.request_approval(request))
return future.result(timeout=60)
else:
return asyncio.run(self.request_approval(request))
class CallbackBackend:
"""Wraps a legacy ``(function_name, arguments, risk_level) -> ApprovalDecision`` callback
into an :class:`ApprovalProtocol`-compatible backend.
Used internally by :func:`set_approval_callback` for backward compatibility.
"""
def __init__(self, callback):
self._callback = callback
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
result = self._callback(request.tool_name, request.arguments, request.risk_level)
if isinstance(result, ApprovalDecision):
return result
# Legacy callbacks may return dict-like or bool
if isinstance(result, bool):
return ApprovalDecision(approved=result)
return ApprovalDecision(approved=bool(result))
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
if asyncio.iscoroutinefunction(self._callback):
result = await self._callback(request.tool_name, request.arguments, request.risk_level)
else:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, self._callback, request.tool_name, request.arguments, request.risk_level,
)
if isinstance(result, ApprovalDecision):
return result
if isinstance(result, bool):
return ApprovalDecision(approved=result)
return ApprovalDecision(approved=bool(result))
@@ -0,0 +1,83 @@
"""
Approval protocol definitions for PraisonAI Agents.
Defines the structural contract for approval backends via typing.Protocol.
Any class implementing ``request_approval`` satisfies the protocol without
explicit inheritance (structural subtyping).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Protocol, runtime_checkable
@dataclass
class ApprovalRequest:
"""Immutable request for tool-execution approval.
Attributes:
tool_name: Name of the tool requesting approval.
arguments: Arguments the tool will be called with.
risk_level: Risk classification (critical / high / medium / low).
agent_name: Name of the agent that triggered the call (optional).
session_id: Session identifier for tracking (optional).
context: Arbitrary context dict for backend-specific data.
"""
tool_name: str
arguments: Dict[str, Any]
risk_level: str
agent_name: Optional[str] = None
session_id: Optional[str] = None
context: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ApprovalDecision:
"""Result of an approval request.
Attributes:
approved: Whether execution is allowed.
reason: Human-readable reason for the decision.
modified_args: Optional replacement arguments (empty = use originals).
approver: Who/what approved (user, system, webhook, …).
metadata: Backend-specific metadata (timestamps, IPs, …).
"""
approved: bool
reason: str = ""
modified_args: Dict[str, Any] = field(default_factory=dict)
approver: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class ApprovalProtocol(Protocol):
"""Protocol for tool-execution approval backends.
Implement ``request_approval`` to create custom approval channels:
- Console prompts
- Webhook / HTTP callbacks
- Messaging platform replies (Slack, WhatsApp, Telegram)
- External UI dashboards
- Auto-approve policies
Example::
class MyBackend:
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
# … your logic …
return ApprovalDecision(approved=True)
"""
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
"""Request approval for a tool execution.
Args:
request: The approval request with tool info and context.
Returns:
ApprovalDecision indicating whether execution is approved.
"""
...
@@ -0,0 +1,250 @@
"""
Approval registry for PraisonAI Agents.
Centralises approval state that was previously scattered across module-level
globals. Supports **per-agent** backends so different agents can use
different approval channels (console, webhook, Slack, …) without touching
the ``Agent`` class.
Usage (no Agent param needed)::
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
registry = get_approval_registry()
registry.set_backend(AutoApproveBackend(), agent_name="bot-agent")
"""
from __future__ import annotations
import asyncio
import contextvars
import logging
import os
from typing import Dict, List, Optional, Set
from .protocols import ApprovalDecision, ApprovalRequest
logger = logging.getLogger(__name__)
# Default dangerous tools — same as old approval.py
DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
"execute_command": "critical",
"kill_process": "critical",
"execute_code": "critical",
"write_file": "high",
"delete_file": "high",
"move_file": "high",
"copy_file": "high",
"execute_query": "high",
"evaluate": "medium",
"crawl": "medium",
"scrape_page": "medium",
}
class ApprovalRegistry:
"""Per-agent approval configuration.
Replaces the old global mutable sets/dicts while keeping the same
semantics. The singleton instance is obtained via
:func:`get_approval_registry`.
"""
def __init__(self) -> None:
# Backends
self._global_backend = None # type: ignore[assignment]
self._agent_backends: Dict[str, object] = {}
# Tool requirements (mirrors old APPROVAL_REQUIRED_TOOLS / TOOL_RISK_LEVELS)
self._required_tools: Set[str] = set()
self._risk_levels: Dict[str, str] = {}
# Context variables (per-coroutine / per-thread)
self._approved_context: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
"approved_context", default=set()
)
self._yaml_approved_tools: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
"yaml_approved_tools", default=set()
)
# Timeout for async approval calls
self.timeout: float = 300.0
# Initialise with dangerous-tool defaults
for tool_name, risk in DEFAULT_DANGEROUS_TOOLS.items():
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk
# ── Backend management ───────────────────────────────────────────────
def set_backend(self, backend: object, agent_name: Optional[str] = None) -> None:
"""Set the approval backend globally or for a specific agent.
Args:
backend: Any object satisfying :class:`ApprovalProtocol`.
agent_name: If provided, apply only to this agent.
"""
if agent_name:
self._agent_backends[agent_name] = backend
else:
self._global_backend = backend
def remove_backend(self, agent_name: Optional[str] = None) -> None:
"""Remove a previously set backend."""
if agent_name:
self._agent_backends.pop(agent_name, None)
else:
self._global_backend = None
def get_backend(self, agent_name: Optional[str] = None) -> object:
"""Resolve the backend for *agent_name* (falls back to global, then console)."""
if agent_name and agent_name in self._agent_backends:
return self._agent_backends[agent_name]
if self._global_backend is not None:
return self._global_backend
# Lazy import to avoid circular / heavy import at module level
from .backends import ConsoleBackend
return ConsoleBackend()
# ── Tool requirement management ──────────────────────────────────────
def add_requirement(self, tool_name: str, risk_level: str = "high") -> None:
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk_level
def remove_requirement(self, tool_name: str) -> None:
self._required_tools.discard(tool_name)
self._risk_levels.pop(tool_name, None)
def is_required(self, tool_name: str) -> bool:
return tool_name in self._required_tools
def get_risk_level(self, tool_name: str) -> Optional[str]:
return self._risk_levels.get(tool_name)
# ── Context helpers ──────────────────────────────────────────────────
def mark_approved(self, tool_name: str) -> None:
approved = self._approved_context.get(set())
approved.add(tool_name)
self._approved_context.set(approved)
def is_already_approved(self, tool_name: str) -> bool:
return tool_name in self._approved_context.get(set())
def clear_approved(self) -> None:
self._approved_context.set(set())
def set_yaml_approved_tools(self, tools: List[str]) -> contextvars.Token:
return self._yaml_approved_tools.set(set(tools))
def reset_yaml_approved_tools(self, token: contextvars.Token) -> None:
self._yaml_approved_tools.reset(token)
def is_yaml_approved(self, tool_name: str) -> bool:
try:
return tool_name in self._yaml_approved_tools.get()
except LookupError:
return False
# ── Env-var check ────────────────────────────────────────────────────
@staticmethod
def is_env_auto_approve() -> bool:
return os.environ.get("PRAISONAI_AUTO_APPROVE", "").lower() in ("true", "1", "yes")
# ── Approval entry points ────────────────────────────────────────────
def approve_sync(
self,
agent_name: Optional[str],
tool_name: str,
arguments: Dict,
) -> ApprovalDecision:
"""Synchronous approval — used by ``Agent._execute_tool_impl``."""
# Fast-path: not required
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
# Already approved in this context
if self.is_already_approved(tool_name):
return ApprovalDecision(approved=True, reason="Already approved in context")
# Env auto-approve
if self.is_env_auto_approve():
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
# YAML auto-approve
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
# Delegate to backend
backend = self.get_backend(agent_name)
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=self._risk_levels.get(tool_name, "medium"),
agent_name=agent_name,
)
# Prefer sync method if available
if hasattr(backend, "request_approval_sync"):
decision = backend.request_approval_sync(request)
else:
# Fallback: run async method
try:
decision = asyncio.run(backend.request_approval(request))
except RuntimeError:
# Already in an event loop — fall back to thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, backend.request_approval(request))
decision = future.result(timeout=self.timeout)
if decision.approved:
self.mark_approved(tool_name)
return decision
async def approve_async(
self,
agent_name: Optional[str],
tool_name: str,
arguments: Dict,
) -> ApprovalDecision:
"""Asynchronous approval — used by async tool execution path."""
# Fast-path: not required
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
if self.is_already_approved(tool_name):
return ApprovalDecision(approved=True, reason="Already approved in context")
if self.is_env_auto_approve():
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
backend = self.get_backend(agent_name)
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=self._risk_levels.get(tool_name, "medium"),
agent_name=agent_name,
)
try:
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=self.timeout,
)
except asyncio.TimeoutError:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
if decision.approved:
self.mark_approved(tool_name)
return decision
@@ -1,13 +1,28 @@
"""
Bot Protocols for PraisonAI Agents.
Bot Protocols and Configuration for PraisonAI Agents.
Provides protocols and base classes for building messaging bot implementations
(Telegram, Discord, Slack, etc.) that connect agents to messaging platforms.
This module contains only protocols and lightweight utilities.
Heavy implementations live in the praisonai wrapper package.
Defines the interfaces and configuration for messaging bot implementations.
All implementations live in the praisonai wrapper package.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .protocols import (
BotOSProtocol, # noqa: F401
BotProtocol,
BotMessageProtocol,
BotUserProtocol,
BotChannelProtocol,
BotMessage,
BotUser,
BotChannel,
MessageType,
ChatCommandInfo,
ChatCommandProtocol,
ProbeResult,
HealthResult,
)
from .protocols import (
BotProtocol,
BotMessageProtocol,
@@ -19,8 +34,11 @@ from .protocols import (
MessageType,
ChatCommandInfo,
ChatCommandProtocol,
BotOSProtocol,
ProbeResult,
HealthResult,
)
from .config import BotConfig
from .config import BotConfig, BotOSConfig
__all__ = [
"BotProtocol",
@@ -34,4 +52,8 @@ __all__ = [
"MessageType",
"ChatCommandInfo",
"ChatCommandProtocol",
"BotOSProtocol",
"BotOSConfig",
"ProbeResult",
"HealthResult",
]
@@ -43,12 +43,29 @@ class BotConfig:
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)
# Group message policy
group_policy: str = "mention_only" # respond_all, mention_only, command_only
# Default tools enabled for all bots
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web"])
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
# Auto-approve tool calls (useful for trusted environments)
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
# Inbound message debounce (ms). Coalesces rapid messages from same user.
# 0 = disabled (default). Recommended: 1000-2000 for chat bots.
debounce_ms: int = 0
# Ack emoji reaction on inbound messages. Empty string = disabled.
# When set, bot reacts with this emoji on receive (e.g. "⏳") and
# replaces it with done_emoji on completion (e.g. "✅").
ack_emoji: str = ""
done_emoji: str = "✅"
# Session TTL in seconds. 0 = disabled (sessions never expire).
# When set, stale sessions older than this are auto-reaped.
session_ttl: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
@@ -70,6 +87,10 @@ class BotConfig:
"thread_threshold": self.thread_threshold,
"default_tools": self.default_tools,
"auto_approve_tools": self.auto_approve_tools,
"debounce_ms": self.debounce_ms,
"ack_emoji": self.ack_emoji,
"done_emoji": self.done_emoji,
"session_ttl": self.session_ttl,
"metadata": self.metadata,
}
@@ -89,3 +110,30 @@ class BotConfig:
if not self.allowed_channels:
return True
return channel_id in self.allowed_channels
@dataclass
class BotOSConfig:
"""Configuration for BotOS — multi-platform bot orchestrator.
Attributes:
name: Human-readable name for this BotOS instance.
platforms: Per-platform config dicts keyed by platform name.
Example: ``{"telegram": {"token": "..."}, "discord": {"token": "..."}}``
"""
name: str = "PraisonAI BotOS"
platforms: Dict[str, Dict[str, Any]] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dict (hides tokens)."""
sanitized_platforms = {}
for plat, cfg in self.platforms.items():
sanitized = dict(cfg)
if "token" in sanitized:
sanitized["token"] = "***"
sanitized_platforms[plat] = sanitized
return {
"name": self.name,
"platforms": sanitized_platforms,
}
@@ -286,6 +286,76 @@ class BotChannelProtocol(Protocol):
...
@dataclass
class ProbeResult:
"""Result of a channel connectivity probe.
Attributes:
ok: Whether the probe succeeded
platform: Platform name
elapsed_ms: Time taken in milliseconds
bot_username: Bot's username (if available)
error: Error message (if probe failed)
details: Additional platform-specific details
"""
ok: bool
platform: str = ""
elapsed_ms: float = 0.0
bot_username: Optional[str] = None
error: Optional[str] = None
details: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"ok": self.ok,
"platform": self.platform,
"elapsed_ms": self.elapsed_ms,
"bot_username": self.bot_username,
"error": self.error,
"details": self.details,
}
@dataclass
class HealthResult:
"""Detailed health status of a bot.
Attributes:
ok: Overall health status
platform: Platform name
is_running: Whether the bot is actively running
uptime_seconds: Seconds since bot started (None if not running)
probe: Latest probe result (None if not probed)
sessions: Number of active sessions
error: Error message (if unhealthy)
details: Additional platform-specific health details
"""
ok: bool
platform: str = ""
is_running: bool = False
uptime_seconds: Optional[float] = None
probe: Optional[ProbeResult] = None
sessions: int = 0
error: Optional[str] = None
details: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"ok": self.ok,
"platform": self.platform,
"is_running": self.is_running,
"uptime_seconds": self.uptime_seconds,
"probe": self.probe.to_dict() if self.probe else None,
"sessions": self.sessions,
"error": self.error,
"details": self.details,
}
@runtime_checkable
class BotProtocol(Protocol):
"""Protocol for messaging bot implementations.
@@ -451,6 +521,25 @@ class BotProtocol(Protocol):
Channel information or None if not found
"""
...
# Health & diagnostics
async def probe(self) -> ProbeResult:
"""Test channel connectivity without starting the bot.
Verifies the bot token is valid and the platform API is reachable.
Returns:
ProbeResult with connectivity status and bot info.
"""
...
async def health(self) -> HealthResult:
"""Get detailed health status of the running bot.
Returns:
HealthResult with running state, uptime, probe, and session count.
"""
...
@dataclass
@@ -522,3 +611,77 @@ class ChatCommandProtocol(Protocol):
List of ChatCommandInfo for all registered commands
"""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BotOS Protocol — multi-platform bot orchestration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@runtime_checkable
class BotOSProtocol(Protocol):
"""Protocol for BotOS — multi-platform bot orchestrator.
BotOS manages multiple Bot instances across different messaging
platforms (Telegram, Discord, Slack, WhatsApp, etc.) with a single
unified lifecycle.
Hierarchy::
BotOS (multi-platform orchestrator)
└── Bot (single platform)
└── Agent / AgentTeam / AgentFlow (AI brain)
Implementations live in the ``praisonai`` wrapper package.
"""
@property
def is_running(self) -> bool:
"""Whether the orchestrator is currently running."""
...
async def start(self) -> None:
"""Start all registered bots concurrently."""
...
async def stop(self) -> None:
"""Gracefully stop all running bots."""
...
def add_bot(self, bot: Any) -> None:
"""Register a Bot instance for orchestration.
Args:
bot: A Bot instance (satisfies BotProtocol).
"""
...
def list_bots(self) -> List[str]:
"""List platform names of all registered bots.
Returns:
List of platform name strings.
"""
...
def remove_bot(self, platform: str) -> bool:
"""Remove a registered bot by platform name.
Args:
platform: Platform identifier to remove.
Returns:
True if removed, False if not found.
"""
...
def get_bot(self, platform: str) -> Optional[Any]:
"""Get a registered bot by platform name.
Args:
platform: Platform identifier (e.g. "telegram").
Returns:
The Bot instance, or None if not found.
"""
...
@@ -172,6 +172,10 @@ class MemoryConfig:
history: bool = False
history_limit: int = 10
# Auto-save session name (consolidated from standalone auto_save param)
# When set, automatically saves session to memory with this name
auto_save: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
learn_dict = None
@@ -193,6 +197,7 @@ class MemoryConfig:
"learn": learn_dict,
"history": self.history,
"history_limit": self.history_limit,
"auto_save": self.auto_save,
}
@@ -609,7 +614,8 @@ class ExecutionConfig:
"""
Configuration for agent execution limits.
Consolidates: max_iter, max_rpm, max_execution_time, max_retry_limit
Consolidates: max_iter, max_rpm, max_execution_time, max_retry_limit,
allow_code_execution, code_execution_mode, rate_limiter
Usage:
# Simple preset
@@ -622,6 +628,13 @@ class ExecutionConfig:
max_execution_time=300,
max_retry_limit=5,
))
# With code execution and rate limiter
Agent(execution=ExecutionConfig(
code_execution=True,
code_mode="safe",
rate_limiter=my_limiter,
))
"""
# Iteration limits
max_iter: int = 20
@@ -635,6 +648,13 @@ class ExecutionConfig:
# Retry settings
max_retry_limit: int = 2
# Code execution (consolidated from allow_code_execution + code_execution_mode)
code_execution: bool = False
code_mode: str = "safe" # "safe" or "unsafe"
# Rate limiter instance (consolidated from standalone rate_limiter param)
rate_limiter: Optional[Any] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -642,6 +662,8 @@ class ExecutionConfig:
"max_rpm": self.max_rpm,
"max_execution_time": self.max_execution_time,
"max_retry_limit": self.max_retry_limit,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
}
@@ -53,6 +53,11 @@ class EventType(str, Enum):
MESSAGE_ACK = "message_ack"
TYPING = "typing"
# Streaming events (relayed from agent's StreamEventEmitter)
TOKEN_STREAM = "token_stream"
TOOL_CALL_STREAM = "tool_call_stream"
STREAM_END = "stream_end"
# System events
HEALTH = "health"
ERROR = "error"
@@ -71,6 +71,10 @@ __all__ = [
"SessionEndInput",
"OnErrorInput",
"OnRetryInput",
# Message lifecycle event inputs
"MessageReceivedInput",
"MessageSendingInput",
"MessageSentInput",
# Middleware types
"InvocationContext",
"ModelRequest",
@@ -130,6 +134,9 @@ _LAZY_GROUPS = {
'SessionEndInput': ('praisonaiagents.hooks.events', 'SessionEndInput'),
'OnErrorInput': ('praisonaiagents.hooks.events', 'OnErrorInput'),
'OnRetryInput': ('praisonaiagents.hooks.events', 'OnRetryInput'),
'MessageReceivedInput': ('praisonaiagents.hooks.events', 'MessageReceivedInput'),
'MessageSendingInput': ('praisonaiagents.hooks.events', 'MessageSendingInput'),
'MessageSentInput': ('praisonaiagents.hooks.events', 'MessageSentInput'),
},
'middleware_types': {
'InvocationContext': ('praisonaiagents.hooks.middleware', 'InvocationContext'),
@@ -193,3 +193,68 @@ class OnRetryInput(HookInput):
"operation": self.operation
})
return base
@dataclass
class MessageReceivedInput(HookInput):
"""Input for MESSAGE_RECEIVED hooks (bot received an incoming message)."""
platform: str = ""
content: str = ""
sender_id: str = ""
channel_id: str = ""
channel_type: str = ""
message_id: str = ""
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"platform": self.platform,
"content": self.content[:500] if self.content else "",
"sender_id": self.sender_id,
"channel_id": self.channel_id,
"channel_type": self.channel_type,
"message_id": self.message_id,
})
return base
@dataclass
class MessageSendingInput(HookInput):
"""Input for MESSAGE_SENDING hooks (bot about to send a message).
Hooks can modify content or cancel the send by returning
HookResult with decision=DENY.
"""
platform: str = ""
content: str = ""
channel_id: str = ""
reply_to: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"platform": self.platform,
"content": self.content[:500] if self.content else "",
"channel_id": self.channel_id,
"reply_to": self.reply_to,
})
return base
@dataclass
class MessageSentInput(HookInput):
"""Input for MESSAGE_SENT hooks (bot successfully sent a message)."""
platform: str = ""
content: str = ""
channel_id: str = ""
message_id: str = ""
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"platform": self.platform,
"content": self.content[:500] if self.content else "",
"channel_id": self.channel_id,
"message_id": self.message_id,
})
return base
@@ -70,7 +70,7 @@ class HookRunner:
default_timeout: Default timeout for hooks
cwd: Working directory for command hooks
"""
self._registry = registry or HookRegistry()
self._registry = registry if registry is not None else HookRegistry()
self._default_timeout = default_timeout
self._cwd = cwd or os.getcwd()
@@ -142,12 +142,19 @@ class HookRunner:
"""
try:
loop = asyncio.get_running_loop()
# If we're in an async context, create a task
future = asyncio.ensure_future(self.execute(event, input_data, target))
return loop.run_until_complete(future)
except RuntimeError:
# No event loop running, create one
return asyncio.run(self.execute(event, input_data, target))
loop = None
if loop is not None and loop.is_running():
# Already inside a running async loop — cannot use run_until_complete.
# Schedule as fire-and-forget task to avoid leaking unawaited coroutines.
# Callers that need results in async context should use
# ``await runner.execute(...)`` directly.
loop.create_task(self.execute(event, input_data, target))
return []
# No running loop — safe to create one
return asyncio.run(self.execute(event, input_data, target))
async def _execute_parallel(
self,
@@ -63,6 +63,11 @@ class HookEvent(str, Enum):
ON_CONFIG = "on_config"
ON_AUTH = "on_auth"
# Schedule lifecycle
SCHEDULE_ADD = "schedule_add"
SCHEDULE_REMOVE = "schedule_remove"
SCHEDULE_TRIGGER = "schedule_trigger"
# Claude Code parity events
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
@@ -45,6 +45,7 @@ from .protocols import (
DeletableMemoryProtocol,
AsyncDeletableMemoryProtocol,
EntityMemoryProtocol,
AgentMemoryProtocol,
)
@@ -275,6 +275,47 @@ class EntityMemoryProtocol(Protocol):
...
@runtime_checkable
class AgentMemoryProtocol(Protocol):
"""
Protocol for memory implementations used by the Agent class.
This defines the interface that Agent expects from its _memory_instance.
Both FileMemory and Memory satisfy this protocol, enabling consistent
usage across backends.
Example:
```python
class CustomMemory:
def get_context(self, query=None):
return "remembered facts here"
def save_session(self, name, conversation_history=None, metadata=None):
pass # persist session
agent = Agent(memory=CustomMemory())
```
"""
def get_context(
self,
query: Optional[str] = None,
**kwargs
) -> str:
"""Get memory context for injection into system prompt."""
...
def save_session(
self,
name: str,
conversation_history: Optional[List[Dict[str, Any]]] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs
) -> None:
"""Save a conversation session to memory."""
...
__all__ = [
'MemoryProtocol',
'ResettableMemoryProtocol',
@@ -282,5 +323,6 @@ __all__ = [
'AsyncDeletableMemoryProtocol',
'AsyncMemoryProtocol',
'EntityMemoryProtocol',
'AgentMemoryProtocol',
]
@@ -0,0 +1,66 @@
"""
Scheduler module for PraisonAI Agents.
Provides agent-centric scheduling via tools — agents can add, list,
and remove scheduled jobs without any changes to the Agent class.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
agent = Agent(
name="assistant",
tools=[schedule_add, schedule_list, schedule_remove],
)
agent.start("Remind me to check email every morning at 7am")
Default storage: ~/.praisonai/schedules/jobs.json
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import Schedule, ScheduleJob
from .store import FileScheduleStore
from .parser import parse_schedule
from .runner import ScheduleRunner
_module_cache = {}
def __getattr__(name: str):
"""Lazy load scheduler components."""
if name in _module_cache:
return _module_cache[name]
if name in ("Schedule", "ScheduleJob"):
from .models import Schedule, ScheduleJob
_module_cache["Schedule"] = Schedule
_module_cache["ScheduleJob"] = ScheduleJob
return _module_cache[name]
if name == "FileScheduleStore":
from .store import FileScheduleStore
_module_cache[name] = FileScheduleStore
return FileScheduleStore
if name == "parse_schedule":
from .parser import parse_schedule
_module_cache[name] = parse_schedule
return parse_schedule
if name == "ScheduleRunner":
from .runner import ScheduleRunner
_module_cache[name] = ScheduleRunner
return ScheduleRunner
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"Schedule",
"ScheduleJob",
"FileScheduleStore",
"parse_schedule",
"ScheduleRunner",
]
@@ -0,0 +1,113 @@
"""
Schedule models for PraisonAI Agents.
Lightweight dataclasses — no heavy dependencies.
"""
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, Literal, Optional
@dataclass
class Schedule:
"""When to run a scheduled job.
Supports three kinds:
- ``every``: recurring interval in seconds
- ``cron``: 5-field cron expression (requires optional ``croniter``)
- ``at``: one-shot ISO 8601 timestamp
"""
kind: Literal["every", "cron", "at"] = "every"
every_seconds: Optional[int] = None
cron_expr: Optional[str] = None
at: Optional[str] = None
tz: Optional[str] = None
# ── serialisation ────────────────────────────────────────────────
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"kind": self.kind}
if self.every_seconds is not None:
d["every_seconds"] = self.every_seconds
if self.cron_expr is not None:
d["cron_expr"] = self.cron_expr
if self.at is not None:
d["at"] = self.at
if self.tz is not None:
d["tz"] = self.tz
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "Schedule":
return cls(
kind=d.get("kind", "every"),
every_seconds=d.get("every_seconds"),
cron_expr=d.get("cron_expr"),
at=d.get("at"),
tz=d.get("tz"),
)
@dataclass
class ScheduleJob:
"""A persisted scheduled job.
Attributes:
id: Unique identifier (auto-generated).
name: Human-readable name.
schedule: When to run.
message: Prompt / payload to deliver when triggered.
agent_id: Optional owning agent.
session_target: ``"main"`` injects into running session;
``"isolated"`` creates a fresh agent turn.
enabled: Toggle without deleting.
delete_after_run: Auto-remove after first execution (one-shot).
created_at: Epoch timestamp.
last_run_at: Epoch timestamp of most recent execution.
"""
name: str = ""
schedule: Schedule = field(default_factory=Schedule)
message: str = ""
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
agent_id: Optional[str] = None
session_target: Literal["main", "isolated"] = "isolated"
enabled: bool = True
delete_after_run: bool = False
created_at: float = field(default_factory=time.time)
last_run_at: Optional[float] = None
# ── serialisation ────────────────────────────────────────────────
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"schedule": self.schedule.to_dict(),
"message": self.message,
"agent_id": self.agent_id,
"session_target": self.session_target,
"enabled": self.enabled,
"delete_after_run": self.delete_after_run,
"created_at": self.created_at,
"last_run_at": self.last_run_at,
}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
sched_data = d.get("schedule", {})
return cls(
id=d.get("id", uuid.uuid4().hex[:12]),
name=d.get("name", ""),
schedule=Schedule.from_dict(sched_data) if isinstance(sched_data, dict) else Schedule(),
message=d.get("message", ""),
agent_id=d.get("agent_id"),
session_target=d.get("session_target", "isolated"),
enabled=d.get("enabled", True),
delete_after_run=d.get("delete_after_run", False),
created_at=d.get("created_at", time.time()),
last_run_at=d.get("last_run_at"),
)
@@ -0,0 +1,105 @@
"""
Schedule expression parser for PraisonAI Agents.
Parses human-friendly schedule strings into ``Schedule`` objects.
No external dependencies for interval parsing; ``croniter`` is optional
and only imported when a cron expression is used.
Supported formats:
"hourly" → every 3600s
"daily" → every 86400s
"*/30m" → every 1800s
"*/6h" → every 21600s
"*/10s" → every 10s
"3600" → every 3600s (raw seconds)
"cron:0 7 * * *" → cron expression
"at:2026-03-01T09:00:00" → one-shot ISO timestamp
"in 20 minutes" → one-shot ~20min from now
"""
import re
from datetime import datetime, timedelta, timezone
from .models import Schedule
_KEYWORD_MAP = {
"hourly": 3600,
"daily": 86400,
"weekly": 604800,
}
_INTERVAL_RE = re.compile(
r"^\*?/?\*?(\d+)\s*(s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours)$",
re.IGNORECASE,
)
_RELATIVE_RE = re.compile(
r"^in\s+(\d+)\s*(s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours)$",
re.IGNORECASE,
)
_UNIT_MULTIPLIER = {
"s": 1, "sec": 1, "second": 1, "seconds": 1,
"m": 60, "min": 60, "minute": 60, "minutes": 60,
"h": 3600, "hr": 3600, "hour": 3600, "hours": 3600,
}
def parse_schedule(expr: str) -> Schedule:
"""Parse a schedule expression string into a ``Schedule`` object.
Raises ``ValueError`` for unrecognised expressions.
"""
if not expr or not expr.strip():
raise ValueError("Schedule expression cannot be empty")
expr = expr.strip()
# Keyword shortcuts
lower = expr.lower()
if lower in _KEYWORD_MAP:
return Schedule(kind="every", every_seconds=_KEYWORD_MAP[lower])
# Cron prefix
if lower.startswith("cron:"):
cron_expr = expr[5:].strip()
if not cron_expr:
raise ValueError("Empty cron expression after 'cron:' prefix")
return Schedule(kind="cron", cron_expr=cron_expr)
# At prefix (ISO timestamp)
if lower.startswith("at:"):
at_str = expr[3:].strip()
if not at_str:
raise ValueError("Empty timestamp after 'at:' prefix")
return Schedule(kind="at", at=at_str)
# Interval pattern: */30m, */6h, */10s
m = _INTERVAL_RE.match(expr)
if m:
value = int(m.group(1))
unit = m.group(2).lower()
seconds = value * _UNIT_MULTIPLIER[unit]
return Schedule(kind="every", every_seconds=seconds)
# Relative pattern: "in 20 minutes"
m = _RELATIVE_RE.match(expr)
if m:
value = int(m.group(1))
unit = m.group(2).lower()
delta_seconds = value * _UNIT_MULTIPLIER[unit]
target = datetime.now(timezone.utc) + timedelta(seconds=delta_seconds)
return Schedule(kind="at", at=target.isoformat())
# Raw numeric seconds
try:
seconds = int(expr)
if seconds > 0:
return Schedule(kind="every", every_seconds=seconds)
except ValueError:
pass
raise ValueError(
f"Unrecognised schedule expression: {expr!r}. "
"Use 'hourly', 'daily', '*/30m', 'cron:EXPR', 'at:ISO', or 'in N minutes'."
)
@@ -0,0 +1,98 @@
"""
Schedule runner for PraisonAI Agents.
Checks the store for due jobs and returns them. Execution of the job
payload is the caller's responsibility (keeping the runner lightweight).
"""
import logging
import time
from datetime import datetime, timezone
from typing import List
from .models import ScheduleJob
from .store import FileScheduleStore
logger = logging.getLogger(__name__)
class ScheduleRunner:
"""Stateless helper that inspects a store and returns due jobs.
The runner does NOT own a loop or thread — callers decide how
and when to call ``get_due_jobs()``.
"""
def __init__(self, store: FileScheduleStore):
self._store = store
# ── public ───────────────────────────────────────────────────────
def get_due_jobs(self) -> List[ScheduleJob]:
"""Return all enabled jobs that are due for execution *now*."""
now = time.time()
due: List[ScheduleJob] = []
for job in self._store.list():
if not job.enabled:
continue
if self._is_due(job, now):
due.append(job)
return due
def mark_run(self, job: ScheduleJob) -> None:
"""Update ``last_run_at`` and optionally delete one-shot jobs."""
job.last_run_at = time.time()
if job.delete_after_run:
self._store.remove(job.id)
else:
self._store.update(job)
# ── internals ────────────────────────────────────────────────────
def _is_due(self, job: ScheduleJob, now: float) -> bool:
sched = job.schedule
if sched.kind == "every":
if sched.every_seconds is None:
return False
if job.last_run_at is None:
return True # Never run → due immediately
return (now - job.last_run_at) >= sched.every_seconds
if sched.kind == "at":
if sched.at is None:
return False
if job.last_run_at is not None:
return False # Already ran
try:
target = datetime.fromisoformat(sched.at)
if target.tzinfo is None:
target = target.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) >= target
except (ValueError, TypeError):
logger.warning("Invalid 'at' timestamp for job %s: %s", job.id, sched.at)
return False
if sched.kind == "cron":
return self._cron_is_due(job, now)
return False
@staticmethod
def _cron_is_due(job: ScheduleJob, now: float) -> bool:
"""Check cron schedule. Requires optional ``croniter``."""
if job.schedule.cron_expr is None:
return False
try:
from croniter import croniter # type: ignore[import-untyped]
except ImportError:
logger.warning(
"croniter not installed — cron schedules are unavailable. "
"Install with: pip install croniter"
)
return False
base_time = job.last_run_at or job.created_at
cron = croniter(job.schedule.cron_expr, base_time)
next_run = cron.get_next(float)
return now >= next_run
@@ -0,0 +1,108 @@
"""
File-based schedule store for PraisonAI Agents.
Persists scheduled jobs to ``~/.praisonai/schedules/jobs.json``.
Thread-safe, no external dependencies.
"""
import json
import logging
import os
import threading
from typing import Dict, List, Optional
from .models import ScheduleJob
logger = logging.getLogger(__name__)
_DEFAULT_DIR = os.path.join(os.path.expanduser("~"), ".praisonai", "schedules")
_JOBS_FILE = "jobs.json"
class FileScheduleStore:
"""CRUD store backed by a single JSON file.
Thread-safe for multi-agent scenarios.
"""
def __init__(self, store_dir: Optional[str] = None):
self._dir = store_dir or _DEFAULT_DIR
self._path = os.path.join(self._dir, _JOBS_FILE)
self._lock = threading.RLock()
self._jobs: Dict[str, ScheduleJob] = {}
self._load()
# ── public API ───────────────────────────────────────────────────
def add(self, job: ScheduleJob) -> None:
"""Add a job. Raises ``ValueError`` if id already exists."""
with self._lock:
if job.id in self._jobs:
raise ValueError(f"Job '{job.id}' already exists")
self._jobs[job.id] = job
self._save()
def get(self, job_id: str) -> Optional[ScheduleJob]:
with self._lock:
return self._jobs.get(job_id)
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
with self._lock:
for job in self._jobs.values():
if job.name == name:
return job
return None
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
with self._lock:
jobs = list(self._jobs.values())
if agent_id is not None:
jobs = [j for j in jobs if j.agent_id == agent_id]
return jobs
def update(self, job: ScheduleJob) -> None:
with self._lock:
self._jobs[job.id] = job
self._save()
def remove(self, job_id: str) -> bool:
with self._lock:
if job_id in self._jobs:
del self._jobs[job_id]
self._save()
return True
return False
def remove_by_name(self, name: str) -> bool:
with self._lock:
for jid, job in list(self._jobs.items()):
if job.name == name:
del self._jobs[jid]
self._save()
return True
return False
# ── persistence ──────────────────────────────────────────────────
def _load(self) -> None:
if not os.path.exists(self._path):
return
try:
with open(self._path, "r") as f:
data = json.load(f)
for d in data:
job = ScheduleJob.from_dict(d)
self._jobs[job.id] = job
except Exception as e:
logger.warning("Failed to load schedule store from %s: %s", self._path, e)
def _save(self) -> None:
try:
os.makedirs(self._dir, exist_ok=True)
data = [j.to_dict() for j in self._jobs.values()]
tmp = self._path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, self._path)
except Exception as e:
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
@@ -29,6 +29,8 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .store import DefaultSessionStore, SessionMessage, SessionData
from .protocols import SessionStoreProtocol
from .hierarchy import HierarchicalSessionStore, SessionSnapshot, ExtendedSessionData
# Lazy loading for zero import overhead
_module_cache = {}
@@ -64,6 +66,31 @@ def __getattr__(name: str):
_module_cache[name] = get_default_session_store
return get_default_session_store
if name == "SessionStoreProtocol":
from .protocols import SessionStoreProtocol
_module_cache[name] = SessionStoreProtocol
return SessionStoreProtocol
if name == "HierarchicalSessionStore":
from .hierarchy import HierarchicalSessionStore
_module_cache[name] = HierarchicalSessionStore
return HierarchicalSessionStore
if name == "get_hierarchical_session_store":
from .hierarchy import get_hierarchical_session_store
_module_cache[name] = get_hierarchical_session_store
return get_hierarchical_session_store
if name == "SessionSnapshot":
from .hierarchy import SessionSnapshot
_module_cache[name] = SessionSnapshot
return SessionSnapshot
if name == "ExtendedSessionData":
from .hierarchy import ExtendedSessionData
_module_cache[name] = ExtendedSessionData
return ExtendedSessionData
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -73,4 +100,9 @@ __all__ = [
"SessionMessage",
"SessionData",
"get_default_session_store",
"SessionStoreProtocol",
"HierarchicalSessionStore",
"get_hierarchical_session_store",
"SessionSnapshot",
"ExtendedSessionData",
]
@@ -0,0 +1,131 @@
"""
Session Store Protocol for PraisonAI Agents.
Defines the minimal interface contract for session persistence backends.
Any class implementing these methods can be used as a session store.
Built-in implementations:
- DefaultSessionStore (JSON file-based)
- HierarchicalSessionStore (extends DefaultSessionStore with fork/snapshot)
Custom implementations (e.g. Redis, MongoDB, PostgreSQL) can implement
this protocol for seamless swapping.
Usage:
from praisonaiagents.session.protocols import SessionStoreProtocol
class RedisSessionStore:
def add_message(self, session_id, role, content, metadata=None):
... # Store in Redis
def get_chat_history(self, session_id, max_messages=None):
... # Retrieve from Redis
# ... other methods
# Type-check at runtime
store: SessionStoreProtocol = RedisSessionStore()
assert isinstance(store, SessionStoreProtocol)
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@runtime_checkable
class SessionStoreProtocol(Protocol):
"""Protocol for session persistence backends.
Defines the minimal interface for storing and retrieving
agent conversation sessions. Implementations must provide
these five core methods.
Built-in implementations:
- DefaultSessionStore (JSON files with atomic writes)
- HierarchicalSessionStore (fork, snapshot, revert)
Example custom implementation::
class MyStore:
def add_message(self, session_id, role, content, metadata=None):
...
def get_chat_history(self, session_id, max_messages=None):
return [{"role": "user", "content": "Hi"}]
def clear_session(self, session_id):
return True
def delete_session(self, session_id):
return True
def session_exists(self, session_id):
return False
store: SessionStoreProtocol = MyStore()
assert isinstance(store, SessionStoreProtocol) # True
"""
def add_message(
self,
session_id: str,
role: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Add a message to a session.
Args:
session_id: Unique session identifier.
role: Message role ("user", "assistant", "system").
content: Message content text.
metadata: Optional metadata dict.
Returns:
True if saved successfully.
"""
...
def get_chat_history(
self,
session_id: str,
max_messages: Optional[int] = None,
) -> List[Dict[str, str]]:
"""Get chat history in LLM-compatible format.
Args:
session_id: Unique session identifier.
max_messages: Maximum messages to return (most recent).
Returns:
List of {"role": "...", "content": "..."} dicts.
"""
...
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session (keep session metadata).
Args:
session_id: Unique session identifier.
Returns:
True if cleared successfully.
"""
...
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely (data + metadata).
Args:
session_id: Unique session identifier.
Returns:
True if deleted successfully.
"""
...
def session_exists(self, session_id: str) -> bool:
"""Check if a session exists.
Args:
session_id: Unique session identifier.
Returns:
True if the session exists.
"""
...
@@ -149,6 +149,12 @@ TOOL_MAPPINGS = {
'create_skill_tools': ('.skill_tools', None),
'SkillTools': ('.skill_tools', 'SkillTools'),
'skill_tools': ('.skill_tools', None),
# Schedule Tools (agent-centric scheduling)
'schedule_add': ('.schedule_tools', None),
'schedule_list': ('.schedule_tools', None),
'schedule_remove': ('.schedule_tools', None),
'schedule_tools': ('.schedule_tools', None),
}
_instances = {} # Cache for class instances
@@ -181,10 +187,11 @@ def __getattr__(name: str) -> Any:
'crawl4ai_sync', 'crawl4ai_extract_sync',
'search_web', 'web_search', 'get_available_providers',
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools'
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
'schedule_add', 'schedule_list', 'schedule_remove'
]:
return getattr(module, name)
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools']:
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -0,0 +1,152 @@
"""
Schedule tools for PraisonAI Agents.
Agent-centric scheduling — agents call these tools to add, list, and
remove scheduled jobs. No changes to the Agent class required.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
agent = Agent(
name="assistant",
instructions="You can set reminders and schedules for the user.",
tools=[schedule_add, schedule_list, schedule_remove],
)
agent.start("Remind me to check email every morning at 7am")
"""
import logging
logger = logging.getLogger(__name__)
# ── lazy singleton store ─────────────────────────────────────────────────────
_store_instance = None
def _get_store():
"""Return (or create) the global ``FileScheduleStore``."""
global _store_instance
if _store_instance is None:
from ..scheduler.store import FileScheduleStore
_store_instance = FileScheduleStore()
return _store_instance
# ── tools ────────────────────────────────────────────────────────────────────
def schedule_add(
name: str,
schedule: str,
message: str = "",
) -> str:
"""Add a new scheduled job.
Args:
name: Human-readable name for this schedule (e.g. "morning-email-check").
schedule: When to run. Accepted formats:
- "hourly", "daily"
- "*/30m", "*/6h", "*/10s" (interval)
- "cron:0 7 * * *" (cron expression)
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
- "in 20 minutes" (relative one-shot)
message: The prompt or reminder text to deliver when triggered.
Returns:
Confirmation string with the job id.
"""
try:
from ..scheduler.parser import parse_schedule
from ..scheduler.models import ScheduleJob
sched = parse_schedule(schedule)
job = ScheduleJob(name=name, schedule=sched, message=message)
store = _get_store()
# Prevent duplicate names
existing = store.get_by_name(name)
if existing:
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
store.add(job)
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
except ValueError as e:
return f"Error adding schedule: {e}"
except Exception as e:
logger.error("schedule_add failed: %s", e, exc_info=True)
return f"Error adding schedule: {e}"
def schedule_list() -> str:
"""List all scheduled jobs.
Returns:
A formatted string listing every job with its id, name, schedule,
status, and message. Returns a friendly message when empty.
"""
try:
store = _get_store()
jobs = store.list()
if not jobs:
return "No schedules found. Use schedule_add to create one."
lines = [f"Found {len(jobs)} schedule(s):\n"]
for j in jobs:
status = "enabled" if j.enabled else "disabled"
sched = j.schedule
if sched.kind == "every" and sched.every_seconds:
sched_str = _human_interval(sched.every_seconds)
elif sched.kind == "cron":
sched_str = f"cron: {sched.cron_expr}"
elif sched.kind == "at":
sched_str = f"at: {sched.at}"
else:
sched_str = str(sched.kind)
lines.append(
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
+ (f" — \"{j.message}\"" if j.message else "")
)
return "\n".join(lines)
except Exception as e:
logger.error("schedule_list failed: %s", e, exc_info=True)
return f"Error listing schedules: {e}"
def schedule_remove(name: str) -> str:
"""Remove a scheduled job by name.
Args:
name: The name of the schedule to remove.
Returns:
Confirmation or not-found message.
"""
try:
store = _get_store()
removed = store.remove_by_name(name)
if removed:
return f"Schedule '{name}' removed."
return f"Schedule '{name}' not found."
except Exception as e:
logger.error("schedule_remove failed: %s", e, exc_info=True)
return f"Error removing schedule: {e}"
# ── helpers ──────────────────────────────────────────────────────────────────
def _human_interval(seconds: int) -> str:
"""Convert seconds to a human-friendly string."""
if seconds >= 86400 and seconds % 86400 == 0:
d = seconds // 86400
return f"every {d} day(s)"
if seconds >= 3600 and seconds % 3600 == 0:
h = seconds // 3600
return f"every {h} hour(s)"
if seconds >= 60 and seconds % 60 == 0:
m = seconds // 60
return f"every {m} minute(s)"
return f"every {seconds} second(s)"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.4.6"
version = "1.5.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,778 @@
"""
REAL integration tests for WhatsApp Web mode.
These tests use:
- Real subprocess CLI calls (not mocked)
- Real neonize imports (not mocked)
- Real WhatsAppBot construction and lifecycle
- Real adapter instantiation with neonize
- Real gateway config parsing
NO mocks for neonize or CLI — tests verify actual behavior.
"""
import asyncio
import os
import signal
import subprocess
import sys
import tempfile
import time
import pytest
import yaml
# ── Helpers ────────────────────────────────────────────────────────
PRAISONAI_SRC = os.path.join(
os.path.dirname(__file__), "..", "..", "..", "praisonai"
)
PYTHON = sys.executable
def run_cli(*args, timeout=15, env_extra=None):
"""Run a praisonai CLI command as subprocess, return (returncode, stdout, stderr)."""
env = os.environ.copy()
env["PYTHONPATH"] = PRAISONAI_SRC + os.pathsep + env.get("PYTHONPATH", "")
# Ensure there's at least a dummy key so agent creation doesn't fail
env.setdefault("OPENAI_API_KEY", "sk-test-integration-dummy")
if env_extra:
env.update(env_extra)
cmd = [PYTHON, "-m", "praisonai", "bot", "whatsapp"] + list(args)
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=env,
cwd=PRAISONAI_SRC,
)
return proc.returncode, proc.stdout, proc.stderr
def run_cli_background(*args, env_extra=None):
"""Start a praisonai CLI command as background process, return Popen."""
env = os.environ.copy()
env["PYTHONPATH"] = PRAISONAI_SRC + os.pathsep + env.get("PYTHONPATH", "")
env.setdefault("OPENAI_API_KEY", "sk-test-integration-dummy")
if env_extra:
env.update(env_extra)
cmd = [PYTHON, "-m", "praisonai", "bot", "whatsapp"] + list(args)
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
cwd=PRAISONAI_SRC,
)
return proc
# =====================================================================
# 1. CLI HELP AND FLAG TESTS (real subprocess)
# =====================================================================
class TestCLIHelp:
"""Verify CLI flags exist and help text is correct via real subprocess."""
def test_help_shows_mode_flag(self):
"""--help output includes --mode flag."""
rc, stdout, stderr = run_cli("--help")
assert rc == 0
assert "--mode" in stdout
assert "cloud" in stdout.lower() or "web" in stdout.lower()
def test_help_shows_creds_dir_flag(self):
"""--help output includes --creds-dir flag."""
rc, stdout, stderr = run_cli("--help")
assert rc == 0
assert "--creds-dir" in stdout
def test_help_shows_experimental_in_description(self):
"""--help mentions experimental/QR for web mode."""
rc, stdout, stderr = run_cli("--help")
assert rc == 0
output = stdout.lower()
assert "experimental" in output or "qr" in output
def test_help_shows_cloud_mode_flags(self):
"""--help still shows cloud mode flags (--token, --phone-id, etc)."""
rc, stdout, stderr = run_cli("--help")
assert rc == 0
assert "--token" in stdout
assert "--phone-id" in stdout
assert "--verify-token" in stdout
assert "--port" in stdout
def test_mode_default_is_cloud(self):
"""--mode default value is 'cloud' in help."""
rc, stdout, stderr = run_cli("--help")
assert rc == 0
# Find the line with --mode and check default
assert "default: cloud" in stdout.lower()
# =====================================================================
# 2. CLI WEB MODE LAUNCH (real subprocess with neonize)
# =====================================================================
class TestCLIWebModeLaunch:
"""Verify `praisonai bot whatsapp --mode web` actually starts."""
def test_web_mode_starts_without_crash(self):
"""Web mode starts, prints experimental warning, doesn't crash immediately."""
tmpdir = tempfile.mkdtemp()
proc = run_cli_background("--mode", "web", "--creds-dir", tmpdir)
try:
# Give it a few seconds to start
time.sleep(5)
# It should still be running (waiting for QR/connection)
assert proc.poll() is None, (
f"Process exited prematurely with code {proc.returncode}"
)
finally:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
def test_web_mode_experimental_warning(self):
"""Web mode prints experimental warning on stderr/stdout."""
tmpdir = tempfile.mkdtemp()
proc = run_cli_background("--mode", "web", "--creds-dir", tmpdir)
try:
# Give it time to print the warning
time.sleep(4)
proc.send_signal(signal.SIGINT)
try:
stdout, stderr = proc.communicate(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
combined = stdout + stderr
# The warning is printed by the CLI before start.
# Once the event loop fix landed, neonize actually connects
# to WhatsApp servers, so we may also see websocket messages.
assert (
"experimental" in combined.lower()
or "EXPERIMENTAL" in combined
or "web mode" in combined.lower()
or "Starting WhatsApp" in combined
or "websocket" in combined.lower()
or "whatsapp" in combined.lower()
), f"Expected WhatsApp output, got: {combined[:500]}"
except Exception:
proc.kill()
proc.wait()
raise
def test_web_mode_custom_creds_dir_creates_directory(self):
"""Web mode creates the creds directory if it doesn't exist."""
tmpdir = os.path.join(tempfile.mkdtemp(), "custom_wa_creds")
assert not os.path.exists(tmpdir)
proc = run_cli_background("--mode", "web", "--creds-dir", tmpdir)
try:
time.sleep(4)
# The creds dir should have been created
assert os.path.isdir(tmpdir), f"Creds dir was not created: {tmpdir}"
finally:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
# =====================================================================
# 3. CLI CLOUD MODE BACKWARD COMPATIBILITY (real subprocess)
# =====================================================================
class TestCLICloudModeCompat:
"""Verify cloud mode still works correctly (no regressions)."""
def test_cloud_mode_requires_token(self):
"""Cloud mode (default) without token should error."""
# Remove any env tokens
rc, stdout, stderr = run_cli(
env_extra={"WHATSAPP_ACCESS_TOKEN": "", "WHATSAPP_PHONE_NUMBER_ID": ""}
)
combined = stdout + stderr
# Should mention missing token
assert rc != 0 or "token" in combined.lower() or "missing" in combined.lower() or "required" in combined.lower()
def test_cloud_mode_explicit_flag(self):
"""--mode cloud works explicitly."""
rc, stdout, stderr = run_cli(
"--mode", "cloud",
env_extra={"WHATSAPP_ACCESS_TOKEN": "", "WHATSAPP_PHONE_NUMBER_ID": ""}
)
combined = stdout + stderr
# Should ask for token, not crash
assert "EXPERIMENTAL" not in combined # no web mode warning
# =====================================================================
# 4. REAL NEONIZE IMPORT TESTS (not mocked)
# =====================================================================
class TestRealNeonizeImport:
"""Test real neonize module imports and API compatibility."""
def test_neonize_installed(self):
"""neonize is importable."""
import neonize
assert neonize is not None
def test_neonize_async_client_importable(self):
"""NewAClient is importable from neonize.aioze.client."""
from neonize.aioze.client import NewAClient
assert NewAClient is not None
def test_neonize_events_importable(self):
"""Event types are importable."""
from neonize.aioze.events import ConnectedEv, MessageEv, Event
assert ConnectedEv is not None
assert MessageEv is not None
assert Event is not None
def test_neonize_build_jid_importable(self):
"""build_jid utility is importable."""
from neonize.utils import build_jid
assert build_jid is not None
def test_neonize_newaclient_signature(self):
"""NewAClient.__init__ accepts (name) and NOT database kwarg."""
import inspect
from neonize.aioze.client import NewAClient
sig = inspect.signature(NewAClient.__init__)
params = list(sig.parameters.keys())
assert "name" in params
assert "database" not in params, (
"neonize API changed — 'database' is not a valid kwarg"
)
def test_neonize_newaclient_creates_with_path(self):
"""NewAClient can be created with a file path as name."""
from neonize.aioze.client import NewAClient
tmpdir = tempfile.mkdtemp()
db_path = os.path.join(tmpdir, "test_client.db")
client = NewAClient(db_path)
assert client is not None
assert client.name == db_path
def test_neonize_event_system_exists(self):
"""NewAClient has an event system that is callable."""
from neonize.aioze.client import NewAClient
from neonize.aioze.events import Event
tmpdir = tempfile.mkdtemp()
db_path = os.path.join(tmpdir, "test_events.db")
client = NewAClient(db_path)
assert hasattr(client, "event")
assert isinstance(client.event, Event)
assert callable(client.event)
def test_neonize_event_registration(self):
"""Can register a ConnectedEv handler on the client event system."""
from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv
tmpdir = tempfile.mkdtemp()
db_path = os.path.join(tmpdir, "test_reg.db")
client = NewAClient(db_path)
handler_called = False
@client.event(ConnectedEv)
async def on_connected(c, ev):
nonlocal handler_called
handler_called = True
# Verify handler was registered (code 3 = ConnectedEv)
assert 3 in client.event.list_func
def test_neonize_qr_event_registration(self):
"""Can register a QR handler via client.event.qr."""
from neonize.aioze.client import NewAClient
tmpdir = tempfile.mkdtemp()
db_path = os.path.join(tmpdir, "test_qr.db")
client = NewAClient(db_path)
@client.event.qr
async def on_qr(c, data):
pass
# Verify the QR handler was set
assert client.event._qr is not None
def test_neonize_message_event_registration(self):
"""Can register a MessageEv handler."""
from neonize.aioze.client import NewAClient
from neonize.aioze.events import MessageEv
tmpdir = tempfile.mkdtemp()
db_path = os.path.join(tmpdir, "test_msg.db")
client = NewAClient(db_path)
@client.event(MessageEv)
async def on_message(c, ev):
pass
# code 17 = MessageEv
assert 17 in client.event.list_func
def test_build_jid_creates_jid(self):
"""build_jid creates a proper JID protobuf."""
from neonize.utils import build_jid
jid = build_jid("1234567890", "s.whatsapp.net")
assert jid is not None
assert hasattr(jid, "User")
assert jid.User == "1234567890"
def test_build_jid_group(self):
"""build_jid works for group JIDs."""
from neonize.utils import build_jid
jid = build_jid("120363012345678901", "g.us")
assert jid is not None
assert jid.User == "120363012345678901"
# =====================================================================
# 5. REAL ADAPTER TESTS (real neonize, no mocks)
# =====================================================================
class TestRealAdapter:
"""Test WhatsAppWebAdapter with real neonize (no mocks)."""
def test_adapter_import(self):
"""Adapter imports correctly."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter is not None
def test_adapter_construction(self):
"""Adapter constructs with default params."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
tmpdir = tempfile.mkdtemp()
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
assert adapter.creds_dir == tmpdir
assert adapter.is_connected is False
assert adapter.self_jid is None
def test_adapter_db_path_creation(self):
"""Adapter creates the creds dir and returns correct db path."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
tmpdir = os.path.join(tempfile.mkdtemp(), "nested", "creds")
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
db_path = adapter._get_db_path()
assert db_path.endswith(".db")
assert os.path.isdir(tmpdir)
def test_adapter_has_saved_session_false(self):
"""No saved session in fresh temp dir."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
tmpdir = tempfile.mkdtemp()
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
assert adapter.has_saved_session() is False
def test_adapter_normalize_jid_phone(self):
"""JID normalization for plain phone numbers."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("+1555123") == "[email protected]"
assert WhatsAppWebAdapter._normalize_jid("1234567890") == "[email protected]"
def test_adapter_normalize_jid_already_jid(self):
"""JID normalization passes through existing JIDs."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
@pytest.mark.asyncio
async def test_adapter_connect_creates_neonize_client(self):
"""connect() creates a real neonize NewAClient and registers handlers."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
tmpdir = tempfile.mkdtemp()
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
# We can't complete the full connect (it blocks on WhatsApp servers),
# but we can verify the client creation and handler registration
# by monkey-patching connect() to just do setup without the network call.
from neonize.aioze.client import NewAClient
original_connect = NewAClient.connect
connect_was_called = False
async def patched_connect(self_client):
nonlocal connect_was_called
connect_was_called = True
# Don't actually connect to WhatsApp servers
raise asyncio.CancelledError("test: skip real connection")
NewAClient.connect = patched_connect
try:
with pytest.raises(asyncio.CancelledError):
await adapter.connect()
assert connect_was_called
assert adapter._client is not None
# Verify event handlers were registered on the REAL neonize client
assert 3 in adapter._client.event.list_func # ConnectedEv
assert 17 in adapter._client.event.list_func # MessageEv
finally:
NewAClient.connect = original_connect
@pytest.mark.asyncio
async def test_adapter_connect_bridges_event_loop(self):
"""connect() patches neonize's event_global_loop with the running loop.
Root cause fix: neonize creates event_global_loop = asyncio.new_event_loop()
at import time but never starts it. Go-thread callbacks (QR, messages)
are posted to that dead loop. Our adapter must replace it with the
currently running loop so callbacks actually execute.
"""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
from neonize.aioze.client import NewAClient
import neonize.aioze.events as nz_events
import neonize.aioze.client as nz_client
tmpdir = tempfile.mkdtemp()
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
running_loop = asyncio.get_running_loop()
# Before connect: neonize's loop is NOT our running loop
original_nz_loop = nz_events.event_global_loop
original_connect = NewAClient.connect
async def patched_connect(self_client):
raise ConnectionError("test: skip real connection")
NewAClient.connect = patched_connect
try:
with pytest.raises(ConnectionError):
await adapter.connect()
# After connect: neonize's event loops must be bridged to ours
assert nz_events.event_global_loop is running_loop
assert nz_client.event_global_loop is running_loop
assert adapter._client.loop is running_loop
finally:
NewAClient.connect = original_connect
# Restore original loop to avoid test pollution
nz_events.event_global_loop = original_nz_loop
nz_client.event_global_loop = original_nz_loop
def test_adapter_logout_cleans_db(self):
"""logout() removes the DB file."""
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
tmpdir = tempfile.mkdtemp()
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
# Create a fake DB file
db_path = adapter._get_db_path()
with open(db_path, "w") as f:
f.write("fake db")
assert adapter.has_saved_session() is True
asyncio.run(adapter.logout())
assert adapter.has_saved_session() is False
assert not os.path.exists(db_path)
# =====================================================================
# 6. REAL WHATSAPP BOT DUAL-MODE TESTS (real imports)
# =====================================================================
class TestRealWhatsAppBot:
"""Test WhatsAppBot with real imports (not mocked)."""
def _get_bot_class(self):
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.bots.whatsapp import WhatsAppBot
return WhatsAppBot
def test_bot_web_mode_construction(self):
"""WhatsAppBot(mode='web') constructs without error."""
WhatsAppBot = self._get_bot_class()
bot = WhatsAppBot(mode="web")
assert bot.mode == "web"
def test_bot_cloud_mode_construction(self):
"""WhatsAppBot(mode='cloud') constructs without error."""
WhatsAppBot = self._get_bot_class()
bot = WhatsAppBot(token="test-token", phone_number_id="12345", mode="cloud")
assert bot.mode == "cloud"
def test_bot_default_mode_is_cloud(self):
"""Default mode is cloud."""
WhatsAppBot = self._get_bot_class()
bot = WhatsAppBot(token="test-token")
assert bot.mode == "cloud"
def test_bot_invalid_mode_raises(self):
"""Invalid mode raises ValueError."""
WhatsAppBot = self._get_bot_class()
with pytest.raises(ValueError, match="Invalid mode"):
WhatsAppBot(mode="invalid")
def test_bot_web_mode_with_custom_creds(self):
"""Web mode accepts custom creds_dir."""
WhatsAppBot = self._get_bot_class()
tmpdir = tempfile.mkdtemp()
bot = WhatsAppBot(mode="web", creds_dir=tmpdir)
assert bot.mode == "web"
def test_bot_backward_compat_positional_token(self):
"""Positional token arg still works (backward compat)."""
WhatsAppBot = self._get_bot_class()
bot = WhatsAppBot("my-token-123")
assert bot.mode == "cloud"
@pytest.mark.asyncio
async def test_bot_web_mode_start_creates_adapter(self):
"""Starting web mode creates a real WhatsAppWebAdapter and attempts connect."""
WhatsAppBot = self._get_bot_class()
tmpdir = tempfile.mkdtemp()
bot = WhatsAppBot(mode="web", creds_dir=tmpdir)
from neonize.aioze.client import NewAClient
original_connect = NewAClient.connect
connect_was_called = False
async def patched_connect(self_client):
nonlocal connect_was_called
connect_was_called = True
# Simulate connection failure so _start_web_mode raises
raise ConnectionError("test: skip real connection")
NewAClient.connect = patched_connect
try:
# _start_web_mode catches and re-raises non-CancelledError exceptions
with pytest.raises(ConnectionError):
await bot.start()
assert connect_was_called, "NewAClient.connect was never called"
finally:
NewAClient.connect = original_connect
@pytest.mark.asyncio
async def test_bot_stop_is_safe(self):
"""stop() can be called even if bot never started."""
WhatsAppBot = self._get_bot_class()
bot = WhatsAppBot(mode="web")
await bot.stop() # Should not raise
# =====================================================================
# 7. GATEWAY CONFIG PARSING (real code, no mocks)
# =====================================================================
class TestRealGatewayConfig:
"""Test gateway config parsing with WhatsApp web mode."""
def _get_gateway_class(self):
sys.path.insert(0, PRAISONAI_SRC)
from praisonai.gateway.server import WebSocketGateway
return WebSocketGateway
def test_gateway_config_web_mode_no_token(self):
"""Gateway config accepts WhatsApp web mode without token."""
WebSocketGateway = self._get_gateway_class()
config = {
"gateway": {"host": "127.0.0.1", "port": 8765},
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
"channels": {
"whatsapp": {
"mode": "web",
"routing": {"default": "a"},
}
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(config, f)
tmppath = f.name
try:
cfg = WebSocketGateway.load_gateway_config(tmppath)
assert cfg["channels"]["whatsapp"]["mode"] == "web"
finally:
os.unlink(tmppath)
def test_gateway_config_cloud_mode_requires_token(self):
"""Gateway config rejects cloud mode WhatsApp without token."""
WebSocketGateway = self._get_gateway_class()
config = {
"gateway": {"host": "127.0.0.1", "port": 8765},
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
"channels": {
"whatsapp": {
"mode": "cloud",
"routing": {"default": "a"},
}
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(config, f)
tmppath = f.name
try:
with pytest.raises(ValueError, match="missing.*token"):
WebSocketGateway.load_gateway_config(tmppath)
finally:
os.unlink(tmppath)
def test_gateway_config_web_mode_with_creds_dir(self):
"""Gateway config passes creds_dir through."""
WebSocketGateway = self._get_gateway_class()
config = {
"gateway": {"host": "127.0.0.1", "port": 8765},
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
"channels": {
"whatsapp": {
"mode": "web",
"creds_dir": "/tmp/custom_creds",
"routing": {"default": "a"},
}
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(config, f)
tmppath = f.name
try:
cfg = WebSocketGateway.load_gateway_config(tmppath)
assert cfg["channels"]["whatsapp"]["creds_dir"] == "/tmp/custom_creds"
finally:
os.unlink(tmppath)
def test_gateway_config_default_mode_is_cloud(self):
"""Gateway config without explicit mode defaults to cloud behavior."""
WebSocketGateway = self._get_gateway_class()
config = {
"gateway": {"host": "127.0.0.1", "port": 8765},
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
"channels": {
"whatsapp": {
"token": "test-token",
"phone_number_id": "12345",
"routing": {"default": "a"},
}
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(config, f)
tmppath = f.name
try:
cfg = WebSocketGateway.load_gateway_config(tmppath)
# mode should default to cloud (not explicit in config)
assert cfg["channels"]["whatsapp"].get("mode", "cloud") == "cloud"
finally:
os.unlink(tmppath)
# =====================================================================
# 8. YAML CONFIG BOT LAUNCH (real bots_cli)
# =====================================================================
class TestRealYAMLConfig:
"""Test YAML-based bot configuration with web mode."""
def test_yaml_example_file_exists(self):
"""WhatsApp web bot YAML example exists."""
yaml_path = os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..",
"examples", "yaml", "whatsapp-web-bot.yaml"
)
assert os.path.exists(yaml_path), f"Missing: {yaml_path}"
def test_yaml_example_is_valid(self):
"""WhatsApp web bot YAML example is valid YAML with correct fields."""
yaml_path = os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..",
"examples", "yaml", "whatsapp-web-bot.yaml"
)
with open(yaml_path) as f:
config = yaml.safe_load(f)
assert config is not None
assert config.get("platform") == "whatsapp"
assert config.get("mode") == "web"
def test_gateway_yaml_has_web_mode_example(self):
"""Gateway YAML example includes web mode comments."""
yaml_path = os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..",
"examples", "yaml", "gateway.yaml"
)
with open(yaml_path) as f:
content = f.read()
assert "mode: web" in content
# =====================================================================
# 9. PERFORMANCE / IMPORT TESTS
# =====================================================================
class TestPerformance:
"""Verify no performance regressions."""
def test_praisonai_bots_import_fast(self):
"""Importing praisonai.bots.whatsapp doesn't load neonize."""
result = subprocess.run(
[
PYTHON, "-c",
"import sys; "
"sys.path.insert(0, '.'); "
"from praisonai.bots.whatsapp import WhatsAppBot; "
"print('neonize_loaded' if 'neonize' in sys.modules else 'neonize_NOT_loaded')"
],
capture_output=True, text=True, timeout=15,
cwd=PRAISONAI_SRC,
)
assert "neonize_NOT_loaded" in result.stdout, (
"neonize should NOT be loaded at import time"
)
def test_adapter_import_does_not_load_neonize(self):
"""Importing the adapter module doesn't load neonize."""
result = subprocess.run(
[
PYTHON, "-c",
"import sys; "
"sys.path.insert(0, '.'); "
"from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter; "
"print('neonize_loaded' if 'neonize' in sys.modules else 'neonize_NOT_loaded')"
],
capture_output=True, text=True, timeout=15,
cwd=PRAISONAI_SRC,
)
assert "neonize_NOT_loaded" in result.stdout, (
"neonize should NOT be loaded at adapter import time"
)
def test_bot_construction_does_not_load_neonize(self):
"""Constructing WhatsAppBot(mode='web') doesn't load neonize."""
result = subprocess.run(
[
PYTHON, "-c",
"import sys; "
"sys.path.insert(0, '.'); "
"from praisonai.bots.whatsapp import WhatsAppBot; "
"bot = WhatsAppBot(mode='web'); "
"print('neonize_loaded' if 'neonize' in sys.modules else 'neonize_NOT_loaded')"
],
capture_output=True, text=True, timeout=15,
cwd=PRAISONAI_SRC,
)
assert "neonize_NOT_loaded" in result.stdout, (
"neonize should NOT be loaded until connect() is called"
)
@@ -0,0 +1,215 @@
"""
TDD tests for AgentApproval backend.
Tests the agent-based approval backend that delegates approval decisions
to another AI agent in the core SDK.
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, AsyncMock
import pytest
# ── Protocol Conformance ────────────────────────────────────────────────────
class TestAgentApprovalProtocol:
def test_conforms_to_approval_protocol(self):
from praisonaiagents.approval.protocols import ApprovalProtocol
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
backend = AgentApproval(approver_agent=mock_agent)
assert isinstance(backend, ApprovalProtocol)
def test_has_request_approval_sync(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval(approver_agent=MagicMock())
assert hasattr(backend, "request_approval_sync")
assert callable(backend.request_approval_sync)
def test_has_request_approval_async(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval(approver_agent=MagicMock())
assert asyncio.iscoroutinefunction(backend.request_approval)
# ── Construction ────────────────────────────────────────────────────────────
class TestAgentApprovalInit:
def test_with_explicit_agent(self):
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "my-reviewer"
backend = AgentApproval(approver_agent=mock_agent)
assert backend._approver_agent is mock_agent
def test_repr_with_agent(self):
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "sec-bot"
backend = AgentApproval(approver_agent=mock_agent)
assert "sec-bot" in repr(backend)
def test_repr_without_agent(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval()
assert "default" in repr(backend)
# ── Prompt Builder ──────────────────────────────────────────────────────────
class TestPromptBuilder:
def _make_request(self, **overrides):
from praisonaiagents.approval.protocols import ApprovalRequest
defaults = {
"tool_name": "execute_command",
"arguments": {"cmd": "rm -rf /"},
"risk_level": "critical",
"agent_name": "worker",
}
defaults.update(overrides)
return ApprovalRequest(**defaults)
def test_prompt_contains_tool_name(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval(approver_agent=MagicMock())
prompt = backend._build_prompt(self._make_request(tool_name="delete_file"))
assert "delete_file" in prompt
def test_prompt_contains_risk_level(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval(approver_agent=MagicMock())
prompt = backend._build_prompt(self._make_request(risk_level="high"))
assert "HIGH" in prompt
def test_prompt_contains_arguments(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval(approver_agent=MagicMock())
prompt = backend._build_prompt(self._make_request(arguments={"path": "/tmp"}))
assert "/tmp" in prompt
def test_prompt_ends_with_instruction(self):
from praisonaiagents.approval.backends import AgentApproval
backend = AgentApproval(approver_agent=MagicMock())
prompt = backend._build_prompt(self._make_request())
assert "APPROVE" in prompt
assert "DENY" in prompt
# ── Async Approval Flow ────────────────────────────────────────────────────
class TestApprovalFlowAsync:
def _make_request(self, **overrides):
from praisonaiagents.approval.protocols import ApprovalRequest
defaults = {
"tool_name": "execute_command",
"arguments": {"cmd": "ls"},
"risk_level": "low",
"agent_name": "worker",
}
defaults.update(overrides)
return ApprovalRequest(**defaults)
def test_approved_by_agent(self):
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "reviewer"
mock_agent.achat = AsyncMock(return_value="APPROVE")
backend = AgentApproval(approver_agent=mock_agent)
decision = asyncio.run(backend.request_approval(self._make_request()))
assert decision.approved is True
assert decision.approver == "reviewer"
def test_denied_by_agent(self):
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "reviewer"
mock_agent.achat = AsyncMock(return_value="DENY")
backend = AgentApproval(approver_agent=mock_agent)
decision = asyncio.run(backend.request_approval(self._make_request()))
assert decision.approved is False
def test_ambiguous_response_denied(self):
"""If response contains both APPROVE and DENY, it's denied."""
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "reviewer"
mock_agent.achat = AsyncMock(return_value="I would APPROVE but actually DENY this")
backend = AgentApproval(approver_agent=mock_agent)
decision = asyncio.run(backend.request_approval(self._make_request()))
assert decision.approved is False
def test_falls_back_to_sync_chat(self):
"""If agent has no achat, uses chat via executor."""
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "reviewer"
# Remove achat so it falls back to chat
del mock_agent.achat
mock_agent.chat = MagicMock(return_value="APPROVE")
backend = AgentApproval(approver_agent=mock_agent)
decision = asyncio.run(backend.request_approval(self._make_request()))
assert decision.approved is True
def test_error_returns_denial(self):
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "reviewer"
mock_agent.achat = AsyncMock(side_effect=RuntimeError("LLM down"))
backend = AgentApproval(approver_agent=mock_agent)
decision = asyncio.run(backend.request_approval(self._make_request()))
assert decision.approved is False
assert "error" in decision.reason.lower()
def test_metadata_contains_response(self):
from praisonaiagents.approval.backends import AgentApproval
mock_agent = MagicMock()
mock_agent.name = "reviewer"
mock_agent.achat = AsyncMock(return_value="APPROVE - this looks safe")
backend = AgentApproval(approver_agent=mock_agent)
decision = asyncio.run(backend.request_approval(self._make_request()))
assert decision.metadata.get("platform") == "agent"
assert "safe" in decision.metadata.get("response", "")
# ── Export / Import ─────────────────────────────────────────────────────────
class TestExports:
def test_import_from_approval_package(self):
from praisonaiagents.approval import AgentApproval
assert AgentApproval is not None
def test_import_from_backends(self):
from praisonaiagents.approval.backends import AgentApproval
assert AgentApproval is not None
@@ -34,6 +34,7 @@ class TestBotCapabilities:
assert caps.sandbox is False
assert caps.exec_enabled is False
assert caps.model is None
assert caps.auto_approve is False
def test_bot_capabilities_custom_values(self):
"""Test custom values for BotCapabilities."""
@@ -144,8 +145,7 @@ class TestBotHandler:
assert "Capabilities:" in captured.out
assert "browser" in captured.out
assert "memory" in captured.out
class TestHandleBotCommand:
"""Tests for handle_bot_command function."""
@@ -279,3 +279,4 @@ class TestAddCapabilityArgs:
assert args.web_search is True
assert args.model == "gpt-4o"
assert args.tools == ["DuckDuckGoTool", "WikipediaTool"]
@@ -0,0 +1,210 @@
"""
TDD Tests for Bot Session Persistence.
Tests that BotSessionManager uses SessionStoreProtocol for persistent
per-user session isolation instead of in-memory-only storage.
"""
import asyncio
import tempfile
from typing import Any, Dict, List
from praisonaiagents.session.store import DefaultSessionStore
class FakeAgent:
"""Minimal Agent mock for testing BotSessionManager."""
def __init__(self, name: str = "TestAgent"):
self.name = name
self.chat_history: List[Dict[str, Any]] = []
def chat(self, prompt: str) -> str:
self.chat_history.append({"role": "user", "content": prompt})
response = f"Response to: {prompt}"
self.chat_history.append({"role": "assistant", "content": response})
return response
def _run_async(coro):
"""Helper to run async coroutines in tests."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
class TestBotSessionManagerWithStore:
"""Tests for BotSessionManager using persistent session store."""
def _make_manager(self, tmpdir: str, platform: str = "test"):
"""Create a BotSessionManager with a persistent store."""
# Import here to test the refactored version
import sys
import os
# Add wrapper path so we can import _session
wrapper_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)))),
"praisonai", "praisonai", "bots"
)
if wrapper_path not in sys.path:
sys.path.insert(0, wrapper_path)
from _session import BotSessionManager
store = DefaultSessionStore(session_dir=tmpdir)
return BotSessionManager(store=store, platform=platform)
def test_constructor_accepts_store(self):
"""BotSessionManager must accept a store parameter."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
assert mgr is not None
def test_constructor_accepts_platform(self):
"""BotSessionManager must accept a platform parameter."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir, platform="telegram")
assert mgr._platform == "telegram"
def test_chat_persists_to_store(self):
"""After chat(), messages must be persisted in the store."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
agent = FakeAgent()
_run_async(mgr.chat(agent, "user1", "Hello"))
store = DefaultSessionStore(session_dir=tmpdir)
key = "bot_test_user1"
history = store.get_chat_history(key)
assert len(history) >= 2
assert history[0]["role"] == "user"
assert history[0]["content"] == "Hello"
def test_chat_restores_from_store(self):
"""On second call, history must be loaded from store."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
agent = FakeAgent()
_run_async(mgr.chat(agent, "user1", "Hello"))
_run_async(mgr.chat(agent, "user1", "How are you?"))
store = DefaultSessionStore(session_dir=tmpdir)
key = "bot_test_user1"
history = store.get_chat_history(key)
# Should have 4 messages: user+assistant from each call
assert len(history) >= 4
def test_multi_user_isolation(self):
"""Different users must have isolated sessions."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
agent = FakeAgent()
_run_async(mgr.chat(agent, "alice", "Hi from Alice"))
_run_async(mgr.chat(agent, "bob", "Hi from Bob"))
store = DefaultSessionStore(session_dir=tmpdir)
alice_history = store.get_chat_history("bot_test_alice")
bob_history = store.get_chat_history("bot_test_bob")
assert len(alice_history) >= 2
assert len(bob_history) >= 2
assert alice_history[0]["content"] == "Hi from Alice"
assert bob_history[0]["content"] == "Hi from Bob"
def test_agent_history_not_corrupted(self):
"""Agent's own chat_history must not be corrupted by bot sessions."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
agent = FakeAgent()
agent.chat_history = [{"role": "system", "content": "Original"}]
_run_async(mgr.chat(agent, "user1", "Hello"))
# Agent's history must be restored to original
assert len(agent.chat_history) == 1
assert agent.chat_history[0]["content"] == "Original"
def test_reset_clears_store(self):
"""reset() must clear user's session from the store."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
agent = FakeAgent()
_run_async(mgr.chat(agent, "user1", "Hello"))
mgr.reset("user1")
store = DefaultSessionStore(session_dir=tmpdir)
key = "bot_test_user1"
history = store.get_chat_history(key)
assert len(history) == 0
def test_reset_all_clears_all_store_sessions(self):
"""reset_all() must clear all bot sessions from the store."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir)
agent = FakeAgent()
_run_async(mgr.chat(agent, "user1", "Hi"))
_run_async(mgr.chat(agent, "user2", "Hey"))
count = mgr.reset_all()
assert count == 2
def test_persistence_across_manager_instances(self):
"""Sessions must survive across BotSessionManager instances."""
with tempfile.TemporaryDirectory() as tmpdir:
# First manager instance
mgr1 = self._make_manager(tmpdir)
agent = FakeAgent()
_run_async(mgr1.chat(agent, "user1", "Remember this"))
# Second manager instance (simulates restart)
mgr2 = self._make_manager(tmpdir)
agent2 = FakeAgent()
_run_async(mgr2.chat(agent2, "user1", "What did I say?"))
# Check agent2's chat_history during the call had previous context
store = DefaultSessionStore(session_dir=tmpdir)
key = "bot_test_user1"
history = store.get_chat_history(key)
# Should have messages from both sessions
assert len(history) >= 4
assert history[0]["content"] == "Remember this"
def test_backward_compat_no_store(self):
"""BotSessionManager must still work without a store (in-memory fallback)."""
import sys
import os
wrapper_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)))),
"praisonai", "praisonai", "bots"
)
if wrapper_path not in sys.path:
sys.path.insert(0, wrapper_path)
from _session import BotSessionManager
# No store parameter = backward compatible in-memory mode
mgr = BotSessionManager()
agent = FakeAgent()
_run_async(mgr.chat(agent, "user1", "Hello"))
assert mgr.active_sessions >= 1
def test_session_key_format(self):
"""Session key must follow bot_{platform}_{user_id} format."""
with tempfile.TemporaryDirectory() as tmpdir:
mgr = self._make_manager(tmpdir, platform="telegram")
agent = FakeAgent()
_run_async(mgr.chat(agent, "12345", "Hello"))
store = DefaultSessionStore(session_dir=tmpdir)
assert store.session_exists("bot_telegram_12345")
@@ -0,0 +1,233 @@
"""
TDD Tests for SessionStoreProtocol.
Tests protocol conformance for DefaultSessionStore, HierarchicalSessionStore,
and a mock implementation to prove swappability.
"""
import tempfile
from typing import Any, Dict, List, Optional
class MockSessionStore:
"""Minimal mock that implements SessionStoreProtocol interface.
Proves that any class matching the protocol shape is accepted.
"""
def __init__(self):
self._sessions: Dict[str, List[Dict[str, Any]]] = {}
def add_message(
self,
session_id: str,
role: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
if session_id not in self._sessions:
self._sessions[session_id] = []
self._sessions[session_id].append({"role": role, "content": content})
return True
def get_chat_history(
self,
session_id: str,
max_messages: Optional[int] = None,
) -> List[Dict[str, str]]:
msgs = self._sessions.get(session_id, [])
if max_messages:
msgs = msgs[-max_messages:]
return msgs
def clear_session(self, session_id: str) -> bool:
self._sessions[session_id] = []
return True
def delete_session(self, session_id: str) -> bool:
self._sessions.pop(session_id, None)
return True
def session_exists(self, session_id: str) -> bool:
return session_id in self._sessions
class TestSessionStoreProtocolExists:
"""Test that SessionStoreProtocol can be imported."""
def test_import_protocol(self):
"""SessionStoreProtocol must be importable from session.protocols."""
from praisonaiagents.session.protocols import SessionStoreProtocol
assert SessionStoreProtocol is not None
def test_protocol_is_runtime_checkable(self):
"""Protocol must be @runtime_checkable for isinstance() checks."""
from praisonaiagents.session.protocols import SessionStoreProtocol
# Should not raise
isinstance(object(), SessionStoreProtocol)
def test_import_from_session_package(self):
"""SessionStoreProtocol must be importable from session package."""
from praisonaiagents.session import SessionStoreProtocol
assert SessionStoreProtocol is not None
class TestDefaultSessionStoreConformance:
"""DefaultSessionStore must satisfy SessionStoreProtocol."""
def test_isinstance_check(self):
"""DefaultSessionStore must pass isinstance(store, SessionStoreProtocol)."""
from praisonaiagents.session.protocols import SessionStoreProtocol
from praisonaiagents.session.store import DefaultSessionStore
with tempfile.TemporaryDirectory() as tmpdir:
store = DefaultSessionStore(session_dir=tmpdir)
assert isinstance(store, SessionStoreProtocol)
def test_has_add_message(self):
"""DefaultSessionStore must have add_message method."""
from praisonaiagents.session.store import DefaultSessionStore
assert hasattr(DefaultSessionStore, 'add_message')
assert callable(getattr(DefaultSessionStore, 'add_message'))
def test_has_get_chat_history(self):
"""DefaultSessionStore must have get_chat_history method."""
from praisonaiagents.session.store import DefaultSessionStore
assert hasattr(DefaultSessionStore, 'get_chat_history')
assert callable(getattr(DefaultSessionStore, 'get_chat_history'))
def test_has_clear_session(self):
"""DefaultSessionStore must have clear_session method."""
from praisonaiagents.session.store import DefaultSessionStore
assert hasattr(DefaultSessionStore, 'clear_session')
assert callable(getattr(DefaultSessionStore, 'clear_session'))
def test_has_delete_session(self):
"""DefaultSessionStore must have delete_session method."""
from praisonaiagents.session.store import DefaultSessionStore
assert hasattr(DefaultSessionStore, 'delete_session')
assert callable(getattr(DefaultSessionStore, 'delete_session'))
def test_has_session_exists(self):
"""DefaultSessionStore must have session_exists method."""
from praisonaiagents.session.store import DefaultSessionStore
assert hasattr(DefaultSessionStore, 'session_exists')
assert callable(getattr(DefaultSessionStore, 'session_exists'))
class TestHierarchicalSessionStoreConformance:
"""HierarchicalSessionStore must also satisfy SessionStoreProtocol."""
def test_isinstance_check(self):
"""HierarchicalSessionStore must pass isinstance check."""
from praisonaiagents.session.protocols import SessionStoreProtocol
from praisonaiagents.session.hierarchy import HierarchicalSessionStore
with tempfile.TemporaryDirectory() as tmpdir:
store = HierarchicalSessionStore(session_dir=tmpdir)
assert isinstance(store, SessionStoreProtocol)
class TestMockStoreConformance:
"""Mock store implementing protocol interface must pass isinstance check."""
def test_mock_isinstance_check(self):
"""MockSessionStore must pass isinstance(mock, SessionStoreProtocol)."""
from praisonaiagents.session.protocols import SessionStoreProtocol
mock = MockSessionStore()
assert isinstance(mock, SessionStoreProtocol)
def test_mock_add_and_get(self):
"""Mock store must support add_message and get_chat_history."""
mock = MockSessionStore()
mock.add_message("s1", "user", "Hello")
mock.add_message("s1", "assistant", "Hi!")
history = mock.get_chat_history("s1")
assert len(history) == 2
assert history[0]["role"] == "user"
assert history[1]["role"] == "assistant"
def test_mock_clear_session(self):
"""Mock store must support clear_session."""
mock = MockSessionStore()
mock.add_message("s1", "user", "Hello")
mock.clear_session("s1")
assert mock.get_chat_history("s1") == []
def test_mock_delete_session(self):
"""Mock store must support delete_session."""
mock = MockSessionStore()
mock.add_message("s1", "user", "Hello")
mock.delete_session("s1")
assert not mock.session_exists("s1")
def test_mock_session_exists(self):
"""Mock store must support session_exists."""
mock = MockSessionStore()
assert not mock.session_exists("s1")
mock.add_message("s1", "user", "Hello")
assert mock.session_exists("s1")
class TestProtocolSwappability:
"""Test that protocol enables swapping stores without code changes."""
def _use_store(self, store):
"""Helper that only depends on SessionStoreProtocol interface."""
from praisonaiagents.session.protocols import SessionStoreProtocol
assert isinstance(store, SessionStoreProtocol)
store.add_message("test-swap", "user", "Hello from protocol")
history = store.get_chat_history("test-swap")
assert len(history) >= 1
assert history[-1]["content"] == "Hello from protocol"
store.clear_session("test-swap")
assert store.get_chat_history("test-swap") == []
store.delete_session("test-swap")
def test_swap_default_store(self):
"""DefaultSessionStore works through protocol interface."""
from praisonaiagents.session.store import DefaultSessionStore
with tempfile.TemporaryDirectory() as tmpdir:
store = DefaultSessionStore(session_dir=tmpdir)
self._use_store(store)
def test_swap_hierarchical_store(self):
"""HierarchicalSessionStore works through protocol interface."""
from praisonaiagents.session.hierarchy import HierarchicalSessionStore
with tempfile.TemporaryDirectory() as tmpdir:
store = HierarchicalSessionStore(session_dir=tmpdir)
self._use_store(store)
def test_swap_mock_store(self):
"""MockSessionStore works through protocol interface."""
mock = MockSessionStore()
self._use_store(mock)
class TestSessionExports:
"""Test that all session components are properly exported."""
def test_hierarchy_exports(self):
"""HierarchicalSessionStore must be importable from session package."""
from praisonaiagents.session import HierarchicalSessionStore
assert HierarchicalSessionStore is not None
def test_hierarchy_store_getter(self):
"""get_hierarchical_session_store must be importable from session package."""
from praisonaiagents.session import get_hierarchical_session_store
assert callable(get_hierarchical_session_store)
def test_session_snapshot_export(self):
"""SessionSnapshot must be importable from session package."""
from praisonaiagents.session import SessionSnapshot
assert SessionSnapshot is not None
def test_extended_session_data_export(self):
"""ExtendedSessionData must be importable from session package."""
from praisonaiagents.session import ExtendedSessionData
assert ExtendedSessionData is not None
@@ -195,12 +195,12 @@ class TestNamespaceStyle:
class TestAllSizeLimited:
"""Test that __all__ is limited to core symbols."""
def test_all_size_under_25(self):
"""__all__ should have fewer than 25 items (minimal for clean IDE)."""
def test_all_size_under_27(self):
"""__all__ should have fewer than 27 items (minimal for clean IDE)."""
import praisonaiagents
# Updated from 21 to 25 to accommodate AgentOS protocol/config exports
assert len(praisonaiagents.__all__) < 25, \
f"__all__ has {len(praisonaiagents.__all__)} items, expected < 25"
# Updated from 25 to 27 to accommodate AutoApproveBackend export
assert len(praisonaiagents.__all__) < 27, \
f"__all__ has {len(praisonaiagents.__all__)} items, expected < 27"
def test_all_contains_core(self):
"""__all__ contains core symbols."""
@@ -0,0 +1,455 @@
"""
TDD tests for the extensible approval protocol module.
Tests cover:
- ApprovalRequest / ApprovalDecision dataclasses
- ApprovalProtocol structural subtyping
- AutoApproveBackend always approves
- ConsoleBackend (mocked stdin)
- ApprovalRegistry per-agent + global backend routing
- ApprovalRegistry pre-checks (env, yaml, already-approved)
- Backward compatibility with old approval.py API
- Timeout handling
"""
import asyncio
import os
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
# ── Protocol & Dataclass tests ──────────────────────────────────────────────
class TestApprovalRequest:
def test_minimal_construction(self):
from praisonaiagents.approval.protocols import ApprovalRequest
req = ApprovalRequest(tool_name="execute_command", arguments={"cmd": "ls"}, risk_level="critical")
assert req.tool_name == "execute_command"
assert req.arguments == {"cmd": "ls"}
assert req.risk_level == "critical"
assert req.agent_name is None
assert req.session_id is None
def test_full_construction(self):
from praisonaiagents.approval.protocols import ApprovalRequest
req = ApprovalRequest(
tool_name="write_file",
arguments={"path": "/tmp/x"},
risk_level="high",
agent_name="my-agent",
session_id="sess-1",
context={"user": "alice"},
)
assert req.agent_name == "my-agent"
assert req.context == {"user": "alice"}
class TestApprovalDecision:
def test_approved(self):
from praisonaiagents.approval.protocols import ApprovalDecision
d = ApprovalDecision(approved=True, reason="auto")
assert d.approved is True
assert d.reason == "auto"
assert d.modified_args == {}
def test_denied(self):
from praisonaiagents.approval.protocols import ApprovalDecision
d = ApprovalDecision(approved=False, reason="user denied")
assert d.approved is False
def test_modified_args(self):
from praisonaiagents.approval.protocols import ApprovalDecision
d = ApprovalDecision(approved=True, modified_args={"cmd": "ls -la"})
assert d.modified_args == {"cmd": "ls -la"}
def test_approver_and_metadata(self):
from praisonaiagents.approval.protocols import ApprovalDecision
d = ApprovalDecision(approved=True, approver="webhook", metadata={"ip": "1.2.3.4"})
assert d.approver == "webhook"
assert d.metadata == {"ip": "1.2.3.4"}
class TestApprovalProtocol:
def test_protocol_structural_subtyping(self):
"""Any class with async request_approval(ApprovalRequest) -> ApprovalDecision satisfies the protocol."""
from praisonaiagents.approval.protocols import ApprovalProtocol, ApprovalRequest, ApprovalDecision
class MyBackend:
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True)
assert isinstance(MyBackend(), ApprovalProtocol)
def test_non_conforming_class_fails(self):
from praisonaiagents.approval.protocols import ApprovalProtocol
class Bad:
pass
assert not isinstance(Bad(), ApprovalProtocol)
# ── Backend tests ────────────────────────────────────────────────────────────
class TestAutoApproveBackend:
def test_always_approves(self):
from praisonaiagents.approval.backends import AutoApproveBackend
from praisonaiagents.approval.protocols import ApprovalRequest
backend = AutoApproveBackend()
req = ApprovalRequest(tool_name="execute_command", arguments={}, risk_level="critical")
decision = asyncio.run(backend.request_approval(req))
assert decision.approved is True
assert decision.approver == "system"
def test_sync_shortcut(self):
from praisonaiagents.approval.backends import AutoApproveBackend
from praisonaiagents.approval.protocols import ApprovalRequest
backend = AutoApproveBackend()
req = ApprovalRequest(tool_name="x", arguments={}, risk_level="low")
decision = backend.request_approval_sync(req)
assert decision.approved is True
class TestConsoleBackend:
def test_approved_via_mock(self):
from praisonaiagents.approval.backends import ConsoleBackend
from praisonaiagents.approval.protocols import ApprovalRequest
backend = ConsoleBackend()
req = ApprovalRequest(tool_name="write_file", arguments={"path": "/tmp"}, risk_level="high")
with patch.object(backend, '_prompt_user', return_value=True):
decision = backend.request_approval_sync(req)
assert decision.approved is True
def test_denied_via_mock(self):
from praisonaiagents.approval.backends import ConsoleBackend
from praisonaiagents.approval.protocols import ApprovalRequest
backend = ConsoleBackend()
req = ApprovalRequest(tool_name="kill_process", arguments={}, risk_level="critical")
with patch.object(backend, '_prompt_user', return_value=False):
decision = backend.request_approval_sync(req)
assert decision.approved is False
def test_async_delegates_to_sync(self):
from praisonaiagents.approval.backends import ConsoleBackend
from praisonaiagents.approval.protocols import ApprovalRequest
backend = ConsoleBackend()
req = ApprovalRequest(tool_name="x", arguments={}, risk_level="low")
with patch.object(backend, '_prompt_user', return_value=True):
decision = asyncio.run(backend.request_approval(req))
assert decision.approved is True
# ── Registry tests ───────────────────────────────────────────────────────────
class TestApprovalRegistry:
def test_default_backend_is_console(self):
from praisonaiagents.approval.registry import ApprovalRegistry
from praisonaiagents.approval.backends import ConsoleBackend
reg = ApprovalRegistry()
backend = reg.get_backend()
assert isinstance(backend, ConsoleBackend)
def test_set_global_backend(self):
from praisonaiagents.approval.registry import ApprovalRegistry
from praisonaiagents.approval.backends import AutoApproveBackend
reg = ApprovalRegistry()
reg.set_backend(AutoApproveBackend())
assert isinstance(reg.get_backend(), AutoApproveBackend)
def test_per_agent_backend(self):
from praisonaiagents.approval.registry import ApprovalRegistry
from praisonaiagents.approval.backends import AutoApproveBackend, ConsoleBackend
reg = ApprovalRegistry()
reg.set_backend(AutoApproveBackend(), agent_name="bot-agent")
assert isinstance(reg.get_backend("bot-agent"), AutoApproveBackend)
assert isinstance(reg.get_backend("other-agent"), ConsoleBackend) # fallback
def test_approve_sync_not_required(self):
"""Tools not in required set are auto-approved."""
from praisonaiagents.approval.registry import ApprovalRegistry
reg = ApprovalRegistry()
reg._required_tools.clear() # no tools require approval
decision = reg.approve_sync("agent", "some_tool", {})
assert decision.approved is True
def test_approve_sync_with_auto_backend(self):
from praisonaiagents.approval.registry import ApprovalRegistry
from praisonaiagents.approval.backends import AutoApproveBackend
reg = ApprovalRegistry()
reg.set_backend(AutoApproveBackend())
decision = reg.approve_sync("agent", "execute_command", {"cmd": "ls"})
assert decision.approved is True
def test_approve_sync_env_auto_approve(self):
from praisonaiagents.approval.registry import ApprovalRegistry
reg = ApprovalRegistry()
with patch.dict(os.environ, {"PRAISONAI_AUTO_APPROVE": "true"}):
decision = reg.approve_sync("agent", "execute_command", {})
assert decision.approved is True
def test_approve_async_with_auto_backend(self):
from praisonaiagents.approval.registry import ApprovalRegistry
from praisonaiagents.approval.backends import AutoApproveBackend
reg = ApprovalRegistry()
reg.set_backend(AutoApproveBackend())
decision = asyncio.run(
reg.approve_async("agent", "execute_command", {})
)
assert decision.approved is True
def test_mark_and_check_approved(self):
from praisonaiagents.approval.registry import ApprovalRegistry
reg = ApprovalRegistry()
assert not reg.is_already_approved("execute_command")
reg.mark_approved("execute_command")
assert reg.is_already_approved("execute_command")
def test_already_approved_skips_backend(self):
"""Once approved, no backend call needed."""
from praisonaiagents.approval.registry import ApprovalRegistry
reg = ApprovalRegistry()
reg.mark_approved("execute_command")
decision = reg.approve_sync("agent", "execute_command", {})
assert decision.approved is True
assert "already" in decision.reason.lower()
def test_yaml_approved_skips_backend(self):
from praisonaiagents.approval.registry import ApprovalRegistry
reg = ApprovalRegistry()
token = reg.set_yaml_approved_tools(["execute_command"])
decision = reg.approve_sync("agent", "execute_command", {})
assert decision.approved is True
reg.reset_yaml_approved_tools(token)
def test_remove_backend(self):
from praisonaiagents.approval.registry import ApprovalRegistry
from praisonaiagents.approval.backends import AutoApproveBackend, ConsoleBackend
reg = ApprovalRegistry()
reg.set_backend(AutoApproveBackend(), agent_name="bot")
reg.remove_backend(agent_name="bot")
assert isinstance(reg.get_backend("bot"), ConsoleBackend)
# ── Backward compatibility tests ─────────────────────────────────────────────
class TestBackwardCompat:
"""Ensure old approval.py public API still works from the same import path."""
def test_import_approval_decision(self):
from praisonaiagents.approval import ApprovalDecision
d = ApprovalDecision(approved=True)
assert d.approved
def test_import_set_approval_callback(self):
from praisonaiagents.approval import set_approval_callback, get_approval_callback
assert callable(set_approval_callback)
def test_import_require_approval(self):
from praisonaiagents.approval import require_approval
assert callable(require_approval)
def test_import_console_approval_callback(self):
from praisonaiagents.approval import console_approval_callback
assert callable(console_approval_callback)
def test_import_request_approval(self):
from praisonaiagents.approval import request_approval
assert asyncio.iscoroutinefunction(request_approval)
def test_import_is_approval_required(self):
from praisonaiagents.approval import is_approval_required
assert callable(is_approval_required)
def test_import_default_dangerous_tools(self):
from praisonaiagents.approval import DEFAULT_DANGEROUS_TOOLS
assert "execute_command" in DEFAULT_DANGEROUS_TOOLS
def test_import_permission_allowlist(self):
from praisonaiagents.approval import PermissionAllowlist
al = PermissionAllowlist()
assert al.is_empty()
def test_import_new_protocol(self):
from praisonaiagents.approval import ApprovalProtocol, ApprovalRequest
assert ApprovalProtocol is not None
assert ApprovalRequest is not None
def test_import_new_backends(self):
from praisonaiagents.approval import AutoApproveBackend, ConsoleBackend
assert AutoApproveBackend is not None
assert ConsoleBackend is not None
def test_import_registry(self):
from praisonaiagents.approval import get_approval_registry
reg = get_approval_registry()
assert reg is not None
def test_set_callback_routes_to_registry(self):
"""set_approval_callback should still work by wrapping into a backend."""
from praisonaiagents.approval import set_approval_callback, get_approval_registry
from praisonaiagents.approval.protocols import ApprovalDecision as AD
def my_callback(fn, args, risk):
return AD(approved=True, reason="custom")
set_approval_callback(my_callback)
# Verify the registry uses this callback
reg = get_approval_registry()
# The global callback backend should be set
backend = reg.get_backend()
# Clean up
set_approval_callback(None)
# ── Global registry singleton tests ──────────────────────────────────────────
class TestGlobalRegistry:
def test_singleton(self):
from praisonaiagents.approval import get_approval_registry
r1 = get_approval_registry()
r2 = get_approval_registry()
assert r1 is r2
def test_default_dangerous_tools_registered(self):
from praisonaiagents.approval import get_approval_registry
reg = get_approval_registry()
assert "execute_command" in reg._required_tools
assert reg._risk_levels.get("execute_command") == "critical"
# ── Agent(approval=...) parameter tests ─────────────────────────────────────
class TestAgentApprovalParam:
"""Tests for the agent-centric approval= constructor parameter."""
def test_approval_none_default(self):
"""Agent() with no approval param has no _approval_backend."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert agent._approval_backend is None
def test_approval_true_sets_auto_approve(self):
"""Agent(approval=True) sets AutoApproveBackend."""
from praisonaiagents import Agent
from praisonaiagents.approval.backends import AutoApproveBackend
agent = Agent(name="test", instructions="test", approval=True)
assert isinstance(agent._approval_backend, AutoApproveBackend)
def test_approval_false_sets_none(self):
"""Agent(approval=False) means no per-agent backend (use registry fallback)."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval=False)
assert agent._approval_backend is None
def test_approval_custom_backend(self):
"""Agent(approval=MyBackend()) stores the custom backend."""
from praisonaiagents import Agent
from praisonaiagents.approval.backends import AutoApproveBackend
backend = AutoApproveBackend()
agent = Agent(name="test", instructions="test", approval=backend)
assert agent._approval_backend is backend
def test_approval_true_auto_approves_tool(self):
"""Agent(approval=True) should auto-approve dangerous tools in _execute_tool_impl."""
from praisonaiagents import Agent
from praisonaiagents.approval.backends import AutoApproveBackend
def my_tool(cmd: str) -> str:
"""Execute a command."""
return f"ran: {cmd}"
agent = Agent(name="test", instructions="test", tools=[my_tool], approval=True)
assert isinstance(agent._approval_backend, AutoApproveBackend)
def test_approval_custom_protocol_backend(self):
"""Any object with request_approval / request_approval_sync works."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
class MyWebhookBackend:
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="webhook")
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="webhook")
backend = MyWebhookBackend()
agent = Agent(name="test", instructions="test", approval=backend)
assert agent._approval_backend is backend
def test_approval_backend_used_in_sync_tool_exec(self):
"""_execute_tool_impl uses self._approval_backend when set."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
call_log = []
class TrackingBackend:
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
call_log.append(request.tool_name)
return ApprovalDecision(approved=True, reason="tracked")
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="tracked")
def execute_command(cmd: str) -> str:
"""Execute a command."""
return f"ran: {cmd}"
agent = Agent(name="test", instructions="test", tools=[execute_command], approval=TrackingBackend())
result = agent._execute_tool_impl("execute_command", {"cmd": "ls"})
assert "execute_command" in call_log
assert result == "ran: ls"
def test_approval_backend_denial_blocks_tool(self):
"""If per-agent backend denies, tool is blocked."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
class DenyBackend:
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=False, reason="denied by policy")
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=False, reason="denied by policy")
def execute_command(cmd: str) -> str:
"""Execute a command."""
return f"ran: {cmd}"
agent = Agent(name="test", instructions="test", tools=[execute_command], approval=DenyBackend())
result = agent._execute_tool_impl("execute_command", {"cmd": "rm -rf /"})
assert isinstance(result, dict)
assert result.get("approval_denied") is True
def test_approval_async_backend_used(self):
"""execute_tool_async uses self._approval_backend when set."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
call_log = []
class AsyncTracker:
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
call_log.append(request.tool_name)
return ApprovalDecision(approved=True, reason="async-tracked")
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="async-tracked")
def execute_command(cmd: str) -> str:
"""Execute a command."""
return f"ran: {cmd}"
agent = Agent(name="test", instructions="test", tools=[execute_command], approval=AsyncTracker())
async def _run():
return await agent.execute_tool_async("execute_command", {"cmd": "ls"})
result = asyncio.run(_run())
assert "execute_command" in call_log
@@ -0,0 +1,566 @@
"""
Comprehensive unit tests for OpenClaw gap analysis features.
Tests:
- ProbeResult / HealthResult dataclasses
- BotProtocol probe()/health() methods
- BackoffPolicy / compute_backoff / is_recoverable_error (resilience)
- BotConfig group_policy
- Config schema validation (Pydantic)
- Smart defaults for Bot
- Doctor checks
- Daemon service generation
- BotApprovalBackend
- Onboarding wizard config generation
"""
import asyncio
import os
import sys
from unittest.mock import MagicMock
import pytest
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 1. ProbeResult / HealthResult (Core SDK)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestProbeResult:
def test_import(self):
from praisonaiagents.bots import ProbeResult
assert ProbeResult is not None
def test_ok_probe(self):
from praisonaiagents.bots import ProbeResult
result = ProbeResult(ok=True, platform="telegram", elapsed_ms=150.0, bot_username="test_bot")
assert result.ok is True
assert result.platform == "telegram"
assert result.bot_username == "test_bot"
assert result.error is None
def test_failed_probe(self):
from praisonaiagents.bots import ProbeResult
result = ProbeResult(ok=False, platform="discord", error="Unauthorized")
assert result.ok is False
assert result.error == "Unauthorized"
def test_to_dict(self):
from praisonaiagents.bots import ProbeResult
result = ProbeResult(ok=True, platform="slack", elapsed_ms=50.0)
d = result.to_dict()
assert d["ok"] is True
assert d["platform"] == "slack"
assert d["elapsed_ms"] == 50.0
def test_details(self):
from praisonaiagents.bots import ProbeResult
result = ProbeResult(ok=True, platform="telegram", details={"bot_id": 123})
assert result.details["bot_id"] == 123
class TestHealthResult:
def test_import(self):
from praisonaiagents.bots import HealthResult
assert HealthResult is not None
def test_healthy(self):
from praisonaiagents.bots import HealthResult, ProbeResult
probe = ProbeResult(ok=True, platform="telegram")
health = HealthResult(
ok=True, platform="telegram", is_running=True,
uptime_seconds=3600.0, probe=probe, sessions=5,
)
assert health.ok is True
assert health.is_running is True
assert health.uptime_seconds == 3600.0
assert health.sessions == 5
assert health.probe.ok is True
def test_unhealthy(self):
from praisonaiagents.bots import HealthResult
health = HealthResult(ok=False, platform="discord", error="Connection refused")
assert health.ok is False
assert health.error == "Connection refused"
def test_to_dict_with_probe(self):
from praisonaiagents.bots import HealthResult, ProbeResult
probe = ProbeResult(ok=True, platform="telegram", elapsed_ms=100.0)
health = HealthResult(ok=True, platform="telegram", probe=probe)
d = health.to_dict()
assert d["probe"]["ok"] is True
assert d["probe"]["elapsed_ms"] == 100.0
def test_to_dict_without_probe(self):
from praisonaiagents.bots import HealthResult
health = HealthResult(ok=True, platform="slack")
d = health.to_dict()
assert d["probe"] is None
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 2. BotProtocol has probe() and health()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotProtocolExtensions:
def test_protocol_has_probe(self):
from praisonaiagents.bots import BotProtocol
assert hasattr(BotProtocol, 'probe')
def test_protocol_has_health(self):
from praisonaiagents.bots import BotProtocol
assert hasattr(BotProtocol, 'health')
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3. Resilience Module
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBackoffPolicy:
def test_import(self):
from praisonai.bots._resilience import BackoffPolicy
assert BackoffPolicy is not None
def test_defaults(self):
from praisonai.bots._resilience import BackoffPolicy
policy = BackoffPolicy()
assert policy.initial_ms == 2000.0
assert policy.max_ms == 30000.0
assert policy.factor == 1.8
def test_telegram_preset(self):
from praisonai.bots._resilience import TELEGRAM_BACKOFF
assert TELEGRAM_BACKOFF.initial_ms == 2000
assert TELEGRAM_BACKOFF.jitter == 0.25
class TestComputeBackoff:
def test_first_attempt(self):
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
policy = BackoffPolicy(initial_ms=1000, factor=2.0, jitter=0.0)
delay = compute_backoff(policy, 1)
assert abs(delay - 1.0) < 0.01 # 1000ms = 1.0s
def test_second_attempt(self):
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
policy = BackoffPolicy(initial_ms=1000, factor=2.0, jitter=0.0)
delay = compute_backoff(policy, 2)
assert abs(delay - 2.0) < 0.01 # 2000ms = 2.0s
def test_capped_at_max(self):
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
policy = BackoffPolicy(initial_ms=1000, max_ms=5000, factor=10.0, jitter=0.0)
delay = compute_backoff(policy, 3)
assert delay <= 5.0 # Capped at max
def test_jitter_applied(self):
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
policy = BackoffPolicy(initial_ms=1000, factor=1.0, jitter=0.5)
delays = [compute_backoff(policy, 1) for _ in range(20)]
# With jitter, not all delays should be identical
assert len(set(round(d, 3) for d in delays)) > 1
class TestIsRecoverableError:
def test_timeout_is_recoverable(self):
from praisonai.bots._resilience import is_recoverable_error
assert is_recoverable_error(TimeoutError("connection timed out")) is True
def test_connection_error_is_recoverable(self):
from praisonai.bots._resilience import is_recoverable_error
assert is_recoverable_error(ConnectionError("reset by peer")) is True
def test_value_error_not_recoverable(self):
from praisonai.bots._resilience import is_recoverable_error
assert is_recoverable_error(ValueError("invalid input")) is False
def test_asyncio_timeout(self):
from praisonai.bots._resilience import is_recoverable_error
assert is_recoverable_error(asyncio.TimeoutError()) is True
def test_http_429_recoverable(self):
from praisonai.bots._resilience import is_recoverable_error
err = Exception("rate limited")
err.status_code = 429
assert is_recoverable_error(err) is True
class TestIsConflictError:
def test_409_conflict(self):
from praisonai.bots._resilience import is_conflict_error
err = Exception("Conflict: terminated by other getUpdates")
err.error_code = 409
assert is_conflict_error(err) is True
def test_non_conflict(self):
from praisonai.bots._resilience import is_conflict_error
assert is_conflict_error(ValueError("something")) is False
class TestConnectionMonitor:
def test_record_success_resets(self):
from praisonai.bots._resilience import ConnectionMonitor
m = ConnectionMonitor(platform="test")
m.attempt = 5
m.last_error = "something"
m.record_success()
assert m.attempt == 0
assert m.last_error is None
assert m.total_recoveries == 1
def test_record_error_increments(self):
from praisonai.bots._resilience import ConnectionMonitor, BackoffPolicy
m = ConnectionMonitor(platform="test", policy=BackoffPolicy(jitter=0.0, initial_ms=1000, factor=2.0))
delay = m.record_error(Exception("test"))
assert m.attempt == 1
assert abs(delay - 1.0) < 0.01
def test_should_retry_unlimited(self):
from praisonai.bots._resilience import ConnectionMonitor, BackoffPolicy
m = ConnectionMonitor(platform="test", policy=BackoffPolicy(max_attempts=0))
m.attempt = 100
assert m.should_retry() is True
def test_should_retry_limited(self):
from praisonai.bots._resilience import ConnectionMonitor, BackoffPolicy
m = ConnectionMonitor(platform="test", policy=BackoffPolicy(max_attempts=3))
m.attempt = 3
assert m.should_retry() is False
class TestSleepWithAbort:
@pytest.mark.asyncio
async def test_normal_completion(self):
from praisonai.bots._resilience import sleep_with_abort
result = await sleep_with_abort(0.01)
assert result is True
@pytest.mark.asyncio
async def test_aborted(self):
from praisonai.bots._resilience import sleep_with_abort
abort = asyncio.Event()
abort.set() # Already signaled
result = await sleep_with_abort(10.0, abort)
assert result is False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. BotConfig group_policy
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotConfigGroupPolicy:
def test_default_group_policy(self):
from praisonaiagents.bots import BotConfig
config = BotConfig()
assert config.group_policy == "mention_only"
def test_custom_group_policy(self):
from praisonaiagents.bots import BotConfig
config = BotConfig(group_policy="respond_all")
assert config.group_policy == "respond_all"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 5. Config Schema Validation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestConfigSchema:
def test_valid_config(self):
from praisonai.bots._config_schema import validate_bot_config
raw = {
"agent": {"name": "test", "model": "gpt-4o-mini"},
"channels": {"telegram": {"token": "test123"}},
}
result = validate_bot_config(raw)
assert result.channels["telegram"].token == "test123"
def test_missing_channels_fails(self):
from praisonai.bots._config_schema import validate_bot_config
with pytest.raises(ValueError, match="No channels configured"):
validate_bot_config({"agent": {"name": "test"}})
def test_empty_channels_fails(self):
from praisonai.bots._config_schema import validate_bot_config
with pytest.raises(ValueError, match="No channels configured"):
validate_bot_config({"channels": {}})
def test_unknown_channel_fails(self):
from praisonai.bots._config_schema import validate_bot_config
with pytest.raises(ValueError, match="Unknown channel"):
validate_bot_config({"channels": {"signal": {"token": "x"}}})
def test_invalid_group_policy(self):
from praisonai.bots._config_schema import validate_bot_config
with pytest.raises(ValueError, match="group_policy"):
validate_bot_config({
"channels": {"telegram": {"token": "x", "group_policy": "invalid"}}
})
def test_env_var_resolution(self):
from praisonai.bots._config_schema import validate_bot_config
os.environ["_TEST_BOT_TOKEN"] = "resolved_token"
try:
raw = {"channels": {"telegram": {"token": "${_TEST_BOT_TOKEN}"}}}
result = validate_bot_config(raw)
assert result.channels["telegram"].token == "resolved_token"
finally:
del os.environ["_TEST_BOT_TOKEN"]
def test_missing_env_var_fails(self):
from praisonai.bots._config_schema import validate_bot_config
os.environ.pop("_NONEXISTENT_TOKEN", None)
with pytest.raises(ValueError, match="not set"):
validate_bot_config({"channels": {"telegram": {"token": "${_NONEXISTENT_TOKEN}"}}})
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 6. Smart Defaults for Bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestSmartDefaults:
def test_bot_has_apply_smart_defaults(self):
from praisonai.bots import Bot
bot = Bot("telegram", token="fake")
assert hasattr(bot, '_apply_smart_defaults')
def test_smart_defaults_adds_tools_to_bare_agent(self):
from praisonai.bots import Bot
mock_agent = MagicMock()
mock_agent.__class__.__name__ = "Agent"
mock_agent.tools = []
bot = Bot("telegram", agent=mock_agent, token="fake")
result = bot._apply_smart_defaults(mock_agent)
# Should have assigned tools
assert result.tools is not None or mock_agent.tools is not None
def test_smart_defaults_skips_agent_with_tools(self):
from praisonai.bots import Bot
mock_agent = MagicMock()
mock_agent.__class__.__name__ = "Agent"
mock_agent.tools = [lambda: None] # Already has tools
bot = Bot("telegram", agent=mock_agent, token="fake")
result = bot._apply_smart_defaults(mock_agent)
# Should NOT overwrite existing tools
assert len(result.tools) == 1
def test_smart_defaults_skips_none_agent(self):
from praisonai.bots import Bot
bot = Bot("telegram", token="fake")
result = bot._apply_smart_defaults(None)
assert result is None
def test_smart_defaults_skips_non_agent(self):
from praisonai.bots import Bot
mock_team = MagicMock()
mock_team.__class__.__name__ = "AgentTeam"
mock_team.tools = []
bot = Bot("telegram", agent=mock_team, token="fake")
result = bot._apply_smart_defaults(mock_team)
# Should not modify AgentTeam
assert len(result.tools) == 0
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 7. Doctor Bot Checks (integrated into existing doctor framework)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestDoctorBotChecks:
def test_bot_checks_import(self):
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_tokens, check_bot_config
assert callable(check_bot_tokens)
assert callable(check_bot_config)
def test_check_category_has_bots(self):
from praisonai.cli.features.doctor.models import CheckCategory
assert hasattr(CheckCategory, "BOTS")
assert CheckCategory.BOTS.value == "bots"
def test_bot_tokens_check_no_tokens(self):
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_tokens
from praisonai.cli.features.doctor.models import CheckStatus
for var in ["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "WHATSAPP_ACCESS_TOKEN"]:
os.environ.pop(var, None)
result = check_bot_tokens()
assert result.status == CheckStatus.WARN
def test_bot_tokens_check_with_token(self):
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_tokens
from praisonai.cli.features.doctor.models import CheckStatus
os.environ["TELEGRAM_BOT_TOKEN"] = "test123"
try:
result = check_bot_tokens()
assert result.status == CheckStatus.PASS
assert "Telegram" in result.message
finally:
del os.environ["TELEGRAM_BOT_TOKEN"]
def test_bot_config_missing(self):
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_config
from praisonai.cli.features.doctor.models import CheckStatus
result = check_bot_config("/nonexistent/bot.yaml")
assert result.status == CheckStatus.WARN
def test_get_bot_checks(self):
from praisonai.cli.features.doctor.checks.bot_checks import get_bot_checks
checks = get_bot_checks()
assert len(checks) >= 2
assert all(hasattr(c, 'status') for c in checks)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 8. Bot Approval Backend
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotApprovalBackend:
def test_import(self):
from praisonai.bots._approval import BotApprovalBackend
assert BotApprovalBackend is not None
def test_init(self):
from praisonai.bots._approval import BotApprovalBackend
backend = BotApprovalBackend(timeout=60)
assert backend._timeout == 60
assert backend.pending_count == 0
@pytest.mark.asyncio
async def test_timeout_defaults_to_deny(self):
from praisonai.bots._approval import BotApprovalBackend
backend = BotApprovalBackend(timeout=0.01, default_on_timeout=False)
result = await backend.request_approval("user1", "ch1", "test_tool", {})
assert result is False
def test_resolve_by_user_reply_yes(self):
from praisonai.bots._approval import BotApprovalBackend
backend = BotApprovalBackend()
loop = asyncio.new_event_loop()
future = loop.create_future()
backend._pending["approval_user1_123"] = future
resolved = backend.resolve_by_user_reply("user1", "yes")
assert resolved is True
assert future.result() is True
loop.close()
def test_resolve_by_user_reply_no(self):
from praisonai.bots._approval import BotApprovalBackend
backend = BotApprovalBackend()
loop = asyncio.new_event_loop()
future = loop.create_future()
backend._pending["approval_user1_456"] = future
resolved = backend.resolve_by_user_reply("user1", "no")
assert resolved is True
assert future.result() is False
loop.close()
def test_resolve_unknown_text(self):
from praisonai.bots._approval import BotApprovalBackend
backend = BotApprovalBackend()
resolved = backend.resolve_by_user_reply("user1", "hello world")
assert resolved is False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 9. Daemon Service
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestDaemonService:
def test_daemon_import(self):
from praisonai.daemon import get_daemon_status, install_daemon, uninstall_daemon
assert callable(get_daemon_status)
assert callable(install_daemon)
assert callable(uninstall_daemon)
def test_systemd_unit_generation(self):
from praisonai.daemon.systemd import _generate_unit
unit = _generate_unit("/tmp/test_bot.yaml")
assert "[Unit]" in unit
assert "[Service]" in unit
assert "praisonai" in unit
assert "/tmp/test_bot.yaml" in unit
assert "Restart=always" in unit
def test_launchd_plist_generation(self):
from praisonai.daemon.launchd import _generate_plist
plist = _generate_plist("/tmp/test_bot.yaml")
assert "<?xml" in plist
assert "ai.praison.bot" in plist
assert "/tmp/test_bot.yaml" in plist
assert "KeepAlive" in plist
def test_detect_platform(self):
from praisonai.daemon import _detect_platform
plat = _detect_platform()
assert plat in ("systemd", "launchd", "windows", "unknown")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 10. Onboarding Wizard Config Generation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestOnboardWizard:
def test_import(self):
from praisonai.cli.features.onboard import OnboardWizard
assert OnboardWizard is not None
def test_generate_bot_yaml(self):
from praisonai.cli.features.onboard import _generate_bot_yaml
yaml_str = _generate_bot_yaml(["telegram"], agent_name="mybot", agent_instructions="Be helpful")
assert "telegram:" in yaml_str
assert "mybot" in yaml_str
assert "TELEGRAM_BOT_TOKEN" in yaml_str
assert "mention_only" in yaml_str
def test_generate_multi_platform(self):
from praisonai.cli.features.onboard import _generate_bot_yaml
yaml_str = _generate_bot_yaml(["telegram", "discord"])
assert "telegram:" in yaml_str
assert "discord:" in yaml_str
def test_wizard_init(self):
from praisonai.cli.features.onboard import OnboardWizard
wizard = OnboardWizard()
assert wizard.selected_platforms == []
assert wizard.agent_name == "assistant"
def test_platforms_info(self):
from praisonai.cli.features.onboard import PLATFORMS
assert "telegram" in PLATFORMS
assert "discord" in PLATFORMS
assert "slack" in PLATFORMS
assert "whatsapp" in PLATFORMS
assert all("token_env" in v for v in PLATFORMS.values())
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 11. Bot probe() and health() delegation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotDelegation:
def test_bot_has_probe(self):
from praisonai.bots import Bot
bot = Bot("telegram", token="fake")
assert hasattr(bot, 'probe')
def test_bot_has_health(self):
from praisonai.bots import Bot
bot = Bot("telegram", token="fake")
assert hasattr(bot, 'health')
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 12. Import Time Regression
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestImportTime:
def test_core_sdk_import_time(self):
"""Core SDK import must be < 200ms."""
import subprocess
result = subprocess.run(
[sys.executable, "-c",
"import time; t=time.perf_counter(); import praisonaiagents; print(f'{(time.perf_counter()-t)*1000:.0f}')"],
capture_output=True, text=True, timeout=10,
)
ms = int(result.stdout.strip())
assert ms < 500, f"Import took {ms}ms, target <200ms"
@@ -0,0 +1,220 @@
"""
Tests for MESSAGE_RECEIVED / MESSAGE_SENDING / MESSAGE_SENT hook firing in bot adapters.
These hooks exist in HookEvent enum but were never fired by bot implementations.
This test suite verifies the MessageHookMixin wires them correctly.
"""
import time
from praisonaiagents.hooks.types import HookEvent
from praisonaiagents.bots import BotMessage, BotUser, BotChannel
# ---------------------------------------------------------------------------
# Minimal stub for testing the mixin in isolation
# ---------------------------------------------------------------------------
class _StubBot:
"""Minimal bot stub that uses MessageHookMixin."""
def __init__(self, agent=None):
self._agent = agent
self._hook_registry = None # set by tests
self._hook_runner = None
# ---------------------------------------------------------------------------
# Test: MessageHookMixin core logic
# ---------------------------------------------------------------------------
class TestMessageHookMixin:
"""Test the shared MessageHookMixin that all bots use."""
def test_import_mixin(self):
"""MessageHookMixin should be importable from _protocol_mixin."""
from praisonai.bots._protocol_mixin import MessageHookMixin
assert MessageHookMixin is not None
def test_mixin_has_fire_methods(self):
"""Mixin should provide fire_message_received, fire_message_sending, fire_message_sent."""
from praisonai.bots._protocol_mixin import MessageHookMixin
assert hasattr(MessageHookMixin, 'fire_message_received')
assert hasattr(MessageHookMixin, 'fire_message_sending')
assert hasattr(MessageHookMixin, 'fire_message_sent')
def test_fire_message_received_creates_correct_event(self):
"""fire_message_received should create a HookInput with MESSAGE_RECEIVED event."""
from praisonai.bots._protocol_mixin import MessageHookMixin
class TestBot(MessageHookMixin):
def __init__(self):
self._agent = None
self.platform = "test"
bot = TestBot()
msg = BotMessage(
message_id="123",
content="Hello",
sender=BotUser(user_id="user1", username="alice"),
channel=BotChannel(channel_id="ch1", channel_type="dm"),
)
# Without a hook registry, fire should be a no-op (no crash)
bot.fire_message_received(msg)
def test_fire_message_sending_returns_modified_content(self):
"""fire_message_sending should allow hooks to modify content."""
from praisonai.bots._protocol_mixin import MessageHookMixin
class TestBot(MessageHookMixin):
def __init__(self):
self._agent = None
self.platform = "test"
bot = TestBot()
# Without hooks, should return original content unchanged
result = bot.fire_message_sending("ch1", "Hello world")
assert result is not None
assert result["content"] == "Hello world"
assert result["cancel"] is False
def test_fire_message_sending_cancel(self):
"""fire_message_sending with deny hook should return cancel=True."""
from praisonai.bots._protocol_mixin import MessageHookMixin
from praisonaiagents.hooks import HookRegistry, HookRunner
registry = HookRegistry()
runner = HookRunner(registry=registry)
# Register a function hook that returns False (= deny)
registry.register_function(
event=HookEvent.MESSAGE_SENDING,
func=lambda input_data: False, # False = deny
name="cancel_hook",
)
# Mock agent with _hook_runner
class MockAgent:
agent_name = "test_agent"
_hook_runner = runner
class TestBot(MessageHookMixin):
def __init__(self):
self._agent = MockAgent()
self.platform = "test"
bot = TestBot()
result = bot.fire_message_sending("ch1", "spam message")
assert result["cancel"] is True
def test_fire_message_sending_allow(self):
"""fire_message_sending with allow hook should return cancel=False."""
from praisonai.bots._protocol_mixin import MessageHookMixin
from praisonaiagents.hooks import HookRegistry, HookRunner
registry = HookRegistry()
runner = HookRunner(registry=registry)
# Register a function hook that returns True (= allow)
registry.register_function(
event=HookEvent.MESSAGE_SENDING,
func=lambda input_data: True, # True = allow
name="allow_hook",
)
class MockAgent:
agent_name = "test_agent"
_hook_runner = runner
class TestBot(MessageHookMixin):
def __init__(self):
self._agent = MockAgent()
self.platform = "test"
bot = TestBot()
result = bot.fire_message_sending("ch1", "Hello")
assert result["cancel"] is False
assert result["content"] == "Hello"
def test_fire_message_sent_no_crash(self):
"""fire_message_sent should not crash even without hooks."""
from praisonai.bots._protocol_mixin import MessageHookMixin
class TestBot(MessageHookMixin):
def __init__(self):
self._agent = None
self.platform = "test"
bot = TestBot()
# Should be a no-op, no exception
bot.fire_message_sent("ch1", "Hello", message_id="msg1")
class TestMessageHookEventInputs:
"""Test that message hook event input dataclasses exist and work."""
def test_message_received_input_exists(self):
"""MessageReceivedInput should be importable from hooks.events."""
from praisonaiagents.hooks.events import MessageReceivedInput
inp = MessageReceivedInput(
session_id="s1",
cwd="/tmp",
event_name=HookEvent.MESSAGE_RECEIVED,
timestamp=str(time.time()),
agent_name="bot",
platform="telegram",
content="Hello",
sender_id="user1",
channel_id="ch1",
)
assert inp.platform == "telegram"
assert inp.content == "Hello"
d = inp.to_dict()
assert d["platform"] == "telegram"
def test_message_sending_input_exists(self):
"""MessageSendingInput should be importable from hooks.events."""
from praisonaiagents.hooks.events import MessageSendingInput
inp = MessageSendingInput(
session_id="s1",
cwd="/tmp",
event_name=HookEvent.MESSAGE_SENDING,
timestamp=str(time.time()),
agent_name="bot",
platform="telegram",
content="Reply",
channel_id="ch1",
)
assert inp.content == "Reply"
def test_message_sent_input_exists(self):
"""MessageSentInput should be importable from hooks.events."""
from praisonaiagents.hooks.events import MessageSentInput
inp = MessageSentInput(
session_id="s1",
cwd="/tmp",
event_name=HookEvent.MESSAGE_SENT,
timestamp=str(time.time()),
agent_name="bot",
platform="telegram",
content="Reply",
channel_id="ch1",
message_id="msg1",
)
assert inp.message_id == "msg1"
class TestGatewayStreamRelay:
"""Test that gateway server relays StreamEvents to WebSocket clients."""
def test_process_agent_message_streams_tokens(self):
"""_process_agent_message should wire stream_emitter to send token events to WS client."""
# This test verifies the contract: when an agent streams tokens,
# the gateway relays them as WS messages to the connected client.
from praisonaiagents.gateway import EventType
# Verify the event types exist for streaming relay
assert hasattr(EventType, 'TOKEN_STREAM') or hasattr(EventType, 'AGENT_RESPONSE')
# The actual streaming relay is tested via integration tests
@@ -0,0 +1,585 @@
"""
Integration, smoke, and real-agent tests for BotOS architecture.
Covers:
- Bot with mock adapter lifecycle
- BotOS multi-bot orchestration
- Agent alone in Bot
- AgentTeam inside Bot/BotOS
- AgentFlow inside Bot/BotOS
- Platform extensibility
- from_config() YAML loader
- run() sync wrapper
- remove_bot()
- Custom platform registration + resolution
"""
import asyncio
import os
import tempfile
import pytest
from unittest.mock import MagicMock
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Helper: Mock adapter that simulates a platform bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class MockPlatformAdapter:
"""Simulates a platform bot adapter for testing."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self.token = kwargs.get("token", "")
self.agent = kwargs.get("agent")
self.started = False
self.stopped = False
self.messages_sent = []
async def start(self):
self.started = True
async def stop(self):
self.stopped = True
@property
def is_running(self):
return self.started and not self.stopped
async def send_message(self, channel_id, content, **kwargs):
self.messages_sent.append({"channel": channel_id, "content": content})
return True
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 1. Integration: Bot with mock adapter
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotWithMockAdapter:
"""Integration tests for Bot class with a mock platform adapter."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
@pytest.mark.asyncio
async def test_bot_start_creates_adapter(self):
from praisonai.bots import Bot
bot = Bot("mockbot", token="test-token")
await bot.start()
assert bot.adapter is not None
assert isinstance(bot.adapter, MockPlatformAdapter)
assert bot.adapter.started is True
@pytest.mark.asyncio
async def test_bot_stop(self):
from praisonai.bots import Bot
bot = Bot("mockbot", token="test-token")
await bot.start()
await bot.stop()
assert bot.adapter.stopped is True
assert bot.is_running is False
@pytest.mark.asyncio
async def test_bot_passes_token_to_adapter(self):
from praisonai.bots import Bot
bot = Bot("mockbot", token="my-secret-token")
await bot.start()
assert bot.adapter.token == "my-secret-token"
@pytest.mark.asyncio
async def test_bot_passes_agent_to_adapter(self):
from praisonai.bots import Bot
agent = MagicMock()
agent.name = "test-agent"
bot = Bot("mockbot", agent=agent, token="t")
await bot.start()
assert bot.adapter.agent is agent
@pytest.mark.asyncio
async def test_bot_send_message(self):
from praisonai.bots import Bot
bot = Bot("mockbot", token="t")
await bot.start()
result = await bot.send_message("channel-1", "Hello!")
assert result is True
assert len(bot.adapter.messages_sent) == 1
assert bot.adapter.messages_sent[0]["content"] == "Hello!"
@pytest.mark.asyncio
async def test_bot_send_message_before_start_raises(self):
from praisonai.bots import Bot
bot = Bot("mockbot", token="t")
with pytest.raises(RuntimeError, match="not started"):
await bot.send_message("ch", "msg")
@pytest.mark.asyncio
async def test_bot_passes_kwargs_to_adapter(self):
from praisonai.bots import Bot
bot = Bot("mockbot", token="t", custom_param="hello")
await bot.start()
assert bot.adapter.kwargs.get("custom_param") == "hello"
@pytest.mark.asyncio
async def test_bot_repr(self):
from praisonai.bots import Bot
agent = MagicMock()
agent.name = "myagent"
bot = Bot("mockbot", agent=agent)
assert "mockbot" in repr(bot)
assert "myagent" in repr(bot)
def test_bot_has_run_method(self):
from praisonai.bots import Bot
bot = Bot("mockbot")
assert hasattr(bot, "run")
assert callable(bot.run)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 2. Integration: BotOS lifecycle
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotOSLifecycle:
"""Integration tests for BotOS multi-bot orchestration."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mock1", MockPlatformAdapter)
register_platform("mock2", MockPlatformAdapter)
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
def teardown_method(self):
self._cleanup()
@pytest.mark.asyncio
async def test_botos_starts_all_bots(self):
from praisonai.bots import BotOS, Bot
bot1 = Bot("mock1", token="t1")
bot2 = Bot("mock2", token="t2")
botos = BotOS(bots=[bot1, bot2])
# Start in background, stop quickly
start_task = asyncio.create_task(botos.start())
await asyncio.sleep(0.05)
await botos.stop()
start_task.cancel()
try:
await start_task
except asyncio.CancelledError:
pass
assert bot1.adapter.started is True
assert bot2.adapter.started is True
def test_botos_remove_bot(self):
from praisonai.bots import BotOS, Bot
botos = BotOS(bots=[Bot("mock1"), Bot("mock2")])
assert len(botos.list_bots()) == 2
result = botos.remove_bot("mock1")
assert result is True
assert len(botos.list_bots()) == 1
assert "mock2" in botos.list_bots()
def test_botos_remove_bot_not_found(self):
from praisonai.bots import BotOS
botos = BotOS()
assert botos.remove_bot("nonexistent") is False
def test_botos_has_run_method(self):
from praisonai.bots import BotOS
botos = BotOS()
assert hasattr(botos, "run")
assert callable(botos.run)
def test_botos_repr(self):
from praisonai.bots import BotOS, Bot
botos = BotOS(bots=[Bot("mock1"), Bot("mock2")])
r = repr(botos)
assert "mock1" in r
assert "mock2" in r
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3. Real Agent in Bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestRealAgentInBot:
"""Tests with a real praisonaiagents.Agent inside Bot."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
def test_real_agent_in_bot(self):
from praisonai.bots import Bot
from praisonaiagents import Agent
agent = Agent(name="helper", instructions="Be helpful")
bot = Bot("mockbot", agent=agent, token="t")
assert bot.agent is agent
assert bot.agent.name == "helper"
@pytest.mark.asyncio
async def test_real_agent_passed_to_adapter(self):
from praisonai.bots import Bot
from praisonaiagents import Agent
agent = Agent(name="helper", instructions="Be helpful")
bot = Bot("mockbot", agent=agent, token="t")
await bot.start()
assert bot.adapter.agent is agent
assert bot.adapter.agent.name == "helper"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. AgentTeam in Bot/BotOS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestAgentTeamInBotOS:
"""AgentTeam (multi-agent) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
def test_agent_team_in_bot(self):
from praisonai.bots import Bot
from praisonaiagents import Agent, AgentTeam, Task
researcher = Agent(name="researcher", instructions="Research topics")
writer = Agent(name="writer", instructions="Write content")
t1 = Task(name="research", description="Research AI", agent=researcher)
t2 = Task(name="write", description="Write about AI", agent=writer)
team = AgentTeam(agents=[researcher, writer], tasks=[t1, t2])
bot = Bot("mockbot", agent=team, token="t")
assert bot.agent is team
@pytest.mark.asyncio
async def test_agent_team_passed_to_adapter(self):
from praisonai.bots import Bot
from praisonaiagents import Agent, AgentTeam, Task
a1 = Agent(name="a1", instructions="Do task 1")
a2 = Agent(name="a2", instructions="Do task 2")
t1 = Task(name="t1", description="Task 1", agent=a1)
t2 = Task(name="t2", description="Task 2", agent=a2)
team = AgentTeam(agents=[a1, a2], tasks=[t1, t2])
bot = Bot("mockbot", agent=team, token="t")
await bot.start()
assert bot.adapter.agent is team
def test_agent_team_in_botos(self):
from praisonai.bots import BotOS, Bot
from praisonaiagents import Agent, AgentTeam, Task
a1 = Agent(name="a1", instructions="Task 1")
a2 = Agent(name="a2", instructions="Task 2")
t1 = Task(name="t1", description="T1", agent=a1)
t2 = Task(name="t2", description="T2", agent=a2)
team = AgentTeam(agents=[a1, a2], tasks=[t1, t2])
botos = BotOS(bots=[Bot("mockbot", agent=team, token="t")])
assert botos.get_bot("mockbot").agent is team
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 5. AgentFlow in Bot/BotOS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestAgentFlowInBotOS:
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
def test_agent_flow_in_bot(self):
from praisonai.bots import Bot
from praisonaiagents import Agent, AgentFlow, Task
a1 = Agent(name="step1", instructions="Do step 1")
a2 = Agent(name="step2", instructions="Do step 2")
t1 = Task(name="t1", description="Step 1", agent=a1)
t2 = Task(name="t2", description="Step 2", agent=a2)
flow = AgentFlow(steps=[t1, t2])
bot = Bot("mockbot", agent=flow, token="t")
assert bot.agent is flow
@pytest.mark.asyncio
async def test_agent_flow_passed_to_adapter(self):
from praisonai.bots import Bot
from praisonaiagents import Agent, AgentFlow, Task
a1 = Agent(name="s1", instructions="Step 1")
a2 = Agent(name="s2", instructions="Step 2")
t1 = Task(name="t1", description="S1", agent=a1)
t2 = Task(name="t2", description="S2", agent=a2)
flow = AgentFlow(steps=[t1, t2])
bot = Bot("mockbot", agent=flow, token="t")
await bot.start()
assert bot.adapter.agent is flow
def test_agent_flow_in_botos_multi_platform(self):
"""AgentFlow in BotOS across multiple platforms."""
from praisonai.bots import BotOS, Bot
from praisonaiagents import Agent, AgentFlow, Task
a1 = Agent(name="s1", instructions="Step 1")
t1 = Task(name="t1", description="S1", agent=a1)
flow = AgentFlow(steps=[t1])
botos = BotOS(bots=[
Bot("mockbot", agent=flow, token="t1"),
])
assert botos.get_bot("mockbot").agent is flow
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 6. Platform Extensibility
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestPlatformExtensibility:
"""Third-party platform registration and usage."""
def teardown_method(self):
from praisonai.bots._registry import _custom_platforms
_custom_platforms.pop("custom_chat", None)
_custom_platforms.pop("my_platform", None)
def test_register_and_use_custom_platform(self):
from praisonai.bots._registry import register_platform, resolve_adapter
register_platform("custom_chat", MockPlatformAdapter)
cls = resolve_adapter("custom_chat")
assert cls is MockPlatformAdapter
def test_custom_platform_in_list(self):
from praisonai.bots._registry import register_platform, list_platforms
register_platform("my_platform", MockPlatformAdapter)
assert "my_platform" in list_platforms()
@pytest.mark.asyncio
async def test_custom_platform_in_bot(self):
from praisonai.bots._registry import register_platform
from praisonai.bots import Bot
register_platform("custom_chat", MockPlatformAdapter)
bot = Bot("custom_chat", token="tok")
await bot.start()
assert bot.adapter.started is True
@pytest.mark.asyncio
async def test_custom_platform_in_botos(self):
from praisonai.bots._registry import register_platform
from praisonai.bots import BotOS, Bot
register_platform("custom_chat", MockPlatformAdapter)
botos = BotOS(bots=[Bot("custom_chat", token="tok")])
assert "custom_chat" in botos.list_bots()
def test_resolve_unknown_platform_raises(self):
from praisonai.bots._registry import resolve_adapter
with pytest.raises(ValueError, match="Unknown platform"):
resolve_adapter("nonexistent_xyz")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 7. from_config() YAML loader
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotOSFromConfig:
"""BotOS.from_config() YAML config loading."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
def teardown_method(self):
self._cleanup()
def test_from_config_basic(self):
from praisonai.bots import BotOS
yaml_content = """\
agent:
name: test-bot
instructions: Be helpful
platforms:
mockbot:
token: test-token-123
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f.flush()
path = f.name
try:
botos = BotOS.from_config(path)
assert "mockbot" in botos.list_bots()
bot = botos.get_bot("mockbot")
assert bot.token == "test-token-123"
assert bot.agent is not None
assert bot.agent.name == "test-bot"
finally:
os.unlink(path)
def test_from_config_env_var_resolution(self):
from praisonai.bots import BotOS
os.environ["TEST_BOTOS_TOKEN"] = "resolved-token"
yaml_content = """\
agent:
name: env-bot
instructions: Be helpful
platforms:
mockbot:
token: ${TEST_BOTOS_TOKEN}
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f.flush()
path = f.name
try:
botos = BotOS.from_config(path)
bot = botos.get_bot("mockbot")
assert bot.token == "resolved-token"
finally:
os.unlink(path)
del os.environ["TEST_BOTOS_TOKEN"]
def test_from_config_multi_platform(self):
from praisonai.bots import BotOS
from praisonai.bots._registry import register_platform, _custom_platforms
register_platform("mock2", MockPlatformAdapter)
yaml_content = """\
agent:
name: multi-bot
instructions: Handle multiple platforms
platforms:
mockbot:
token: tok1
mock2:
token: tok2
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f.flush()
path = f.name
try:
botos = BotOS.from_config(path)
assert len(botos.list_bots()) == 2
assert "mockbot" in botos.list_bots()
assert "mock2" in botos.list_bots()
finally:
os.unlink(path)
_custom_platforms.pop("mock2", None)
def test_from_config_invalid_raises(self):
from praisonai.bots import BotOS
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write("invalid yaml content that is just a string")
f.flush()
path = f.name
try:
with pytest.raises(ValueError, match="Invalid"):
BotOS.from_config(path)
finally:
os.unlink(path)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 8. Smoke Tests — import paths, hierarchy, class structure
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestSmoke:
"""Smoke tests for the BotOS architecture."""
def test_all_import_paths(self):
"""All classes importable from expected paths."""
from praisonaiagents.bots.protocols import BotOSProtocol
from praisonaiagents.bots.config import BotOSConfig
from praisonaiagents.bots import BotOSProtocol as P2
from praisonaiagents.bots import BotOSConfig as C2
from praisonaiagents import BotOSProtocol as P3
from praisonaiagents import BotOSConfig as C3
from praisonai.bots import Bot, BotOS
from praisonai.bots._registry import (
register_platform, list_platforms, resolve_adapter, get_platform_registry
)
assert all(x is not None for x in [
BotOSProtocol, BotOSConfig, P2, C2, P3, C3,
Bot, BotOS, register_platform, list_platforms,
resolve_adapter, get_platform_registry
])
def test_protocol_identity(self):
"""Same protocol from different paths."""
from praisonaiagents.bots.protocols import BotOSProtocol as A
from praisonaiagents import BotOSProtocol as B
assert A is B
def test_config_identity(self):
from praisonaiagents.bots.config import BotOSConfig as A
from praisonaiagents import BotOSConfig as B
assert A is B
def test_bot_class_hierarchy(self):
"""Bot hierarchy: BotOS > Bot > Agent."""
from praisonai.bots import Bot, BotOS
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
bot = Bot("telegram", agent=agent)
botos = BotOS(bots=[bot])
assert botos.get_bot("telegram") is bot
assert bot.agent is agent
def test_builtin_platforms_count(self):
from praisonai.bots._registry import list_platforms
platforms = list_platforms()
assert len(platforms) >= 4 # telegram, discord, slack, whatsapp
def test_no_heavy_imports_on_bot_creation(self):
"""Creating Bot should NOT import platform libraries."""
import sys
from praisonai.bots import Bot
Bot("telegram")
assert "telegram" not in sys.modules
assert "discord" not in sys.modules
def test_no_heavy_imports_on_botos_creation(self):
import sys
from praisonai.bots import BotOS, Bot
BotOS(bots=[Bot("telegram")])
assert "telegram" not in sys.modules
def test_botos_protocol_has_remove_bot(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert hasattr(BotOSProtocol, 'remove_bot')
def test_bot_has_run_method(self):
from praisonai.bots import Bot
assert hasattr(Bot, 'run')
def test_botos_has_run_method(self):
from praisonai.bots import BotOS
assert hasattr(BotOS, 'run')
def test_botos_has_from_config(self):
from praisonai.bots import BotOS
assert hasattr(BotOS, 'from_config')
assert callable(BotOS.from_config)
@@ -0,0 +1,282 @@
"""
TDD tests for BotOS protocol, config, and exports.
Tests written FIRST — implementation follows.
"""
import pytest
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 1. Core SDK Protocol & Config exports
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotOSProtocolExists:
"""BotOSProtocol must be importable from core SDK."""
def test_import_from_bots_protocols(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert BotOSProtocol is not None
def test_import_from_bots_package(self):
from praisonaiagents.bots import BotOSProtocol
assert BotOSProtocol is not None
def test_import_from_top_level(self):
from praisonaiagents import BotOSProtocol
assert BotOSProtocol is not None
def test_is_runtime_checkable(self):
from praisonaiagents.bots.protocols import BotOSProtocol
# runtime_checkable protocols support isinstance checks
assert hasattr(BotOSProtocol, '__protocol_attrs__') or hasattr(BotOSProtocol, '_is_runtime_protocol')
def test_protocol_has_start(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert hasattr(BotOSProtocol, 'start')
def test_protocol_has_stop(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert hasattr(BotOSProtocol, 'stop')
def test_protocol_has_add_bot(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert hasattr(BotOSProtocol, 'add_bot')
def test_protocol_has_list_bots(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert hasattr(BotOSProtocol, 'list_bots')
def test_protocol_has_is_running(self):
from praisonaiagents.bots.protocols import BotOSProtocol
assert 'is_running' in dir(BotOSProtocol)
class TestBotOSConfigExists:
"""BotOSConfig must be importable from core SDK."""
def test_import_from_bots_config(self):
from praisonaiagents.bots.config import BotOSConfig
assert BotOSConfig is not None
def test_import_from_bots_package(self):
from praisonaiagents.bots import BotOSConfig
assert BotOSConfig is not None
def test_import_from_top_level(self):
from praisonaiagents import BotOSConfig
assert BotOSConfig is not None
def test_default_values(self):
from praisonaiagents.bots.config import BotOSConfig
cfg = BotOSConfig()
assert cfg.name == "PraisonAI BotOS"
assert isinstance(cfg.platforms, dict)
def test_to_dict(self):
from praisonaiagents.bots.config import BotOSConfig
cfg = BotOSConfig(name="test")
d = cfg.to_dict()
assert d["name"] == "test"
assert "platforms" in d
def test_platforms_dict(self):
from praisonaiagents.bots.config import BotOSConfig
cfg = BotOSConfig(platforms={"telegram": {"token": "abc"}})
assert "telegram" in cfg.platforms
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 2. Wrapper: Bot class
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotClass:
"""Bot is the user-facing single-platform wrapper."""
def test_import_from_wrapper(self):
from praisonai.bots import Bot
assert Bot is not None
def test_bot_requires_platform(self):
from praisonai.bots import Bot
with pytest.raises(TypeError):
Bot() # platform is required
def test_bot_stores_platform(self):
from praisonai.bots import Bot
bot = Bot("telegram")
assert bot.platform == "telegram"
def test_bot_accepts_agent(self):
from praisonai.bots import Bot
from unittest.mock import MagicMock
agent = MagicMock()
agent.name = "test"
bot = Bot("telegram", agent=agent)
assert bot.agent is agent
def test_bot_accepts_agent_team(self):
"""Bot should accept AgentTeam as agent."""
from praisonai.bots import Bot
from unittest.mock import MagicMock
team = MagicMock()
team.name = "team"
bot = Bot("telegram", agent=team)
assert bot.agent is team
def test_bot_accepts_kwargs(self):
"""Platform-specific kwargs should pass through."""
from praisonai.bots import Bot
bot = Bot("whatsapp", mode="web", phone_number_id="123")
assert bot._kwargs.get("mode") == "web"
assert bot._kwargs.get("phone_number_id") == "123"
def test_bot_token_from_env(self):
"""Bot resolves token from env var convention."""
import os
from praisonai.bots import Bot
os.environ["TELEGRAM_BOT_TOKEN"] = "test-token-123"
try:
bot = Bot("telegram")
assert bot.token == "test-token-123"
finally:
del os.environ["TELEGRAM_BOT_TOKEN"]
def test_bot_explicit_token(self):
from praisonai.bots import Bot
bot = Bot("telegram", token="explicit-token")
assert bot.token == "explicit-token"
def test_bot_not_running_initially(self):
from praisonai.bots import Bot
bot = Bot("telegram")
assert bot.is_running is False
def test_bot_unknown_platform(self):
"""Unknown platform should raise ValueError on start, not init."""
from praisonai.bots import Bot
bot = Bot("unknown_platform")
assert bot.platform == "unknown_platform"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3. Wrapper: BotOS class
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestBotOSClass:
"""BotOS is the multi-platform orchestrator."""
def test_import_from_wrapper(self):
from praisonai.bots import BotOS
assert BotOS is not None
def test_botos_empty_init(self):
from praisonai.bots import BotOS
botos = BotOS()
assert botos.is_running is False
def test_botos_with_bots_list(self):
from praisonai.bots import BotOS, Bot
bot1 = Bot("telegram")
bot2 = Bot("discord")
botos = BotOS(bots=[bot1, bot2])
assert len(botos.list_bots()) == 2
def test_botos_add_bot(self):
from praisonai.bots import BotOS, Bot
botos = BotOS()
bot = Bot("telegram")
botos.add_bot(bot)
assert len(botos.list_bots()) == 1
def test_botos_shortcut_agent_plus_platforms(self):
"""BotOS(agent=..., platforms=[...]) convenience."""
from praisonai.bots import BotOS
from unittest.mock import MagicMock
agent = MagicMock()
agent.name = "test"
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
assert len(botos.list_bots()) == 2
def test_botos_list_bots_returns_platform_names(self):
from praisonai.bots import BotOS, Bot
botos = BotOS(bots=[Bot("telegram"), Bot("slack")])
names = botos.list_bots()
assert "telegram" in names
assert "slack" in names
def test_botos_get_bot(self):
from praisonai.bots import BotOS, Bot
bot = Bot("telegram")
botos = BotOS(bots=[bot])
assert botos.get_bot("telegram") is bot
def test_botos_get_bot_missing(self):
from praisonai.bots import BotOS
botos = BotOS()
assert botos.get_bot("telegram") is None
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. Platform Registry
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestPlatformRegistry:
"""Platform registry for extensible bot creation."""
def test_registry_import(self):
from praisonai.bots._registry import get_platform_registry
reg = get_platform_registry()
assert reg is not None
def test_builtin_platforms_registered(self):
from praisonai.bots._registry import get_platform_registry
reg = get_platform_registry()
assert "telegram" in reg
assert "discord" in reg
assert "slack" in reg
assert "whatsapp" in reg
def test_register_custom_platform(self):
from praisonai.bots._registry import get_platform_registry, register_platform
class CustomBot:
pass
register_platform("custom", CustomBot)
reg = get_platform_registry()
assert "custom" in reg
# Clean up
reg.pop("custom", None)
def test_list_platforms(self):
from praisonai.bots._registry import list_platforms
platforms = list_platforms()
assert isinstance(platforms, list)
assert "telegram" in platforms
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 5. No performance impact
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class TestNoPerformanceImpact:
"""Importing BotOSProtocol/BotOSConfig should not pull heavy deps."""
def test_protocol_import_lightweight(self):
"""Protocol import should not load telegram/discord/slack."""
import sys
# These should NOT be in sys.modules after importing protocol
from praisonaiagents.bots.protocols import BotOSProtocol
assert 'telegram' not in sys.modules
assert 'discord' not in sys.modules
assert 'slack_bolt' not in sys.modules
def test_config_import_lightweight(self):
from praisonaiagents.bots.config import BotOSConfig
import sys
assert 'telegram' not in sys.modules
assert 'discord' not in sys.modules
@@ -0,0 +1,242 @@
"""Unit tests for the Chainlit Bot UI streaming bridge.
Tests the core streaming bridge pattern (StreamEventEmitter → Queue → consumer)
WITHOUT requiring chainlit as a dependency. Uses mocks for all chainlit objects.
"""
import asyncio
import queue
import unittest
from unittest.mock import AsyncMock, MagicMock
from praisonaiagents.streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
class TestStreamEventEmitterBridge(unittest.TestCase):
"""Test that StreamEventEmitter can bridge to an async consumer via Queue."""
def test_sync_callback_puts_events_into_queue(self):
"""Core bridge pattern: sync callback → queue."""
event_queue = queue.Queue()
def relay(event):
event_queue.put_nowait(event)
emitter = StreamEventEmitter()
emitter.add_callback(relay)
# Emit events
evt1 = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
evt2 = StreamEvent(type=StreamEventType.DELTA_TEXT, content=" world")
evt3 = StreamEvent(type=StreamEventType.STREAM_END)
emitter.emit(evt1)
emitter.emit(evt2)
emitter.emit(evt3)
# Verify queue has all events
self.assertEqual(event_queue.qsize(), 3)
self.assertEqual(event_queue.get_nowait().content, "Hello")
self.assertEqual(event_queue.get_nowait().content, " world")
self.assertEqual(event_queue.get_nowait().type, StreamEventType.STREAM_END)
def test_callback_cleanup_after_chat(self):
"""Verify callback is removed after agent.chat completes."""
emitter = StreamEventEmitter()
def relay(event):
pass
emitter.add_callback(relay)
self.assertEqual(len(emitter._callbacks), 1)
emitter.remove_callback(relay)
self.assertEqual(len(emitter._callbacks), 0)
def test_multiple_callbacks_isolation(self):
"""Multiple callbacks (e.g., gateway + chainlit) don't interfere."""
q1 = queue.Queue()
q2 = queue.Queue()
def relay1(event):
q1.put_nowait(event)
def relay2(event):
q2.put_nowait(event)
emitter = StreamEventEmitter()
emitter.add_callback(relay1)
emitter.add_callback(relay2)
evt = StreamEvent(type=StreamEventType.DELTA_TEXT, content="test")
emitter.emit(evt)
self.assertEqual(q1.qsize(), 1)
self.assertEqual(q2.qsize(), 1)
self.assertEqual(q1.get_nowait().content, "test")
self.assertEqual(q2.get_nowait().content, "test")
class TestStreamConsumer(unittest.TestCase):
"""Test the async consumer that reads from queue and dispatches to Chainlit."""
def test_delta_text_calls_stream_token(self):
"""DELTA_TEXT events should call msg.stream_token(content)."""
event_queue = queue.Queue()
event_queue.put_nowait(StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello"))
event_queue.put_nowait(StreamEvent(type=StreamEventType.DELTA_TEXT, content=" world"))
# Use sentinel to signal end
_STREAM_END = object()
event_queue.put_nowait(_STREAM_END)
mock_msg = MagicMock()
mock_msg.stream_token = AsyncMock()
mock_msg.update = AsyncMock()
async def consume():
while True:
try:
event = event_queue.get_nowait()
except queue.Empty:
await asyncio.sleep(0.01)
continue
if event is _STREAM_END:
break
if hasattr(event, 'type') and event.type == StreamEventType.DELTA_TEXT:
content = getattr(event, 'content', '') or ''
if content:
await mock_msg.stream_token(content)
asyncio.run(consume())
self.assertEqual(mock_msg.stream_token.call_count, 2)
mock_msg.stream_token.assert_any_call("Hello")
mock_msg.stream_token.assert_any_call(" world")
def test_tool_call_events(self):
"""DELTA_TOOL_CALL events should be captured."""
events = [
StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
tool_call={"index": 0, "name": "search_web", "arguments": '{"query":'}
),
StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
tool_call={"index": 0, "arguments": '"python"}'}
),
StreamEvent(type=StreamEventType.TOOL_CALL_END),
]
captured_tool_calls = {}
for event in events:
if event.type == StreamEventType.DELTA_TOOL_CALL:
tc = event.tool_call or {}
idx = tc.get("index", 0)
if idx not in captured_tool_calls:
captured_tool_calls[idx] = {"name": tc.get("name"), "args": ""}
args = tc.get("arguments", "")
if args:
captured_tool_calls[idx]["args"] += args
self.assertIn(0, captured_tool_calls)
self.assertEqual(captured_tool_calls[0]["name"], "search_web")
self.assertEqual(captured_tool_calls[0]["args"], '{"query":"python"}')
def test_error_event(self):
"""ERROR events should be captured."""
event = StreamEvent(type=StreamEventType.ERROR, error="Connection failed")
self.assertEqual(event.type, StreamEventType.ERROR)
self.assertEqual(event.error, "Connection failed")
def test_stream_end_signals_completion(self):
"""STREAM_END should signal the consumer to stop."""
event_queue = queue.Queue()
event_queue.put_nowait(StreamEvent(type=StreamEventType.STREAM_END))
_STREAM_END = object()
event_queue.put_nowait(_STREAM_END)
consumed = []
async def consume():
while True:
try:
event = event_queue.get_nowait()
except queue.Empty:
break
if event is _STREAM_END:
break
consumed.append(event)
asyncio.run(consume())
self.assertEqual(len(consumed), 1)
self.assertEqual(consumed[0].type, StreamEventType.STREAM_END)
class TestNoChainlitInCoreSdk(unittest.TestCase):
"""Verify chainlit is NOT imported in core SDK."""
def test_no_chainlit_import_in_core(self):
"""grep-equivalent: no 'import chainlit' in praisonaiagents/ source."""
import os
core_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"praisonaiagents"
)
violations = []
for root, dirs, files in os.walk(core_path):
# Skip venv, __pycache__, .egg-info
dirs[:] = [d for d in dirs if d not in ("venv", "__pycache__", ".egg-info", "praisonaiagents.egg-info")]
for fname in files:
if not fname.endswith(".py"):
continue
fpath = os.path.join(root, fname)
try:
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
for i, line in enumerate(f, 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
if "import chainlit" in stripped or "from chainlit" in stripped:
violations.append(f"{fpath}:{i}: {stripped}")
except Exception:
pass
self.assertEqual(
violations, [],
"Core SDK must not import chainlit. Found:\n" + "\n".join(violations)
)
class TestStreamEventTypes(unittest.TestCase):
"""Verify all event types used by the bridge exist."""
def test_required_event_types_exist(self):
self.assertTrue(hasattr(StreamEventType, "DELTA_TEXT"))
self.assertTrue(hasattr(StreamEventType, "DELTA_TOOL_CALL"))
self.assertTrue(hasattr(StreamEventType, "TOOL_CALL_END"))
self.assertTrue(hasattr(StreamEventType, "STREAM_END"))
self.assertTrue(hasattr(StreamEventType, "ERROR"))
self.assertTrue(hasattr(StreamEventType, "FIRST_TOKEN"))
def test_stream_event_fields(self):
"""StreamEvent has all fields needed by the bridge."""
evt = StreamEvent(
type=StreamEventType.DELTA_TEXT,
content="hello",
tool_call={"name": "test"},
error="err",
agent_id="agent1",
)
self.assertEqual(evt.content, "hello")
self.assertEqual(evt.tool_call, {"name": "test"})
self.assertEqual(evt.error, "err")
self.assertEqual(evt.agent_id, "agent1")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,272 @@
"""
TDD Tests for Memory System Fixes.
Tests written BEFORE implementation to drive the fixes for:
1. history=True auto-generates session_id (was broken: _session_store stayed None)
2. auto_memory wires process_interaction after agent response
3. backend='redis'/'postgres' handling (was storing raw string as _memory_instance)
4. MemoryProtocol extended with get_context/save_session
5. _history_session_id reconciled with _session_id
"""
from unittest.mock import patch
import logging
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_agent(**kwargs):
"""Create an Agent with memory config, mocking LLM to avoid real API calls."""
from praisonaiagents import Agent
defaults = dict(
name="test_agent",
instructions="Be helpful",
llm="gpt-4o-mini",
)
defaults.update(kwargs)
agent = Agent(**defaults)
return agent
# ===========================================================================
# 1. history=True auto-generates session_id
# ===========================================================================
class TestHistoryAutoSessionId:
"""history=True should auto-generate a session_id so _init_session_store
can create the store and history injection fires."""
def test_history_true_sets_session_id(self):
"""When memory=MemoryConfig(history=True), _session_id must NOT be None."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True))
# After construction, session_id should be auto-set
assert agent._session_id is not None, (
"history=True must auto-generate a session_id"
)
def test_history_true_sets_history_session_id(self):
"""_history_session_id must match _session_id when auto-generated."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True))
assert agent._history_session_id is not None
assert agent._history_session_id == agent._session_id
def test_history_true_with_explicit_session_id(self):
"""Explicit session_id should be respected, not overwritten."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True, session_id="my-session"))
assert agent._session_id == "my-session"
assert agent._history_session_id == "my-session"
def test_history_true_with_auto_save(self):
"""auto_save should be used as session_id when history=True."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True, auto_save="chat1"))
assert agent._history_session_id == "chat1"
def test_history_false_no_session_id(self):
"""history=False should NOT auto-generate session_id."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=False))
# session_id should remain None (no auto-gen)
assert agent._history_enabled is False
def test_history_preset_sets_session_id(self):
"""memory='history' preset should auto-generate session_id."""
from praisonaiagents import Agent
agent = _make_agent(memory="history")
assert agent._history_enabled is True
assert agent._session_id is not None
# ===========================================================================
# 2. auto_memory wires process_interaction after agent response
# ===========================================================================
class TestAutoMemoryWiring:
"""When auto_memory=True in MemoryConfig, AutoMemory.process_interaction
should be called after each agent response."""
def test_auto_memory_flag_stored(self):
"""auto_memory flag should be accessible on agent."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(auto_memory=True))
assert agent._auto_memory is True or agent._auto_memory is not None
def test_auto_memory_instance_created_lazily(self):
"""AutoMemory wrapper should be created when auto_memory=True."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(auto_memory=True))
# The _auto_memory_instance should exist (may be lazy)
assert hasattr(agent, '_auto_memory_instance') or hasattr(agent, '_auto_memory')
def test_process_auto_memory_method_exists(self):
"""Agent should have a _process_auto_memory method."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(auto_memory=True))
assert hasattr(agent, '_process_auto_memory'), (
"Agent must have _process_auto_memory method for wiring"
)
def test_process_auto_memory_noop_when_disabled(self):
"""_process_auto_memory should be a no-op when auto_memory=False."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(auto_memory=False))
# Should not raise
agent._process_auto_memory("hello", "world")
# ===========================================================================
# 3. backend='redis'/'postgres' handling
# ===========================================================================
class TestBackendHandling:
"""backend='redis' and 'postgres' should NOT silently store a raw string
as _memory_instance. They should either work or raise a clear error."""
def test_redis_backend_not_string(self):
"""backend='redis' must NOT result in _memory_instance being a string."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(backend="redis"))
if agent._memory_instance is not None:
assert not isinstance(agent._memory_instance, str), (
"backend='redis' must not store raw string as memory instance"
)
def test_postgres_backend_not_string(self):
"""backend='postgres' must NOT result in _memory_instance being a string."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(backend="postgres"))
if agent._memory_instance is not None:
assert not isinstance(agent._memory_instance, str), (
"backend='postgres' must not store raw string as memory instance"
)
def test_file_backend_creates_file_memory(self):
"""backend='file' should create a FileMemory instance."""
from praisonaiagents import Agent, MemoryConfig
from praisonaiagents.memory.file_memory import FileMemory
agent = _make_agent(memory=MemoryConfig(backend="file"))
assert isinstance(agent._memory_instance, FileMemory)
def test_unknown_backend_warns(self):
"""Unknown backend should log a warning, not silently break."""
from praisonaiagents import Agent, MemoryConfig
import logging
with patch.object(logging, 'warning') as mock_warn:
agent = _make_agent(memory=MemoryConfig(backend="unknown_db"))
# Should either warn or the instance should be None/FileMemory fallback
if agent._memory_instance is not None:
assert not isinstance(agent._memory_instance, str)
# ===========================================================================
# 4. MemoryProtocol extended with get_context and save_session
# ===========================================================================
class TestMemoryProtocolExtensions:
"""MemoryProtocol should include get_context and save_session for
consistent interface across FileMemory and Memory."""
def test_agent_memory_protocol_exists(self):
"""AgentMemoryProtocol should exist with get_context/save_session."""
from praisonaiagents.memory.protocols import AgentMemoryProtocol
assert hasattr(AgentMemoryProtocol, 'get_context')
assert hasattr(AgentMemoryProtocol, 'save_session')
def test_file_memory_satisfies_agent_protocol(self):
"""FileMemory should satisfy AgentMemoryProtocol."""
from praisonaiagents.memory.protocols import AgentMemoryProtocol
from praisonaiagents.memory.file_memory import FileMemory
fm = FileMemory(user_id="test")
assert isinstance(fm, AgentMemoryProtocol)
def test_agent_memory_protocol_runtime_checkable(self):
"""AgentMemoryProtocol should be runtime_checkable."""
from praisonaiagents.memory.protocols import AgentMemoryProtocol
class FakeMemory:
def get_context(self, query=None):
return ""
def save_session(self, name, conversation_history=None, metadata=None):
pass
assert isinstance(FakeMemory(), AgentMemoryProtocol)
# ===========================================================================
# 5. _history_session_id reconciled with _session_id
# ===========================================================================
class TestHistorySessionIdReconciliation:
"""_history_session_id and _session_id should be reconciled so that
_init_session_store creates the store AND history injection uses the right ID."""
def test_history_session_id_matches_session_id_when_auto(self):
"""When history=True and no explicit IDs, both should match."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True))
if agent._session_id is not None:
assert agent._history_session_id == agent._session_id
def test_explicit_session_id_used_for_history(self):
"""Explicit session_id should propagate to _history_session_id."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True, session_id="explicit"))
assert agent._session_id == "explicit"
assert agent._history_session_id == "explicit"
def test_auto_save_session_id_for_history(self):
"""auto_save should be used as _history_session_id."""
from praisonaiagents import Agent, MemoryConfig
agent = _make_agent(memory=MemoryConfig(history=True, auto_save="my_session"))
assert agent._history_session_id == "my_session"
# ===========================================================================
# 6. Smoke tests - end-to-end memory config
# ===========================================================================
class TestMemoryConfigSmoke:
"""Smoke tests for common memory configurations."""
def test_memory_true(self):
"""memory=True should create agent with FileMemory, no crash."""
from praisonaiagents import Agent
agent = _make_agent(memory=True)
assert agent._memory_instance is not None
def test_memory_false(self):
"""memory=False should create agent with no memory."""
from praisonaiagents import Agent
agent = _make_agent(memory=False)
assert agent._memory_instance is None
def test_memory_history_preset(self):
"""memory='history' preset should enable history."""
from praisonaiagents import Agent
agent = _make_agent(memory="history")
assert agent._history_enabled is True
def test_memory_learn_preset(self):
"""memory='learn' preset should enable learn."""
from praisonaiagents import Agent
agent = _make_agent(memory="learn")
# Should not crash; learn config should be present
assert agent._memory_instance is not None
def test_store_memory_public_api(self):
"""agent.store_memory() should work with memory=True."""
from praisonaiagents import Agent
agent = _make_agent(memory=True)
# Should not raise
agent.store_memory("User likes Python", "long_term")
def test_get_memory_context(self):
"""agent.get_memory_context() should return string."""
from praisonaiagents import Agent
agent = _make_agent(memory=True)
ctx = agent.get_memory_context()
assert isinstance(ctx, str)
@@ -0,0 +1,319 @@
"""
TDD tests for Agent parameter consolidation.
Tests:
1. Deprecation warnings fire for old params
2. New config fields work (ExecutionConfig.code_execution, MemoryConfig.auto_save, AutonomyConfig.verification_hooks)
3. Backward compatibility: old params still function correctly
4. Config fields take precedence over deprecated standalone params
"""
import warnings
import pytest
class TestDeprecationWarnings:
"""Test that deprecated params emit DeprecationWarning."""
def test_allow_delegation_warns(self):
"""allow_delegation=True should emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", allow_delegation=True)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert any("allow_delegation" in m for m in msgs), f"Expected allow_delegation deprecation, got: {msgs}"
def test_allow_code_execution_warns(self):
"""allow_code_execution=True should emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", allow_code_execution=True)
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert any("allow_code_execution" in m for m in msgs), f"Expected allow_code_execution deprecation, got: {msgs}"
def test_code_execution_mode_unsafe_warns(self):
"""code_execution_mode='unsafe' should emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", code_execution_mode="unsafe")
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert any("code_execution_mode" in m for m in msgs), f"Expected code_execution_mode deprecation, got: {msgs}"
def test_code_execution_mode_safe_no_warn(self):
"""code_execution_mode='safe' (default) should NOT emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", code_execution_mode="safe")
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert not any("code_execution_mode" in m for m in msgs), f"Unexpected code_execution_mode deprecation for default 'safe'"
def test_auto_save_warns(self):
"""auto_save='session' should emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", auto_save="my_session")
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert any("auto_save" in m for m in msgs), f"Expected auto_save deprecation, got: {msgs}"
def test_rate_limiter_warns(self):
"""rate_limiter=obj should emit DeprecationWarning."""
from praisonaiagents import Agent
class FakeLimiter:
def acquire(self): pass
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", rate_limiter=FakeLimiter())
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert any("rate_limiter" in m for m in msgs), f"Expected rate_limiter deprecation, got: {msgs}"
def test_verification_hooks_warns(self):
"""verification_hooks=[...] should emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", verification_hooks=["hook1"])
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
msgs = [str(x.message) for x in deprecation_warnings]
assert any("verification_hooks" in m for m in msgs), f"Expected verification_hooks deprecation, got: {msgs}"
def test_no_deprecation_on_clean_usage(self):
"""Clean usage (no deprecated params) should NOT emit DeprecationWarning."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test")
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
# Filter out llm= deprecation which is a separate concern
non_llm = [x for x in deprecation_warnings if "llm" not in str(x.message).lower()]
assert len(non_llm) == 0, f"Unexpected deprecation warnings: {[str(x.message) for x in non_llm]}"
class TestNewConfigFields:
"""Test new fields on config dataclasses."""
def test_execution_config_code_execution_field(self):
"""ExecutionConfig should have code_execution field."""
from praisonaiagents.config.feature_configs import ExecutionConfig
config = ExecutionConfig(code_execution=True, code_mode="unsafe")
assert config.code_execution is True
assert config.code_mode == "unsafe"
def test_execution_config_rate_limiter_field(self):
"""ExecutionConfig should have rate_limiter field."""
from praisonaiagents.config.feature_configs import ExecutionConfig
class FakeLimiter:
def acquire(self): pass
limiter = FakeLimiter()
config = ExecutionConfig(rate_limiter=limiter)
assert config.rate_limiter is limiter
def test_execution_config_defaults(self):
"""ExecutionConfig defaults should be safe."""
from praisonaiagents.config.feature_configs import ExecutionConfig
config = ExecutionConfig()
assert config.code_execution is False
assert config.code_mode == "safe"
assert config.rate_limiter is None
def test_execution_config_to_dict(self):
"""ExecutionConfig.to_dict() should include code_execution and code_mode."""
from praisonaiagents.config.feature_configs import ExecutionConfig
config = ExecutionConfig(code_execution=True, code_mode="unsafe")
d = config.to_dict()
assert d["code_execution"] is True
assert d["code_mode"] == "unsafe"
def test_memory_config_auto_save_field(self):
"""MemoryConfig should have auto_save field."""
from praisonaiagents.config.feature_configs import MemoryConfig
config = MemoryConfig(auto_save="my_session")
assert config.auto_save == "my_session"
def test_memory_config_auto_save_default(self):
"""MemoryConfig.auto_save should default to None."""
from praisonaiagents.config.feature_configs import MemoryConfig
config = MemoryConfig()
assert config.auto_save is None
def test_memory_config_to_dict_includes_auto_save(self):
"""MemoryConfig.to_dict() should include auto_save."""
from praisonaiagents.config.feature_configs import MemoryConfig
config = MemoryConfig(auto_save="bot_session")
d = config.to_dict()
assert d["auto_save"] == "bot_session"
def test_autonomy_config_verification_hooks_field(self):
"""AutonomyConfig should have verification_hooks field."""
from praisonaiagents.agent.autonomy import AutonomyConfig
hooks = ["hook1", "hook2"]
config = AutonomyConfig(verification_hooks=hooks)
assert config.verification_hooks == hooks
def test_autonomy_config_verification_hooks_default(self):
"""AutonomyConfig.verification_hooks should default to None."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig()
assert config.verification_hooks is None
def test_autonomy_config_from_dict_verification_hooks(self):
"""AutonomyConfig.from_dict() should extract verification_hooks."""
from praisonaiagents.agent.autonomy import AutonomyConfig
hooks = ["hook_a"]
config = AutonomyConfig.from_dict({"verification_hooks": hooks})
assert config.verification_hooks == hooks
class TestConsolidatedExtraction:
"""Test that config fields are extracted into Agent attributes."""
def test_execution_config_code_execution_sets_attribute(self):
"""Agent(execution=ExecutionConfig(code_execution=True)) should set allow_code_execution."""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(
name="test",
instructions="test",
execution=ExecutionConfig(code_execution=True, code_mode="unsafe"),
)
assert agent.allow_code_execution is True
assert agent.code_execution_mode == "unsafe"
def test_execution_config_rate_limiter_sets_attribute(self):
"""Agent(execution=ExecutionConfig(rate_limiter=obj)) should set _rate_limiter."""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
class FakeLimiter:
def acquire(self): pass
limiter = FakeLimiter()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(
name="test",
instructions="test",
execution=ExecutionConfig(rate_limiter=limiter),
)
assert agent._rate_limiter is limiter
def test_autonomy_config_verification_hooks_sets_attribute(self):
"""Agent(autonomy=AutonomyConfig(verification_hooks=[...])) should set _verification_hooks."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
hooks = ["hook1"]
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(
name="test",
instructions="test",
autonomy=AutonomyConfig(verification_hooks=hooks),
)
assert agent._verification_hooks == hooks
class TestBackwardCompatibility:
"""Test that deprecated params still work (no breakage)."""
def test_allow_delegation_still_stored(self):
"""allow_delegation should still be stored on Agent."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", allow_delegation=True)
assert agent.allow_delegation is True
def test_allow_code_execution_still_stored(self):
"""allow_code_execution should still be stored on Agent."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", allow_code_execution=True)
assert agent.allow_code_execution is True
def test_auto_save_still_stored(self):
"""auto_save should still be stored on Agent."""
from praisonaiagents import Agent
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", auto_save="session1")
assert agent.auto_save == "session1"
def test_rate_limiter_still_stored(self):
"""rate_limiter should still be stored on Agent."""
from praisonaiagents import Agent
class FakeLimiter:
def acquire(self): pass
limiter = FakeLimiter()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", rate_limiter=limiter)
assert agent._rate_limiter is limiter
def test_verification_hooks_still_stored(self):
"""verification_hooks should still be stored on Agent."""
from praisonaiagents import Agent
hooks = ["hook1"]
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(name="test", instructions="test", verification_hooks=hooks)
assert agent._verification_hooks == hooks
class TestConfigPrecedence:
"""Test that config fields take precedence over deprecated standalone params."""
def test_execution_config_overrides_standalone_code_execution(self):
"""ExecutionConfig.code_execution should override standalone allow_code_execution."""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(
name="test",
instructions="test",
allow_code_execution=False, # deprecated standalone
execution=ExecutionConfig(code_execution=True), # config takes precedence
)
assert agent.allow_code_execution is True
def test_execution_config_overrides_standalone_rate_limiter(self):
"""ExecutionConfig.rate_limiter should override standalone rate_limiter."""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
class Limiter1:
def acquire(self): pass
class Limiter2:
def acquire(self): pass
old_limiter = Limiter1()
new_limiter = Limiter2()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
agent = Agent(
name="test",
instructions="test",
rate_limiter=old_limiter, # deprecated standalone
execution=ExecutionConfig(rate_limiter=new_limiter), # config takes precedence
)
assert agent._rate_limiter is new_limiter
@@ -0,0 +1,186 @@
"""Integration tests for schedule tools with Agent class.
Tests that agents can use schedule tools via:
1. Direct function references in tools list
2. String-name resolution via TOOL_MAPPINGS
3. Tool schema generation for LLM
"""
from unittest.mock import patch
class TestAgentScheduleToolIntegration:
"""Test Agent + schedule tools integration (no LLM calls)."""
def test_agent_accepts_schedule_tools_as_functions(self):
"""Agent can be created with schedule tool functions in tools list."""
from praisonaiagents import Agent
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
agent = Agent(
name="test-scheduler",
instructions="You can set schedules.",
tools=[schedule_add, schedule_list, schedule_remove],
)
assert len(agent.tools) == 3
def test_agent_resolves_schedule_tool_strings(self):
"""Agent resolves 'schedule_add' etc. from TOOL_MAPPINGS."""
from praisonaiagents import Agent
agent = Agent(
name="test-scheduler",
instructions="You can set schedules.",
tools=["schedule_add", "schedule_list", "schedule_remove"],
)
# String tool names are resolved via _resolve_tool_names → registry
# They may or may not resolve depending on registry state,
# but the tools list should be populated
# The key test is that Agent creation doesn't crash
assert agent is not None
def test_schedule_tool_schema_generation(self):
"""Schedule tools produce valid OpenAI function schemas."""
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
from praisonaiagents.tools.decorator import get_tool_schema
for fn in [schedule_add, schedule_list, schedule_remove]:
schema = get_tool_schema(fn)
assert schema is not None
assert schema["type"] == "function"
assert "function" in schema
assert "name" in schema["function"]
assert "parameters" in schema["function"]
def test_schedule_add_schema_has_required_params(self):
"""schedule_add schema includes name, schedule, message parameters."""
from praisonaiagents.tools.schedule_tools import schedule_add
from praisonaiagents.tools.decorator import get_tool_schema
schema = get_tool_schema(schedule_add)
params = schema["function"]["parameters"]
assert "name" in params["properties"]
assert "schedule" in params["properties"]
assert "message" in params["properties"]
# name and schedule are required, message has a default
assert "name" in params["required"]
assert "schedule" in params["required"]
def test_schedule_remove_schema_has_name_param(self):
"""schedule_remove schema includes name parameter."""
from praisonaiagents.tools.schedule_tools import schedule_remove
from praisonaiagents.tools.decorator import get_tool_schema
schema = get_tool_schema(schedule_remove)
params = schema["function"]["parameters"]
assert "name" in params["properties"]
assert "name" in params["required"]
def test_schedule_tools_execute_via_agent_execute_tool(self, tmp_path):
"""Agent._execute_tool_call can invoke schedule tools."""
from praisonaiagents import Agent
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
agent = Agent(
name="test-exec",
instructions="You can set schedules.",
tools=[schedule_add, schedule_list, schedule_remove],
)
# Simulate LLM calling schedule_add
result = agent._execute_tool_impl(
"schedule_add",
{"name": "test-job", "schedule": "hourly", "message": "Check email"},
)
assert "test-job" in str(result)
assert "added" in str(result).lower()
# Simulate LLM calling schedule_list
result = agent._execute_tool_impl("schedule_list", {})
assert "test-job" in str(result)
# Simulate LLM calling schedule_remove
result = agent._execute_tool_impl(
"schedule_remove", {"name": "test-job"}
)
assert "removed" in str(result).lower()
def test_end_to_end_schedule_crud(self, tmp_path):
"""Full CRUD cycle: add → list → remove → list (empty)."""
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
# Add
r = schedule_add(name="daily-news", schedule="daily", message="Summarize AI news")
assert "added" in r.lower()
# List
r = schedule_list()
assert "daily-news" in r
assert "every 1 day" in r
# Add another
r = schedule_add(name="hourly-check", schedule="*/30m", message="Check inbox")
assert "added" in r.lower()
# List shows both
r = schedule_list()
assert "daily-news" in r
assert "hourly-check" in r
assert "2 schedule" in r
# Remove one
r = schedule_remove(name="daily-news")
assert "removed" in r.lower()
# List shows one
r = schedule_list()
assert "daily-news" not in r
assert "hourly-check" in r
assert "1 schedule" in r
# Duplicate name prevention
r = schedule_add(name="hourly-check", schedule="hourly", message="Dup")
assert "already exists" in r.lower()
class TestScheduleToolImportPaths:
"""Test various import paths work correctly."""
def test_import_from_tools_module(self):
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
assert callable(schedule_add)
assert callable(schedule_list)
assert callable(schedule_remove)
def test_import_from_schedule_tools_directly(self):
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
assert callable(schedule_add)
assert callable(schedule_list)
assert callable(schedule_remove)
def test_import_scheduler_models(self):
from praisonaiagents.scheduler import Schedule, ScheduleJob
s = Schedule(kind="every", every_seconds=60)
j = ScheduleJob(name="test", schedule=s)
assert j.name == "test"
def test_import_scheduler_parser(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("hourly")
assert s.kind == "every"
def test_import_scheduler_store(self):
from praisonaiagents.scheduler import FileScheduleStore
assert FileScheduleStore is not None
def test_import_scheduler_runner(self):
from praisonaiagents.scheduler import ScheduleRunner
assert ScheduleRunner is not None
@@ -0,0 +1,412 @@
"""TDD tests for schedule tools — agent-centric scheduling via tools.
Tests cover:
1. Schedule models (ScheduleJob, Schedule)
2. FileScheduleStore (CRUD + persistence)
3. Schedule tools (schedule_add, schedule_list, schedule_remove)
4. Schedule parser (interval, cron, at)
5. ScheduleRunner (tick loop)
6. Integration: agent uses schedule tools
"""
import time
import pytest
from unittest.mock import patch
# ─── 1. Model Tests ──────────────────────────────────────────────────────────
class TestScheduleModels:
"""Test ScheduleJob and Schedule dataclasses."""
def test_schedule_every_creation(self):
from praisonaiagents.scheduler.models import Schedule
s = Schedule(kind="every", every_seconds=3600)
assert s.kind == "every"
assert s.every_seconds == 3600
def test_schedule_cron_creation(self):
from praisonaiagents.scheduler.models import Schedule
s = Schedule(kind="cron", cron_expr="0 7 * * *")
assert s.kind == "cron"
assert s.cron_expr == "0 7 * * *"
def test_schedule_at_creation(self):
from praisonaiagents.scheduler.models import Schedule
s = Schedule(kind="at", at="2026-03-01T09:00:00")
assert s.kind == "at"
assert s.at == "2026-03-01T09:00:00"
def test_schedule_job_creation(self):
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
sched = Schedule(kind="every", every_seconds=60)
job = ScheduleJob(
name="test-job",
schedule=sched,
message="Hello world",
)
assert job.name == "test-job"
assert job.id # auto-generated
assert job.enabled is True
assert job.message == "Hello world"
def test_schedule_job_to_dict_roundtrip(self):
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
sched = Schedule(kind="every", every_seconds=300)
job = ScheduleJob(name="roundtrip", schedule=sched, message="test")
d = job.to_dict()
assert isinstance(d, dict)
assert d["name"] == "roundtrip"
assert d["schedule"]["kind"] == "every"
# Roundtrip
job2 = ScheduleJob.from_dict(d)
assert job2.name == job.name
assert job2.id == job.id
assert job2.schedule.every_seconds == 300
def test_schedule_job_defaults(self):
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
sched = Schedule(kind="every", every_seconds=60)
job = ScheduleJob(name="defaults", schedule=sched)
assert job.enabled is True
assert job.message == ""
assert job.agent_id is None
assert job.session_target == "isolated"
assert job.delete_after_run is False
# ─── 2. Store Tests ──────────────────────────────────────────────────────────
class TestFileScheduleStore:
"""Test FileScheduleStore CRUD and persistence."""
def _make_store(self, tmp_path):
from praisonaiagents.scheduler.store import FileScheduleStore
return FileScheduleStore(store_dir=str(tmp_path))
def _make_job(self, name="test-job"):
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
return ScheduleJob(
name=name,
schedule=Schedule(kind="every", every_seconds=60),
message="test message",
)
def test_add_and_get(self, tmp_path):
store = self._make_store(tmp_path)
job = self._make_job()
store.add(job)
retrieved = store.get(job.id)
assert retrieved is not None
assert retrieved.name == "test-job"
def test_add_duplicate_id_raises(self, tmp_path):
store = self._make_store(tmp_path)
job = self._make_job()
store.add(job)
with pytest.raises(ValueError, match="already exists"):
store.add(job)
def test_list_all(self, tmp_path):
store = self._make_store(tmp_path)
store.add(self._make_job("job-1"))
store.add(self._make_job("job-2"))
jobs = store.list()
assert len(jobs) == 2
def test_list_by_agent_id(self, tmp_path):
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
store = self._make_store(tmp_path)
j1 = ScheduleJob(name="a", schedule=Schedule(kind="every", every_seconds=60), agent_id="agent-1")
j2 = ScheduleJob(name="b", schedule=Schedule(kind="every", every_seconds=60), agent_id="agent-2")
store.add(j1)
store.add(j2)
assert len(store.list(agent_id="agent-1")) == 1
def test_remove(self, tmp_path):
store = self._make_store(tmp_path)
job = self._make_job()
store.add(job)
assert store.remove(job.id) is True
assert store.get(job.id) is None
def test_remove_nonexistent(self, tmp_path):
store = self._make_store(tmp_path)
assert store.remove("nonexistent") is False
def test_persistence_across_instances(self, tmp_path):
store1 = self._make_store(tmp_path)
job = self._make_job()
store1.add(job)
# New store instance reads from same dir
store2 = self._make_store(tmp_path)
retrieved = store2.get(job.id)
assert retrieved is not None
assert retrieved.name == "test-job"
def test_update(self, tmp_path):
store = self._make_store(tmp_path)
job = self._make_job()
store.add(job)
job.enabled = False
store.update(job)
retrieved = store.get(job.id)
assert retrieved.enabled is False
def test_empty_store_list(self, tmp_path):
store = self._make_store(tmp_path)
assert store.list() == []
# ─── 3. Parser Tests ─────────────────────────────────────────────────────────
class TestScheduleParser:
"""Test schedule expression parsing."""
def test_parse_hourly(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("hourly")
assert s.kind == "every"
assert s.every_seconds == 3600
def test_parse_daily(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("daily")
assert s.kind == "every"
assert s.every_seconds == 86400
def test_parse_interval_minutes(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("*/30m")
assert s.kind == "every"
assert s.every_seconds == 1800
def test_parse_interval_hours(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("*/6h")
assert s.kind == "every"
assert s.every_seconds == 21600
def test_parse_interval_seconds(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("*/10s")
assert s.kind == "every"
assert s.every_seconds == 10
def test_parse_cron_expression(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("cron:0 7 * * *")
assert s.kind == "cron"
assert s.cron_expr == "0 7 * * *"
def test_parse_at_iso(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("at:2026-03-01T09:00:00")
assert s.kind == "at"
assert s.at == "2026-03-01T09:00:00"
def test_parse_numeric_seconds(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("3600")
assert s.kind == "every"
assert s.every_seconds == 3600
def test_parse_relative_minutes(self):
from praisonaiagents.scheduler.parser import parse_schedule
s = parse_schedule("in 20 minutes")
assert s.kind == "at"
assert s.at is not None # Should be an ISO timestamp ~20min from now
def test_parse_invalid_raises(self):
from praisonaiagents.scheduler.parser import parse_schedule
with pytest.raises(ValueError):
parse_schedule("")
# ─── 4. Schedule Tool Tests ──────────────────────────────────────────────────
class TestScheduleTools:
"""Test schedule_add, schedule_list, schedule_remove tool functions."""
def test_schedule_add_returns_confirmation(self, tmp_path):
from praisonaiagents.tools.schedule_tools import schedule_add
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
result = schedule_add(name="test-reminder", schedule="hourly", message="Check email")
assert "test-reminder" in result
assert "scheduled" in result.lower() or "added" in result.lower()
def test_schedule_list_returns_jobs(self, tmp_path):
from praisonaiagents.tools.schedule_tools import schedule_list, schedule_add
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
store = FileScheduleStore(store_dir=str(tmp_path))
mock_gs.return_value = store
schedule_add(name="job-1", schedule="hourly", message="Task 1")
schedule_add(name="job-2", schedule="daily", message="Task 2")
result = schedule_list()
assert "job-1" in result
assert "job-2" in result
def test_schedule_remove_returns_confirmation(self, tmp_path):
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_remove
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
store = FileScheduleStore(store_dir=str(tmp_path))
mock_gs.return_value = store
schedule_add(name="to-remove", schedule="hourly", message="Temp")
result = schedule_remove(name="to-remove")
assert "removed" in result.lower() or "deleted" in result.lower()
def test_schedule_remove_nonexistent(self, tmp_path):
from praisonaiagents.tools.schedule_tools import schedule_remove
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
result = schedule_remove(name="nonexistent")
assert "not found" in result.lower()
def test_schedule_list_empty(self, tmp_path):
from praisonaiagents.tools.schedule_tools import schedule_list
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
from praisonaiagents.scheduler.store import FileScheduleStore
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
result = schedule_list()
assert "no schedule" in result.lower() or "empty" in result.lower() or "0" in result
# ─── 5. Runner Tests ─────────────────────────────────────────────────────────
class TestScheduleRunner:
"""Test ScheduleRunner tick loop."""
def test_runner_creation(self, tmp_path):
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
store = FileScheduleStore(store_dir=str(tmp_path))
runner = ScheduleRunner(store=store)
assert runner is not None
def test_get_due_jobs_interval(self, tmp_path):
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
store = FileScheduleStore(store_dir=str(tmp_path))
# Job with 1-second interval, last_run 10 seconds ago
job = ScheduleJob(
name="due-job",
schedule=Schedule(kind="every", every_seconds=1),
message="test",
)
job.last_run_at = time.time() - 10 # Ran 10s ago, interval is 1s → due
store.add(job)
runner = ScheduleRunner(store=store)
due = runner.get_due_jobs()
assert len(due) == 1
assert due[0].name == "due-job"
def test_get_due_jobs_not_yet(self, tmp_path):
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
store = FileScheduleStore(store_dir=str(tmp_path))
job = ScheduleJob(
name="not-due",
schedule=Schedule(kind="every", every_seconds=9999),
message="test",
)
job.last_run_at = time.time() # Just ran
store.add(job)
runner = ScheduleRunner(store=store)
due = runner.get_due_jobs()
assert len(due) == 0
def test_get_due_jobs_at_past(self, tmp_path):
"""One-shot 'at' jobs in the past should be due."""
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
store = FileScheduleStore(store_dir=str(tmp_path))
past = "2020-01-01T00:00:00"
job = ScheduleJob(
name="past-at",
schedule=Schedule(kind="at", at=past),
message="test",
)
store.add(job)
runner = ScheduleRunner(store=store)
due = runner.get_due_jobs()
assert len(due) == 1
def test_disabled_job_not_due(self, tmp_path):
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
store = FileScheduleStore(store_dir=str(tmp_path))
job = ScheduleJob(
name="disabled",
schedule=Schedule(kind="every", every_seconds=1),
message="test",
enabled=False,
)
store.add(job)
runner = ScheduleRunner(store=store)
due = runner.get_due_jobs()
assert len(due) == 0
# ─── 6. Hook Event Tests ─────────────────────────────────────────────────────
class TestScheduleHookEvents:
"""Test that scheduling hook events exist."""
def test_schedule_hook_events_exist(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, 'SCHEDULE_ADD')
assert hasattr(HookEvent, 'SCHEDULE_REMOVE')
assert hasattr(HookEvent, 'SCHEDULE_TRIGGER')
def test_schedule_hook_event_values(self):
from praisonaiagents.hooks.types import HookEvent
assert HookEvent.SCHEDULE_ADD.value == "schedule_add"
assert HookEvent.SCHEDULE_REMOVE.value == "schedule_remove"
assert HookEvent.SCHEDULE_TRIGGER.value == "schedule_trigger"
# ─── 7. Lazy Import Tests ────────────────────────────────────────────────────
class TestLazyImport:
"""Ensure schedule tools don't impact import time."""
def test_praisonaiagents_import_no_scheduler(self):
"""Importing praisonaiagents should NOT load scheduler module."""
# We verify by checking TOOL_MAPPINGS references lazy paths
from praisonaiagents.tools import TOOL_MAPPINGS
for key in ['schedule_add', 'schedule_list', 'schedule_remove']:
assert key in TOOL_MAPPINGS
module_path, _ = TOOL_MAPPINGS[key]
assert module_path == '.schedule_tools'
# ─── 8. TOOL_MAPPINGS Registration Test ──────────────────────────────────────
class TestToolMappings:
"""Test schedule tools are registered in TOOL_MAPPINGS for string-name resolution."""
def test_schedule_tools_in_mappings(self):
from praisonaiagents.tools import TOOL_MAPPINGS
assert 'schedule_add' in TOOL_MAPPINGS
assert 'schedule_list' in TOOL_MAPPINGS
assert 'schedule_remove' in TOOL_MAPPINGS
def test_schedule_tools_lazy_loadable(self):
"""Verify tools can be lazy-loaded via __getattr__."""
from praisonaiagents import tools
schedule_add = getattr(tools, 'schedule_add')
assert callable(schedule_add)
schedule_list = getattr(tools, 'schedule_list')
assert callable(schedule_list)
schedule_remove = getattr(tools, 'schedule_remove')
assert callable(schedule_remove)
@@ -0,0 +1,819 @@
"""
Comprehensive tests for WhatsApp message filtering feature.
Tests cover:
- Constructor parameter handling (allowed_numbers, allowed_groups, respond_to_all)
- Phone number normalization
- Filtering logic in _on_web_message (self-only, allowlists, respond_to_all)
- CLI flag parsing and wiring
- YAML config wiring
- Edge cases
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest # noqa: F401 — used by test discovery
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "praisonai"))
# ── Helpers ──────────────────────────────────────────────────────
def _make_bot(**kwargs):
"""Create a WhatsAppBot in web mode with filtering kwargs."""
from praisonai.bots.whatsapp import WhatsAppBot
defaults = dict(mode="web")
defaults.update(kwargs)
return WhatsAppBot(**defaults)
def _make_neonize_event(
is_from_me=False,
is_group=False,
sender="[email protected]",
chat="[email protected]",
text="hello",
timestamp=None,
):
"""Create a mock neonize MessageEv with MessageSource fields.
Args:
timestamp: Epoch seconds (float). If *None* defaults to
``time.time()`` so the event looks "fresh".
"""
import time as _time
event = MagicMock()
event.Info.MessageSource.IsFromMe = is_from_me
event.Info.MessageSource.IsGroup = is_group
event.Info.MessageSource.Sender = MagicMock()
event.Info.MessageSource.Sender.__str__ = lambda self: sender
event.Info.MessageSource.Chat = MagicMock()
event.Info.MessageSource.Chat.__str__ = lambda self: chat
event.Info.ID = "msg-test-123"
# Store as seconds (the production code handles ms→s conversion)
event.Info.Timestamp = timestamp if timestamp is not None else _time.time()
event.Message.conversation = text
event.Message.extendedTextMessage = None
return event
# ── Constructor: Filtering Defaults ──────────────────────────────
class TestFilteringDefaults:
"""Test default filtering behavior (self-only)."""
def test_default_respond_to_all_false(self):
bot = _make_bot()
assert bot._respond_to_all is False
def test_default_allowed_numbers_empty(self):
bot = _make_bot()
assert bot._allowed_numbers == set()
def test_default_allowed_groups_empty(self):
bot = _make_bot()
assert bot._allowed_groups == set()
# ── Constructor: respond_to_all ──────────────────────────────────
class TestRespondToAll:
"""Test respond_to_all parameter."""
def test_respond_to_all_true(self):
bot = _make_bot(respond_to_all=True)
assert bot._respond_to_all is True
def test_respond_to_all_false_explicit(self):
bot = _make_bot(respond_to_all=False)
assert bot._respond_to_all is False
# ── Constructor: Phone Number Normalization ──────────────────────
class TestPhoneNumberNormalization:
"""Test that allowed_numbers are normalized (digits only)."""
def test_plain_number(self):
bot = _make_bot(allowed_numbers=["1234567890"])
assert "1234567890" in bot._allowed_numbers
def test_plus_prefix_stripped(self):
bot = _make_bot(allowed_numbers=["+1234567890"])
assert "1234567890" in bot._allowed_numbers
def test_dashes_stripped(self):
bot = _make_bot(allowed_numbers=["1-234-567-8901"])
assert "12345678901" in bot._allowed_numbers
def test_spaces_stripped(self):
bot = _make_bot(allowed_numbers=["1 234 567 8901"])
assert "12345678901" in bot._allowed_numbers
def test_parentheses_stripped(self):
bot = _make_bot(allowed_numbers=["(123) 456-7890"])
assert "1234567890" in bot._allowed_numbers
def test_multiple_numbers(self):
bot = _make_bot(allowed_numbers=["+1-555-1234", "9876543210"])
assert "15551234" in bot._allowed_numbers
assert "9876543210" in bot._allowed_numbers
def test_empty_string_ignored(self):
bot = _make_bot(allowed_numbers=["", "1234"])
assert "" not in bot._allowed_numbers
assert "1234" in bot._allowed_numbers
def test_none_allowed_numbers(self):
bot = _make_bot(allowed_numbers=None)
assert bot._allowed_numbers == set()
# ── Constructor: Allowed Groups ──────────────────────────────────
class TestAllowedGroups:
"""Test allowed_groups parameter."""
def test_single_group(self):
bot = _make_bot(allowed_groups=["[email protected]"])
assert "[email protected]" in bot._allowed_groups
def test_multiple_groups(self):
bot = _make_bot(allowed_groups=["[email protected]", "[email protected]"])
assert "[email protected]" in bot._allowed_groups
assert "[email protected]" in bot._allowed_groups
def test_whitespace_stripped(self):
bot = _make_bot(allowed_groups=[" [email protected] "])
assert "[email protected]" in bot._allowed_groups
def test_none_allowed_groups(self):
bot = _make_bot(allowed_groups=None)
assert bot._allowed_groups == set()
# ── Filtering Logic: _on_web_message ─────────────────────────────
class TestFilteringSelfOnly:
"""Test default self-only filtering (no allowlists, no respond_to_all).
'Self-only' means the user is messaging their own number (self-chat).
IsFromMe alone is NOT enough — the chat JID must equal the sender JID.
"""
def test_true_self_chat_processed(self):
"""Self-chat (sender==chat, IsFromMe) should pass."""
bot = _make_bot()
assert bot._respond_to_all is False
assert bot._allowed_numbers == set()
assert bot._allowed_groups == set()
event = _make_neonize_event(
is_from_me=True,
sender="[email protected]",
chat="[email protected]",
text="hello bot",
)
is_from_me = bool(event.Info.MessageSource.IsFromMe)
assert is_from_me is True
def test_is_from_me_in_other_chat_rejected(self):
"""IsFromMe=True but in someone else's chat → rejected (not self-chat)."""
bot = _make_bot()
event = _make_neonize_event(
is_from_me=True,
sender="[email protected]",
chat="[email protected]", # different chat
text="hello",
)
# sender != chat → NOT self-chat → should be filtered
sender_user = "1234567890"
chat_user = "9999999999"
assert sender_user != chat_user # not self-chat
def test_other_person_message_rejected(self):
"""IsFromMe=False with no allowlists → should be rejected."""
bot = _make_bot()
event = _make_neonize_event(is_from_me=False, text="hello")
is_from_me = bool(event.Info.MessageSource.IsFromMe)
is_group = bool(event.Info.MessageSource.IsGroup) or "@g.us" in str(event.Info.MessageSource.Chat)
should_filter = True
if bot._respond_to_all:
should_filter = False
elif is_from_me:
# Changed: is_from_me alone no longer passes; need self-chat check
should_filter = False # but E2E test covers the stricter logic
elif is_group and bot._allowed_groups:
should_filter = False
elif not is_group and bot._allowed_numbers:
should_filter = False
assert should_filter is True # message should be filtered out
class TestFilteringRespondToAll:
"""Test respond_to_all=True skips filtering entirely."""
def test_respond_to_all_allows_any_message(self):
bot = _make_bot(respond_to_all=True)
# With respond_to_all, filter is skipped for all messages
assert bot._respond_to_all is True
def test_respond_to_all_allows_group_messages(self):
bot = _make_bot(respond_to_all=True)
assert bot._respond_to_all is True
class TestFilteringAllowedNumbers:
"""Test allowed_numbers filtering."""
def test_allowed_number_passes(self):
bot = _make_bot(allowed_numbers=["1234567890"])
sender = "[email protected]"
sender_num = sender.split("@")[0].split(":")[0]
assert sender_num in bot._allowed_numbers
def test_disallowed_number_rejected(self):
bot = _make_bot(allowed_numbers=["1234567890"])
sender = "[email protected]"
sender_num = sender.split("@")[0].split(":")[0]
assert sender_num not in bot._allowed_numbers
def test_sender_with_device_suffix(self):
"""Neonize sometimes includes :device_id in sender JID."""
bot = _make_bot(allowed_numbers=["1234567890"])
sender = "1234567890:[email protected]"
sender_num = sender.split("@")[0].split(":")[0]
assert sender_num in bot._allowed_numbers
def test_self_still_allowed_with_allowlist(self):
"""Self-messages should still pass even when allowlist is set."""
bot = _make_bot(allowed_numbers=["9999999999"])
# Self-message from different number — IsFromMe takes priority
event = _make_neonize_event(is_from_me=True, sender="[email protected]")
is_from_me = bool(event.Info.MessageSource.IsFromMe)
assert is_from_me is True # self always passes
class TestFilteringAllowedGroups:
"""Test allowed_groups filtering."""
def test_allowed_group_passes(self):
bot = _make_bot(allowed_groups=["[email protected]"])
chat = "[email protected]"
chat_str = chat.split("@")[0]
assert chat in bot._allowed_groups or chat_str in bot._allowed_groups
def test_allowed_group_by_id_only(self):
"""Allow matching by group ID without @g.us suffix."""
bot = _make_bot(allowed_groups=["120363123456"])
chat = "[email protected]"
chat_str = chat.split("@")[0]
assert chat_str in bot._allowed_groups
def test_disallowed_group_rejected(self):
bot = _make_bot(allowed_groups=["[email protected]"])
chat = "[email protected]"
chat_str = chat.split("@")[0]
assert chat not in bot._allowed_groups
assert chat_str not in bot._allowed_groups
def test_group_message_from_other_ignored_without_group_allowlist(self):
"""Group message without allowed_groups set → rejected (handled by e2e tests)."""
default_bot = _make_bot()
assert default_bot._allowed_groups == set()
class TestFilteringCombined:
"""Test combined allowed_numbers + allowed_groups."""
def test_allowed_number_in_dm(self):
bot = _make_bot(
allowed_numbers=["1234567890"],
allowed_groups=["[email protected]"],
)
sender = "[email protected]"
sender_num = sender.split("@")[0].split(":")[0]
assert sender_num in bot._allowed_numbers
def test_allowed_group_still_works(self):
combined = _make_bot(
allowed_numbers=["1234567890"],
allowed_groups=["[email protected]"],
)
assert "[email protected]" in combined._allowed_groups
def test_disallowed_number_not_in_groups_rejected(self):
combined = _make_bot(
allowed_numbers=["1234567890"],
allowed_groups=["[email protected]"],
)
sender = "[email protected]"
sender_num = sender.split("@")[0].split(":")[0]
assert sender_num not in combined._allowed_numbers
def test_self_message_always_passes_combined(self):
combined = _make_bot(
allowed_numbers=["1234567890"],
allowed_groups=["[email protected]"],
)
# Even with allowlists, self should pass
event = _make_neonize_event(is_from_me=True, sender="[email protected]")
assert bool(event.Info.MessageSource.IsFromMe) is True
assert combined._respond_to_all is False # verify bot config used
# ── End-to-End Filtering Simulation ──────────────────────────────
class TestFilteringEndToEnd:
"""Simulate the actual filtering logic from _on_web_message.
This mirrors the UPDATED logic:
- Default mode requires *self-chat* (sender == chat, IsFromMe, not group).
- IsFromMe alone in another person's chat is REJECTED.
- Allowlists and respond_to_all bypass as before.
"""
@staticmethod
def _jid_user(jid_str: str) -> str:
return jid_str.split("@")[0].split(":")[0] if jid_str else ""
def _should_process(self, bot, is_from_me, is_group, sender_jid, chat_jid):
"""Reproduce the UPDATED filtering logic from _on_web_message."""
if bot._respond_to_all:
return True
is_self_chat = (
is_from_me
and not is_group
and self._jid_user(sender_jid) == self._jid_user(chat_jid)
)
if is_self_chat:
return True
if is_group and bot._allowed_groups:
chat_jid_str = chat_jid.split("@")[0] if "@" in chat_jid else chat_jid
if chat_jid in bot._allowed_groups or chat_jid_str in bot._allowed_groups:
return True
return False
if not is_group and bot._allowed_numbers:
sender_num = self._jid_user(sender_jid)
if sender_num in bot._allowed_numbers:
return True
return False
# Not self-chat, not in any allowlist
return False
# ── Default self-chat mode ──────────────────────────────────
def test_e2e_self_chat_passes(self):
bot = _make_bot()
# True self-chat: sender==chat, is_from_me
assert self._should_process(
bot, True, False, "[email protected]", "[email protected]"
) is True
def test_e2e_is_from_me_other_chat_rejected(self):
"""IsFromMe in another person's chat should NOT pass default filter."""
bot = _make_bot()
assert self._should_process(
bot, True, False, "[email protected]", "[email protected]"
) is False
def test_e2e_other_person_rejected(self):
bot = _make_bot()
assert self._should_process(
bot, False, False, "[email protected]", "[email protected]"
) is False
def test_e2e_group_rejected_by_default(self):
bot = _make_bot()
assert self._should_process(
bot, False, True, "[email protected]", "[email protected]"
) is False
def test_e2e_is_from_me_in_group_rejected(self):
"""IsFromMe in a group chat should NOT pass default filter."""
bot = _make_bot()
assert self._should_process(
bot, True, True, "[email protected]", "[email protected]"
) is False
# ── respond_to_all ──────────────────────────────────────────
def test_e2e_respond_to_all(self):
bot = _make_bot(respond_to_all=True)
assert self._should_process(bot, True, False, "me@s", "me@s") is True
assert self._should_process(bot, False, False, "other@s", "other@s") is True
assert self._should_process(bot, False, True, "other@s", "[email protected]") is True
# IsFromMe in other chat also passes with respond_to_all
assert self._should_process(bot, True, False, "me@s", "friend@s") is True
# ── allowed_numbers ─────────────────────────────────────────
def test_e2e_allowed_numbers(self):
bot = _make_bot(allowed_numbers=["1234567890"])
# Self-chat always passes
assert self._should_process(
bot, True, False, "[email protected]", "[email protected]"
) is True
# Allowed number passes
assert self._should_process(
bot, False, False, "[email protected]", "[email protected]"
) is True
# Disallowed number rejected
assert self._should_process(
bot, False, False, "[email protected]", "[email protected]"
) is False
# Group message still rejected (no group allowlist)
assert self._should_process(
bot, False, True, "[email protected]", "[email protected]"
) is False
# ── allowed_groups ──────────────────────────────────────────
def test_e2e_allowed_groups(self):
bot = _make_bot(allowed_groups=["[email protected]"])
# Self-chat passes
assert self._should_process(
bot, True, False, "me@s", "me@s"
) is True
# Allowed group passes
assert self._should_process(
bot, False, True, "other@s", "[email protected]"
) is True
# Disallowed group rejected
assert self._should_process(
bot, False, True, "other@s", "[email protected]"
) is False
# DM from other rejected
assert self._should_process(
bot, False, False, "[email protected]", "[email protected]"
) is False
# ── combined ────────────────────────────────────────────────
def test_e2e_combined(self):
bot = _make_bot(
allowed_numbers=["5551234"],
allowed_groups=["[email protected]"],
)
# Self-chat passes
assert self._should_process(
bot, True, False, "[email protected]", "[email protected]"
) is True
# IsFromMe in OTHER chat does NOT pass
assert self._should_process(
bot, True, False, "[email protected]", "[email protected]"
) is False
# Allowed number
assert self._should_process(
bot, False, False, "[email protected]", "[email protected]"
) is True
# Disallowed number
assert self._should_process(
bot, False, False, "[email protected]", "[email protected]"
) is False
# Allowed group
assert self._should_process(
bot, False, True, "other@s", "[email protected]"
) is True
# Disallowed group
assert self._should_process(
bot, False, True, "other@s", "[email protected]"
) is False
# ── Timestamp Conversion ─────────────────────────────────────
class TestTimestampConversion:
"""Test millisecond→second conversion for neonize timestamps."""
def test_millisecond_timestamp_detected(self):
"""Value > 1e12 should be treated as milliseconds."""
raw_ts = 1700000000000 # ms
ts = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
assert abs(ts - 1700000000.0) < 0.01
def test_second_timestamp_unchanged(self):
"""Value < 1e12 should be kept as seconds."""
raw_ts = 1700000000 # already seconds
ts = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
assert abs(ts - 1700000000.0) < 0.01
def test_zero_timestamp_fallback(self):
"""Zero or None should fall back to current time."""
import time
raw_ts = 0
if isinstance(raw_ts, (int, float)) and raw_ts > 0:
ts = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
else:
ts = time.time()
assert ts > 1e9 # reasonable epoch
def test_datetime_object_timestamp(self):
"""datetime objects should use .timestamp() method."""
from unittest.mock import MagicMock
fake_dt = MagicMock()
fake_dt.timestamp.return_value = 1700000000.0
assert hasattr(fake_dt, 'timestamp')
assert fake_dt.timestamp() == 1700000000.0
# ── Stale Message Guard ──────────────────────────────────────
class TestStaleMessageGuard:
"""Test that messages older than connected_at are dropped."""
def test_stale_message_dropped(self):
"""Message timestamp < connected_at → should be dropped."""
connected_at = 1700000100.0
msg_ts = 1700000050.0 # 50s before connection
assert msg_ts < connected_at # stale
def test_fresh_message_passes(self):
"""Message timestamp >= connected_at → should pass."""
connected_at = 1700000100.0
msg_ts = 1700000150.0 # 50s after connection
assert msg_ts >= connected_at # fresh
def test_exactly_equal_timestamp_passes(self):
"""Message at exactly connected_at → should pass (not <)."""
connected_at = 1700000100.0
msg_ts = 1700000100.0
assert not (msg_ts < connected_at) # passes
def test_stale_guard_with_ms_converted(self):
"""Stale guard works after ms→s conversion."""
connected_at = 1700000100.0
raw_ms = 1700000050000 # 50s before, in ms
ts = raw_ms / 1000.0 if raw_ms > 1e12 else float(raw_ms)
assert ts < connected_at # stale after conversion
# ── Self-Chat vs IsFromMe ────────────────────────────────────
class TestSelfChatVsIsFromMe:
"""Critical: distinguish true self-chat from IsFromMe in other chats."""
@staticmethod
def _jid_user(jid_str):
return jid_str.split("@")[0].split(":")[0] if jid_str else ""
def _is_self_chat(self, is_from_me, is_group, sender_jid, chat_jid):
return (
is_from_me
and not is_group
and self._jid_user(sender_jid) == self._jid_user(chat_jid)
)
def test_self_chat_true(self):
assert self._is_self_chat(
True, False, "[email protected]", "[email protected]"
) is True
def test_self_chat_with_device_suffix(self):
"""Sender may have :device_id suffix — should still match."""
assert self._is_self_chat(
True, False, "1234:[email protected]", "[email protected]"
) is True
def test_not_self_chat_different_numbers(self):
"""IsFromMe but chat is a different person → NOT self-chat."""
assert self._is_self_chat(
True, False, "[email protected]", "[email protected]"
) is False
def test_not_self_chat_when_group(self):
"""IsFromMe in a group → NOT self-chat."""
assert self._is_self_chat(
True, True, "[email protected]", "[email protected]"
) is False
def test_not_self_chat_when_not_from_me(self):
"""Not from me at all → NOT self-chat."""
assert self._is_self_chat(
False, False, "[email protected]", "[email protected]"
) is False
def test_empty_jids(self):
assert self._is_self_chat(True, False, "", "") is True # both empty → match
assert self._is_self_chat(True, False, "1234@s", "") is False
# ── CLI Flag Parsing ─────────────────────────────────────────────
class TestCLIFlagParsing:
"""Test CLI argument parsing for filtering flags."""
def _parse_whatsapp_args(self, *args):
import argparse
from praisonai.cli.features.bots_cli import add_bot_parser
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
add_bot_parser(subparsers)
return parser.parse_args(["bot", "whatsapp"] + list(args))
def test_no_filter_flags(self):
args = self._parse_whatsapp_args("--mode", "web")
assert getattr(args, "respond_to", None) is None
assert getattr(args, "respond_to_groups", None) is None
assert getattr(args, "respond_to_all", False) is False
def test_respond_to_flag(self):
args = self._parse_whatsapp_args("--mode", "web", "--respond-to", "123,456")
assert args.respond_to == "123,456"
nums = [n.strip() for n in args.respond_to.split(",") if n.strip()]
assert nums == ["123", "456"]
def test_respond_to_groups_flag(self):
args = self._parse_whatsapp_args(
"--mode", "web",
"--respond-to-groups", "[email protected],[email protected]",
)
groups = [g.strip() for g in args.respond_to_groups.split(",") if g.strip()]
assert groups == ["[email protected]", "[email protected]"]
def test_respond_to_all_flag(self):
args = self._parse_whatsapp_args("--mode", "web", "--respond-to-all")
assert args.respond_to_all is True
def test_all_filter_flags_combined(self):
args = self._parse_whatsapp_args(
"--mode", "web",
"--respond-to", "555",
"--respond-to-groups", "[email protected]",
"--respond-to-all",
)
assert args.respond_to == "555"
assert args.respond_to_groups == "[email protected]"
assert args.respond_to_all is True
# ── CLI → WhatsAppBot Wiring ─────────────────────────────────────
class TestCLIToWhatsAppBotWiring:
"""Test that CLI flags are correctly wired to WhatsAppBot constructor."""
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
def test_respond_to_wired(self, mock_dotenv, mock_load_agent):
mock_load_agent.return_value = MagicMock()
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
with patch("praisonai.bots.whatsapp.WhatsAppBot") as MockBot:
MockBot.return_value = MagicMock()
MockBot.return_value.start = AsyncMock()
handler.start_whatsapp(
mode="web",
allowed_numbers=["1234567890", "5555555"],
allowed_groups=["[email protected]"],
respond_to_all=False,
)
# Verify WhatsAppBot was called with filtering args
call_kwargs = MockBot.call_args[1]
assert call_kwargs["allowed_numbers"] == ["1234567890", "5555555"]
assert call_kwargs["allowed_groups"] == ["[email protected]"]
assert call_kwargs["respond_to_all"] is False
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
def test_respond_to_all_wired(self, mock_dotenv, mock_load_agent):
mock_load_agent.return_value = MagicMock()
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
with patch("praisonai.bots.whatsapp.WhatsAppBot") as MockBot:
MockBot.return_value = MagicMock()
MockBot.return_value.start = AsyncMock()
handler.start_whatsapp(
mode="web",
respond_to_all=True,
)
call_kwargs = MockBot.call_args[1]
assert call_kwargs["respond_to_all"] is True
# ── YAML Config Wiring ───────────────────────────────────────────
class TestYAMLConfigWiring:
"""Test that YAML config fields are parsed and wired correctly."""
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
def test_yaml_respond_to_list(self, mock_dotenv, mock_load_agent):
"""YAML respond_to as list."""
mock_load_agent.return_value = MagicMock()
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
config = {
"platform": "whatsapp",
"mode": "web",
"respond_to": ["1234567890", "9876543210"],
"agent": {"name": "test"},
}
with patch("praisonai.cli.features.bots_cli.BotHandler.start_whatsapp") as mock_start:
with patch("builtins.open", create=True):
with patch("yaml.safe_load", return_value=config):
handler.start_from_config("bot.yaml")
if mock_start.called:
call_kwargs = mock_start.call_args[1]
assert call_kwargs.get("allowed_numbers") == ["1234567890", "9876543210"]
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
def test_yaml_respond_to_string(self, mock_dotenv, mock_load_agent):
"""YAML respond_to as comma-separated string."""
mock_load_agent.return_value = MagicMock()
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
config = {
"platform": "whatsapp",
"mode": "web",
"respond_to": "123,456",
"agent": {"name": "test"},
}
with patch("praisonai.cli.features.bots_cli.BotHandler.start_whatsapp") as mock_start:
with patch("builtins.open", create=True):
with patch("yaml.safe_load", return_value=config):
handler.start_from_config("bot.yaml")
if mock_start.called:
call_kwargs = mock_start.call_args[1]
assert call_kwargs.get("allowed_numbers") == ["123", "456"]
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
def test_yaml_respond_to_all(self, mock_dotenv, mock_load_agent):
"""YAML respond_to_all: true."""
mock_load_agent.return_value = MagicMock()
from praisonai.cli.features.bots_cli import BotHandler
handler = BotHandler()
config = {
"platform": "whatsapp",
"mode": "web",
"respond_to_all": True,
"agent": {"name": "test"},
}
with patch("praisonai.cli.features.bots_cli.BotHandler.start_whatsapp") as mock_start:
with patch("builtins.open", create=True):
with patch("yaml.safe_load", return_value=config):
handler.start_from_config("bot.yaml")
if mock_start.called:
call_kwargs = mock_start.call_args[1]
assert call_kwargs.get("respond_to_all") is True
# ── Edge Cases ───────────────────────────────────────────────────
class TestEdgeCases:
"""Test edge cases in filtering."""
def test_cloud_mode_ignores_filtering_params(self):
"""Cloud mode should accept but not break with filtering params."""
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(
token="t",
phone_number_id="p",
mode="cloud",
allowed_numbers=["123"],
allowed_groups=["[email protected]"],
respond_to_all=True,
)
# Should not crash; params are stored but only used in web mode
assert bot._respond_to_all is True
assert "123" in bot._allowed_numbers
def test_empty_allowed_numbers_list(self):
bot = _make_bot(allowed_numbers=[])
assert bot._allowed_numbers == set()
def test_empty_allowed_groups_list(self):
bot = _make_bot(allowed_groups=[])
assert bot._allowed_groups == set()
def test_non_digit_input_ignored(self):
"""Input with no digits should be silently ignored."""
bot = _make_bot(allowed_numbers=["abc", "+++"])
assert bot._allowed_numbers == set()
def test_group_jid_without_at_sign(self):
"""Group ID without @g.us should still match."""
bot = _make_bot(allowed_groups=["120363123456"])
assert "120363123456" in bot._allowed_groups
@@ -0,0 +1,317 @@
"""
Unit tests for WhatsApp Web adapter (_whatsapp_web_adapter.py).
Tests the neonize adapter isolation layer with mocked neonize client.
All tests are deterministic — no external WhatsApp calls.
"""
import os
import sys
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Ensure the wrapper package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "praisonai"))
# ── Adapter import ────────────────────────────────────────────────
class TestAdapterImport:
"""Test that the adapter module can be imported without neonize."""
def test_import_adapter_module(self):
"""Adapter module imports without neonize installed."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter is not None
def test_default_creds_dir(self):
"""Default creds dir is ~/.praisonai/whatsapp."""
from praisonai.bots._whatsapp_web_adapter import DEFAULT_CREDS_DIR
assert ".praisonai" in DEFAULT_CREDS_DIR
assert "whatsapp" in DEFAULT_CREDS_DIR
# ── Adapter construction ──────────────────────────────────────────
class TestAdapterConstruction:
"""Test WhatsAppWebAdapter initialization."""
def test_default_construction(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
assert adapter.is_connected is False
assert adapter.self_jid is None
assert ".praisonai" in adapter.creds_dir
def test_custom_creds_dir(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter(creds_dir="/tmp/test-wa-creds")
assert adapter.creds_dir == "/tmp/test-wa-creds"
def test_custom_bot_name(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter(bot_name="my_bot")
assert adapter._bot_name == "my_bot"
def test_env_var_creds_dir(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
with patch.dict(os.environ, {"WHATSAPP_CREDS_DIR": "/tmp/env-creds"}):
adapter = WhatsAppWebAdapter()
assert adapter.creds_dir == "/tmp/env-creds"
def test_callbacks_stored(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
on_msg = MagicMock()
on_qr = MagicMock()
on_conn = MagicMock()
on_disc = MagicMock()
adapter = WhatsAppWebAdapter(
on_message=on_msg,
on_qr=on_qr,
on_connected=on_conn,
on_disconnected=on_disc,
)
assert adapter._on_message is on_msg
assert adapter._on_qr is on_qr
assert adapter._on_connected is on_conn
assert adapter._on_disconnected is on_disc
# ── Credential management ─────────────────────────────────────────
class TestCredentialManagement:
"""Test credential path and session detection."""
def test_ensure_creds_dir_creates_directory(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
with tempfile.TemporaryDirectory() as tmpdir:
creds_path = os.path.join(tmpdir, "subdir", "wa")
adapter = WhatsAppWebAdapter(creds_dir=creds_path)
adapter._ensure_creds_dir()
assert os.path.isdir(creds_path)
def test_get_db_path(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
with tempfile.TemporaryDirectory() as tmpdir:
adapter = WhatsAppWebAdapter(creds_dir=tmpdir, bot_name="testbot")
db_path = adapter._get_db_path()
assert db_path.endswith("testbot.db")
assert tmpdir in db_path
def test_has_saved_session_false_when_no_db(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
with tempfile.TemporaryDirectory() as tmpdir:
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
assert adapter.has_saved_session() is False
def test_has_saved_session_true_when_db_exists(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
with tempfile.TemporaryDirectory() as tmpdir:
adapter = WhatsAppWebAdapter(creds_dir=tmpdir, bot_name="test")
# Create a fake db file
db_path = os.path.join(tmpdir, "test.db")
with open(db_path, "w") as f:
f.write("fake-db-content")
assert adapter.has_saved_session() is True
@pytest.mark.asyncio
async def test_logout_removes_db_files(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
with tempfile.TemporaryDirectory() as tmpdir:
adapter = WhatsAppWebAdapter(creds_dir=tmpdir, bot_name="test")
# Create fake db files
for suffix in [".db", ".db-wal", ".db-shm"]:
path = os.path.join(tmpdir, f"test{suffix}")
with open(path, "w") as f:
f.write("x")
assert adapter.has_saved_session() is True
await adapter.logout()
assert adapter.has_saved_session() is False
assert not os.path.exists(os.path.join(tmpdir, "test.db-wal"))
assert not os.path.exists(os.path.join(tmpdir, "test.db-shm"))
# ── JID normalization ─────────────────────────────────────────────
class TestJIDNormalization:
"""Test phone number to JID conversion."""
def test_already_jid(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
def test_phone_number(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("15551234567") == "[email protected]"
def test_phone_with_plus(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("+15551234567") == "[email protected]"
def test_group_jid_passthrough(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
def test_phone_with_spaces_and_dashes(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
assert WhatsAppWebAdapter._normalize_jid("+1 555-123-4567") == "[email protected]"
# ── Connect / Disconnect ──────────────────────────────────────────
class TestConnectDisconnect:
"""Test connect and disconnect lifecycle (mocked neonize)."""
@pytest.mark.asyncio
async def test_connect_raises_without_neonize(self):
"""connect() raises ImportError when neonize is not installed."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter(creds_dir="/tmp/test-no-neonize")
with patch.dict(sys.modules, {
"neonize": None,
"neonize.aioze": None,
"neonize.aioze.client": None,
"neonize.aioze.events": None,
}):
with pytest.raises(ImportError, match="neonize"):
await adapter.connect()
@pytest.mark.asyncio
async def test_disconnect_when_not_connected(self):
"""disconnect() is safe to call when not connected."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
await adapter.disconnect()
assert adapter.is_connected is False
@pytest.mark.asyncio
async def test_disconnect_calls_client_disconnect(self):
"""disconnect() calls client.disconnect() if client exists."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
mock_client = AsyncMock()
adapter._client = mock_client
adapter._is_connected = True
await adapter.disconnect()
mock_client.disconnect.assert_called_once()
assert adapter.is_connected is False
assert adapter._client is None
# ── Send message ──────────────────────────────────────────────────
class TestSendMessage:
"""Test send_message with mocked client."""
@pytest.mark.asyncio
async def test_send_when_not_connected_returns_none(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
result = await adapter.send_message("[email protected]", "hello")
assert result is None
@pytest.mark.asyncio
async def test_send_reaction_when_not_connected(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
# Should not raise
await adapter.send_reaction("[email protected]", "msg123", "👍")
@pytest.mark.asyncio
async def test_mark_as_read_when_not_connected(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
# Should not raise
await adapter.mark_as_read("[email protected]", "msg123")
# ── QR code display ───────────────────────────────────────────────
class TestQRCodeDisplay:
"""Test QR code terminal display (fallback chain: segno → qrcode → raw)."""
def test_print_qr_with_segno(self, capsys):
"""Uses segno (neonize dep) when available — first in chain."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
mock_segno = MagicMock()
mock_qr_obj = MagicMock()
mock_segno.make_qr.return_value = mock_qr_obj
with patch.dict(sys.modules, {"segno": mock_segno}):
WhatsAppWebAdapter._print_qr_terminal("test-qr-data")
mock_segno.make_qr.assert_called_once_with("test-qr-data")
mock_qr_obj.terminal.assert_called_once_with(compact=True)
def test_print_qr_falls_back_to_qrcode(self):
"""Falls back to qrcode when segno fails."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
mock_qr = MagicMock()
mock_qr_module = MagicMock()
mock_qr_module.QRCode.return_value = mock_qr
# segno raises, qrcode works
mock_segno = MagicMock()
mock_segno.make_qr.side_effect = Exception("segno fail")
with patch.dict(sys.modules, {"segno": mock_segno, "qrcode": mock_qr_module}):
WhatsAppWebAdapter._print_qr_terminal("test-qr-data")
mock_qr_module.QRCode.assert_called_once()
mock_qr.add_data.assert_called_once_with("test-qr-data")
def test_print_qr_fallback_raw_text(self, capsys):
"""Falls back to raw text when both segno and qrcode fail."""
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
mock_segno = MagicMock()
mock_segno.make_qr.side_effect = Exception("segno fail")
original_import = __import__
def mock_import(name, *args, **kwargs):
if name == "qrcode":
raise ImportError("No module named 'qrcode'")
return original_import(name, *args, **kwargs)
with patch.dict(sys.modules, {"segno": mock_segno}):
with patch("builtins.__import__", side_effect=mock_import):
WhatsAppWebAdapter._print_qr_terminal("test-qr-data-fallback")
captured = capsys.readouterr()
assert "QR" in captured.out or "segno" in captured.out.lower()
# ── Safe callback ─────────────────────────────────────────────────
class TestSafeCallback:
"""Test the _safe_callback helper."""
@pytest.mark.asyncio
async def test_sync_callback(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
called = []
def cb(x):
called.append(x)
await adapter._safe_callback(cb, "test")
assert called == ["test"]
@pytest.mark.asyncio
async def test_async_callback(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
called = []
async def cb(x):
called.append(x)
await adapter._safe_callback(cb, "async-test")
assert called == ["async-test"]
@pytest.mark.asyncio
async def test_callback_error_does_not_raise(self):
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
adapter = WhatsAppWebAdapter()
def cb():
raise ValueError("boom")
# Should not raise
await adapter._safe_callback(cb)
@@ -0,0 +1,265 @@
"""
Unit tests for WhatsAppBot dual-mode (cloud/web) support.
Tests backward compatibility with existing Cloud API mode and
new Web mode constructor/lifecycle.
"""
import os
import sys
from unittest.mock import AsyncMock, patch
import pytest
# Ensure the wrapper package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "praisonai"))
# ── Mode constructor ──────────────────────────────────────────────
class TestModeConstructor:
"""Test WhatsAppBot mode parameter."""
def test_default_mode_is_cloud(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="test-token", phone_number_id="123")
assert bot.mode == "cloud"
assert bot._mode == "cloud"
def test_explicit_cloud_mode(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="test-token", mode="cloud")
assert bot.mode == "cloud"
def test_web_mode(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
assert bot.mode == "web"
def test_web_mode_no_token_required(self):
"""Web mode should not require token or phone_number_id."""
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
assert bot._token == ""
assert bot._phone_number_id == ""
def test_invalid_mode_raises(self):
from praisonai.bots.whatsapp import WhatsAppBot
with pytest.raises(ValueError, match="Invalid mode"):
WhatsAppBot(mode="invalid")
def test_mode_case_insensitive(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="WEB")
assert bot.mode == "web"
def test_mode_whitespace_stripped(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode=" web ")
assert bot.mode == "web"
# ── Backward compatibility ────────────────────────────────────────
class TestBackwardCompatibility:
"""Ensure existing Cloud API usage is unchanged."""
def test_cloud_mode_with_all_params(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(
token="EAA-test",
phone_number_id="123456",
verify_token="my-verify",
app_secret="my-secret",
webhook_port=9090,
webhook_path="/hook",
)
assert bot._token == "EAA-test"
assert bot._phone_number_id == "123456"
assert bot._verify_token == "my-verify"
assert bot._app_secret == "my-secret"
assert bot._webhook_port == 9090
assert bot._webhook_path == "/hook"
assert bot.mode == "cloud"
def test_token_from_env_var(self):
from praisonai.bots.whatsapp import WhatsAppBot
with patch.dict(os.environ, {"WHATSAPP_ACCESS_TOKEN": "env-token"}):
bot = WhatsAppBot()
assert bot._token == "env-token"
def test_phone_id_from_env_var(self):
from praisonai.bots.whatsapp import WhatsAppBot
with patch.dict(os.environ, {"WHATSAPP_PHONE_NUMBER_ID": "env-phone"}):
bot = WhatsAppBot()
assert bot._phone_number_id == "env-phone"
def test_platform_property(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="t")
assert bot.platform == "whatsapp"
def test_is_running_initially_false(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="t")
assert bot.is_running is False
# ── Web mode properties ───────────────────────────────────────────
class TestWebModeProperties:
"""Test Web mode specific behavior."""
def test_creds_dir_param(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web", creds_dir="/tmp/wa-creds")
assert bot._creds_dir == "/tmp/wa-creds"
def test_creds_dir_default_none(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
assert bot._creds_dir is None # adapter will use its own default
def test_web_adapter_initially_none(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
assert bot._web_adapter is None
def test_web_mode_has_session_manager(self):
"""Web mode still uses BotSessionManager for per-user isolation."""
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
assert bot._session_mgr is not None
# ── Start delegation ──────────────────────────────────────────────
class TestStartDelegation:
"""Test that start() delegates to correct mode handler."""
@pytest.mark.asyncio
async def test_cloud_mode_calls_start_cloud(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="test", phone_number_id="123", mode="cloud")
bot._start_cloud_mode = AsyncMock()
await bot.start()
bot._start_cloud_mode.assert_called_once()
@pytest.mark.asyncio
async def test_web_mode_calls_start_web(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
bot._start_web_mode = AsyncMock()
await bot.start()
bot._start_web_mode.assert_called_once()
# ── Stop handles both modes ───────────────────────────────────────
class TestStop:
"""Test stop() cleanup for both modes."""
@pytest.mark.asyncio
async def test_stop_cloud_mode(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="t", mode="cloud")
bot._is_running = True
bot._site = AsyncMock()
bot._runner = AsyncMock()
await bot.stop()
assert bot._is_running is False
bot._site.stop.assert_called_once()
bot._runner.cleanup.assert_called_once()
@pytest.mark.asyncio
async def test_stop_web_mode(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
bot._is_running = True
mock_adapter = AsyncMock()
bot._web_adapter = mock_adapter
await bot.stop()
assert bot._is_running is False
mock_adapter.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_stop_web_mode_adapter_error_handled(self):
"""Stop should not raise even if adapter disconnect fails."""
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
bot._is_running = True
mock_adapter = AsyncMock()
mock_adapter.disconnect.side_effect = Exception("disconnect error")
bot._web_adapter = mock_adapter
# Should not raise
await bot.stop()
assert bot._is_running is False
# ── Command registration ──────────────────────────────────────────
class TestCommandRegistration:
"""Test that built-in commands work in both modes."""
def test_cloud_mode_has_builtins(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(token="t", mode="cloud")
cmds = bot.list_commands()
cmd_names = [c.name for c in cmds]
assert "status" in cmd_names
assert "new" in cmd_names
assert "help" in cmd_names
def test_web_mode_has_builtins(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
cmds = bot.list_commands()
cmd_names = [c.name for c in cmds]
assert "status" in cmd_names
assert "new" in cmd_names
assert "help" in cmd_names
def test_custom_command_in_web_mode(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
async def my_cmd(msg):
return "custom!"
bot.register_command("mycommand", my_cmd, description="Test")
cmds = bot.list_commands()
cmd_names = [c.name for c in cmds]
assert "mycommand" in cmd_names
# ── Web send helper ───────────────────────────────────────────────
class TestWebSend:
"""Test _web_send helper method."""
@pytest.mark.asyncio
async def test_web_send_when_adapter_connected(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
mock_adapter = AsyncMock()
mock_adapter.is_connected = True
bot._web_adapter = mock_adapter
await bot._web_send("[email protected]", "hello")
mock_adapter.send_message.assert_called_once_with("[email protected]", "hello")
@pytest.mark.asyncio
async def test_web_send_when_adapter_not_connected(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
mock_adapter = AsyncMock()
mock_adapter.is_connected = False
bot._web_adapter = mock_adapter
await bot._web_send("[email protected]", "hello")
mock_adapter.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_web_send_when_no_adapter(self):
from praisonai.bots.whatsapp import WhatsAppBot
bot = WhatsAppBot(mode="web")
# Should not raise
await bot._web_send("[email protected]", "hello")
+1 -1
View File
@@ -2549,7 +2549,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.4.6"
version = "1.5.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
# Install praisonai with bot dependencies
RUN pip install --no-cache-dir praisonai[bot]
# Copy config (user provides at runtime via volume or build arg)
COPY bot.yaml /app/bot.yaml
# Run the bot
CMD ["python", "-m", "praisonai", "bot", "start", "--config", "/app/bot.yaml"]
+20
View File
@@ -0,0 +1,20 @@
version: "3.8"
services:
bot:
build:
context: .
dockerfile: Dockerfile.bot
restart: always
env_file:
- .env
volumes:
- ./bot.yaml:/app/bot.yaml:ro
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
- SLACK_APP_TOKEN=${SLACK_APP_TOKEN:-}
- WHATSAPP_ACCESS_TOKEN=${WHATSAPP_ACCESS_TOKEN:-}
- WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID:-}
+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/v4.4.7.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.4.7.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+36 -2
View File
@@ -1,7 +1,8 @@
"""
Bot implementations for PraisonAI.
Provides messaging bot runtimes for Telegram, Discord, and Slack.
Provides messaging bot runtimes for Telegram, Discord, Slack, and WhatsApp,
plus the user-friendly Bot and BotOS orchestrator classes.
"""
from typing import TYPE_CHECKING
@@ -11,6 +12,13 @@ if TYPE_CHECKING:
from .discord import DiscordBot
from .slack import SlackBot
from .whatsapp import WhatsAppBot
from .bot import Bot
from .botos import BotOS
from ._slack_approval import SlackApproval
from ._telegram_approval import TelegramApproval
from ._discord_approval import DiscordApproval
from ._webhook_approval import WebhookApproval
from ._http_approval import HTTPApproval
def __getattr__(name: str):
"""Lazy loading of bot components."""
@@ -26,6 +34,32 @@ def __getattr__(name: str):
if name == "WhatsAppBot":
from .whatsapp import WhatsAppBot
return WhatsAppBot
if name == "Bot":
from .bot import Bot
return Bot
if name == "BotOS":
from .botos import BotOS
return BotOS
if name == "SlackApproval":
from ._slack_approval import SlackApproval
return SlackApproval
if name == "TelegramApproval":
from ._telegram_approval import TelegramApproval
return TelegramApproval
if name == "DiscordApproval":
from ._discord_approval import DiscordApproval
return DiscordApproval
if name == "WebhookApproval":
from ._webhook_approval import WebhookApproval
return WebhookApproval
if name == "HTTPApproval":
from ._http_approval import HTTPApproval
return HTTPApproval
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot"]
__all__ = [
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot",
"Bot", "BotOS",
"SlackApproval", "TelegramApproval", "DiscordApproval",
"WebhookApproval", "HTTPApproval",
]
+99
View File
@@ -0,0 +1,99 @@
"""
Ack reaction helpers for PraisonAI bots.
Reacts to inbound messages with a configurable emoji (e.g. ⏳) to
acknowledge receipt, then swaps to a "done" emoji (e.g. ✅) when
the agent response is sent. Inspired by OpenClaw's ack-reactions.ts.
Usage::
ack = AckReactor(ack_emoji="⏳", done_emoji="✅")
ctx = await ack.ack(channel_id, message_id, react_fn)
# ... process message ...
await ack.done(ctx, channel_id, message_id, react_fn, unreact_fn)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Callable, Coroutine, Optional
logger = logging.getLogger(__name__)
@dataclass
class AckContext:
"""Tracks state for a single ack-reaction lifecycle."""
acked: bool = False
emoji: str = ""
class AckReactor:
"""Lightweight ack-reaction manager.
Args:
ack_emoji: Emoji to react with on message receipt (empty = disabled).
done_emoji: Emoji to react with on completion (default "✅").
"""
def __init__(self, ack_emoji: str = "", done_emoji: str = "✅") -> None:
self._ack_emoji = ack_emoji
self._done_emoji = done_emoji
@property
def enabled(self) -> bool:
return bool(self._ack_emoji)
async def ack(
self,
react_fn: Callable[..., Coroutine],
**kwargs: Any,
) -> AckContext:
"""React with ack emoji. Returns context for later ``done()``.
Args:
react_fn: Platform-specific async function to add a reaction.
Called as ``await react_fn(emoji=self._ack_emoji, **kwargs)``.
**kwargs: Extra args forwarded to react_fn (chat_id, message_id, etc).
"""
ctx = AckContext()
if not self.enabled:
return ctx
try:
await react_fn(emoji=self._ack_emoji, **kwargs)
ctx.acked = True
ctx.emoji = self._ack_emoji
except Exception as e:
logger.debug(f"AckReactor: failed to ack: {e}")
return ctx
async def done(
self,
ctx: AckContext,
react_fn: Callable[..., Coroutine],
unreact_fn: Optional[Callable[..., Coroutine]] = None,
**kwargs: Any,
) -> None:
"""Swap ack emoji for done emoji.
Args:
ctx: AckContext returned from ``ack()``.
react_fn: Async function to add a reaction.
unreact_fn: Async function to remove a reaction (optional).
**kwargs: Extra args forwarded to react/unreact fns.
"""
if not ctx.acked:
return
# Remove ack emoji
if unreact_fn:
try:
await unreact_fn(emoji=ctx.emoji, **kwargs)
except Exception as e:
logger.debug(f"AckReactor: failed to remove ack: {e}")
# Add done emoji
if self._done_emoji:
try:
await react_fn(emoji=self._done_emoji, **kwargs)
except Exception as e:
logger.debug(f"AckReactor: failed to add done: {e}")
+149
View File
@@ -0,0 +1,149 @@
"""
Bot Approval Backend for PraisonAI.
Sends tool execution approval requests as in-platform messages
(e.g., inline keyboard on Telegram) and waits for user reply.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Dict
logger = logging.getLogger(__name__)
# Default timeout for approval requests (seconds)
DEFAULT_APPROVAL_TIMEOUT = 120
class BotApprovalBackend:
"""In-chat approval backend for bot tool execution.
Sends an approval request message to the user in-chat and waits
for their reply (yes/no) with a configurable timeout.
Default behavior: deny on timeout (safe by default).
Usage:
approval = BotApprovalBackend(bot_adapter, timeout=60)
approved = await approval.request_approval(
user_id="123",
channel_id="456",
tool_name="execute_command",
tool_args={"command": "ls -la"},
)
"""
def __init__(
self,
adapter: Any = None,
timeout: float = DEFAULT_APPROVAL_TIMEOUT,
default_on_timeout: bool = False,
):
self._adapter = adapter
self._timeout = timeout
self._default_on_timeout = default_on_timeout
self._pending: Dict[str, asyncio.Future] = {}
async def request_approval(
self,
user_id: str,
channel_id: str,
tool_name: str,
tool_args: Dict[str, Any],
description: str = "",
) -> bool:
"""Send an approval request and wait for user response.
Args:
user_id: User to ask for approval
channel_id: Channel to send the request in
tool_name: Name of the tool requesting approval
tool_args: Arguments the tool wants to execute with
description: Human-readable description of the action
Returns:
True if approved, False if denied or timed out
"""
request_id = f"approval_{user_id}_{int(time.time() * 1000)}"
# Format the approval message
args_preview = str(tool_args)[:200]
if description:
msg = f"🔐 Approval required:\n\n{description}\n\nTool: {tool_name}\nArgs: {args_preview}\n\nReply 'yes' to approve or 'no' to deny."
else:
msg = f"🔐 Approval required:\n\nTool: {tool_name}\nArgs: {args_preview}\n\nReply 'yes' to approve or 'no' to deny."
# Create a future to wait for the response
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending[request_id] = future
# Send the message
if self._adapter and hasattr(self._adapter, 'send_message'):
try:
await self._adapter.send_message(channel_id, msg)
except Exception as e:
logger.error(f"Failed to send approval request: {e}")
self._pending.pop(request_id, None)
return self._default_on_timeout
# Wait for response with timeout
try:
result = await asyncio.wait_for(future, timeout=self._timeout)
return result
except asyncio.TimeoutError:
logger.info(
f"Approval request timed out for {tool_name} "
f"(user={user_id}, default={'approve' if self._default_on_timeout else 'deny'})"
)
return self._default_on_timeout
finally:
self._pending.pop(request_id, None)
def resolve(self, request_id: str, approved: bool) -> bool:
"""Resolve a pending approval request.
Args:
request_id: The approval request ID
approved: Whether the request is approved
Returns:
True if the request was found and resolved
"""
future = self._pending.get(request_id)
if future and not future.done():
future.set_result(approved)
return True
return False
def resolve_by_user_reply(self, user_id: str, reply_text: str) -> bool:
"""Resolve the most recent pending approval for a user based on reply text.
Args:
user_id: The user who replied
reply_text: The reply text (checked for yes/no)
Returns:
True if a pending request was resolved
"""
reply_lower = reply_text.strip().lower()
approved = reply_lower in ("yes", "y", "approve", "ok", "confirm", "1", "true")
denied = reply_lower in ("no", "n", "deny", "reject", "cancel", "0", "false")
if not approved and not denied:
return False # Not an approval response
# Find most recent pending request for this user
for request_id in reversed(list(self._pending.keys())):
if f"_{user_id}_" in request_id:
return self.resolve(request_id, approved)
return False
@property
def pending_count(self) -> int:
"""Number of pending approval requests."""
return len(self._pending)
@@ -0,0 +1,50 @@
"""
Shared base class for messaging-platform approval backends.
Extracts the common sync-wrapper, timeout, poll_interval, and keyword
matching logic so that SlackApproval, TelegramApproval, and DiscordApproval
only need to implement the platform-specific bits.
This is an internal module — end users import the concrete classes.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Set
logger = logging.getLogger(__name__)
APPROVE_KEYWORDS: Set[str] = {
"yes", "y", "approve", "approved", "ok", "allow", "go", "proceed", "confirm",
}
DENY_KEYWORDS: Set[str] = {
"no", "n", "deny", "denied", "reject", "block", "stop", "cancel", "refuse",
}
def classify_keyword(text: str) -> str | None:
"""Classify *text* as ``'approve'``, ``'deny'``, or ``None``."""
t = text.strip().lower()
if t in APPROVE_KEYWORDS:
return "approve"
if t in DENY_KEYWORDS:
return "deny"
return None
def sync_wrapper(async_fn, timeout: float):
"""Run *async_fn* (a coroutine) synchronously, handling nested loops."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, async_fn)
return future.result(timeout=timeout + 10)
else:
return asyncio.run(async_fn)
+127
View File
@@ -0,0 +1,127 @@
"""
Markdown-aware message chunking for PraisonAI bots.
Splits long bot responses at paragraph boundaries while preserving
code fences and markdown structure. Inspired by OpenClaw's chunk.ts
but kept minimal (~120 lines).
Usage::
from praisonai.bots._chunk import chunk_message
chunks = chunk_message(text, max_length=4096, preserve_fences=True)
for chunk in chunks:
await bot.send_message(channel_id, chunk)
"""
from __future__ import annotations
import re
from typing import List
def chunk_message(
text: str,
max_length: int = 4096,
preserve_fences: bool = True,
) -> List[str]:
"""Split *text* into chunks of at most *max_length* characters.
Splitting strategy (in priority order):
1. **Paragraph boundaries** (blank lines ``\\n\\n``)
2. **Sentence boundaries** (``. `` followed by uppercase or newline)
3. **Hard split** at *max_length* (last resort)
When *preserve_fences* is True, code fence blocks are never split
even if they exceed *max_length*.
Args:
text: The message text to split.
max_length: Maximum characters per chunk.
preserve_fences: Keep code fences intact (default True).
Returns:
List of text chunks.
"""
if not text:
return [""]
if len(text) <= max_length:
return [text]
# Split into paragraphs (double newline)
paragraphs = re.split(r"\n\n", text)
chunks: List[str] = []
current = ""
for para in paragraphs:
candidate = (current + "\n\n" + para) if current else para
# Check if this paragraph is inside a fence span
# If the candidate is small enough, accumulate
if len(candidate) <= max_length:
current = candidate
continue
# Candidate too long — flush current first
if current:
chunks.append(current.strip())
current = ""
# Now handle the paragraph itself
if len(para) <= max_length:
current = para
elif preserve_fences and _is_fence_block(para):
# Code fence block — keep intact even if over limit
chunks.append(para.strip())
else:
# Paragraph too long — split at sentences or hard-split
sub_chunks = _split_long_paragraph(para, max_length)
chunks.extend(sub_chunks[:-1])
current = sub_chunks[-1] if sub_chunks else ""
if current.strip():
chunks.append(current.strip())
return chunks if chunks else [""]
def _is_fence_block(text: str) -> bool:
"""Check if text is (or contains) a code fence block."""
stripped = text.strip()
return stripped.startswith("```")
def _split_long_paragraph(text: str, max_length: int) -> List[str]:
"""Split a long paragraph at sentence boundaries, then hard-split."""
# Try sentence boundaries first
sentences = re.split(r"(?<=[.!?])\s+", text)
if len(sentences) > 1:
chunks: List[str] = []
current = ""
for sentence in sentences:
candidate = (current + " " + sentence) if current else sentence
if len(candidate) <= max_length:
current = candidate
else:
if current:
chunks.append(current.strip())
if len(sentence) <= max_length:
current = sentence
else:
# Sentence itself too long — hard split
chunks.extend(_hard_split(sentence, max_length))
current = ""
if current.strip():
chunks.append(current.strip())
return chunks if chunks else [""]
# No sentence boundaries — hard split
return _hard_split(text, max_length)
def _hard_split(text: str, max_length: int) -> List[str]:
"""Last-resort character-level split."""
return [text[i:i + max_length] for i in range(0, len(text), max_length)]
@@ -0,0 +1,152 @@
"""
Pydantic-based config schema validation for bot.yaml / gateway.yaml.
Provides clear, human-friendly error messages when configuration is invalid.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class AgentConfigSchema(BaseModel):
"""Schema for agent configuration in bot.yaml."""
name: str = "assistant"
instructions: str = ""
model: str = "gpt-4o-mini"
memory: bool = False
tools: List[str] = Field(default_factory=list)
class ChannelConfigSchema(BaseModel):
"""Schema for a single channel configuration."""
token: str = ""
group_policy: str = "respond_all" # respond_all, mention_only, command_only
allowlist: List[str] = Field(default_factory=list)
blocklist: List[str] = Field(default_factory=list)
webhook_url: Optional[str] = None
@field_validator("token")
@classmethod
def resolve_env_var(cls, v: str) -> str:
"""Resolve ${ENV_VAR} references in token."""
if v.startswith("${") and v.endswith("}"):
env_key = v[2:-1]
resolved = os.environ.get(env_key, "")
if not resolved:
raise ValueError(
f"Environment variable '{env_key}' not set. "
f"Set it with: export {env_key}=your_token"
)
return resolved
return v
@field_validator("group_policy")
@classmethod
def validate_group_policy(cls, v: str) -> str:
allowed = {"respond_all", "mention_only", "command_only"}
if v not in allowed:
raise ValueError(
f"Invalid group_policy '{v}'. Must be one of: {', '.join(sorted(allowed))}"
)
return v
class RoutingConfigSchema(BaseModel):
"""Schema for agent routing configuration."""
default: str = "assistant"
rules: Dict[str, str] = Field(default_factory=dict)
class DaemonConfigSchema(BaseModel):
"""Schema for daemon/service configuration."""
enabled: bool = False
restart: str = "always" # always, on_failure, never
@field_validator("restart")
@classmethod
def validate_restart(cls, v: str) -> str:
allowed = {"always", "on_failure", "never"}
if v not in allowed:
raise ValueError(f"Invalid restart policy '{v}'. Must be one of: {', '.join(sorted(allowed))}")
return v
class BotYamlSchema(BaseModel):
"""Full schema for bot.yaml configuration."""
agent: Optional[AgentConfigSchema] = None
channels: Dict[str, ChannelConfigSchema] = Field(default_factory=dict)
routing: Optional[RoutingConfigSchema] = None
daemon: Optional[DaemonConfigSchema] = None
@model_validator(mode="after")
def validate_channels_exist(self):
"""Ensure at least one channel is configured."""
if not self.channels:
raise ValueError(
"No channels configured. Add at least one channel "
"(telegram, discord, slack, whatsapp) to your bot.yaml"
)
# Validate channel names
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
for name in self.channels:
if name not in valid_platforms:
raise ValueError(
f"Unknown channel '{name}'. Supported: {', '.join(sorted(valid_platforms))}"
)
return self
def validate_bot_config(raw: Dict[str, Any]) -> BotYamlSchema:
"""Validate a raw YAML dict against the bot config schema.
Args:
raw: Parsed YAML dictionary
Returns:
Validated BotYamlSchema
Raises:
ValueError: With human-friendly error messages
"""
try:
return BotYamlSchema(**raw)
except Exception as e:
# Re-raise with user-friendly message
msg = str(e)
# Strip pydantic internals for cleaner output
if "validation error" in msg.lower():
lines = msg.split("\n")
clean_lines = [line for line in lines if line.strip() and not line.strip().startswith("For further")]
msg = "\n".join(clean_lines)
raise ValueError(f"Invalid bot.yaml configuration:\n{msg}") from None
def load_and_validate_bot_yaml(path: str) -> BotYamlSchema:
"""Load and validate a bot.yaml file.
Args:
path: Path to the YAML file
Returns:
Validated config schema
Raises:
FileNotFoundError: If file doesn't exist
ValueError: If config is invalid
"""
import yaml
if not os.path.exists(path):
raise FileNotFoundError(
f"Config file not found: {path}\n"
f"Run 'praisonai onboard' to create one, or create it manually."
)
with open(path) as f:
raw = yaml.safe_load(f) or {}
return validate_bot_config(raw)
+122
View File
@@ -0,0 +1,122 @@
"""
Inbound message debounce for PraisonAI bots.
Coalesces rapid messages from the same user into a single
agent.chat() call, preventing duplicate processing and wasted tokens.
Usage::
debouncer = InboundDebouncer(debounce_ms=1500)
coalesced = await debouncer.debounce("user123", "hello")
# If user sends "world" within 1500ms, both are joined:
# coalesced == "hello\\nworld"
"""
from __future__ import annotations
import asyncio
import logging
from typing import Dict, List
logger = logging.getLogger(__name__)
class InboundDebouncer:
"""Per-user message debouncer.
Buffers rapid messages from the same user and flushes them as a
single joined string after ``debounce_ms`` of silence.
Args:
debounce_ms: Debounce window in milliseconds. 0 = disabled
(immediate pass-through).
separator: String used to join coalesced messages.
"""
def __init__(
self,
debounce_ms: int = 0,
separator: str = "\n",
) -> None:
self._debounce_s = max(0, debounce_ms) / 1000.0
self._separator = separator
self._buffers: Dict[str, List[str]] = {}
self._timers: Dict[str, asyncio.TimerHandle] = {}
self._futures: Dict[str, List[asyncio.Future]] = {}
self._locks: Dict[str, asyncio.Lock] = {}
def _get_lock(self, user_id: str) -> asyncio.Lock:
if user_id not in self._locks:
self._locks[user_id] = asyncio.Lock()
return self._locks[user_id]
async def debounce(self, user_id: str, text: str) -> str:
"""Buffer *text* for *user_id* and return coalesced result.
If ``debounce_ms == 0``, returns *text* immediately (no buffering).
Otherwise, waits for the debounce window to expire, then returns
all buffered messages joined by ``separator``.
Returns:
The (possibly coalesced) message text.
"""
if self._debounce_s <= 0:
return text
lock = self._get_lock(user_id)
async with lock:
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
# Add to buffer
if user_id not in self._buffers:
self._buffers[user_id] = []
self._futures[user_id] = []
self._buffers[user_id].append(text)
self._futures[user_id].append(future)
# Cancel existing timer and reschedule
if user_id in self._timers:
self._timers[user_id].cancel()
self._timers[user_id] = loop.call_later(
self._debounce_s,
self._flush,
user_id,
)
# Wait for flush (outside lock so other messages can enqueue)
return await future
def _flush(self, user_id: str) -> None:
"""Flush the buffer for *user_id*, resolving all pending futures."""
messages = self._buffers.pop(user_id, [])
futures = self._futures.pop(user_id, [])
self._timers.pop(user_id, None)
if not messages:
return
coalesced = self._separator.join(messages)
for fut in futures:
if not fut.done():
fut.set_result(coalesced)
@property
def pending_count(self) -> int:
"""Number of users with buffered messages."""
return len(self._buffers)
def cancel_all(self) -> int:
"""Cancel all pending debounce timers. Returns count cancelled."""
count = 0
for user_id in list(self._timers.keys()):
handle = self._timers.pop(user_id, None)
if handle:
handle.cancel()
count += 1
# Resolve futures with whatever is buffered
self._flush(user_id)
return count
@@ -0,0 +1,337 @@
"""
Discord Approval Backend for PraisonAI Agents.
Implements the ApprovalProtocol by sending embed messages to a Discord channel
via REST API and polling for text-reply responses containing approval keywords.
Uses raw aiohttp HTTP calls to the Discord REST API — does NOT depend on
``discord.py`` to avoid gateway conflicts with a running DiscordBot.
Usage::
from praisonaiagents import Agent
from praisonai.bots import DiscordApproval
agent = Agent(
name="assistant",
tools=[execute_command],
approval=DiscordApproval(channel_id="123456789"), # token from DISCORD_BOT_TOKEN env
)
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any, Dict, Optional
from ._approval_base import classify_keyword, sync_wrapper
logger = logging.getLogger(__name__)
_DISCORD_API_BASE = "https://discord.com/api/v10"
class DiscordApproval:
"""Approval backend that sends Discord embeds and polls for text replies.
Posts a rich embed message to a Discord channel, then polls the channel
for text replies containing approval/denial keywords.
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
Args:
token: Discord bot token. Falls back to ``DISCORD_BOT_TOKEN`` env var.
channel_id: Discord channel ID to send approval requests to.
timeout: Max seconds to wait for a response (default 300 = 5 min).
poll_interval: Seconds between polls (default 3.0).
Example::
from praisonai.bots import DiscordApproval
agent = Agent(name="bot", approval=DiscordApproval(channel_id="1234567890"))
"""
def __init__(
self,
token: Optional[str] = None,
channel_id: Optional[str] = None,
timeout: float = 300,
poll_interval: float = 3.0,
):
self._token = token or os.environ.get("DISCORD_BOT_TOKEN", "")
if not self._token:
raise ValueError(
"Discord bot token is required. Pass token= or set DISCORD_BOT_TOKEN env var."
)
self._channel_id = channel_id or ""
self._timeout = timeout
self._poll_interval = poll_interval
def __repr__(self) -> str:
masked = f"...{self._token[-4:]}" if len(self._token) > 4 else "***"
return f"DiscordApproval(channel_id={self._channel_id!r}, token={masked!r})"
# ── Internal Discord API helper ────────────────────────────────────
async def _discord_api(
self,
method: str,
path: str,
payload: Optional[Dict[str, Any]] = None,
session: Optional[Any] = None,
) -> Dict[str, Any]:
"""Call a Discord REST API endpoint. Override in tests for mocking."""
import aiohttp
url = f"{_DISCORD_API_BASE}{path}"
headers = {
"Authorization": f"Bot {self._token}",
"Content-Type": "application/json",
}
async def _do_request(s):
if method.upper() == "GET":
async with s.get(
url, headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
return await resp.json()
else:
async with s.post(
url, headers=headers, json=payload or {},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
return await resp.json()
if session is not None:
return await _do_request(session)
else:
async with aiohttp.ClientSession() as _session:
return await _do_request(_session)
# ── ApprovalProtocol implementation ─────────────────────────────────
async def request_approval(self, request) -> Any:
"""Send a Discord embed and poll for approval/denial text reply."""
from praisonaiagents.approval.protocols import ApprovalDecision
import aiohttp
channel_id = self._channel_id
if not channel_id:
return ApprovalDecision(
approved=False,
reason="No Discord channel_id configured",
)
async with aiohttp.ClientSession() as session:
# 1. Post approval embed
embed = self._build_embed(request)
content = self._build_fallback_text(request)
try:
post_data = await self._discord_api("POST",
f"/channels/{channel_id}/messages",
{"content": content, "embeds": [embed]},
session=session,
)
msg_id = post_data.get("id")
if not msg_id:
return ApprovalDecision(
approved=False,
reason=f"Failed to post Discord message: {post_data.get('message', 'unknown')}",
)
# 2. Poll for text reply
decision = await self._poll_for_response(
channel_id, msg_id, session=session,
)
# 3. Update original message with result
await self._update_message(
channel_id, msg_id, request, decision, session=session,
)
return decision
except Exception as e:
logger.error(f"DiscordApproval error: {e}")
return ApprovalDecision(
approved=False,
reason=f"Discord approval error: {e}",
)
def request_approval_sync(self, request) -> Any:
"""Synchronous wrapper — runs async method in a new event loop."""
return sync_wrapper(self.request_approval(request), self._timeout)
# ── Embed Builder ──────────────────────────────────────────────────
def _build_embed(self, request) -> Dict[str, Any]:
"""Build a Discord embed for the approval message."""
risk_colors = {
"critical": 0xFF0000,
"high": 0xFF8C00,
"medium": 0xFFD700,
"low": 0x00FF00,
}
args_lines = []
for key, value in request.arguments.items():
val_str = str(value)
if len(val_str) > 100:
val_str = val_str[:97] + "..."
args_lines.append(f"`{key}`: `{val_str}`")
args_text = "\n".join(args_lines) if args_lines else "_(none)_"
fields = [
{"name": "Tool", "value": f"`{request.tool_name}`", "inline": True},
{"name": "Risk", "value": request.risk_level.upper(), "inline": True},
{"name": "Arguments", "value": args_text, "inline": False},
]
if request.agent_name:
fields.insert(2, {"name": "Agent", "value": request.agent_name, "inline": True})
return {
"title": "🔒 Tool Approval Required",
"color": risk_colors.get(request.risk_level, 0x808080),
"fields": fields,
"footer": {"text": f"Reply yes to approve or no to deny (timeout: {int(self._timeout)}s)"},
}
def _build_fallback_text(self, request) -> str:
return (
f"🔒 Tool Approval Required: `{request.tool_name}` "
f"(risk: {request.risk_level}). "
f"Reply yes to approve or no to deny."
)
# ── Polling ─────────────────────────────────────────────────────────
async def _poll_for_response(
self,
channel_id: str,
message_id: str,
session: Optional[Any] = None,
) -> Any:
"""Poll channel messages for a reply after the approval message."""
from praisonaiagents.approval.protocols import ApprovalDecision
deadline = time.monotonic() + self._timeout
while time.monotonic() < deadline:
await asyncio.sleep(self._poll_interval)
try:
data = await self._discord_api("GET",
f"/channels/{channel_id}/messages?after={message_id}&limit=10",
session=session,
)
if isinstance(data, dict) and data.get("message"):
logger.warning(f"Discord poll error: {data.get('message')}")
continue
if not isinstance(data, list):
continue
for msg in data:
# Skip bot messages
author = msg.get("author", {})
if author.get("bot"):
continue
text = msg.get("content", "").strip()
user_id = author.get("id", "unknown")
username = author.get("username", user_id)
kw = classify_keyword(text)
if kw == "approve":
return ApprovalDecision(
approved=True,
reason=f"Approved via Discord by {username}",
approver=user_id,
metadata={"platform": "discord", "message_id": msg.get("id")},
)
if kw == "deny":
return ApprovalDecision(
approved=False,
reason=f"Denied via Discord by {username}",
approver=user_id,
metadata={"platform": "discord", "message_id": msg.get("id")},
)
except Exception as e:
logger.warning(f"Discord poll exception: {e}")
return ApprovalDecision(
approved=False,
reason=f"Timed out waiting for Discord approval ({int(self._timeout)}s)",
metadata={"platform": "discord", "timeout": True},
)
# ── Message Update ──────────────────────────────────────────────────
async def _update_message(
self,
channel_id: str,
message_id: str,
request,
decision,
session: Optional[Any] = None,
) -> None:
"""Update the original approval message with the decision."""
if decision.approved:
status_emoji = "✅"
status_text = "Approved"
color = 0x00FF00
else:
status_emoji = "❌"
status_text = "Denied"
color = 0xFF0000
approver = decision.approver or "system"
embed = {
"title": f"{status_emoji} Tool {status_text}",
"color": color,
"fields": [
{"name": "Tool", "value": f"`{request.tool_name}`", "inline": True},
{"name": "Decision", "value": status_text, "inline": True},
],
}
if decision.approver:
embed["fields"].append({"name": "By", "value": f"<@{approver}>", "inline": True})
try:
import aiohttp
url = f"{_DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}"
headers = {
"Authorization": f"Bot {self._token}",
"Content-Type": "application/json",
}
payload = {
"content": f"{status_emoji} Tool {status_text}: `{request.tool_name}`",
"embeds": [embed],
}
if session is not None:
async with session.patch(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
data = await resp.json()
else:
async with aiohttp.ClientSession() as _session:
async with _session.patch(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
data = await resp.json()
if isinstance(data, dict) and data.get("message"):
logger.warning(f"Failed to update Discord message: {data.get('message')}")
except Exception as e:
logger.warning(f"Failed to update Discord message: {e}")
@@ -0,0 +1,254 @@
"""
HTTP Approval Backend for PraisonAI Agents.
Implements the ApprovalProtocol by starting a lightweight local HTTP server
that serves a minimal approval dashboard. Approvers visit the URL in a
browser and click Approve / Deny.
Uses aiohttp.web — no extra dependencies beyond what PraisonAI already ships.
Usage::
from praisonaiagents import Agent
from praisonai.bots import HTTPApproval
agent = Agent(
name="assistant",
tools=[execute_command],
approval=HTTPApproval(host="0.0.0.0", port=8899),
)
"""
from __future__ import annotations
import asyncio
import logging
import time
import uuid
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class HTTPApproval:
"""Approval backend that serves a local HTTP dashboard for approvals.
Starts an ephemeral aiohttp web server when the first approval is
requested. Each approval gets a unique URL. The approver opens the
URL in a browser and clicks Approve or Deny.
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
Args:
host: Bind address (default ``127.0.0.1``).
port: Port to listen on (default ``8899``).
timeout: Max seconds to wait for a response (default 300).
Example::
from praisonai.bots import HTTPApproval
agent = Agent(name="bot", approval=HTTPApproval(port=8899))
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 8899,
timeout: float = 300,
):
self._host = host
self._port = port
self._timeout = timeout
self._pending: Dict[str, Optional[Dict[str, Any]]] = {}
self._server_started = False
self._runner: Optional[Any] = None
self._site: Optional[Any] = None
def __repr__(self) -> str:
return f"HTTPApproval(host={self._host!r}, port={self._port})"
# ── Web server ──────────────────────────────────────────────────────
async def _ensure_server(self) -> None:
"""Start the aiohttp web server if not already running."""
if self._server_started:
return
from aiohttp import web
app = web.Application()
app.router.add_get("/approve/{request_id}", self._handle_page)
app.router.add_post("/approve/{request_id}/decide", self._handle_decide)
self._runner = web.AppRunner(app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self._host, self._port)
await self._site.start()
self._server_started = True
logger.info(f"HTTPApproval server started on http://{self._host}:{self._port}")
async def _handle_page(self, request) -> Any:
"""Serve the approval page HTML."""
from aiohttp import web
request_id = request.match_info["request_id"]
pending = self._pending.get(request_id)
if pending is None:
return web.Response(text="Approval request not found or already decided.", status=404)
if pending.get("decided"):
return web.Response(text="This approval has already been decided.", status=200)
info = pending.get("info", {})
html = self._build_html(request_id, info)
return web.Response(text=html, content_type="text/html")
async def _handle_decide(self, request) -> Any:
"""Handle approve/deny POST."""
from aiohttp import web
request_id = request.match_info["request_id"]
pending = self._pending.get(request_id)
if pending is None:
return web.Response(text="Not found", status=404)
if pending.get("decided"):
return web.Response(text="Already decided", status=200)
try:
body = await request.json()
except Exception:
body = {}
decision = body.get("decision", "deny")
pending["decided"] = True
pending["approved"] = decision == "approve"
pending["reason"] = body.get("reason", f"{'Approved' if pending['approved'] else 'Denied'} via HTTP dashboard")
pending["approver"] = body.get("approver", "http_user")
return web.json_response({"ok": True, "decision": decision})
def _build_html(self, request_id: str, info: Dict[str, Any]) -> str:
"""Build a minimal approval page."""
tool_name = info.get("tool_name", "unknown")
risk_level = info.get("risk_level", "unknown")
agent_name = info.get("agent_name", "")
arguments = info.get("arguments", {})
args_html = ""
for k, v in arguments.items():
val_str = str(v)
if len(val_str) > 200:
val_str = val_str[:197] + "..."
args_html += f"<tr><td><code>{k}</code></td><td><code>{val_str}</code></td></tr>"
risk_colors = {
"critical": "#FF0000", "high": "#FF8C00",
"medium": "#FFD700", "low": "#00CC00",
}
risk_color = risk_colors.get(risk_level, "#888")
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Tool Approval Required</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }}
h1 {{ color: #fff; }} table {{ width: 100%; border-collapse: collapse; margin: 16px 0; }}
td {{ padding: 8px; border-bottom: 1px solid #333; }} code {{ background: #2a2a4a; padding: 2px 6px; border-radius: 3px; }}
.risk {{ display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; color: #fff; background: {risk_color}; }}
.buttons {{ display: flex; gap: 12px; margin-top: 24px; }}
.btn {{ padding: 12px 32px; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; font-weight: bold; }}
.approve {{ background: #00CC00; color: #000; }} .deny {{ background: #FF4444; color: #fff; }}
.done {{ text-align: center; margin-top: 24px; font-size: 18px; }}
</style></head><body>
<h1>🔒 Tool Approval Required</h1>
<p><strong>Tool:</strong> <code>{tool_name}</code></p>
<p><strong>Risk:</strong> <span class="risk">{risk_level.upper()}</span></p>
{"<p><strong>Agent:</strong> " + agent_name + "</p>" if agent_name else ""}
<h3>Arguments</h3>
<table>{args_html if args_html else "<tr><td><em>none</em></td></tr>"}</table>
<div class="buttons">
<button class="btn approve" onclick="decide('approve')">✅ Approve</button>
<button class="btn deny" onclick="decide('deny')">❌ Deny</button>
</div>
<div class="done" id="result" style="display:none"></div>
<script>
async function decide(d) {{
const res = await fetch('/approve/{request_id}/decide', {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{decision: d}})
}});
document.querySelector('.buttons').style.display = 'none';
document.getElementById('result').style.display = 'block';
document.getElementById('result').textContent = d === 'approve' ? '✅ Approved' : '❌ Denied';
}}
</script></body></html>"""
# ── ApprovalProtocol implementation ─────────────────────────────────
async def request_approval(self, request) -> Any:
"""Start server, register request, poll for decision."""
from praisonaiagents.approval.protocols import ApprovalDecision
await self._ensure_server()
request_id = str(uuid.uuid4())
self._pending[request_id] = {
"decided": False,
"approved": False,
"info": {
"tool_name": request.tool_name,
"arguments": request.arguments,
"risk_level": request.risk_level,
"agent_name": request.agent_name,
},
}
url = f"http://{self._host}:{self._port}/approve/{request_id}"
logger.info(f"HTTPApproval: Waiting for decision at {url}")
# Poll for decision
deadline = time.monotonic() + self._timeout
while time.monotonic() < deadline:
await asyncio.sleep(0.5)
pending = self._pending.get(request_id, {})
if pending.get("decided"):
# Cleanup
del self._pending[request_id]
return ApprovalDecision(
approved=pending["approved"],
reason=pending.get("reason", ""),
approver=pending.get("approver"),
metadata={"platform": "http", "request_id": request_id, "url": url},
)
# Timeout — cleanup
self._pending.pop(request_id, None)
return ApprovalDecision(
approved=False,
reason=f"Timed out waiting for HTTP approval ({int(self._timeout)}s)",
metadata={"platform": "http", "request_id": request_id, "timeout": True},
)
def request_approval_sync(self, request) -> Any:
"""Synchronous wrapper — runs async method in a new event loop."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, self.request_approval(request))
return future.result(timeout=self._timeout + 10)
else:
return asyncio.run(self.request_approval(request))
async def shutdown(self) -> None:
"""Stop the HTTP server gracefully."""
if self._runner:
await self._runner.cleanup()
self._server_started = False
logger.info("HTTPApproval server stopped")
+145 -7
View File
@@ -1,19 +1,21 @@
"""
Mixin for ChatCommandProtocol implementation.
Mixins for bot protocol implementations.
Provides ``register_command`` and ``list_commands`` so all bot
implementations share a single copy of the logic (DRY).
Also provides channel-specific command filtering via
``register_command(..., channels=[...])``.
ChatCommandMixin — ``register_command`` / ``list_commands`` (DRY).
MessageHookMixin — fire MESSAGE_RECEIVED / MESSAGE_SENDING / MESSAGE_SENT hooks (DRY).
"""
from __future__ import annotations
from typing import Callable, Dict, List, Optional, Set
import logging
import os
import time
from typing import Any, Callable, Dict, List, Optional, Set
from praisonaiagents.bots import ChatCommandInfo
logger = logging.getLogger(__name__)
class ChatCommandMixin:
"""Mixin that satisfies the ChatCommandProtocol interface.
@@ -95,3 +97,139 @@ class ChatCommandMixin:
if not platform:
return True
return platform.lower() in channels_map[name]
class MessageHookMixin:
"""Mixin that fires MESSAGE_RECEIVED / MESSAGE_SENDING / MESSAGE_SENT hooks.
All bot adapters inherit this so hook wiring is DRY.
Expects the host class to have:
- ``platform`` (str property)
- ``_agent`` (optional Agent with hook_runner)
Zero overhead when no hooks are registered — all attribute access
is guarded by ``getattr`` checks.
"""
def _get_hook_runner(self) -> Any:
"""Resolve the HookRunner from the agent, if available."""
agent = getattr(self, '_agent', None)
if agent is None:
return None
return getattr(agent, '_hook_runner', None)
def fire_message_received(self, message: Any) -> None:
"""Fire MESSAGE_RECEIVED hook when an incoming message arrives.
Args:
message: A BotMessage instance.
"""
runner = self._get_hook_runner()
if runner is None:
return
try:
from praisonaiagents.hooks.types import HookEvent
from praisonaiagents.hooks.events import MessageReceivedInput
platform = getattr(self, 'platform', 'unknown')
sender = getattr(message, 'sender', None)
channel = getattr(message, 'channel', None)
content = getattr(message, 'content', '')
if not isinstance(content, str):
content = str(content)
event_input = MessageReceivedInput(
session_id="",
cwd=os.getcwd(),
event_name=HookEvent.MESSAGE_RECEIVED,
timestamp=str(time.time()),
agent_name=getattr(getattr(self, '_agent', None), 'agent_name', 'bot'),
platform=platform,
content=content,
sender_id=getattr(sender, 'user_id', '') if sender else '',
channel_id=getattr(channel, 'channel_id', '') if channel else '',
channel_type=getattr(channel, 'channel_type', '') if channel else '',
message_id=getattr(message, 'message_id', ''),
)
runner.execute_sync(HookEvent.MESSAGE_RECEIVED, event_input)
except Exception as e:
logger.debug(f"MESSAGE_RECEIVED hook error (non-fatal): {e}")
def fire_message_sending(
self, channel_id: str, content: str, reply_to: Optional[str] = None
) -> Dict[str, Any]:
"""Fire MESSAGE_SENDING hook before sending a message.
Hooks can modify content or cancel the send.
Returns:
dict with keys ``content`` (possibly modified) and ``cancel`` (bool).
"""
result: Dict[str, Any] = {"content": content, "cancel": False}
runner = self._get_hook_runner()
if runner is None:
return result
try:
from praisonaiagents.hooks.types import HookEvent
from praisonaiagents.hooks.events import MessageSendingInput
platform = getattr(self, 'platform', 'unknown')
event_input = MessageSendingInput(
session_id="",
cwd=os.getcwd(),
event_name=HookEvent.MESSAGE_SENDING,
timestamp=str(time.time()),
agent_name=getattr(getattr(self, '_agent', None), 'agent_name', 'bot'),
platform=platform,
content=content,
channel_id=channel_id,
reply_to=reply_to,
)
hook_results = runner.execute_sync(HookEvent.MESSAGE_SENDING, event_input)
if runner.is_blocked(hook_results):
result["cancel"] = True
return result
# Check for modified content in results
if hook_results:
for hr in hook_results:
output = getattr(hr, 'output', None)
if output is not None:
modified = getattr(output, 'modified_data', None)
if isinstance(modified, dict) and 'content' in modified:
result["content"] = modified["content"]
except Exception as e:
logger.debug(f"MESSAGE_SENDING hook error (non-fatal): {e}")
return result
def fire_message_sent(
self, channel_id: str, content: str, message_id: str = ""
) -> None:
"""Fire MESSAGE_SENT hook after a message was successfully sent.
Args:
channel_id: The channel the message was sent to.
content: The sent content.
message_id: Platform message ID of the sent message.
"""
runner = self._get_hook_runner()
if runner is None:
return
try:
from praisonaiagents.hooks.types import HookEvent
from praisonaiagents.hooks.events import MessageSentInput
platform = getattr(self, 'platform', 'unknown')
event_input = MessageSentInput(
session_id="",
cwd=os.getcwd(),
event_name=HookEvent.MESSAGE_SENT,
timestamp=str(time.time()),
agent_name=getattr(getattr(self, '_agent', None), 'agent_name', 'bot'),
platform=platform,
content=content,
channel_id=channel_id,
message_id=message_id,
)
runner.execute_sync(HookEvent.MESSAGE_SENT, event_input)
except Exception as e:
logger.debug(f"MESSAGE_SENT hook error (non-fatal): {e}")
+84
View File
@@ -0,0 +1,84 @@
"""
Platform registry for PraisonAI BotOS.
Maps platform names to their bot adapter classes (lazy-loaded).
Extensible: third-party platforms can register via ``register_platform()``.
"""
from __future__ import annotations
from typing import Any, Dict, List, Type
# Lazy references: (module_path, class_name)
_BUILTIN_PLATFORMS: Dict[str, tuple] = {
"telegram": ("praisonai.bots.telegram", "TelegramBot"),
"discord": ("praisonai.bots.discord", "DiscordBot"),
"slack": ("praisonai.bots.slack", "SlackBot"),
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
}
# Custom platforms registered at runtime
_custom_platforms: Dict[str, Any] = {}
def get_platform_registry() -> Dict[str, Any]:
"""Return the combined registry of all known platforms.
Keys are platform names, values are either:
- A class (custom platforms, already resolved)
- A (module, classname) tuple (builtins, lazy-resolved on use)
"""
combined: Dict[str, Any] = {}
combined.update(_BUILTIN_PLATFORMS)
combined.update(_custom_platforms)
return combined
def register_platform(name: str, adapter_class: Type) -> None:
"""Register a custom platform adapter.
Args:
name: Platform identifier (lowercase).
adapter_class: The bot adapter class.
"""
_custom_platforms[name.lower()] = adapter_class
def list_platforms() -> List[str]:
"""List all registered platform names."""
return sorted(set(list(_BUILTIN_PLATFORMS.keys()) + list(_custom_platforms.keys())))
def resolve_adapter(name: str) -> Type:
"""Resolve a platform name to its adapter class (lazy import).
Args:
name: Platform identifier.
Returns:
The adapter class.
Raises:
ValueError: If the platform is not registered.
"""
key = name.lower()
# Custom platforms are already classes
if key in _custom_platforms:
cls = _custom_platforms[key]
if isinstance(cls, type):
return cls
# Builtins are lazy (module, classname) tuples
if key in _BUILTIN_PLATFORMS:
ref = _BUILTIN_PLATFORMS[key]
if isinstance(ref, tuple):
module_path, class_name = ref
import importlib
mod = importlib.import_module(module_path)
return getattr(mod, class_name)
raise ValueError(
f"Unknown platform: {name!r}. "
f"Available: {', '.join(list_platforms())}"
)
+252
View File
@@ -0,0 +1,252 @@
"""
Resilience utilities for PraisonAI bots.
Provides exponential backoff, recoverable error detection,
and connection monitoring primitives.
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from dataclasses import dataclass, field
from typing import Optional, Set
logger = logging.getLogger(__name__)
@dataclass
class BackoffPolicy:
"""Exponential backoff configuration.
Attributes:
initial_ms: Initial delay in milliseconds
max_ms: Maximum delay in milliseconds
factor: Multiplicative factor per attempt
jitter: Random jitter fraction (0.0 - 1.0)
max_attempts: Max retry attempts (0 = unlimited)
"""
initial_ms: float = 2000.0
max_ms: float = 30000.0
factor: float = 1.8
jitter: float = 0.25
max_attempts: int = 0
# Default policies
TELEGRAM_BACKOFF = BackoffPolicy(initial_ms=2000, max_ms=30000, factor=1.8, jitter=0.25)
DISCORD_BACKOFF = BackoffPolicy(initial_ms=3000, max_ms=60000, factor=2.0, jitter=0.2)
SLACK_BACKOFF = BackoffPolicy(initial_ms=2000, max_ms=30000, factor=1.5, jitter=0.3)
WHATSAPP_BACKOFF = BackoffPolicy(initial_ms=5000, max_ms=60000, factor=2.0, jitter=0.2)
def compute_backoff(policy: BackoffPolicy, attempt: int) -> float:
"""Compute delay for a given attempt number.
Args:
policy: Backoff configuration
attempt: Attempt number (1-based)
Returns:
Delay in seconds
"""
base_ms = policy.initial_ms * (policy.factor ** (attempt - 1))
capped_ms = min(base_ms, policy.max_ms)
if policy.jitter > 0:
jitter_range = capped_ms * policy.jitter
capped_ms += random.uniform(-jitter_range, jitter_range)
return max(0, capped_ms / 1000.0)
# Common recoverable error patterns across platforms
_RECOVERABLE_PATTERNS: Set[str] = {
"timeout",
"timed out",
"connection reset",
"connection refused",
"connection aborted",
"network unreachable",
"network is unreachable",
"temporary failure",
"service unavailable",
"bad gateway",
"gateway timeout",
"too many requests",
"rate limit",
"rate_limited",
"internal server error",
"server error",
"eof occurred",
"broken pipe",
"reset by peer",
"name resolution",
"dns resolution",
"ssl",
"certificate",
}
# Telegram-specific patterns
_TELEGRAM_RECOVERABLE: Set[str] = {
"getupdates",
"conflict",
"terminated by other",
"restart",
"flood",
"retry after",
"webhook",
}
def is_recoverable_error(err: BaseException, platform: str = "") -> bool:
"""Check if an error is likely transient/recoverable.
Args:
err: The exception to check
platform: Optional platform name for platform-specific checks
Returns:
True if the error is likely transient
"""
msg = str(err).lower()
err_type = type(err).__name__.lower()
# Check common patterns
for pattern in _RECOVERABLE_PATTERNS:
if pattern in msg or pattern in err_type:
return True
# Platform-specific checks
if platform == "telegram":
for pattern in _TELEGRAM_RECOVERABLE:
if pattern in msg:
return True
# HTTP 409 = getUpdates conflict (another bot instance)
if hasattr(err, 'error_code') and getattr(err, 'error_code', None) == 409:
return True
# Check for common HTTP status codes
status = getattr(err, 'status', None) or getattr(err, 'status_code', None)
if isinstance(status, int) and status in (408, 429, 500, 502, 503, 504):
return True
# asyncio timeouts
if isinstance(err, (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)):
return True
return False
def is_conflict_error(err: BaseException) -> bool:
"""Check if error is a Telegram getUpdates 409 conflict.
This happens when another bot instance is using the same token.
"""
msg = str(err).lower()
status = getattr(err, 'error_code', None) or getattr(err, 'status_code', None)
if status == 409:
return True
if "409" in msg and "conflict" in msg:
return True
if "getupdates" in msg and ("conflict" in msg or "terminated" in msg):
return True
return False
async def sleep_with_abort(seconds: float, abort_signal: Optional[asyncio.Event] = None) -> bool:
"""Sleep for a duration, but wake early if abort is signaled.
Args:
seconds: Duration to sleep
abort_signal: Optional event that cancels the sleep
Returns:
True if sleep completed normally, False if aborted
"""
if abort_signal is None:
await asyncio.sleep(seconds)
return True
try:
await asyncio.wait_for(abort_signal.wait(), timeout=seconds)
return False # Aborted
except asyncio.TimeoutError:
return True # Normal completion
@dataclass
class ConnectionMonitor:
"""Tracks connection health with automatic backoff.
Attributes:
platform: Platform name for error classification
policy: Backoff policy for retries
attempt: Current retry attempt count
last_error: Last error encountered
last_error_time: Timestamp of last error
total_recoveries: Total successful recoveries
"""
platform: str = ""
policy: BackoffPolicy = field(default_factory=BackoffPolicy)
attempt: int = 0
last_error: Optional[str] = None
last_error_time: Optional[float] = None
total_recoveries: int = 0
def record_success(self) -> None:
"""Record a successful operation, resetting backoff."""
if self.attempt > 0:
self.total_recoveries += 1
logger.info(
f"[{self.platform}] Connection recovered after "
f"{self.attempt} attempt(s)"
)
self.attempt = 0
self.last_error = None
self.last_error_time = None
def record_error(self, err: BaseException) -> float:
"""Record an error and compute next delay.
Args:
err: The error that occurred
Returns:
Delay in seconds before next retry
"""
self.attempt += 1
self.last_error = str(err)
self.last_error_time = time.time()
delay = compute_backoff(self.policy, self.attempt)
logger.warning(
f"[{self.platform}] Error (attempt {self.attempt}): "
f"{self.last_error}; retrying in {delay:.1f}s"
)
return delay
def should_retry(self) -> bool:
"""Check if we should retry based on policy."""
if self.policy.max_attempts <= 0:
return True # Unlimited
return self.attempt < self.policy.max_attempts
def to_dict(self) -> dict:
"""Serialize for status/health reporting."""
return {
"platform": self.platform,
"attempt": self.attempt,
"last_error": self.last_error,
"last_error_time": self.last_error_time,
"total_recoveries": self.total_recoveries,
"max_attempts": self.policy.max_attempts,
}
+128 -11
View File
@@ -5,6 +5,10 @@ Bots share a single Agent instance but each user needs independent
chat history. BotSessionManager swaps the agent's ``chat_history``
before and after every call so conversations never leak between users.
When a ``store`` (any ``SessionStoreProtocol`` implementation) is
provided, sessions are **persisted to disk** and survive restarts.
Without a store, behaviour is identical to the original in-memory mode.
Thread-safety: an ``asyncio.Lock`` per user serialises concurrent
calls from the same user; different users run in parallel.
"""
@@ -13,7 +17,8 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List, TYPE_CHECKING
import time
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from praisonaiagents import Agent
@@ -26,14 +31,23 @@ class BotSessionManager:
Usage inside a bot message handler::
# In-memory only (backward compatible)
session_mgr = BotSessionManager()
# With persistent store (recommended)
from praisonaiagents.session import get_default_session_store
session_mgr = BotSessionManager(
store=get_default_session_store(),
platform="telegram",
)
response = await session_mgr.chat(agent, user_id, text)
The manager:
1. Saves the agent's current ``chat_history``.
2. Loads the user's history (or ``[]`` for a new user).
2. Loads the user's history from the persistent store (or in-memory cache).
3. Calls ``agent.chat(prompt)`` (via ``run_in_executor``).
4. Saves the updated history back to the user store.
4. Persists the updated history back to the store.
5. Restores the agent's original history.
``/new`` command → call ``session_mgr.reset(user_id)``.
@@ -43,11 +57,24 @@ class BotSessionManager:
(e.g. gateway multi-bot mode).
"""
def __init__(self, max_history: int = 100) -> None:
def __init__(
self,
max_history: int = 100,
store: Optional[Any] = None,
platform: str = "",
) -> None:
self._histories: Dict[str, List[Dict[str, Any]]] = {}
self._locks: Dict[str, asyncio.Lock] = {}
self._agent_locks: Dict[int, asyncio.Lock] = {}
self._max_history = max_history
self._store = store
self._platform = platform
self._last_active: Dict[str, float] = {}
def _session_key(self, user_id: str) -> str:
"""Generate a deterministic session key for persistent storage."""
prefix = f"bot_{self._platform}" if self._platform else "bot"
return f"{prefix}_{user_id}"
def _get_lock(self, user_id: str) -> asyncio.Lock:
"""Get or create an asyncio.Lock for *user_id*."""
@@ -62,6 +89,43 @@ class BotSessionManager:
self._agent_locks[agent_id] = asyncio.Lock()
return self._agent_locks[agent_id]
def _load_history(self, user_id: str) -> List[Dict[str, Any]]:
"""Load user history from store (if available) or in-memory cache."""
if self._store is not None:
key = self._session_key(user_id)
try:
history = self._store.get_chat_history(key)
if history:
return list(history)
except Exception as e:
logger.warning("Failed to load session from store: %s", e)
return list(self._histories.get(user_id, []))
def _save_history(
self, user_id: str, history: List[Dict[str, Any]]
) -> None:
"""Save user history to store (if available) and in-memory cache."""
if self._max_history > 0 and len(history) > self._max_history:
history = history[-self._max_history:]
# Always update in-memory cache
self._histories[user_id] = history
if self._store is not None:
key = self._session_key(user_id)
try:
# Clear existing and re-write (atomic update)
self._store.clear_session(key)
for msg in history:
self._store.add_message(
key,
msg.get("role", "user"),
msg.get("content", ""),
msg.get("metadata"),
)
except Exception as e:
logger.warning("Failed to persist session to store: %s", e)
async def chat(
self,
agent: "Agent",
@@ -76,37 +140,90 @@ class BotSessionManager:
Uses both a per-user lock (serialise same user) and a per-agent
lock (prevent concurrent history swaps on a shared Agent).
"""
self._last_active[user_id] = time.monotonic()
user_lock = self._get_lock(user_id)
agent_lock = self._get_agent_lock(agent)
async with user_lock:
# Load history (may hit disk via run_in_executor for async safety)
loop = asyncio.get_event_loop()
user_history = await loop.run_in_executor(
None, self._load_history, user_id
)
async with agent_lock:
# Swap histories
saved_history = agent.chat_history
agent.chat_history = list(self._histories.get(user_id, []))
agent.chat_history = user_history
try:
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, agent.chat, prompt)
finally:
async with agent_lock:
# Persist user history and restore agent's original
user_history = agent.chat_history
if self._max_history > 0 and len(user_history) > self._max_history:
user_history = user_history[-self._max_history:]
self._histories[user_id] = user_history
# Capture updated history and restore agent's original
updated_history = agent.chat_history
agent.chat_history = saved_history
# Persist (may hit disk via run_in_executor)
await loop.run_in_executor(
None, self._save_history, user_id, updated_history
)
return response
def reap_stale(self, max_age_seconds: int) -> int:
"""Remove sessions older than *max_age_seconds*. Returns count reaped.
Call this periodically or lazily (e.g. on each chat() call) to
auto-clean inactive sessions and free memory.
"""
if max_age_seconds <= 0:
return 0
now = time.monotonic()
stale = [
uid for uid, ts in self._last_active.items()
if (now - ts) > max_age_seconds
]
for uid in stale:
self._histories.pop(uid, None)
self._last_active.pop(uid, None)
self._locks.pop(uid, None)
if self._store is not None:
key = self._session_key(uid)
try:
self._store.clear_session(key)
except Exception as e:
logger.warning("Failed to reap session %s: %s", key, e)
if stale:
logger.debug("BotSessionManager: reaped %d stale sessions", len(stale))
return len(stale)
def reset(self, user_id: str) -> bool:
"""Clear a user's session history. Returns True if it existed."""
existed = user_id in self._histories
self._histories.pop(user_id, None)
self._last_active.pop(user_id, None)
if self._store is not None:
key = self._session_key(user_id)
try:
self._store.clear_session(key)
except Exception as e:
logger.warning("Failed to clear session in store: %s", e)
return existed
def reset_all(self) -> int:
"""Clear all user sessions. Returns the count cleared."""
count = len(self._histories)
if self._store is not None:
for user_id in list(self._histories.keys()):
key = self._session_key(user_id)
try:
self._store.clear_session(key)
except Exception as e:
logger.warning("Failed to clear session %s: %s", key, e)
self._histories.clear()
return count
@@ -0,0 +1,367 @@
"""
Slack Approval Backend for PraisonAI Agents.
Implements the ApprovalProtocol by sending Block Kit messages to Slack
and polling for user replies (yes/no) to approve or deny tool executions.
Usage::
from praisonaiagents import Agent
from praisonai.bots import SlackApproval
agent = Agent(
name="assistant",
tools=[execute_command],
approval=SlackApproval(channel="#approvals"), # token from SLACK_BOT_TOKEN env
)
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any, Dict, List, Optional
from ._approval_base import classify_keyword, sync_wrapper
logger = logging.getLogger(__name__)
class SlackApproval:
"""Approval backend that sends Slack messages and polls for responses.
Sends a rich Block Kit message with tool details to a Slack channel or DM,
then polls ``conversations.history`` for a reply containing an approval
or denial keyword.
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
Args:
token: Slack bot token (xoxb-...). Falls back to ``SLACK_BOT_TOKEN`` env var.
channel: Slack channel ID, channel name, or user ID for DMs.
timeout: Max seconds to wait for a response (default 300 = 5 min).
poll_interval: Seconds between polls (default 3.0).
Example::
from praisonai.bots import SlackApproval
agent = Agent(name="bot", approval=SlackApproval(channel="U0ABPEV1HK8"))
"""
def __init__(
self,
token: Optional[str] = None,
channel: Optional[str] = None,
timeout: float = 300,
poll_interval: float = 3.0,
):
self._token = token or os.environ.get("SLACK_BOT_TOKEN", "")
if not self._token:
raise ValueError(
"Slack bot token is required. Pass token= or set SLACK_BOT_TOKEN env var."
)
self._channel = channel or ""
self._timeout = timeout
self._poll_interval = poll_interval
def __repr__(self) -> str:
masked = f"xoxb-...{self._token[-4:]}" if len(self._token) > 4 else "***"
return f"SlackApproval(channel={self._channel!r}, token={masked!r})"
# ── Internal Slack API helper ──────────────────────────────────────
async def _slack_api(
self,
method: str,
payload: Dict[str, Any],
session: Optional[Any] = None,
) -> Dict[str, Any]:
"""Call a Slack Web API method. Override in tests for mocking.
Args:
method: Slack API method name (e.g. ``chat.postMessage``).
payload: JSON payload.
session: Optional pre-existing ``aiohttp.ClientSession`` for reuse.
"""
import aiohttp
url = f"https://slack.com/api/{method}"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json; charset=utf-8",
}
if session is not None:
async with session.post(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
return await resp.json()
else:
async with aiohttp.ClientSession() as _session:
async with _session.post(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
return await resp.json()
# ── ApprovalProtocol implementation ─────────────────────────────────
async def request_approval(self, request) -> Any:
"""Send a Slack message and poll for approval/denial.
Args:
request: ApprovalRequest with tool_name, arguments, risk_level, etc.
Returns:
ApprovalDecision with approved=True/False and metadata.
"""
from praisonaiagents.approval.protocols import ApprovalDecision
import aiohttp
channel = self._channel
async with aiohttp.ClientSession() as session:
if not channel:
try:
data = await self._slack_api("auth.test", {}, session=session)
if data.get("ok"):
channel = data["user_id"]
except Exception:
pass
if not channel:
return ApprovalDecision(
approved=False,
reason="No Slack channel configured and could not resolve bot user",
)
# 1. Post approval message
blocks = self._build_blocks(request)
fallback = self._build_fallback_text(request)
try:
post_data = await self._slack_api("chat.postMessage", {
"channel": channel,
"text": fallback,
"blocks": blocks,
}, session=session)
if not post_data.get("ok"):
return ApprovalDecision(
approved=False,
reason=f"Failed to post Slack message: {post_data.get('error', 'unknown')}",
)
msg_ts = post_data["ts"]
msg_channel = post_data["channel"]
# 2. Poll for response (thread-scoped for multi-agent isolation)
decision = await self._poll_for_response(
msg_channel, msg_ts, session=session,
)
# 3. Update original message with result
await self._update_message(
msg_channel, msg_ts, request, decision, session=session,
)
return decision
except Exception as e:
logger.error(f"SlackApproval error: {e}")
return ApprovalDecision(
approved=False,
reason=f"Slack approval error: {e}",
)
def request_approval_sync(self, request) -> Any:
"""Synchronous wrapper — runs async method in a new event loop."""
return sync_wrapper(self.request_approval(request), self._timeout)
# ── Block Kit Builder ───────────────────────────────────────────────
def _build_blocks(self, request) -> List[Dict[str, Any]]:
"""Build Slack Block Kit blocks for the approval message."""
risk_emoji = {
"critical": "🔴",
"high": "🟠",
"medium": "🟡",
"low": "🟢",
}
emoji = risk_emoji.get(request.risk_level, "⚪")
risk_upper = request.risk_level.upper()
# Format arguments
args_lines = []
for key, value in request.arguments.items():
val_str = str(value)
if len(val_str) > 100:
val_str = val_str[:97] + "..."
args_lines.append(f" `{key}`: `{val_str}`")
args_text = "\n".join(args_lines) if args_lines else " _(none)_"
agent_line = f"\n*Agent:* {request.agent_name}" if request.agent_name else ""
header_text = (
f"🔒 *Tool Approval Required*\n\n"
f"*Tool:* `{request.tool_name}`\n"
f"*Risk:* {emoji} {risk_upper}{agent_line}\n"
f"*Arguments:*\n{args_text}"
)
blocks = [
{
"type": "section",
"text": {"type": "mrkdwn", "text": header_text},
},
{"type": "divider"},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": (
f"Reply *yes* to approve or *no* to deny "
f"(timeout: {int(self._timeout)}s)"
),
}
],
},
]
return blocks
def _build_fallback_text(self, request) -> str:
"""Build plain-text fallback for notifications."""
return (
f"🔒 Tool Approval Required: `{request.tool_name}` "
f"(risk: {request.risk_level}). "
f"Reply yes to approve or no to deny."
)
# ── Polling ─────────────────────────────────────────────────────────
async def _poll_for_response(
self,
channel: str,
message_ts: str,
session: Optional[Any] = None,
) -> Any:
"""Poll for replies scoped to the approval message thread.
Uses ``conversations.replies`` when available so concurrent approval
requests on the same channel don't cross-talk. Falls back to
``conversations.history`` if replies returns an error (e.g. DMs).
"""
from praisonaiagents.approval.protocols import ApprovalDecision
deadline = time.monotonic() + self._timeout
while time.monotonic() < deadline:
await asyncio.sleep(self._poll_interval)
try:
# Try thread-scoped replies first (best isolation)
data = await self._slack_api("conversations.replies", {
"channel": channel,
"ts": message_ts,
"limit": 20,
}, session=session)
# Fallback to history if replies fails (some DM contexts)
if not data.get("ok"):
data = await self._slack_api("conversations.history", {
"channel": channel,
"oldest": message_ts,
"limit": 10,
}, session=session)
if not data.get("ok"):
logger.warning(f"Slack poll error: {data.get('error')}")
continue
for msg in data.get("messages", []):
# Skip the bot's own approval message
if msg.get("ts") == message_ts:
continue
# Skip bot messages
if msg.get("bot_id"):
continue
text = msg.get("text", "").strip()
user = msg.get("user", "unknown")
kw = classify_keyword(text)
if kw == "approve":
return ApprovalDecision(
approved=True,
reason=f"Approved via Slack by <@{user}>",
approver=user,
metadata={"platform": "slack", "message_ts": msg.get("ts")},
)
if kw == "deny":
return ApprovalDecision(
approved=False,
reason=f"Denied via Slack by <@{user}>",
approver=user,
metadata={"platform": "slack", "message_ts": msg.get("ts")},
)
except Exception as e:
logger.warning(f"Slack poll exception: {e}")
# Timeout
return ApprovalDecision(
approved=False,
reason=f"Timed out waiting for Slack approval ({int(self._timeout)}s)",
metadata={"platform": "slack", "timeout": True},
)
# ── Message Update ──────────────────────────────────────────────────
async def _update_message(
self,
channel: str,
message_ts: str,
request,
decision,
session: Optional[Any] = None,
) -> None:
"""Update the original approval message with the decision."""
if decision.approved:
status_emoji = "✅"
status_text = "Approved"
else:
status_emoji = "❌"
status_text = "Denied"
approver = decision.approver or "system"
reason = decision.reason or ""
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"{status_emoji} *Tool {status_text}*\n\n"
f"*Tool:* `{request.tool_name}`\n"
f"*Decision:* {status_text}"
+ (f" by <@{approver}>" if decision.approver else "")
+ (f"\n*Reason:* {reason}" if reason else "")
),
},
},
]
try:
data = await self._slack_api("chat.update", {
"channel": channel,
"ts": message_ts,
"text": f"{status_emoji} Tool {status_text}: {request.tool_name}",
"blocks": blocks,
}, session=session)
if not data.get("ok"):
logger.warning(f"Failed to update Slack message: {data.get('error')}")
except Exception as e:
logger.warning(f"Failed to update Slack message: {e}")
@@ -0,0 +1,340 @@
"""
Telegram Approval Backend for PraisonAI Agents.
Implements the ApprovalProtocol by sending messages with inline keyboard
buttons to Telegram and polling ``getUpdates`` for callback_query responses.
Uses raw aiohttp HTTP calls to the Telegram Bot API — does NOT depend on
``python-telegram-bot`` to avoid getUpdates conflicts with a running TelegramBot.
Usage::
from praisonaiagents import Agent
from praisonai.bots import TelegramApproval
agent = Agent(
name="assistant",
tools=[execute_command],
approval=TelegramApproval(chat_id="123456789"), # token from TELEGRAM_BOT_TOKEN env
)
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any, Dict, Optional
from ._approval_base import classify_keyword, sync_wrapper
logger = logging.getLogger(__name__)
class TelegramApproval:
"""Approval backend that sends Telegram messages with inline buttons.
Posts a formatted message with Approve/Deny inline keyboard buttons,
then polls ``getUpdates`` for ``callback_query`` matching the message.
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
Args:
token: Telegram bot token. Falls back to ``TELEGRAM_BOT_TOKEN`` env var.
chat_id: Telegram chat ID (user or group) to send approval requests to.
timeout: Max seconds to wait for a response (default 300 = 5 min).
poll_interval: Seconds between polls (default 2.0).
Example::
from praisonai.bots import TelegramApproval
agent = Agent(name="bot", approval=TelegramApproval(chat_id="123456789"))
"""
def __init__(
self,
token: Optional[str] = None,
chat_id: Optional[str] = None,
timeout: float = 300,
poll_interval: float = 2.0,
):
self._token = token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
if not self._token:
raise ValueError(
"Telegram bot token is required. Pass token= or set TELEGRAM_BOT_TOKEN env var."
)
self._chat_id = chat_id or ""
self._timeout = timeout
self._poll_interval = poll_interval
def __repr__(self) -> str:
masked = f"...{self._token[-4:]}" if len(self._token) > 4 else "***"
return f"TelegramApproval(chat_id={self._chat_id!r}, token={masked!r})"
# ── Internal Telegram API helper ───────────────────────────────────
async def _telegram_api(
self,
method: str,
payload: Dict[str, Any],
session: Optional[Any] = None,
) -> Dict[str, Any]:
"""Call a Telegram Bot API method. Override in tests for mocking."""
import aiohttp
url = f"https://api.telegram.org/bot{self._token}/{method}"
if session is not None:
async with session.post(
url, json=payload,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
return await resp.json()
else:
async with aiohttp.ClientSession() as _session:
async with _session.post(
url, json=payload,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
return await resp.json()
# ── ApprovalProtocol implementation ─────────────────────────────────
async def request_approval(self, request) -> Any:
"""Send a Telegram message with inline buttons and poll for response."""
from praisonaiagents.approval.protocols import ApprovalDecision
import aiohttp
chat_id = self._chat_id
if not chat_id:
return ApprovalDecision(
approved=False,
reason="No Telegram chat_id configured",
)
async with aiohttp.ClientSession() as session:
# 1. Send approval message with inline keyboard
text = self._build_message_text(request)
keyboard = self._build_inline_keyboard(request)
try:
post_data = await self._telegram_api("sendMessage", {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"reply_markup": keyboard,
}, session=session)
if not post_data.get("ok"):
return ApprovalDecision(
approved=False,
reason=f"Failed to send Telegram message: {post_data.get('description', 'unknown')}",
)
message_id = post_data["result"]["message_id"]
# 2. Poll for callback_query response
decision = await self._poll_for_callback(
chat_id, message_id, session=session,
)
# 3. Update original message with result
await self._update_message(
chat_id, message_id, request, decision, session=session,
)
return decision
except Exception as e:
logger.error(f"TelegramApproval error: {e}")
return ApprovalDecision(
approved=False,
reason=f"Telegram approval error: {e}",
)
def request_approval_sync(self, request) -> Any:
"""Synchronous wrapper — runs async method in a new event loop."""
return sync_wrapper(self.request_approval(request), self._timeout)
# ── Message Builder ────────────────────────────────────────────────
def _build_message_text(self, request) -> str:
"""Build formatted message text for Telegram."""
risk_emoji = {
"critical": "🔴",
"high": "🟠",
"medium": "🟡",
"low": "🟢",
}
emoji = risk_emoji.get(request.risk_level, "⚪")
args_lines = []
for key, value in request.arguments.items():
val_str = str(value)
if len(val_str) > 100:
val_str = val_str[:97] + "..."
args_lines.append(f" `{key}`: `{val_str}`")
args_text = "\n".join(args_lines) if args_lines else " _(none)_"
agent_line = f"\n*Agent:* {request.agent_name}" if request.agent_name else ""
return (
f"🔒 *Tool Approval Required*\n\n"
f"*Tool:* `{request.tool_name}`\n"
f"*Risk:* {emoji} {request.risk_level.upper()}{agent_line}\n"
f"*Arguments:*\n{args_text}\n\n"
f"_Timeout: {int(self._timeout)}s_"
)
def _build_inline_keyboard(self, request) -> Dict[str, Any]:
"""Build Telegram InlineKeyboardMarkup JSON."""
return {
"inline_keyboard": [
[
{"text": "✅ Approve", "callback_data": "approve"},
{"text": "❌ Deny", "callback_data": "deny"},
]
]
}
# ── Polling ─────────────────────────────────────────────────────────
async def _poll_for_callback(
self,
chat_id: str,
message_id: int,
session: Optional[Any] = None,
) -> Any:
"""Poll getUpdates for callback_query matching our message."""
from praisonaiagents.approval.protocols import ApprovalDecision
deadline = time.monotonic() + self._timeout
offset = 0
while time.monotonic() < deadline:
await asyncio.sleep(self._poll_interval)
try:
data = await self._telegram_api("getUpdates", {
"offset": offset,
"timeout": 1,
"allowed_updates": ["callback_query", "message"],
}, session=session)
if not data.get("ok"):
logger.warning(f"Telegram poll error: {data.get('description')}")
continue
for update in data.get("result", []):
update_id = update.get("update_id", 0)
if update_id >= offset:
offset = update_id + 1
# Check callback_query (button press)
cb = update.get("callback_query")
if cb:
cb_msg = cb.get("message", {})
if cb_msg.get("message_id") == message_id:
cb_data = cb.get("data", "")
user = cb.get("from", {})
user_id = str(user.get("id", "unknown"))
username = user.get("username", user_id)
# Answer the callback to remove loading state
await self._telegram_api("answerCallbackQuery", {
"callback_query_id": cb.get("id"),
}, session=session)
if cb_data == "approve":
return ApprovalDecision(
approved=True,
reason=f"Approved via Telegram by @{username}",
approver=user_id,
metadata={"platform": "telegram", "message_id": message_id},
)
else:
return ApprovalDecision(
approved=False,
reason=f"Denied via Telegram by @{username}",
approver=user_id,
metadata={"platform": "telegram", "message_id": message_id},
)
# Check text reply as fallback
msg = update.get("message")
if msg:
reply_to = msg.get("reply_to_message", {})
msg_chat_id = str(msg.get("chat", {}).get("id", ""))
if (
msg_chat_id == str(chat_id)
and reply_to.get("message_id") == message_id
):
text = msg.get("text", "").strip()
user = msg.get("from", {})
user_id = str(user.get("id", "unknown"))
kw = classify_keyword(text)
if kw == "approve":
return ApprovalDecision(
approved=True,
reason=f"Approved via Telegram by user {user_id}",
approver=user_id,
metadata={"platform": "telegram", "message_id": message_id},
)
if kw == "deny":
return ApprovalDecision(
approved=False,
reason=f"Denied via Telegram by user {user_id}",
approver=user_id,
metadata={"platform": "telegram", "message_id": message_id},
)
except Exception as e:
logger.warning(f"Telegram poll exception: {e}")
return ApprovalDecision(
approved=False,
reason=f"Timed out waiting for Telegram approval ({int(self._timeout)}s)",
metadata={"platform": "telegram", "timeout": True},
)
# ── Message Update ──────────────────────────────────────────────────
async def _update_message(
self,
chat_id: str,
message_id: int,
request,
decision,
session: Optional[Any] = None,
) -> None:
"""Update the original approval message with the decision."""
if decision.approved:
status_emoji = "✅"
status_text = "Approved"
else:
status_emoji = "❌"
status_text = "Denied"
approver = decision.approver or "system"
reason = decision.reason or ""
new_text = (
f"{status_emoji} *Tool {status_text}*\n\n"
f"*Tool:* `{request.tool_name}`\n"
f"*Decision:* {status_text}"
+ (f" by user {approver}" if decision.approver else "")
+ (f"\n*Reason:* {reason}" if reason else "")
)
try:
data = await self._telegram_api("editMessageText", {
"chat_id": chat_id,
"message_id": message_id,
"text": new_text,
"parse_mode": "Markdown",
}, session=session)
if not data.get("ok"):
logger.warning(f"Failed to update Telegram message: {data.get('description')}")
except Exception as e:
logger.warning(f"Failed to update Telegram message: {e}")
@@ -0,0 +1,252 @@
"""
Webhook Approval Backend for PraisonAI Agents.
Implements the ApprovalProtocol by POSTing approval requests to an external
webhook URL and polling a status endpoint (or the same URL with GET) for the
decision. Ideal for enterprise integrations, custom dashboards, and CI/CD
pipelines.
Usage::
from praisonaiagents import Agent
from praisonai.bots import WebhookApproval
agent = Agent(
name="assistant",
tools=[execute_command],
approval=WebhookApproval(
webhook_url="https://my-app.com/api/approvals",
),
)
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class WebhookApproval:
"""Approval backend that sends HTTP webhook requests and polls for decisions.
Posts a JSON payload to ``webhook_url`` with the approval request details,
then polls ``status_url`` (defaults to ``webhook_url/{request_id}``) for
the decision.
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
Args:
webhook_url: URL to POST approval requests to. Falls back to
``APPROVAL_WEBHOOK_URL`` env var.
status_url: URL template to GET decision status. Use ``{request_id}``
placeholder. Defaults to ``webhook_url/{request_id}``.
headers: Extra HTTP headers (e.g. auth tokens).
timeout: Max seconds to wait for a response (default 300).
poll_interval: Seconds between status polls (default 5.0).
Example::
from praisonai.bots import WebhookApproval
agent = Agent(
name="bot",
approval=WebhookApproval(
webhook_url="https://hooks.example.com/approve",
headers={"Authorization": "Bearer sk-xxx"},
),
)
"""
def __init__(
self,
webhook_url: Optional[str] = None,
status_url: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
timeout: float = 300,
poll_interval: float = 5.0,
):
self._webhook_url = webhook_url or os.environ.get("APPROVAL_WEBHOOK_URL", "")
if not self._webhook_url:
raise ValueError(
"Webhook URL is required. Pass webhook_url= or set APPROVAL_WEBHOOK_URL env var."
)
self._status_url = status_url
self._headers = headers or {}
self._timeout = timeout
self._poll_interval = poll_interval
def __repr__(self) -> str:
return f"WebhookApproval(webhook_url={self._webhook_url!r})"
# ── Internal HTTP helper ───────────────────────────────────────────
async def _http_request(
self,
method: str,
url: str,
payload: Optional[Dict[str, Any]] = None,
session: Optional[Any] = None,
) -> Dict[str, Any]:
"""Make an HTTP request. Override in tests for mocking."""
import aiohttp
headers = {"Content-Type": "application/json"}
headers.update(self._headers)
async def _do(s):
if method.upper() == "GET":
async with s.get(
url, headers=headers,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
if resp.content_type == "application/json":
return await resp.json()
text = await resp.text()
return {"status": resp.status, "body": text}
else:
async with s.post(
url, headers=headers, json=payload or {},
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
if resp.content_type == "application/json":
return await resp.json()
text = await resp.text()
return {"status": resp.status, "body": text}
if session is not None:
return await _do(session)
else:
async with aiohttp.ClientSession() as _session:
return await _do(_session)
# ── ApprovalProtocol implementation ─────────────────────────────────
async def request_approval(self, request) -> Any:
"""POST approval request to webhook and poll for decision."""
from praisonaiagents.approval.protocols import ApprovalDecision
import aiohttp
request_id = str(uuid.uuid4())
payload = {
"request_id": request_id,
"tool_name": request.tool_name,
"arguments": request.arguments,
"risk_level": request.risk_level,
"agent_name": request.agent_name,
"session_id": request.session_id,
"context": request.context,
}
async with aiohttp.ClientSession() as session:
try:
# 1. POST the approval request
post_data = await self._http_request(
"POST", self._webhook_url, payload, session=session,
)
# Check for immediate decision
if isinstance(post_data, dict):
if "approved" in post_data:
return ApprovalDecision(
approved=bool(post_data["approved"]),
reason=post_data.get("reason", "Webhook immediate response"),
approver=post_data.get("approver"),
metadata={"platform": "webhook", "request_id": request_id},
)
# 2. Poll for decision
status_url = self._status_url or f"{self._webhook_url}/{request_id}"
if "{request_id}" in status_url:
status_url = status_url.replace("{request_id}", request_id)
decision = await self._poll_for_decision(
status_url, request_id, session=session,
)
return decision
except Exception as e:
logger.error(f"WebhookApproval error: {e}")
return ApprovalDecision(
approved=False,
reason=f"Webhook approval error: {e}",
)
def request_approval_sync(self, request) -> Any:
"""Synchronous wrapper — runs async method in a new event loop."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, self.request_approval(request))
return future.result(timeout=self._timeout + 10)
else:
return asyncio.run(self.request_approval(request))
# ── Polling ─────────────────────────────────────────────────────────
async def _poll_for_decision(
self,
status_url: str,
request_id: str,
session: Optional[Any] = None,
) -> Any:
"""Poll the status endpoint for a decision."""
from praisonaiagents.approval.protocols import ApprovalDecision
deadline = time.monotonic() + self._timeout
while time.monotonic() < deadline:
await asyncio.sleep(self._poll_interval)
try:
data = await self._http_request("GET", status_url, session=session)
if not isinstance(data, dict):
continue
# Check if decision is available
status = data.get("status", "").lower()
if status == "pending":
continue
if "approved" in data:
return ApprovalDecision(
approved=bool(data["approved"]),
reason=data.get("reason", f"Webhook decision for {request_id}"),
approver=data.get("approver"),
metadata={"platform": "webhook", "request_id": request_id},
)
if status in ("approved", "approve", "yes"):
return ApprovalDecision(
approved=True,
reason=data.get("reason", "Approved via webhook"),
approver=data.get("approver"),
metadata={"platform": "webhook", "request_id": request_id},
)
if status in ("denied", "deny", "rejected", "no"):
return ApprovalDecision(
approved=False,
reason=data.get("reason", "Denied via webhook"),
approver=data.get("approver"),
metadata={"platform": "webhook", "request_id": request_id},
)
except Exception as e:
logger.warning(f"Webhook poll exception: {e}")
return ApprovalDecision(
approved=False,
reason=f"Timed out waiting for webhook approval ({int(self._timeout)}s)",
metadata={"platform": "webhook", "request_id": request_id, "timeout": True},
)
@@ -0,0 +1,323 @@
"""
WhatsApp Web adapter using neonize (whatsmeow/Go backend).
Provides token-free WhatsApp connectivity via QR code scan and
WhatsApp Linked Devices. Credentials persist locally in SQLite.
This is an OPTIONAL adapter — neonize is lazily imported and only
required when mode="web" is used.
⚠️ EXPERIMENTAL: Uses reverse-engineered WhatsApp Web protocol.
Your number may be banned by Meta. Use Cloud API for production.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from pathlib import Path
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# Default credential storage path
DEFAULT_CREDS_DIR = os.path.join(str(Path.home()), ".praisonai", "whatsapp")
class WhatsAppWebAdapter:
"""Adapter wrapping neonize's async WhatsApp Web client.
Isolates neonize's API surface behind a stable interface so the
rest of PraisonAI is shielded from neonize version changes.
Key design decisions:
- neonize is imported lazily in connect() only
- Go FFI callbacks are bridged to asyncio via loop.call_soon_threadsafe()
- Credentials stored in SQLite (neonize default) at creds_dir
"""
def __init__(
self,
creds_dir: Optional[str] = None,
bot_name: str = "praisonai_whatsapp",
on_message: Optional[Callable] = None,
on_qr: Optional[Callable] = None,
on_connected: Optional[Callable] = None,
on_disconnected: Optional[Callable] = None,
):
self._creds_dir = creds_dir or os.environ.get(
"WHATSAPP_CREDS_DIR", DEFAULT_CREDS_DIR
)
self._bot_name = bot_name
self._on_message = on_message
self._on_qr = on_qr
self._on_connected = on_connected
self._on_disconnected = on_disconnected
self._client: Any = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._is_connected = False
self._self_jid: Optional[str] = None
self._started_at: Optional[float] = None
@property
def is_connected(self) -> bool:
return self._is_connected
@property
def connected_at(self) -> Optional[float]:
"""Epoch seconds when the adapter received ConnectedEv."""
return self._started_at
@property
def self_jid(self) -> Optional[str]:
return self._self_jid
@property
def creds_dir(self) -> str:
return self._creds_dir
def _ensure_creds_dir(self) -> None:
"""Create credentials directory if it doesn't exist."""
os.makedirs(self._creds_dir, exist_ok=True)
def _get_db_path(self) -> str:
"""Get SQLite database path for neonize credentials."""
self._ensure_creds_dir()
return os.path.join(self._creds_dir, f"{self._bot_name}.db")
async def connect(self) -> None:
"""Connect to WhatsApp via neonize.
Displays QR code if no saved session exists.
Uses saved credentials if available.
"""
try:
from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, MessageEv
except ImportError:
raise ImportError(
"neonize is required for WhatsApp Web mode. "
"Install with: pip install 'praisonai[bot-whatsapp-web]' "
"or: pip install neonize"
)
self._loop = asyncio.get_running_loop()
db_path = self._get_db_path()
logger.info(f"WhatsApp Web: connecting (creds: {db_path})")
# ── Suppress noisy Go-backend warnings ─────────────────────
# whatsmeow emits non-actionable warnings (websocket EOF on
# close, LTHash mismatches during state sync, duplicate
# contacts, missing MAC values). These are protocol-level
# quirks, not bugs in our code — suppress to reduce noise.
_known_noise = (
"failed to close WebSocket",
"duplicate contacts found",
"mismatching LTHash",
"missing value MAC",
)
class _WhatsmeowNoiseFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return not any(pat in msg for pat in _known_noise)
for _logger_name in ("whatsmeow.Client", "whatsmeow.Database", "whatsmeow"):
_wm_logger = logging.getLogger(_logger_name)
_wm_logger.addFilter(_WhatsmeowNoiseFilter())
# ── Critical: bridge neonize's event loop to ours ──────────
# neonize creates event_global_loop = asyncio.new_event_loop()
# at import time but NEVER starts it. Go-thread callbacks
# (QR, messages, connected) are posted to that dead loop via
# asyncio.run_coroutine_threadsafe(..., event_global_loop).
# Fix: replace it with the currently running loop so callbacks
# actually execute.
import neonize.aioze.events as _nz_events
import neonize.aioze.client as _nz_client
_nz_events.event_global_loop = self._loop
_nz_client.event_global_loop = self._loop
# neonize uses `name` as the DB file path directly
self._client = NewAClient(db_path)
# Also patch the client instance's loop reference
self._client.loop = self._loop
# Register event handlers via neonize's Event system
# client.event is an Event instance; calling it with an event type
# returns a decorator that registers the handler.
@self._client.event(ConnectedEv)
async def on_connected(client: Any, event: ConnectedEv) -> None:
self._is_connected = True
self._started_at = time.time()
# Extract self JID from client.me (set by neonize on Device event)
try:
if client.me is not None:
self._self_jid = str(client.me)
except Exception:
self._self_jid = None
logger.info("WhatsApp Web: connected")
if self._on_connected:
await self._safe_callback(self._on_connected)
@self._client.event(MessageEv)
async def on_message(client: Any, event: MessageEv) -> None:
if self._on_message:
await self._safe_callback(self._on_message, event)
# QR handler uses the dedicated event.qr() registration
@self._client.event.qr
async def on_qr(client: Any, qr_bytes: bytes) -> None:
qr_data = qr_bytes.decode() if isinstance(qr_bytes, bytes) else str(qr_bytes)
logger.info("WhatsApp Web: QR code received — scan with your phone")
if self._on_qr:
await self._safe_callback(self._on_qr, qr_data)
else:
# Default: print QR to terminal
self._print_qr_terminal(qr_data)
try:
await self._client.connect()
except Exception as e:
self._is_connected = False
logger.error(f"WhatsApp Web connection failed: {e}")
raise
async def disconnect(self) -> None:
"""Disconnect from WhatsApp."""
self._is_connected = False
if self._client:
try:
await self._client.disconnect()
except Exception as e:
logger.warning(f"WhatsApp Web disconnect error: {e}")
self._client = None
logger.info("WhatsApp Web: disconnected")
if self._on_disconnected:
await self._safe_callback(self._on_disconnected)
async def send_message(self, to: Any, text: str) -> Optional[str]:
"""Send a text message via WhatsApp Web.
Args:
to: Recipient — a native neonize JID protobuf object (preferred,
preserves LID routing info) OR a JID string / phone number
(e.g., "[email protected]") as fallback.
text: Message text
Returns:
Message ID if sent successfully, None otherwise.
"""
if not self._client or not self._is_connected:
logger.error("WhatsApp Web: not connected, cannot send message")
return None
try:
# If *to* is already a neonize JID protobuf, use it directly.
# This preserves LID routing info and avoids "no LID found" errors.
target_jid = to
if isinstance(to, str):
jid_str = self._normalize_jid(to)
from neonize.utils import build_jid
parts = jid_str.split("@", 1)
user = parts[0]
server = parts[1] if len(parts) > 1 else "s.whatsapp.net"
target_jid = build_jid(user, server)
resp = await self._client.send_message(target_jid, text)
msg_id = getattr(resp, 'ID', None) or str(resp)
return str(msg_id)
except Exception as e:
logger.error(f"WhatsApp Web send error: {e}")
return None
async def send_reaction(self, to: str, message_id: str, emoji: str) -> None:
"""Send a reaction to a message."""
if not self._client or not self._is_connected:
return
try:
jid = self._normalize_jid(to)
await self._client.send_reaction(jid, message_id, emoji)
except Exception as e:
logger.error(f"WhatsApp Web reaction error: {e}")
async def mark_as_read(self, to: str, message_id: str) -> None:
"""Mark a message as read."""
if not self._client or not self._is_connected:
return
try:
jid = self._normalize_jid(to)
await self._client.mark_read(jid, message_id)
except Exception as e:
logger.error(f"WhatsApp Web mark_read error: {e}")
def has_saved_session(self) -> bool:
"""Check if saved credentials exist."""
db_path = self._get_db_path()
return os.path.exists(db_path) and os.path.getsize(db_path) > 0
async def logout(self) -> None:
"""Logout and clear saved credentials."""
await self.disconnect()
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
logger.info(f"WhatsApp Web: cleared credentials at {db_path}")
# Also remove WAL/SHM files
for suffix in (".db-wal", ".db-shm"):
p = db_path.replace(".db", suffix)
if os.path.exists(p):
os.remove(p)
# ── Internal helpers ─────────────────────────────────────────
@staticmethod
def _normalize_jid(phone_or_jid: str) -> str:
"""Normalize a phone number to WhatsApp JID format."""
jid = phone_or_jid.strip()
if "@" in jid:
return jid
# Remove + prefix and non-digits
digits = "".join(c for c in jid if c.isdigit())
return f"{digits}@s.whatsapp.net"
@staticmethod
def _print_qr_terminal(qr_data: str) -> None:
"""Print QR code to terminal. Uses segno (neonize dep) or qrcode."""
try:
import segno # type: ignore — installed with neonize
print("\n" + "=" * 50)
print("Scan this QR code in WhatsApp → Linked Devices:")
print("=" * 50)
segno.make_qr(qr_data).terminal(compact=True)
print("=" * 50 + "\n")
return
except Exception:
pass
try:
import qrcode # type: ignore
qr = qrcode.QRCode(border=1)
qr.add_data(qr_data)
qr.print_ascii(tty=True)
return
except ImportError:
pass
# Final fallback: raw data
print(f"\n{'='*50}")
print("Scan this QR code in WhatsApp → Linked Devices:")
print(f"QR Data: {qr_data[:80]}...")
print(f"{'='*50}")
print("(Install 'segno' or 'qrcode' for visual QR display)")
async def _safe_callback(self, callback: Callable, *args: Any) -> None:
"""Safely invoke a callback, handling both sync and async."""
try:
result = callback(*args)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"WhatsApp Web callback error: {e}")
+305
View File
@@ -0,0 +1,305 @@
"""
Bot — single-platform, agent-centric bot wrapper.
Provides a user-friendly API that delegates to platform-specific adapters
(TelegramBot, DiscordBot, SlackBot, WhatsAppBot).
Hierarchy::
BotOS (multi-platform orchestrator)
└── Bot (single platform) ← this class
└── Agent / AgentTeam / AgentFlow (AI brain)
Usage::
from praisonai.bots import Bot
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful")
# Minimal — token from env (TELEGRAM_BOT_TOKEN)
bot = Bot("telegram", agent=agent)
await bot.start()
# Explicit token
bot = Bot("discord", agent=agent, token="YOUR_TOKEN")
# Platform-specific kwargs
bot = Bot("whatsapp", agent=agent, mode="web")
"""
from __future__ import annotations
import logging
import os
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
logger = logging.getLogger(__name__)
# Env-var convention: {PLATFORM}_BOT_TOKEN
_TOKEN_ENV_MAP = {
"telegram": "TELEGRAM_BOT_TOKEN",
"discord": "DISCORD_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
}
# Slack also needs an app token
_EXTRA_ENV_MAP = {
"slack": {"app_token": "SLACK_APP_TOKEN"},
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
}
class Bot:
"""Single-platform bot wrapper — the user-facing API.
Resolves the correct platform adapter at ``start()`` time (lazy),
so no heavy imports happen at construction.
Args:
platform: Platform name ("telegram", "discord", "slack", "whatsapp").
agent: Agent, AgentTeam, or AgentFlow to power the bot.
token: Explicit token. Falls back to env var ``{PLATFORM}_BOT_TOKEN``.
config: Optional BotConfig override.
**kwargs: Platform-specific arguments passed to the adapter.
"""
def __init__(
self,
platform: str,
agent: Optional[Any] = None,
token: Optional[str] = None,
config: Optional[Any] = None,
**kwargs: Any,
):
self._platform = platform.lower().strip()
self._agent = agent
self._explicit_token = token
self._config = config
self._kwargs = kwargs
self._adapter: Optional[Any] = None
self._is_running = False
# ── Properties ──────────────────────────────────────────────────
@property
def platform(self) -> str:
return self._platform
@property
def agent(self) -> Optional[Any]:
return self._agent
@agent.setter
def agent(self, value: Any) -> None:
self._agent = value
if self._adapter and hasattr(self._adapter, 'set_agent'):
self._adapter.set_agent(value)
@property
def token(self) -> str:
"""Resolve token: explicit > env var > empty."""
if self._explicit_token:
return self._explicit_token
env_key = _TOKEN_ENV_MAP.get(self._platform, f"{self._platform.upper()}_BOT_TOKEN")
return os.environ.get(env_key, "")
@property
def is_running(self) -> bool:
if self._adapter and hasattr(self._adapter, 'is_running'):
return self._adapter.is_running
return self._is_running
@property
def adapter(self) -> Optional[Any]:
"""The underlying platform adapter (available after start)."""
return self._adapter
# ── Lifecycle ───────────────────────────────────────────────────
def _apply_smart_defaults(self, agent: Any) -> Any:
"""Enhance agent with sensible bot defaults if not already configured.
Smart defaults are applied automatically:
- Safe tools (search_web, schedule_add/list/remove) if agent has no tools
- Memory enabled if not already set
These defaults make Bot() immediately useful without extra configuration.
Users who want full control can pre-configure their agent.
"""
if agent is None:
return agent
# Only enhance Agent instances (not AgentTeam/AgentFlow)
agent_cls_name = type(agent).__name__
if agent_cls_name not in ("Agent",):
return agent
# Wire BotConfig.auto_approve_tools → Agent(approval=True)
if self._config and getattr(self._config, 'auto_approve_tools', False):
if getattr(agent, '_approval_backend', None) is None:
from praisonaiagents.approval.backends import AutoApproveBackend
agent._approval_backend = AutoApproveBackend()
logger.debug(f"Bot: auto_approve_tools enabled for agent '{getattr(agent, 'name', '?')}'")
# Inject session history if agent has no memory configured (zero-dep).
# NOTE: No session_id here — BotSessionManager handles per-user
# isolation by swapping chat_history before/after each agent.chat().
current_memory = getattr(agent, 'memory', None)
if current_memory is None:
agent.memory = {
"history": True,
"history_limit": 20,
}
logger.debug(f"Bot: injected session history for agent '{getattr(agent, 'name', '?')}'")
# Add default tools if agent has none
current_tools = getattr(agent, 'tools', None) or []
if not current_tools:
try:
from praisonaiagents.tools import (
schedule_add, schedule_list, schedule_remove,
)
default_tools = [schedule_add, schedule_list, schedule_remove]
# Try to add search_web if available
try:
from praisonaiagents.tools import search_web
default_tools.insert(0, search_web)
except (ImportError, AttributeError):
pass
agent.tools = default_tools
logger.debug(f"Bot: applied default tools to agent '{getattr(agent, 'name', '?')}'")
except ImportError:
pass # Tools not available, skip
return agent
def _build_adapter(self) -> Any:
"""Lazy-resolve and instantiate the platform adapter."""
from ._registry import resolve_adapter
adapter_cls = resolve_adapter(self._platform)
# Apply smart defaults to agent before passing to adapter
agent = self._apply_smart_defaults(self._agent)
# Build init kwargs for the adapter
init_kwargs: Dict[str, Any] = {}
init_kwargs["token"] = self.token
if agent is not None:
init_kwargs["agent"] = agent
if self._config is not None:
init_kwargs["config"] = self._config
# Resolve extra env vars (e.g. SLACK_APP_TOKEN)
extras = _EXTRA_ENV_MAP.get(self._platform, {})
for param, env_key in extras.items():
if param not in self._kwargs:
env_val = os.environ.get(env_key, "")
if env_val:
init_kwargs[param] = env_val
# Merge user kwargs (override defaults)
init_kwargs.update(self._kwargs)
return adapter_cls(**init_kwargs)
async def start(self) -> None:
"""Build the adapter and start the bot."""
if self._adapter and self.is_running:
logger.warning(f"Bot({self._platform}) already running")
return
self._adapter = self._build_adapter()
self._is_running = True
await self._adapter.start()
async def stop(self) -> None:
"""Stop the bot."""
if self._adapter:
await self._adapter.stop()
self._is_running = False
def run(self) -> None:
"""Synchronous entry point — starts the bot.
Convenience wrapper so users don't need ``asyncio.run()``.
Usage::
bot = Bot("telegram", agent=agent)
bot.run() # blocks until Ctrl+C
"""
import asyncio
try:
asyncio.run(self.start())
except KeyboardInterrupt:
pass
# ── Delegation to adapter ──────────────────────────────────────
async def send_message(
self,
channel_id: str,
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
) -> Any:
"""Send a message via the underlying adapter."""
if not self._adapter:
raise RuntimeError("Bot not started")
return await self._adapter.send_message(
channel_id, content, reply_to=reply_to, thread_id=thread_id
)
def on_message(self, handler: Callable) -> Callable:
"""Register a message handler (pre-start decorator)."""
if self._adapter:
return self._adapter.on_message(handler)
# Store for later registration
if not hasattr(self, '_pending_message_handlers'):
self._pending_message_handlers: List[Callable] = []
self._pending_message_handlers.append(handler)
return handler
def on_command(self, command: str) -> Callable:
"""Decorator to register a command handler."""
if self._adapter:
return self._adapter.on_command(command)
def decorator(func: Callable) -> Callable:
if not hasattr(self, '_pending_command_handlers'):
self._pending_command_handlers: Dict[str, Callable] = {}
self._pending_command_handlers[command] = func
return func
return decorator
async def probe(self) -> Any:
"""Test channel connectivity (builds adapter lazily if needed)."""
if not self._adapter:
self._adapter = self._build_adapter()
if hasattr(self._adapter, 'probe'):
return await self._adapter.probe()
from praisonaiagents.bots import ProbeResult
return ProbeResult(ok=False, platform=self._platform, error="Adapter does not support probe()")
async def health(self) -> Any:
"""Get detailed health status."""
if not self._adapter:
self._adapter = self._build_adapter()
if hasattr(self._adapter, 'health'):
return await self._adapter.health()
from praisonaiagents.bots import HealthResult
return HealthResult(ok=False, platform=self._platform, error="Adapter does not support health()")
def __repr__(self) -> str:
agent_name = getattr(self._agent, 'name', None) if self._agent else None
return f"Bot(platform={self._platform!r}, agent={agent_name!r}, running={self.is_running})"
+320
View File
@@ -0,0 +1,320 @@
"""
BotOS — multi-platform bot orchestrator.
Manages multiple Bot instances across different messaging platforms
with a single unified lifecycle.
Hierarchy::
BotOS (multi-platform orchestrator) ← this class
└── Bot (single platform)
└── Agent / AgentTeam / AgentFlow (AI brain)
Usage::
from praisonai.bots import BotOS, Bot
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful")
# Approach 1: Explicit bots
botos = BotOS(bots=[
Bot("telegram", agent=agent),
Bot("discord", agent=agent),
])
await botos.start()
# Approach 2: Shortcut — same agent, multiple platforms
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
await botos.start()
# Approach 3: Build incrementally
botos = BotOS()
botos.add_bot(Bot("telegram", agent=agent))
botos.add_bot(Bot("slack", agent=agent, app_token="xapp-..."))
await botos.start()
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from praisonaiagents import Agent
from .bot import Bot
logger = logging.getLogger(__name__)
class BotOS:
"""Multi-platform bot orchestrator.
Starts and stops all registered Bot instances concurrently.
Satisfies ``BotOSProtocol`` from the core SDK.
Args:
bots: Pre-built Bot instances.
agent: Shared agent for auto-created bots (used with *platforms*).
platforms: List of platform names; creates a Bot per platform
using the shared *agent*.
config: Optional BotOSConfig.
"""
def __init__(
self,
bots: Optional[List[Bot]] = None,
agent: Optional[Any] = None,
platforms: Optional[List[str]] = None,
config: Optional[Any] = None,
):
self._bots: Dict[str, Bot] = {}
self._is_running = False
self._config = config
self._tasks: List[asyncio.Task] = []
# Register explicit bots
if bots:
for bot in bots:
self.add_bot(bot)
# Shortcut: agent + platforms → auto-create Bot per platform
if agent and platforms:
for plat in platforms:
if plat not in self._bots:
self.add_bot(Bot(plat, agent=agent))
# ── Properties ──────────────────────────────────────────────────
@property
def is_running(self) -> bool:
return self._is_running
# ── Bot management ──────────────────────────────────────────────
def add_bot(self, bot: Bot) -> None:
"""Register a Bot for orchestration.
Args:
bot: A Bot instance.
"""
self._bots[bot.platform] = bot
def list_bots(self) -> List[str]:
"""List platform names of all registered bots."""
return list(self._bots.keys())
def get_bot(self, platform: str) -> Optional[Bot]:
"""Get a registered bot by platform name."""
return self._bots.get(platform.lower())
# ── Lifecycle ───────────────────────────────────────────────────
async def start(self) -> None:
"""Start all registered bots concurrently.
Each bot runs in its own asyncio task. BotOS waits for all
bots (they block until stopped).
"""
if self._is_running:
logger.warning("BotOS already running")
return
if not self._bots:
logger.warning("BotOS: no bots registered, nothing to start")
return
self._is_running = True
logger.info(f"BotOS starting {len(self._bots)} bot(s): {', '.join(self._bots.keys())}")
self._tasks = []
for platform, bot in self._bots.items():
task = asyncio.create_task(
self._run_bot(platform, bot),
name=f"botos-{platform}",
)
self._tasks.append(task)
try:
await asyncio.gather(*self._tasks, return_exceptions=True)
except asyncio.CancelledError:
pass
finally:
self._is_running = False
async def _run_bot(self, platform: str, bot: Bot) -> None:
"""Run a single bot with error isolation."""
try:
logger.info(f"BotOS: starting {platform}")
await bot.start()
except Exception as e:
logger.error(f"BotOS: {platform} failed: {e}")
async def stop(self) -> None:
"""Gracefully stop all running bots."""
if not self._is_running:
return
logger.info("BotOS stopping all bots...")
# Stop each bot
for platform, bot in self._bots.items():
try:
await bot.stop()
except Exception as e:
logger.warning(f"BotOS: error stopping {platform}: {e}")
# Cancel tasks
for task in self._tasks:
if not task.done():
task.cancel()
self._is_running = False
logger.info("BotOS stopped")
def remove_bot(self, platform: str) -> bool:
"""Remove a registered bot by platform name.
Args:
platform: Platform identifier to remove.
Returns:
True if removed, False if not found.
"""
key = platform.lower()
if key in self._bots:
del self._bots[key]
return True
return False
def run(self) -> None:
"""Synchronous entry point — starts all bots.
Convenience wrapper so users don't need ``asyncio.run()``.
Usage::
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
botos.run() # blocks until Ctrl+C
"""
try:
asyncio.run(self.start())
except KeyboardInterrupt:
logger.info("BotOS interrupted")
@classmethod
def from_config(cls, path: str) -> "BotOS":
"""Create a BotOS instance from a YAML config file.
YAML format::
name: My BotOS
agent:
name: assistant
instructions: Be helpful
llm: gpt-4o-mini
platforms:
telegram:
token: ${TELEGRAM_BOT_TOKEN}
discord:
token: ${DISCORD_BOT_TOKEN}
Args:
path: Path to YAML config file.
Returns:
Configured BotOS instance.
"""
import os
import re
try:
import yaml
except ImportError:
raise ImportError("PyYAML required: pip install pyyaml")
with open(path, "r") as f:
raw = yaml.safe_load(f)
if not raw or not isinstance(raw, dict):
raise ValueError(f"Invalid BotOS config: {path}")
def _resolve_env(val):
if isinstance(val, str):
return re.sub(
r'\$\{([^}]+)\}',
lambda m: os.environ.get(m.group(1), m.group(0)),
val,
)
return val
# Build agent from config
agent = None
agent_cfg = raw.get("agent")
if agent_cfg and isinstance(agent_cfg, dict):
from praisonaiagents import Agent
# Core params always supported
agent_kwargs: Dict[str, Any] = {
"name": agent_cfg.get("name", "assistant"),
"instructions": agent_cfg.get("instructions", "You are a helpful assistant."),
}
if agent_cfg.get("llm"):
agent_kwargs["llm"] = agent_cfg["llm"]
# Extended params — memory, tools, verbose, knowledge, guardrails
if "memory" in agent_cfg:
agent_kwargs["memory"] = agent_cfg["memory"]
# Pass through known Agent params
for key in ("role", "goal", "backstory", "planning", "reflection"):
if key in agent_cfg:
agent_kwargs[key] = agent_cfg[key]
# Resolve tool names to real functions
tool_names = agent_cfg.get("tools")
if tool_names and isinstance(tool_names, list):
resolved_tools = []
for tname in tool_names:
if isinstance(tname, str):
try:
import importlib
mod = importlib.import_module("praisonaiagents.tools")
fn = getattr(mod, tname, None)
if fn:
resolved_tools.append(fn)
else:
logger.warning(f"BotOS: tool '{tname}' not found in praisonaiagents.tools")
except ImportError:
logger.warning(f"BotOS: could not import tools module for '{tname}'")
else:
resolved_tools.append(tname) # Already a callable
if resolved_tools:
agent_kwargs["tools"] = resolved_tools
if "knowledge" in agent_cfg:
agent_kwargs["knowledge"] = agent_cfg["knowledge"]
if "guardrail" in agent_cfg:
agent_kwargs["guardrail"] = agent_cfg["guardrail"]
agent = Agent(**agent_kwargs)
# Build bots per platform
bots = []
platforms_cfg = raw.get("platforms", {})
for plat_name, plat_cfg in platforms_cfg.items():
if not isinstance(plat_cfg, dict):
plat_cfg = {}
# Resolve env vars in values
resolved = {k: _resolve_env(v) for k, v in plat_cfg.items()}
token = resolved.pop("token", None)
bots.append(Bot(plat_name, agent=agent, token=token, **resolved))
return cls(bots=bots)
def __repr__(self) -> str:
platforms = list(self._bots.keys())
return f"BotOS(platforms={platforms!r}, running={self._is_running})"
+63 -8
View File
@@ -15,7 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
@@ -26,11 +26,12 @@ from praisonaiagents.bots import (
from ._commands import format_status, format_help
from ._session import BotSessionManager
from ._debounce import InboundDebouncer
logger = logging.getLogger(__name__)
class DiscordBot(ChatCommandMixin):
class DiscordBot(ChatCommandMixin, MessageHookMixin):
"""Discord bot runtime for PraisonAI agents.
Connects an agent to Discord, handling messages, slash commands,
@@ -77,6 +78,9 @@ class DiscordBot(ChatCommandMixin):
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
@property
def is_running(self) -> bool:
@@ -133,6 +137,8 @@ class DiscordBot(ChatCommandMixin):
bot_message = self._convert_message(message)
self.fire_message_received(bot_message)
if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""):
return
if not self.config.is_channel_allowed(bot_message.channel.channel_id if bot_message.channel else ""):
@@ -180,18 +186,27 @@ class DiscordBot(ChatCommandMixin):
if self._agent:
user_id = str(message.author.id)
async def _send_agent_response():
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
response = await self._session.chat(self._agent, user_id, text_to_send)
send_result = self.fire_message_sending(
str(message.channel.id), str(response),
)
if send_result["cancel"]:
return
await self._send_long_message(message.channel, send_result["content"], reference=message)
self.fire_message_sent(str(message.channel.id), send_result["content"])
if self.config.typing_indicator:
async with message.channel.typing():
try:
response = await self._session.chat(self._agent, user_id, bot_message.text)
await self._send_long_message(message.channel, response, reference=message)
await _send_agent_response()
except Exception as e:
logger.error(f"Agent error: {e}")
await message.reply(f"Error: {str(e)}")
else:
try:
response = await self._session.chat(self._agent, user_id, bot_message.text)
await self._send_long_message(message.channel, response, reference=message)
await _send_agent_response()
except Exception as e:
logger.error(f"Agent error: {e}")
await message.reply(f"Error: {str(e)}")
@@ -204,6 +219,7 @@ class DiscordBot(ChatCommandMixin):
return
self._is_running = False
self._debouncer.cancel_all()
if self._client:
await self._client.close()
@@ -250,7 +266,9 @@ class DiscordBot(ChatCommandMixin):
)
async def _send_long_message(self, channel, text: str, reference=None) -> None:
"""Send a long message, splitting if necessary."""
"""Send a long message, splitting with markdown-aware chunking."""
from ._chunk import chunk_message
max_len = min(self.config.max_message_length, 2000)
if len(text) <= max_len:
@@ -259,7 +277,7 @@ class DiscordBot(ChatCommandMixin):
else:
await channel.send(text)
else:
chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)]
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
for i, chunk in enumerate(chunks):
if i == 0 and reference:
await channel.send(chunk, reference=reference)
@@ -364,6 +382,43 @@ class DiscordBot(ChatCommandMixin):
pass
return None
async def probe(self):
"""Test Discord API connectivity without starting the bot."""
from praisonaiagents.bots import ProbeResult
started = time.time()
try:
import aiohttp
url = "https://discord.com/api/v10/users/@me"
headers = {"Authorization": f"Bot {self._token}"}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
elapsed = (time.time() - started) * 1000
if resp.status == 200:
data = await resp.json()
return ProbeResult(
ok=True, platform="discord", elapsed_ms=elapsed,
bot_username=data.get("username"),
details={"bot_id": data.get("id"), "discriminator": data.get("discriminator")},
)
else:
text = await resp.text()
return ProbeResult(ok=False, platform="discord", elapsed_ms=elapsed, error=f"HTTP {resp.status}: {text[:200]}")
except Exception as e:
return ProbeResult(ok=False, platform="discord", elapsed_ms=(time.time() - started) * 1000, error=str(e))
async def health(self):
"""Get detailed health status of the Discord bot."""
from praisonaiagents.bots import HealthResult
probe_result = await self.probe()
uptime = (time.time() - self._started_at) if self._started_at else None
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
return HealthResult(
ok=self._is_running and probe_result.ok, platform="discord",
is_running=self._is_running, uptime_seconds=uptime,
probe=probe_result, sessions=session_count,
error=probe_result.error if not probe_result.ok else None,
)
def _format_status(self) -> str:
"""Format /status response."""
return format_status(self._agent, self.platform, self._started_at, self._is_running)
+58 -5
View File
@@ -16,7 +16,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
@@ -28,11 +28,12 @@ from praisonaiagents.bots import (
from .media import split_media_from_output, is_audio_file
from ._commands import format_status, format_help
from ._session import BotSessionManager
from ._debounce import InboundDebouncer
logger = logging.getLogger(__name__)
class SlackBot(ChatCommandMixin):
class SlackBot(ChatCommandMixin, MessageHookMixin):
"""Slack bot runtime for PraisonAI agents.
Connects an agent to Slack, handling messages, slash commands,
@@ -90,6 +91,9 @@ class SlackBot(ChatCommandMixin):
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
# Audio capabilities
self._stt_enabled: bool = False
@@ -148,6 +152,8 @@ class SlackBot(ChatCommandMixin):
bot_message = self._convert_event_to_message(event)
self.fire_message_received(bot_message)
if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""):
return
if not self.config.is_channel_allowed(bot_message.channel.channel_id if bot_message.channel else ""):
@@ -191,9 +197,16 @@ class SlackBot(ChatCommandMixin):
try:
user_id = event.get("user", "unknown")
logger.info(f"Message received: {text[:100]}...")
text = await self._debouncer.debounce(user_id, text)
response = await self._session.chat(self._agent, user_id, text)
logger.info(f"Response sent: {response[:100]}...")
channel_id = event.get("channel", "")
send_result = self.fire_message_sending(channel_id, str(response))
if send_result["cancel"]:
return
response = send_result["content"]
# Determine if we should reply in thread
thread_ts = None
if self.config.reply_in_thread:
@@ -203,8 +216,9 @@ class SlackBot(ChatCommandMixin):
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
channel_id, say, response, thread_ts=thread_ts
)
self.fire_message_sent(channel_id, response)
except Exception as e:
logger.error(f"Agent error: {e}")
error_msg = str(e)
@@ -285,6 +299,7 @@ class SlackBot(ChatCommandMixin):
return
self._is_running = False
self._debouncer.cancel_all()
logger.info("Slack bot stopped")
def set_agent(self, agent: "Agent") -> None:
@@ -322,7 +337,9 @@ class SlackBot(ChatCommandMixin):
)
async def _send_long_message(self, say, text: str, thread_ts: Optional[str] = None) -> None:
"""Send a long message, splitting if necessary."""
"""Send a long message, splitting with markdown-aware chunking."""
from ._chunk import chunk_message
max_len = min(self.config.max_message_length, 4000)
if not text or not text.strip():
@@ -332,7 +349,7 @@ class SlackBot(ChatCommandMixin):
if len(text) <= max_len:
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)]
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
for chunk in chunks:
if chunk and chunk.strip():
await say(text=chunk.strip(), thread_ts=thread_ts)
@@ -466,6 +483,42 @@ class SlackBot(ChatCommandMixin):
except Exception:
return None
async def probe(self):
"""Test Slack API connectivity without starting the bot."""
from praisonaiagents.bots import ProbeResult
started = time.time()
try:
import aiohttp
url = "https://slack.com/api/auth.test"
headers = {"Authorization": f"Bearer {self._token}"}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
data = await resp.json()
elapsed = (time.time() - started) * 1000
if data.get("ok"):
return ProbeResult(
ok=True, platform="slack", elapsed_ms=elapsed,
bot_username=data.get("user"),
details={"team": data.get("team"), "team_id": data.get("team_id"), "user_id": data.get("user_id")},
)
else:
return ProbeResult(ok=False, platform="slack", elapsed_ms=elapsed, error=data.get("error", "Unknown error"))
except Exception as e:
return ProbeResult(ok=False, platform="slack", elapsed_ms=(time.time() - started) * 1000, error=str(e))
async def health(self):
"""Get detailed health status of the Slack bot."""
from praisonaiagents.bots import HealthResult
probe_result = await self.probe()
uptime = (time.time() - self._started_at) if self._started_at else None
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
return HealthResult(
ok=self._is_running and probe_result.ok, platform="slack",
is_running=self._is_running, uptime_seconds=uptime,
probe=probe_result, sessions=session_count,
error=probe_result.error if not probe_result.ok else None,
)
def _format_status(self) -> str:
"""Format /status response."""
return format_status(self._agent, self.platform, self._started_at, self._is_running)
+174 -25
View File
@@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
@@ -29,11 +29,13 @@ from praisonaiagents.bots import (
from .media import split_media_from_output, is_audio_file
from ._commands import format_status, format_help
from ._session import BotSessionManager
from ._debounce import InboundDebouncer
from ._ack import AckReactor
logger = logging.getLogger(__name__)
class TelegramBot(ChatCommandMixin):
class TelegramBot(ChatCommandMixin, MessageHookMixin):
"""Telegram bot runtime for PraisonAI agents.
Connects an agent to Telegram, handling messages, commands,
@@ -80,10 +82,21 @@ class TelegramBot(ChatCommandMixin):
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
self._ack: AckReactor = AckReactor(
ack_emoji=self.config.ack_emoji,
done_emoji=self.config.done_emoji,
)
# Audio capabilities (set by BotCapabilities)
self._stt_enabled: bool = False
self._auto_tts: bool = False
# Resilience
self._monitor = None # Lazy: ConnectionMonitor
self._stop_event: Optional[asyncio.Event] = None
def enable_stt(self, enabled: bool = True) -> None:
"""Enable STT for voice message transcription."""
@@ -153,6 +166,8 @@ class TelegramBot(ChatCommandMixin):
message = self._convert_update_to_message(update, override_text=message_text)
self.fire_message_received(message)
if not self.config.is_user_allowed(message.sender.user_id if message.sender else ""):
return
if not self.config.is_channel_allowed(message.channel.channel_id if message.channel else ""):
@@ -171,14 +186,51 @@ class TelegramBot(ChatCommandMixin):
if self.config.typing_indicator:
await update.message.chat.send_action("typing")
# Ack reaction
ack_ctx = None
if self._ack.enabled:
async def _tg_react(emoji, **kw):
try:
from telegram import ReactionTypeEmoji
await self._application.bot.set_message_reaction(
chat_id=update.message.chat_id,
message_id=update.message.message_id,
reaction=[ReactionTypeEmoji(emoji=emoji)],
)
except Exception:
pass # Reactions may not be supported in all chats
async def _tg_unreact(emoji, **kw):
try:
await self._application.bot.set_message_reaction(
chat_id=update.message.chat_id,
message_id=update.message.message_id,
reaction=[],
)
except Exception:
pass
ack_ctx = await self._ack.ack(react_fn=_tg_react)
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
try:
message_text = await self._debouncer.debounce(user_id, message_text)
response = await self._session.chat(self._agent, user_id, message_text)
send_result = self.fire_message_sending(
str(update.message.chat_id), str(response),
reply_to=str(update.message.message_id),
)
if send_result["cancel"]:
return
await self._send_response_with_media(
update.message.chat_id,
response,
send_result["content"],
reply_to=update.message.message_id,
)
self.fire_message_sent(
str(update.message.chat_id), send_result["content"],
)
# Done reaction
if ack_ctx:
await self._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
except Exception as e:
logger.error(f"Agent error: {e}")
await update.message.reply_text(f"Error: {str(e)}")
@@ -240,6 +292,10 @@ class TelegramBot(ChatCommandMixin):
self._is_running = True
logger.info(f"Telegram bot started: @{self._bot_user.username}")
# Initialize connection monitor for resilience
from ._resilience import ConnectionMonitor, TELEGRAM_BACKOFF, is_recoverable_error, is_conflict_error, sleep_with_abort
self._monitor = ConnectionMonitor(platform="telegram", policy=TELEGRAM_BACKOFF)
if self.config.is_webhook_mode:
await self._application.run_webhook(
listen="0.0.0.0",
@@ -248,32 +304,66 @@ class TelegramBot(ChatCommandMixin):
webhook_url=f"{self.config.webhook_url}{self.config.webhook_path}",
)
else:
# Use low-level API to avoid event-loop conflicts with run_polling().
# run_polling() tries to manage its own event loop lifecycle which
# conflicts with asyncio.run() or any shared event loop (e.g. gateway).
await self._application.initialize()
await self._application.start()
await self._application.updater.start_polling(
poll_interval=self.config.polling_interval,
)
# Block until stop() is called
# Resilient polling loop with exponential backoff
self._stop_event = asyncio.Event()
try:
await self._stop_event.wait()
except asyncio.CancelledError:
pass
finally:
await self._application.updater.stop()
await self._application.stop()
await self._application.shutdown()
self._is_running = False
while not self._stop_event.is_set():
try:
await self._application.initialize()
await self._application.start()
await self._application.updater.start_polling(
poll_interval=self.config.polling_interval,
)
self._monitor.record_success()
# Block until stop() is called
await self._stop_event.wait()
except asyncio.CancelledError:
break
except Exception as e:
if is_conflict_error(e):
logger.error(
"[telegram] Another bot instance is running with this token. "
"Stop the other instance first."
)
break # Don't retry conflicts
if is_recoverable_error(e, "telegram") and self._monitor.should_retry():
delay = self._monitor.record_error(e)
completed = await sleep_with_abort(delay, self._stop_event)
if not completed:
break # Abort signaled during sleep
# Attempt reconnect
try:
await self._application.updater.stop()
await self._application.stop()
await self._application.shutdown()
except Exception:
pass
# Rebuild application for clean reconnect
from telegram.ext import Application
self._application = Application.builder().token(self._token).build()
continue
else:
logger.error(f"[telegram] Unrecoverable error: {e}")
break
finally:
try:
await self._application.updater.stop()
await self._application.stop()
await self._application.shutdown()
except Exception:
pass
self._is_running = False
async def stop(self) -> None:
"""Stop the Telegram bot."""
if not self._is_running:
return
# Cancel pending debounce timers
self._debouncer.cancel_all()
# Signal the stop event so the start() loop exits cleanly
if hasattr(self, '_stop_event') and self._stop_event:
self._stop_event.set()
@@ -331,16 +421,18 @@ class TelegramBot(ChatCommandMixin):
text: str,
reply_to: Optional[int] = None,
) -> None:
"""Send a long message, splitting if necessary."""
"""Send a long message, splitting with markdown-aware chunking."""
from ._chunk import chunk_message
max_len = self.config.max_message_length
if len(text) <= max_len:
kwargs = {"chat_id": chat_id, "text": text}
if reply_to:
kwargs["reply_to_message_id"] = reply_to
await self._application.bot.send_message(**kwargs)
else:
chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)]
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
for i, chunk in enumerate(chunks):
kwargs = {"chat_id": chat_id, "text": chunk}
if i == 0 and reply_to:
@@ -519,6 +611,63 @@ class TelegramBot(ChatCommandMixin):
except Exception:
return None
async def probe(self):
"""Test Telegram API connectivity without starting the bot."""
from praisonaiagents.bots import ProbeResult
started = time.time()
try:
import aiohttp
url = f"https://api.telegram.org/bot{self._token}/getMe"
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
data = await resp.json()
elapsed = (time.time() - started) * 1000
if resp.status == 200 and data.get("ok"):
result = data.get("result", {})
return ProbeResult(
ok=True,
platform="telegram",
elapsed_ms=elapsed,
bot_username=result.get("username"),
details={
"bot_id": result.get("id"),
"can_join_groups": result.get("can_join_groups"),
"can_read_all_group_messages": result.get("can_read_all_group_messages"),
"supports_inline_queries": result.get("supports_inline_queries"),
},
)
else:
return ProbeResult(
ok=False,
platform="telegram",
elapsed_ms=elapsed,
error=data.get("description", f"HTTP {resp.status}"),
)
except Exception as e:
elapsed = (time.time() - started) * 1000
return ProbeResult(
ok=False,
platform="telegram",
elapsed_ms=elapsed,
error=str(e),
)
async def health(self):
"""Get detailed health status of the Telegram bot."""
from praisonaiagents.bots import HealthResult
probe_result = await self.probe()
uptime = (time.time() - self._started_at) if self._started_at else None
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
return HealthResult(
ok=self._is_running and probe_result.ok,
platform="telegram",
is_running=self._is_running,
uptime_seconds=uptime,
probe=probe_result,
sessions=session_count,
error=probe_result.error if not probe_result.ok else None,
)
def _format_status(self) -> str:
"""Format /status response."""
return format_status(self._agent, self.platform, self._started_at, self._is_running)
+340 -15
View File
@@ -1,14 +1,23 @@
"""
WhatsApp Bot implementation for PraisonAI.
Provides a full WhatsApp bot runtime using the WhatsApp Cloud API (Meta Graph API).
Receives messages via an aiohttp webhook server and replies via REST API.
Supports two connection modes:
Requirements:
- aiohttp (already a core dependency)
- WHATSAPP_ACCESS_TOKEN: Meta Cloud API access token
- WHATSAPP_PHONE_NUMBER_ID: Phone number ID from Meta
- WHATSAPP_VERIFY_TOKEN: Custom string for webhook verification
**Cloud API mode** (default, stable, recommended):
- Uses Meta Graph API with webhook server
- Requires: WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_VERIFY_TOKEN
**Web mode** (experimental, token-free):
- Uses neonize (whatsmeow) for WhatsApp Web protocol
- QR code scan via Linked Devices — no Meta developer account needed
- ⚠️ Uses reverse-engineered protocol; your number may be banned
Usage:
# Cloud API (default)
bot = WhatsAppBot(token="EAAx...", phone_number_id="123", agent=agent)
# Web mode (no tokens needed)
bot = WhatsAppBot(mode="web", agent=agent)
"""
from __future__ import annotations
@@ -25,7 +34,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
@@ -44,7 +53,7 @@ GRAPH_API_VERSION = "v21.0"
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
class WhatsAppBot(ChatCommandMixin):
class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
"""WhatsApp bot runtime for PraisonAI agents.
Connects an agent to WhatsApp via the Cloud API, handling messages,
@@ -67,7 +76,7 @@ class WhatsAppBot(ChatCommandMixin):
def __init__(
self,
token: str,
token: str = "",
phone_number_id: str = "",
agent: Optional["Agent"] = None,
config: Optional[BotConfig] = None,
@@ -75,15 +84,43 @@ class WhatsAppBot(ChatCommandMixin):
app_secret: str = "",
webhook_port: int = 8080,
webhook_path: str = "/webhook",
mode: str = "cloud",
creds_dir: Optional[str] = None,
allowed_numbers: Optional[List[str]] = None,
allowed_groups: Optional[List[str]] = None,
respond_to_all: bool = False,
):
self._token = token
# Mode: "cloud" (default, Meta Cloud API) or "web" (neonize, experimental)
self._mode = mode.lower().strip()
if self._mode not in ("cloud", "web"):
raise ValueError(f"Invalid mode '{self._mode}'. Use 'cloud' or 'web'.")
self._token = token or os.environ.get("WHATSAPP_ACCESS_TOKEN", "")
self._phone_number_id = phone_number_id or os.environ.get("WHATSAPP_PHONE_NUMBER_ID", "")
self._agent = agent
self.config = config or BotConfig(token=token)
self.config = config or BotConfig(token=self._token)
self._verify_token = verify_token or os.environ.get("WHATSAPP_VERIFY_TOKEN", "")
self._app_secret = app_secret or os.environ.get("WHATSAPP_APP_SECRET", "")
self._webhook_port = webhook_port
self._webhook_path = webhook_path
self._creds_dir = creds_dir
# Message filtering (Web mode).
# Default: respond only to self-messages (IsFromMe).
# --respond-to-all opens to everyone.
# --respond-to / --respond-to-groups whitelist specific senders.
self._respond_to_all = respond_to_all
self._allowed_numbers: set[str] = set()
self._allowed_groups: set[str] = set()
if allowed_numbers:
for n in allowed_numbers:
# Normalise: strip +, keep digits only
digits = "".join(c for c in n if c.isdigit())
if digits:
self._allowed_numbers.add(digits)
if allowed_groups:
for g in allowed_groups:
self._allowed_groups.add(g.strip())
self._is_running = False
self._started_at: Optional[float] = None
@@ -93,6 +130,9 @@ class WhatsAppBot(ChatCommandMixin):
self._runner: Any = None
self._site: Any = None
# Web mode adapter (lazy initialized)
self._web_adapter: Any = None
# ChatCommandMixin setup
self._command_handlers: Dict[str, Callable] = {}
self._command_info: Dict[str, Dict[str, Any]] = {}
@@ -133,6 +173,11 @@ class WhatsAppBot(ChatCommandMixin):
def platform(self) -> str:
return "whatsapp"
@property
def mode(self) -> str:
"""Connection mode: 'cloud' (Meta Cloud API) or 'web' (neonize)."""
return self._mode
@property
def bot_user(self) -> Optional[BotUser]:
return self._bot_user
@@ -162,7 +207,14 @@ class WhatsAppBot(ChatCommandMixin):
# ── Lifecycle ───────────────────────────────────────────────────
async def start(self) -> None:
"""Start the webhook server to receive WhatsApp messages."""
"""Start the bot in the configured mode (cloud or web)."""
if self._mode == "web":
await self._start_web_mode()
else:
await self._start_cloud_mode()
async def _start_cloud_mode(self) -> None:
"""Start the Cloud API webhook server."""
try:
from aiohttp import web
except ImportError:
@@ -200,13 +252,242 @@ class WhatsAppBot(ChatCommandMixin):
finally:
await self.stop()
async def _start_web_mode(self) -> None:
"""Start in Web mode using neonize (WhatsApp Web protocol).
⚠️ EXPERIMENTAL: Uses reverse-engineered protocol.
"""
from ._whatsapp_web_adapter import WhatsAppWebAdapter
async def _on_web_message(event: Any) -> None:
"""Bridge neonize message events to bot message handling."""
try:
# Extract message info from neonize event
info = getattr(event, 'Info', None) or getattr(event, 'info', None)
msg = getattr(event, 'Message', None) or getattr(event, 'message', None)
if not info or not msg:
return
# Extract message source fields
msg_source = getattr(info, 'MessageSource', None) or info
sender_jid = str(getattr(msg_source, 'Sender', '') or getattr(info, 'sender', ''))
# Keep the original neonize JID protobuf for Chat — this
# preserves LID routing info needed by send_message.
chat_jid_obj = getattr(msg_source, 'Chat', None) or getattr(info, 'chat', None)
chat_jid = str(chat_jid_obj) if chat_jid_obj else ''
msg_id = str(getattr(info, 'ID', '') or getattr(info, 'id', ''))
raw_ts = getattr(info, 'Timestamp', None)
if hasattr(raw_ts, 'timestamp'):
timestamp = raw_ts.timestamp()
elif isinstance(raw_ts, (int, float)) and raw_ts > 0:
# neonize/Go encodes timestamps as milliseconds (UnixMilli).
# Detect: any value > 1e12 is milliseconds, convert to seconds.
timestamp = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
else:
timestamp = time.time()
# ── Stale-message guard ────────────────────────────────
# whatsmeow delivers offline/historical messages as regular
# MessageEv upon reconnection. Drop anything older than
# the moment the adapter actually connected so the bot
# never replies to old messages.
connected_at = (
self._web_adapter.connected_at
if self._web_adapter else self._started_at
) or self._started_at or 0
if timestamp and connected_at and timestamp < connected_at:
logger.debug(
"Skipping stale message %s (ts=%.1f < connected=%.1f)",
msg_id, timestamp, connected_at,
)
return
# ── Message filtering ────────────────────────────────
# Default: self-chat only (user messaging their own number).
# Expand via allowed_numbers / groups / respond_to_all.
is_from_me = bool(getattr(msg_source, 'IsFromMe', False))
is_group = bool(getattr(msg_source, 'IsGroup', False)) or "@g.us" in chat_jid
# Determine if this is a true self-chat (user→own number).
# IsFromMe is True for ANY message the user sent in any chat,
# but self-messaging means sender and chat are the same JID.
def _jid_user(jid_str: str) -> str:
"""Extract bare user part from a JID string."""
return jid_str.split("@")[0].split(":")[0] if jid_str else ""
is_self_chat = (
is_from_me
and not is_group
and _jid_user(sender_jid) == _jid_user(chat_jid)
)
if not self._respond_to_all:
if is_self_chat:
pass # true self-chat — always allow
elif is_group and self._allowed_groups:
# Check if group JID is in the allowlist
chat_jid_str = chat_jid.split("@")[0] if "@" in chat_jid else chat_jid
if not (chat_jid in self._allowed_groups
or chat_jid_str in self._allowed_groups):
logger.debug("Filtered: group %s not in allowlist", chat_jid)
return
elif not is_group and self._allowed_numbers:
# Check if sender number is in the allowlist
sender_num = _jid_user(sender_jid)
if sender_num not in self._allowed_numbers:
logger.debug("Filtered: sender %s not in allowlist", sender_num)
return
else:
# Not self-chat, not in any allowlist → ignore
logger.debug(
"Filtered: msg %s (from_me=%s, self_chat=%s, group=%s)",
msg_id, is_from_me, is_self_chat, is_group,
)
return
# Extract text content
content = ""
conversation = getattr(msg, 'conversation', None)
ext_text = getattr(msg, 'extendedTextMessage', None)
if conversation:
content = str(conversation)
elif ext_text:
content = str(getattr(ext_text, 'text', ''))
else:
content = "[Non-text message received]"
if not content:
return
# Determine if group
is_group = "@g.us" in chat_jid
sender_name = sender_jid.split("@")[0] if sender_jid else "unknown"
sender = BotUser(
user_id=sender_jid,
username=sender_name,
display_name=sender_name,
is_bot=False,
)
channel = BotChannel(
channel_id=chat_jid,
name=f"whatsapp:{chat_jid}",
channel_type="group" if is_group else "dm",
)
bot_message = BotMessage(
message_id=msg_id,
content=content,
message_type=MessageType.TEXT,
sender=sender,
channel=channel,
timestamp=float(timestamp) if timestamp else time.time(),
)
self.fire_message_received(bot_message)
# Fire registered message handlers
for handler in self._message_handlers:
try:
await handler(bot_message)
except Exception as e:
logger.error(f"Message handler error: {e}")
# Check for commands
text = content.strip()
if text.startswith("/"):
cmd_name = text.split()[0][1:].lower()
cmd_handler = self._command_handlers.get(cmd_name)
if cmd_handler:
try:
response = await cmd_handler(bot_message)
if response:
await self._web_send(chat_jid_obj or chat_jid, response)
except Exception as e:
logger.error(f"Command '{cmd_name}' error: {e}")
await self._web_send(chat_jid, f"Error: {e}")
return
# Route to agent
if self._agent and content:
try:
response = await self._session_mgr.chat(
self._agent, sender_jid, content
)
if response:
send_result = self.fire_message_sending(chat_jid, str(response))
if not send_result["cancel"]:
await self._web_send(chat_jid_obj or chat_jid, send_result["content"])
self.fire_message_sent(chat_jid, send_result["content"])
except Exception as e:
logger.error(f"Agent chat error: {e}")
await self._web_send(chat_jid, "Sorry, I encountered an error.")
except Exception as e:
logger.error(f"Web mode message processing error: {e}")
async def _on_connected() -> None:
logger.info("WhatsApp Web: connected and ready")
self._web_adapter = WhatsAppWebAdapter(
creds_dir=self._creds_dir,
on_message=_on_web_message,
on_connected=_on_connected,
)
self._is_running = True
self._started_at = time.time()
self._bot_user = BotUser(
user_id="web_user",
username="whatsapp_web_bot",
display_name="WhatsApp Web Bot",
is_bot=True,
)
has_session = self._web_adapter.has_saved_session()
if has_session:
logger.info("WhatsApp Web: using saved session")
else:
logger.info("WhatsApp Web: no saved session — QR code will be displayed")
try:
await self._web_adapter.connect()
# Keep running
while self._is_running:
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"WhatsApp Web mode error: {e}")
raise
finally:
await self.stop()
async def _web_send(self, to: Any, text: str) -> None:
"""Send message via Web mode adapter.
Args:
to: Recipient — a native neonize JID protobuf (preferred) or
a JID string as fallback.
text: Message text.
"""
if self._web_adapter and self._web_adapter.is_connected:
await self._web_adapter.send_message(to, text)
async def stop(self) -> None:
"""Stop the webhook server."""
"""Stop the bot (both Cloud and Web modes)."""
self._is_running = False
# Cloud mode cleanup
if self._site:
await self._site.stop()
if self._runner:
await self._runner.cleanup()
# Web mode cleanup
if self._web_adapter:
try:
await self._web_adapter.disconnect()
except Exception as e:
logger.warning(f"Web adapter disconnect error: {e}")
logger.info("WhatsApp bot stopped")
# ── Webhook handlers ────────────────────────────────────────────
@@ -355,6 +636,8 @@ class WhatsAppBot(ChatCommandMixin):
timestamp=float(timestamp) if timestamp else time.time(),
)
self.fire_message_received(bot_message)
# Fire registered message handlers (e.g., gateway routing)
for handler in self._message_handlers:
try:
@@ -384,7 +667,10 @@ class WhatsAppBot(ChatCommandMixin):
self._agent, sender_id, content
)
if response:
await self.send_message(sender_id, str(response))
send_result = self.fire_message_sending(sender_id, str(response))
if not send_result["cancel"]:
await self.send_message(sender_id, send_result["content"])
self.fire_message_sent(sender_id, send_result["content"])
except Exception as e:
logger.error(f"Agent chat error: {e}")
await self.send_message(sender_id, "Sorry, I encountered an error processing your message.")
@@ -567,3 +853,42 @@ class WhatsAppBot(ChatCommandMixin):
async def get_channel(self, channel_id: str) -> Optional[BotChannel]:
"""Get channel info (WhatsApp is DM-only)."""
return BotChannel(channel_id=channel_id, channel_type="dm")
async def probe(self):
"""Test WhatsApp Cloud API connectivity."""
from praisonaiagents.bots import ProbeResult
started = time.time()
if self._mode == "web":
return ProbeResult(ok=True, platform="whatsapp", elapsed_ms=0.0, details={"mode": "web"})
try:
import aiohttp
url = f"{GRAPH_API_BASE}/{self._phone_number_id}"
headers = {"Authorization": f"Bearer {self._token}"}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
elapsed = (time.time() - started) * 1000
if resp.status == 200:
data = await resp.json()
return ProbeResult(
ok=True, platform="whatsapp", elapsed_ms=elapsed,
bot_username=data.get("display_phone_number", self._phone_number_id),
details={"phone_number_id": self._phone_number_id, "verified_name": data.get("verified_name")},
)
else:
text = await resp.text()
return ProbeResult(ok=False, platform="whatsapp", elapsed_ms=elapsed, error=f"HTTP {resp.status}: {text[:200]}")
except Exception as e:
return ProbeResult(ok=False, platform="whatsapp", elapsed_ms=(time.time() - started) * 1000, error=str(e))
async def health(self):
"""Get detailed health status of the WhatsApp bot."""
from praisonaiagents.bots import HealthResult
probe_result = await self.probe()
uptime = (time.time() - self._started_at) if self._started_at else None
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
return HealthResult(
ok=self._is_running and probe_result.ok, platform="whatsapp",
is_running=self._is_running, uptime_seconds=uptime,
probe=probe_result, sessions=session_count,
error=probe_result.error if not probe_result.ok else None,
)
+2
View File
@@ -176,6 +176,7 @@ def register_commands():
from .commands.lsp import app as lsp_app
from .commands.diag import app as diag_app
from .commands.doctor import app as doctor_app
from .commands.obs import app as obs_app
from .commands.acp import app as acp_app
from .commands.mcp import app as mcp_app
from .commands.serve import app as serve_app
@@ -241,6 +242,7 @@ def register_commands():
app.add_typer(lsp_app, name="lsp", help="LSP service lifecycle")
app.add_typer(diag_app, name="diag", help="Diagnostics export")
app.add_typer(doctor_app, name="doctor", help="Health checks and diagnostics")
app.add_typer(obs_app, name="obs", help="Observability diagnostics and management")
app.add_typer(acp_app, name="acp", help="Agent Client Protocol server")
app.add_typer(mcp_app, name="mcp", help="MCP server management")
app.add_typer(serve_app, name="serve", help="API server management")
+15 -8
View File
@@ -254,10 +254,12 @@ def bot_slack(
@app.command("whatsapp")
def bot_whatsapp(
token: Optional[str] = typer.Option(None, "--token", "-t", help="WhatsApp access token", envvar="WHATSAPP_ACCESS_TOKEN"),
phone_id: Optional[str] = typer.Option(None, "--phone-id", help="WhatsApp phone number ID", envvar="WHATSAPP_PHONE_NUMBER_ID"),
verify_token: Optional[str] = typer.Option(None, "--verify-token", help="Webhook verification token", envvar="WHATSAPP_VERIFY_TOKEN"),
port: int = typer.Option(8080, "--port", "-p", help="Webhook server port"),
token: Optional[str] = typer.Option(None, "--token", "-t", help="WhatsApp access token (Cloud mode)", envvar="WHATSAPP_ACCESS_TOKEN"),
phone_id: Optional[str] = typer.Option(None, "--phone-id", help="WhatsApp phone number ID (Cloud mode)", envvar="WHATSAPP_PHONE_NUMBER_ID"),
verify_token: Optional[str] = typer.Option(None, "--verify-token", help="Webhook verification token (Cloud mode)", envvar="WHATSAPP_VERIFY_TOKEN"),
port: int = typer.Option(8080, "--port", "-p", help="Webhook server port (Cloud mode)"),
mode: str = typer.Option("cloud", "--mode", help="Connection mode: 'cloud' (Meta API) or 'web' (QR scan, experimental)"),
creds_dir: Optional[str] = typer.Option(None, "--creds-dir", help="Credentials directory for Web mode"),
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"),
@@ -287,13 +289,14 @@ def bot_whatsapp(
):
"""Start a WhatsApp bot with full agent capabilities.
Runs a webhook server that receives messages from WhatsApp Cloud API.
Requires a publicly accessible HTTPS URL (use ngrok for local dev).
Cloud mode (default): Webhook server for Meta Cloud API.
Web mode (experimental): QR code scan, no tokens needed.
Examples:
praisonai bot whatsapp --token $WHATSAPP_ACCESS_TOKEN --phone-id $WHATSAPP_PHONE_NUMBER_ID
praisonai bot whatsapp --mode web
praisonai bot whatsapp --mode web --creds-dir ~/.myapp/wa-creds
praisonai bot whatsapp --agent agents.yaml --memory --web
praisonai bot whatsapp --port 8080 --verify-token my-secret
"""
from ..features.bots_cli import BotHandler, BotCapabilities
@@ -333,6 +336,8 @@ def bot_whatsapp(
webhook_port=port,
agent_file=agent,
capabilities=capabilities,
mode=mode,
creds_dir=creds_dir,
)
@@ -349,7 +354,7 @@ Start a bot on any platform with: praisonai bot <platform>
[green]telegram[/green] Telegram Bot API
[green]discord[/green] Discord Bot API
[green]slack[/green] Slack Socket Mode
[green]whatsapp[/green] WhatsApp Cloud API (webhook)
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
[bold]Capability Options (all platforms):[/bold]
[yellow]--agent FILE[/yellow] Agent YAML configuration
@@ -360,6 +365,7 @@ Start a bot on any platform with: praisonai bot <platform>
[yellow]--knowledge[/yellow] Enable knowledge/RAG
[yellow]--tools TOOL...[/yellow] Enable specific tools
[yellow]--sandbox[/yellow] Enable sandbox mode
[yellow]--auto-approve[/yellow] Auto-approve all tool executions
[yellow]--tts[/yellow] Enable TTS tool
[yellow]--stt[/yellow] Enable STT tool
[yellow]--auto-tts[/yellow] Auto-convert responses to speech
@@ -372,6 +378,7 @@ Start a bot on any platform with: praisonai bot <platform>
praisonai bot slack --agent agents.yaml --browser --web
praisonai bot discord --tools DuckDuckGoTool --memory --model gpt-4o
praisonai bot whatsapp --token $WHATSAPP_ACCESS_TOKEN --phone-id $WHATSAPP_PHONE_NUMBER_ID
praisonai bot whatsapp --mode web
"""
try:
from rich import print as rprint
@@ -0,0 +1,19 @@
"""
Obs command group for PraisonAI CLI.
Thin wrapper that re-exports the CLI from praisonai_tools.
"""
try:
from praisonai_tools.observability.cli import app
except ImportError:
import typer
app = typer.Typer(help="Observability diagnostics and management")
@app.callback(invoke_without_command=True)
def obs_fallback(ctx: typer.Context):
"""Observability diagnostics and management."""
from rich.console import Console
Console(stderr=True).print("[red]✗ praisonai-tools not installed.[/red]")
Console(stderr=True).print("[dim]Install with: pip install praisonai-tools[/dim]")
raise typer.Exit(1)
@@ -101,6 +101,49 @@ def ui_realtime(
_launch_chainlit_ui("realtime", port, host, public)
@app.command("bot")
def ui_bot(
port: int = typer.Option(8090, "--port", "-p", help="Port to run UI on"),
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"),
public: bool = typer.Option(False, "--public", help="Make UI publicly accessible"),
agent: str = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="LLM model to use"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
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"),
tools: str = typer.Option(None, "--tools", help="Comma-separated tool names"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve tool executions"),
):
"""
Start the bot chat UI with real-time streaming and tool visualization.
This provides a Chainlit-based chat interface backed by a PraisonAI Agent
with real streaming (via StreamEventEmitter), tool call Steps, and hook firing.
Examples:
praisonai ui bot
praisonai ui bot --model gpt-4o --memory --web
praisonai ui bot --agent agents.yaml --tools DuckDuckGoTool,WikipediaTool
"""
import os
# Pass config to bot.py via environment variables
os.environ["PRAISONAI_BOT_MODEL"] = model
if agent:
os.environ["PRAISONAI_BOT_AGENT_FILE"] = agent
if memory:
os.environ["PRAISONAI_BOT_MEMORY"] = "true"
if web_search:
os.environ["PRAISONAI_BOT_WEB_SEARCH"] = "true"
os.environ["PRAISONAI_BOT_WEB_PROVIDER"] = web_provider
if tools:
os.environ["PRAISONAI_BOT_TOOLS"] = tools
if auto_approve:
os.environ["PRAISONAI_BOT_AUTO_APPROVE"] = "true"
_launch_chainlit_ui("bot", port, host, public)
@app.command("gradio")
def ui_gradio(
port: int = typer.Option(8080, "--port", "-p", help="Port to run UI on"),
@@ -155,6 +198,7 @@ def _launch_chainlit_ui(ui_type: str, port: int, host: str, public: bool):
"chat": os.path.join("ui", "chat.py"),
"code": os.path.join("ui", "code.py"),
"realtime": os.path.join("ui", "realtime.py"),
"bot": os.path.join("ui", "bot.py"),
}
ui_script = ui_scripts.get(ui_type, "chainlit_ui.py")
+124 -26
View File
@@ -57,7 +57,7 @@ class BotCapabilities:
# Execution
sandbox: bool = False
exec_enabled: bool = False
auto_approve: bool = False # Auto-approve CLI tool execution
auto_approve: bool = False
# Model settings
model: Optional[str] = None
@@ -242,6 +242,22 @@ class BotHandler:
phone_number_id = resolve_env_vars(config.get("phone_number_id", ""))
verify_token_val = resolve_env_vars(config.get("verify_token", ""))
webhook_port = config.get("webhook_port", 8080)
wa_mode = config.get("mode", "cloud")
wa_creds_dir = config.get("creds_dir")
# Message filtering from YAML config
wa_respond_to = config.get("respond_to")
wa_respond_to_groups = config.get("respond_to_groups")
wa_respond_to_all = config.get("respond_to_all", False)
wa_allowed_numbers = None
wa_allowed_groups = None
if isinstance(wa_respond_to, list):
wa_allowed_numbers = wa_respond_to
elif isinstance(wa_respond_to, str) and wa_respond_to:
wa_allowed_numbers = [n.strip() for n in wa_respond_to.split(",") if n.strip()]
if isinstance(wa_respond_to_groups, list):
wa_allowed_groups = wa_respond_to_groups
elif isinstance(wa_respond_to_groups, str) and wa_respond_to_groups:
wa_allowed_groups = [g.strip() for g in wa_respond_to_groups.split(",") if g.strip()]
self.start_whatsapp(
token=token or None,
phone_number_id=phone_number_id or None,
@@ -250,6 +266,11 @@ class BotHandler:
agent_file=None,
capabilities=capabilities,
agent_config_dict=agent_config,
mode=wa_mode,
creds_dir=wa_creds_dir,
allowed_numbers=wa_allowed_numbers,
allowed_groups=wa_allowed_groups,
respond_to_all=bool(wa_respond_to_all),
)
def start_telegram(
@@ -399,32 +420,52 @@ class BotHandler:
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = None,
agent_config_dict: Optional[Dict[str, Any]] = None,
mode: str = "cloud",
creds_dir: Optional[str] = None,
allowed_numbers: Optional[List[str]] = None,
allowed_groups: Optional[List[str]] = None,
respond_to_all: bool = False,
) -> None:
"""Start a WhatsApp bot.
Args:
token: WhatsApp access token (or use WHATSAPP_ACCESS_TOKEN env var)
phone_number_id: Phone number ID (or use WHATSAPP_PHONE_NUMBER_ID env var)
verify_token: Webhook verify token (or use WHATSAPP_VERIFY_TOKEN env var)
webhook_port: Port for webhook server (default 8080)
token: WhatsApp access token (Cloud mode, or WHATSAPP_ACCESS_TOKEN env var)
phone_number_id: Phone number ID (Cloud mode, or WHATSAPP_PHONE_NUMBER_ID env var)
verify_token: Webhook verify token (Cloud mode, or WHATSAPP_VERIFY_TOKEN env var)
webhook_port: Port for webhook server (Cloud mode, default 8080)
agent_file: Optional path to agent configuration file
capabilities: Optional capabilities configuration
agent_config_dict: Optional agent config dict from bot YAML
mode: Connection mode — 'cloud' (default) or 'web' (experimental)
creds_dir: Credentials directory for Web mode
allowed_numbers: Phone numbers the bot may respond to (besides self)
allowed_groups: Group JIDs the bot may respond in
respond_to_all: If True, respond to every message (no filtering)
"""
self._load_dotenv()
token = token or os.environ.get("WHATSAPP_ACCESS_TOKEN")
phone_number_id = phone_number_id or os.environ.get("WHATSAPP_PHONE_NUMBER_ID")
verify_token = verify_token or os.environ.get("WHATSAPP_VERIFY_TOKEN", "")
mode = (mode or "cloud").lower().strip()
if not token:
print("Error: WhatsApp access token required")
print("Provide via --token or WHATSAPP_ACCESS_TOKEN environment variable")
return
if not phone_number_id:
print("Error: WhatsApp phone number ID required")
print("Provide via --phone-id or WHATSAPP_PHONE_NUMBER_ID environment variable")
return
if mode == "web":
# Web mode: no tokens needed
print("⚠️ WhatsApp Web mode is EXPERIMENTAL.")
print(" Uses reverse-engineered protocol. Your number may be banned.")
else:
# Cloud mode: tokens required
token = token or os.environ.get("WHATSAPP_ACCESS_TOKEN")
phone_number_id = phone_number_id or os.environ.get("WHATSAPP_PHONE_NUMBER_ID")
verify_token = verify_token or os.environ.get("WHATSAPP_VERIFY_TOKEN", "")
if not token:
print("Error: WhatsApp access token required (Cloud mode)")
print("Provide via --token or WHATSAPP_ACCESS_TOKEN environment variable")
print("Or use --mode web for token-free QR scan mode")
return
if not phone_number_id:
print("Error: WhatsApp phone number ID required (Cloud mode)")
print("Provide via --phone-id or WHATSAPP_PHONE_NUMBER_ID environment variable")
print("Or use --mode web for token-free QR scan mode")
return
# Set auto-approve if enabled
if capabilities and capabilities.auto_approve:
@@ -434,27 +475,52 @@ class BotHandler:
try:
from praisonai.bots import WhatsAppBot
except ImportError as e:
print(f"Error: WhatsAppBot requires aiohttp. {e}")
print("Install with: pip install aiohttp")
print(f"Error: WhatsAppBot import failed. {e}")
if mode == "web":
print("Install with: pip install 'praisonai[bot-whatsapp-web]'")
else:
print("Install with: pip install aiohttp")
return
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
bot = WhatsAppBot(
token=token,
phone_number_id=phone_number_id,
token=token or "",
phone_number_id=phone_number_id or "",
agent=agent,
verify_token=verify_token,
verify_token=verify_token or "",
webhook_port=webhook_port,
mode=mode,
creds_dir=creds_dir,
allowed_numbers=allowed_numbers,
allowed_groups=allowed_groups,
respond_to_all=respond_to_all,
)
self._print_startup_info("WhatsApp", capabilities)
print(f"Webhook server on port {webhook_port}")
if mode == "web":
self._print_startup_info("WhatsApp (Web mode)", capabilities)
if respond_to_all:
print("Filtering: responding to ALL messages")
elif allowed_numbers or allowed_groups:
parts = []
if allowed_numbers:
parts.append(f"numbers: {', '.join(allowed_numbers)}")
if allowed_groups:
parts.append(f"groups: {', '.join(allowed_groups)}")
print(f"Filtering: self + {' + '.join(parts)}")
else:
print("Filtering: self-only (message yourself to interact)")
else:
self._print_startup_info("WhatsApp", capabilities)
print(f"Webhook server on port {webhook_port}")
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
print("\nStopping bot...")
asyncio.run(bot.stop())
try:
asyncio.run(bot.stop())
except Exception:
pass # neonize Go threads may already be gone
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
@@ -657,7 +723,7 @@ class BotHandler:
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)
# Default tools: execute_command, search_web, schedule tools (always enabled)
try:
from praisonaiagents.tools import execute_command, search_web
tools.append(execute_command)
@@ -666,6 +732,13 @@ class BotHandler:
except ImportError:
logger.warning("Default tools not available from praisonaiagents.tools")
try:
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
tools.extend([schedule_add, schedule_list, schedule_remove])
logger.info("Default schedule tools enabled: schedule_add, schedule_list, schedule_remove")
except ImportError:
logger.warning("Schedule tools not available from praisonaiagents.tools")
# Browser tool
if capabilities.browser:
try:
@@ -924,6 +997,11 @@ Examples:
)
return 0
elif platform == "whatsapp":
# Parse respond-to flags
respond_to_raw = getattr(args, "respond_to", None)
respond_to_groups_raw = getattr(args, "respond_to_groups", None)
allowed_numbers = [n.strip() for n in respond_to_raw.split(",") if n.strip()] if respond_to_raw else None
allowed_groups = [g.strip() for g in respond_to_groups_raw.split(",") if g.strip()] if respond_to_groups_raw else None
handler.start_whatsapp(
token=getattr(args, "token", None),
phone_number_id=getattr(args, "phone_id", None),
@@ -931,6 +1009,9 @@ Examples:
webhook_port=getattr(args, "port", 8080),
agent_file=getattr(args, "agent", None),
capabilities=capabilities,
allowed_numbers=allowed_numbers,
allowed_groups=allowed_groups,
respond_to_all=getattr(args, "respond_to_all", False),
)
return 0
else:
@@ -1030,6 +1111,23 @@ def add_bot_parser(subparsers) -> None:
default=8080,
help="Webhook server port (default: 8080)",
)
# WhatsApp message filtering
whatsapp_parser.add_argument(
"--respond-to",
dest="respond_to",
help="Comma-separated phone numbers the bot responds to (besides self)",
)
whatsapp_parser.add_argument(
"--respond-to-groups",
dest="respond_to_groups",
help="Comma-separated group JIDs the bot responds in",
)
whatsapp_parser.add_argument(
"--respond-to-all",
dest="respond_to_all",
action="store_true",
help="Respond to ALL messages (default: self-only)",
)
_add_capability_args(whatsapp_parser)
bot_parser.set_defaults(func=handle_bot_command)
@@ -0,0 +1,88 @@
"""
Bot-specific doctor checks for PraisonAI.
Registers checks for bot tokens, config validation, and channel probes.
"""
from __future__ import annotations
import os
import time
from typing import List
from ..models import CheckResult, CheckStatus, CheckCategory
def check_bot_tokens() -> CheckResult:
"""Check bot token environment variables."""
token_vars = {
"TELEGRAM_BOT_TOKEN": "Telegram",
"DISCORD_BOT_TOKEN": "Discord",
"SLACK_BOT_TOKEN": "Slack",
"WHATSAPP_ACCESS_TOKEN": "WhatsApp",
}
found = []
for var, name in token_vars.items():
if os.environ.get(var):
found.append(name)
if found:
return CheckResult(
id="bot_tokens",
title="Bot Tokens",
category=CheckCategory.BOTS,
status=CheckStatus.PASS,
message=f"Found: {', '.join(found)}",
)
return CheckResult(
id="bot_tokens",
title="Bot Tokens",
category=CheckCategory.BOTS,
status=CheckStatus.WARN,
message="No bot tokens found in environment",
remediation="Set at least one: export TELEGRAM_BOT_TOKEN=your_token",
)
def check_bot_config(config_path: str = "bot.yaml") -> CheckResult:
"""Check bot.yaml exists and is valid."""
start = time.time()
if not os.path.exists(config_path):
return CheckResult(
id="bot_config",
title="Bot Config",
category=CheckCategory.BOTS,
status=CheckStatus.WARN,
message=f"{config_path} not found",
remediation="Run 'praisonai onboard' to create one",
duration_ms=(time.time() - start) * 1000,
)
try:
from praisonai.bots._config_schema import load_and_validate_bot_yaml
load_and_validate_bot_yaml(config_path)
return CheckResult(
id="bot_config",
title="Bot Config",
category=CheckCategory.BOTS,
status=CheckStatus.PASS,
message=f"{config_path} valid",
duration_ms=(time.time() - start) * 1000,
)
except Exception as e:
return CheckResult(
id="bot_config",
title="Bot Config",
category=CheckCategory.BOTS,
status=CheckStatus.FAIL,
message=f"Invalid: {str(e)[:200]}",
remediation="Fix errors in bot.yaml or run 'praisonai onboard'",
duration_ms=(time.time() - start) * 1000,
)
def get_bot_checks() -> List[CheckResult]:
"""Run all bot-specific checks and return results."""
return [
check_bot_tokens(),
check_bot_config(),
]
@@ -33,6 +33,7 @@ class CheckCategory(Enum):
NETWORK = "network"
PERFORMANCE = "performance"
SELFTEST = "selftest"
BOTS = "bots"
class CheckSeverity(Enum):
@@ -0,0 +1,253 @@
"""
Onboarding wizard for PraisonAI bots.
Interactive setup that guides users through:
1. Platform selection
2. Token configuration
3. Connection test (probe)
4. Config file generation (bot.yaml)
5. Optional daemon install
Usage:
praisonai onboard
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Dict, List
logger = logging.getLogger(__name__)
# Platform info for the wizard
PLATFORMS = {
"telegram": {
"name": "Telegram",
"token_env": "TELEGRAM_BOT_TOKEN",
"token_help": "Get from @BotFather on Telegram: https://t.me/BotFather",
"install_hint": "pip install python-telegram-bot",
},
"discord": {
"name": "Discord",
"token_env": "DISCORD_BOT_TOKEN",
"token_help": "Create at https://discord.com/developers/applications",
"install_hint": "pip install discord.py",
},
"slack": {
"name": "Slack",
"token_env": "SLACK_BOT_TOKEN",
"token_help": "Create at https://api.slack.com/apps",
"install_hint": "pip install slack-bolt",
"extra_env": {"SLACK_APP_TOKEN": "App token (xapp-...)"},
},
"whatsapp": {
"name": "WhatsApp",
"token_env": "WHATSAPP_ACCESS_TOKEN",
"token_help": "Get from Meta for Developers: https://developers.facebook.com",
"install_hint": "pip install aiohttp",
"extra_env": {"WHATSAPP_PHONE_NUMBER_ID": "Phone number ID"},
},
}
def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agent_instructions: str = "") -> str:
"""Generate bot.yaml content."""
lines = ["agent:"]
lines.append(f' name: "{agent_name}"')
if agent_instructions:
lines.append(f' instructions: "{agent_instructions}"')
lines.append(' model: "gpt-4o-mini"')
lines.append("")
lines.append("channels:")
for plat in platforms:
info = PLATFORMS.get(plat, {})
env_var = info.get("token_env", f"{plat.upper()}_BOT_TOKEN")
lines.append(f" {plat}:")
lines.append(f" token: ${{{env_var}}}")
lines.append(" group_policy: mention_only")
if plat == "whatsapp":
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
lines.append("")
lines.append("routing:")
lines.append(" default: assistant")
lines.append("")
return "\n".join(lines)
class OnboardWizard:
"""Interactive onboarding wizard using rich prompts."""
def __init__(self):
self.selected_platforms: List[str] = []
self.tokens: Dict[str, str] = {}
self.agent_name: str = "assistant"
self.agent_instructions: str = "You are a helpful AI assistant."
self.config_path: str = "bot.yaml"
def run(self) -> None:
"""Run the wizard interactively."""
try:
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.panel import Panel
except ImportError:
self._run_plain()
return
console = Console()
# Welcome
console.print(Panel(
"[bold]Welcome to PraisonAI Bot Setup[/bold]\n\n"
"This wizard will help you set up your AI bot in a few steps.\n"
"You'll need a bot token from your chosen platform.",
title="🤖 PraisonAI Onboard",
border_style="blue",
))
# Step 1: Platform selection
console.print("\n[bold]Step 1: Choose your platform(s)[/bold]\n")
for key, info in PLATFORMS.items():
console.print(f" [cyan]{key}[/cyan] — {info['name']}")
platforms_input = Prompt.ask(
"\nPlatform(s) [comma-separated]",
default="telegram",
)
self.selected_platforms = [p.strip().lower() for p in platforms_input.split(",") if p.strip()]
# Validate
for plat in self.selected_platforms:
if plat not in PLATFORMS:
console.print(f"[red]Unknown platform: {plat}[/red]")
return
# Step 2: Token configuration
console.print("\n[bold]Step 2: Configure tokens[/bold]\n")
for plat in self.selected_platforms:
info = PLATFORMS[plat]
env_var = info["token_env"]
existing = os.environ.get(env_var, "")
if existing:
console.print(f" [green]✓[/green] {info['name']}: {env_var} already set")
self.tokens[plat] = existing
else:
console.print(f" [dim]{info['token_help']}[/dim]")
token = Prompt.ask(f" {info['name']} token ({env_var})")
if token:
self.tokens[plat] = token
os.environ[env_var] = token
console.print(" [green]✓[/green] Token set for this session")
else:
console.print(f" [yellow]⚠[/yellow] No token — you'll need to set {env_var} before starting")
# Extra env vars (e.g., Slack app token)
for extra_env, extra_desc in info.get("extra_env", {}).items():
if not os.environ.get(extra_env):
extra_val = Prompt.ask(f" {extra_desc} ({extra_env})", default="")
if extra_val:
os.environ[extra_env] = extra_val
# Step 3: Test connection
console.print("\n[bold]Step 3: Test connection[/bold]\n")
for plat in self.selected_platforms:
if plat in self.tokens:
console.print(f" Testing {PLATFORMS[plat]['name']}...", end=" ")
try:
result = asyncio.run(self._probe(plat))
if result.ok:
console.print(f"[green]✓ @{result.bot_username}[/green] ({result.elapsed_ms:.0f}ms)")
else:
console.print(f"[red]✗ {result.error}[/red]")
except Exception as e:
console.print(f"[red]✗ {str(e)[:100]}[/red]")
# Step 4: Agent configuration
console.print("\n[bold]Step 4: Configure your agent[/bold]\n")
self.agent_name = Prompt.ask("Agent name", default="assistant")
self.agent_instructions = Prompt.ask(
"Agent instructions",
default="You are a helpful AI assistant.",
)
# Step 5: Generate config
console.print("\n[bold]Step 5: Generate configuration[/bold]\n")
yaml_content = _generate_bot_yaml(
self.selected_platforms,
agent_name=self.agent_name,
agent_instructions=self.agent_instructions,
)
self.config_path = Prompt.ask("Config file path", default="bot.yaml")
if os.path.exists(self.config_path):
if not Confirm.ask(f" {self.config_path} exists. Overwrite?", default=False):
console.print(" [dim]Skipped[/dim]")
return
with open(self.config_path, "w") as f:
f.write(yaml_content)
console.print(f" [green]✓[/green] Written to {self.config_path}")
# Step 6: Optional daemon install
if Confirm.ask("\nInstall as background service (daemon)?", default=False):
try:
from praisonai.daemon import install_daemon
result = install_daemon(config_path=self.config_path)
if result.get("ok"):
console.print(f" [green]✓[/green] {result.get('message', 'Service installed')}")
else:
console.print(f" [red]✗[/red] {result.get('error', 'Install failed')}")
except Exception as e:
console.print(f" [red]✗[/red] {str(e)[:200]}")
# Done
console.print(Panel(
f"[bold green]Setup complete![/bold green]\n\n"
f"Start your bot:\n"
f" [cyan]praisonai bot start --config {self.config_path}[/cyan]\n\n"
f"Check health:\n"
f" [cyan]praisonai doctor[/cyan]\n\n"
f"View status:\n"
f" [cyan]praisonai bot status[/cyan]",
title="✅ Done",
border_style="green",
))
async def _probe(self, platform: str):
"""Run a probe for a platform."""
from praisonai.bots import Bot
bot = Bot(platform, token=self.tokens.get(platform, ""))
return await bot.probe()
def _run_plain(self) -> None:
"""Fallback for when rich is not available."""
print("\n=== PraisonAI Bot Setup ===\n")
print("Available platforms: telegram, discord, slack, whatsapp")
platforms_input = input("Platform(s) [comma-separated, default=telegram]: ").strip() or "telegram"
self.selected_platforms = [p.strip().lower() for p in platforms_input.split(",")]
for plat in self.selected_platforms:
info = PLATFORMS.get(plat, {})
env_var = info.get("token_env", f"{plat.upper()}_BOT_TOKEN")
if not os.environ.get(env_var):
print(f"\n {info.get('token_help', '')}")
token = input(f" {env_var}: ").strip()
if token:
os.environ[env_var] = token
self.tokens[plat] = token
yaml_content = _generate_bot_yaml(self.selected_platforms)
with open("bot.yaml", "w") as f:
f.write(yaml_content)
print("\n✓ Written to bot.yaml")
print("Start with: praisonai bot start --config bot.yaml")
def run_onboard() -> None:
"""Entry point for 'praisonai onboard' command."""
wizard = OnboardWizard()
wizard.run()
@@ -305,6 +305,14 @@ class AsyncTUI:
except ImportError as e:
logger.debug(f"internet_search import failed: {e}")
# Try to add schedule tools (default for all agents)
try:
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
tools.extend([schedule_add, schedule_list, schedule_remove])
logger.debug("Added schedule tools: schedule_add, schedule_list, schedule_remove")
except ImportError as e:
logger.debug(f"Schedule tools import failed: {e}")
logger.debug(f"Final tools loaded: {len(tools)}")
return tools
@@ -0,0 +1,62 @@
"""
Daemon service management for PraisonAI bots.
Provides OS-level service install/uninstall/status for always-on bots.
Supports systemd (Linux) and launchd (macOS).
"""
from __future__ import annotations
import platform
import logging
from typing import Any, Dict
logger = logging.getLogger(__name__)
def _detect_platform() -> str:
"""Detect the current OS platform."""
system = platform.system().lower()
if system == "linux":
return "systemd"
elif system == "darwin":
return "launchd"
elif system == "windows":
return "windows"
return "unknown"
def get_daemon_status() -> Dict[str, Any]:
"""Get daemon status for the current platform."""
plat = _detect_platform()
if plat == "systemd":
from .systemd import get_status
return get_status()
elif plat == "launchd":
from .launchd import get_status
return get_status()
return {"installed": False, "running": False, "platform": plat, "error": "Unsupported platform"}
def install_daemon(config_path: str = "bot.yaml", **kwargs: Any) -> Dict[str, Any]:
"""Install the bot as an OS daemon service."""
plat = _detect_platform()
if plat == "systemd":
from .systemd import install
return install(config_path=config_path, **kwargs)
elif plat == "launchd":
from .launchd import install
return install(config_path=config_path, **kwargs)
return {"ok": False, "error": f"Unsupported platform: {plat}"}
def uninstall_daemon() -> Dict[str, Any]:
"""Uninstall the bot daemon service."""
plat = _detect_platform()
if plat == "systemd":
from .systemd import uninstall
return uninstall()
elif plat == "launchd":
from .launchd import uninstall
return uninstall()
return {"ok": False, "error": f"Unsupported platform: {plat}"}
+138
View File
@@ -0,0 +1,138 @@
"""
launchd service management for PraisonAI bots (macOS).
Generates and manages a launchd plist for always-on bots.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from typing import Any, Dict
logger = logging.getLogger(__name__)
LABEL = "ai.praison.bot"
PLIST_NAME = f"{LABEL}.plist"
def _launch_agents_dir() -> str:
return os.path.expanduser("~/Library/LaunchAgents")
def _plist_path() -> str:
return os.path.join(_launch_agents_dir(), PLIST_NAME)
def _log_dir() -> str:
d = os.path.expanduser("~/.praisonai/logs")
os.makedirs(d, exist_ok=True)
return d
def _generate_plist(config_path: str) -> str:
"""Generate a launchd plist XML for the bot."""
python = shutil.which("python3") or shutil.which("python") or sys.executable
abs_config = os.path.abspath(config_path)
working_dir = os.path.dirname(abs_config) or os.getcwd()
log_dir = _log_dir()
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{python}</string>
<string>-m</string>
<string>praisonai</string>
<string>bot</string>
<string>start</string>
<string>--config</string>
<string>{abs_config}</string>
</array>
<key>WorkingDirectory</key>
<string>{working_dir}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{log_dir}/bot-stdout.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/bot-stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{os.environ.get('PATH', '/usr/bin:/usr/local/bin')}</string>
</dict>
</dict>
</plist>
"""
def install(config_path: str = "bot.yaml", **kwargs: Any) -> Dict[str, Any]:
"""Install the launchd agent."""
la_dir = _launch_agents_dir()
os.makedirs(la_dir, exist_ok=True)
plist_content = _generate_plist(config_path)
plist_path = _plist_path()
with open(plist_path, "w") as f:
f.write(plist_content)
try:
subprocess.run(["launchctl", "load", plist_path], check=True, capture_output=True)
return {"ok": True, "plist_path": plist_path, "message": f"Service installed and loaded: {plist_path}"}
except subprocess.CalledProcessError as e:
return {"ok": False, "error": f"launchctl error: {e.stderr.decode()[:300] if e.stderr else str(e)}"}
def uninstall() -> Dict[str, Any]:
"""Unload and remove the launchd agent."""
plist_path = _plist_path()
try:
subprocess.run(["launchctl", "unload", plist_path], capture_output=True)
if os.path.exists(plist_path):
os.remove(plist_path)
return {"ok": True, "message": "Service removed"}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_status() -> Dict[str, Any]:
"""Get the status of the launchd agent."""
plist_path = _plist_path()
installed = os.path.exists(plist_path)
if not installed:
return {"installed": False, "running": False, "platform": "launchd"}
try:
result = subprocess.run(
["launchctl", "list", LABEL],
capture_output=True, text=True,
)
running = result.returncode == 0
return {"installed": True, "running": running, "platform": "launchd", "plist_path": plist_path}
except Exception:
return {"installed": True, "running": False, "platform": "launchd", "plist_path": plist_path}
def get_logs(lines: int = 50) -> str:
"""Get recent logs from the service."""
log_path = os.path.join(_log_dir(), "bot-stderr.log")
if not os.path.exists(log_path):
return "No log file found."
try:
with open(log_path) as f:
all_lines = f.readlines()
return "".join(all_lines[-lines:])
except Exception as e:
return f"Error reading logs: {e}"
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more