Compare commits

...
Author SHA1 Message Date
Mervin Praison 02a1977651 Release v4.5.16 2026-02-27 04:43:28 +00:00
22 changed files with 1477 additions and 32 deletions

No files matched your search

+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.5.15" \
"praisonai>=4.5.16" \
"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.5.15" \
"praisonai>=4.5.16" \
"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.5.15" \
"praisonai>=4.5.16" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -1343,6 +1343,21 @@ class Agent:
self.tools = list(tools)
else:
self.tools = tools or []
# Inject default tools for autonomy mode (after self.tools is initialized)
if self.autonomy_enabled and hasattr(self, '_autonomy_config_obj'):
config = self._autonomy_config_obj
if config.default_tools is not None:
# User provided custom default tools
self.tools.extend(config.default_tools)
else:
# Use ast-grep tools as default (lazy import, graceful fallback)
try:
from ..tools.ast_grep_tool import get_ast_grep_tools
self.tools.extend(get_ast_grep_tools())
except ImportError:
pass # ast-grep tools not available, continue without
self.max_iter = max_iter
self.max_rpm = max_rpm
self.max_execution_time = max_execution_time
@@ -1962,6 +1977,7 @@ Summary:"""
"clear_context": config.clear_context,
"track_changes": config.effective_track_changes,
"snapshot_dir": config.snapshot_dir,
"default_tools": config.default_tools,
}
# Also preserve any extra user-provided keys from dict input
if isinstance(autonomy, dict):
@@ -122,6 +122,8 @@ class AutonomyConfig:
track_changes: Optional[bool] = None # None = auto (True for full_auto)
sandbox: Optional[Any] = None # SandboxConfig (lazy import to avoid circular)
snapshot_dir: Optional[str] = None # Defaults to ~/.praisonai/snapshots
# Default tools for autonomy mode (auto-populated with code intelligence tools)
default_tools: Optional[List[Any]] = None # None = auto (uses get_autonomy_default_tools())
def __post_init__(self):
if self.level not in VALID_AUTONOMY_LEVELS:
@@ -166,6 +168,7 @@ class AutonomyConfig:
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
default_tools=data.get("default_tools"),
)
@@ -155,6 +155,14 @@ TOOL_MAPPINGS = {
'schedule_list': ('.schedule_tools', None),
'schedule_remove': ('.schedule_tools', None),
'schedule_tools': ('.schedule_tools', None),
# AST-Grep Tools (structural code search/rewrite)
'ast_grep_search': ('.ast_grep_tool', None),
'ast_grep_rewrite': ('.ast_grep_tool', None),
'ast_grep_scan': ('.ast_grep_tool', None),
'is_ast_grep_available': ('.ast_grep_tool', None),
'get_ast_grep_tools': ('.ast_grep_tool', None),
'ast_grep_tools': ('.ast_grep_tool', None),
}
_instances = {} # Cache for class instances
@@ -188,10 +196,11 @@ def __getattr__(name: str) -> Any:
'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',
'schedule_add', 'schedule_list', 'schedule_remove'
'schedule_add', 'schedule_list', 'schedule_remove',
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools'
]:
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', 'schedule_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', 'ast_grep_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -0,0 +1,338 @@
"""
AST-Grep Tool for PraisonAI Agents.
Provides AST-based structural code search and rewrite capabilities
using the ast-grep (sg) CLI tool. Gracefully handles cases where
ast-grep is not installed.
Features:
- Structural code search using AST patterns
- Structural code rewrite (dry-run by default)
- YAML rule scanning
- Graceful fallback when not installed
- Zero performance impact when not used (lazy loading)
Installation:
pip install ast-grep-cli
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools import ast_grep_search
agent = Agent(
name="coder",
tools=[ast_grep_search],
)
"""
import shutil
import subprocess
import logging
from typing import Optional, List
logger = logging.getLogger(__name__)
# Availability cache for performance (checked once per process)
_availability_cache: Optional[bool] = None
def is_ast_grep_available() -> bool:
"""Check if ast-grep (sg) CLI is available.
Returns:
True if sg binary is found in PATH, False otherwise.
Note:
Result is cached for performance. First call checks PATH,
subsequent calls return cached result.
"""
global _availability_cache
if _availability_cache is not None:
return _availability_cache
# Check if sg binary is in PATH
sg_path = shutil.which('sg')
_availability_cache = sg_path is not None
if _availability_cache:
logger.debug(f"ast-grep found at: {sg_path}")
else:
logger.debug("ast-grep (sg) not found in PATH")
return _availability_cache
def _get_not_installed_message() -> str:
"""Get helpful error message when ast-grep is not installed."""
return (
"ast-grep is not installed or not available in PATH.\n\n"
"To install ast-grep, run:\n"
" pip install ast-grep-cli\n\n"
"Or install via npm:\n"
" npm install -g @ast-grep/cli\n\n"
"For more information, visit: https://ast-grep.github.io/"
)
def ast_grep_search(
pattern: str,
lang: str,
path: str = ".",
json_output: bool = True,
) -> str:
"""Search code using AST patterns.
Performs structural code search using ast-grep's pattern matching.
Unlike regex, this understands code structure and won't match
patterns in comments or strings.
Args:
pattern: AST pattern to search for. Use $VAR for single nodes,
$$$ for multiple nodes. Example: "def $FN($$$)" matches
all Python function definitions.
lang: Programming language (python, javascript, typescript,
rust, go, java, c, cpp, etc.)
path: Directory or file to search in. Defaults to current directory.
json_output: Whether to return JSON format. Defaults to True.
Returns:
Search results as JSON string (if json_output=True) or plain text.
Returns error message if ast-grep is not installed.
Example:
# Find all function definitions
result = ast_grep_search("def $FN($$$)", lang="python", path="./src")
# Find all console.log calls
result = ast_grep_search("console.log($$$)", lang="javascript")
# Find all async functions
result = ast_grep_search("async def $FN($$$)", lang="python")
"""
if not is_ast_grep_available():
return _get_not_installed_message()
if not pattern:
return "Error: Pattern cannot be empty"
try:
cmd = ['sg', '--pattern', pattern, '--lang', lang]
if json_output:
cmd.append('--json')
cmd.append(path)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0 and result.stderr:
return f"Error: {result.stderr}"
output = result.stdout.strip()
if not output:
return "No matches found"
return output
except subprocess.TimeoutExpired:
return "Error: Search timed out after 60 seconds"
except subprocess.SubprocessError as e:
return f"Error executing ast-grep: {e}"
except Exception as e:
logger.exception("Unexpected error in ast_grep_search")
return f"Error: {e}"
def ast_grep_rewrite(
pattern: str,
replacement: str,
lang: str,
path: str = ".",
dry_run: bool = True,
) -> str:
"""Rewrite code using AST patterns.
Performs structural code transformation using ast-grep's pattern
matching and rewriting. By default, runs in dry-run mode to show
what would be changed without modifying files.
Args:
pattern: AST pattern to match. Use $VAR for single nodes,
$$$ for multiple nodes.
replacement: Replacement pattern. Can reference captured
variables from the pattern.
lang: Programming language (python, javascript, typescript,
rust, go, java, c, cpp, etc.)
path: Directory or file to rewrite. Defaults to current directory.
dry_run: If True (default), show changes without modifying files.
Set to False to actually modify files.
Returns:
Rewrite preview/results as string.
Returns error message if ast-grep is not installed.
Example:
# Preview renaming a function (dry run)
result = ast_grep_rewrite(
"def old_name($$$)",
"def new_name($$$)",
lang="python",
path="./src"
)
# Actually apply the rewrite
result = ast_grep_rewrite(
"console.log($MSG)",
"logger.info($MSG)",
lang="javascript",
dry_run=False
)
"""
if not is_ast_grep_available():
return _get_not_installed_message()
if not pattern:
return "Error: Pattern cannot be empty"
if not replacement:
return "Error: Replacement cannot be empty"
try:
cmd = [
'sg', '--pattern', pattern,
'--rewrite', replacement,
'--lang', lang,
]
if not dry_run:
cmd.append('--update-all')
cmd.append(path)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0 and result.stderr:
return f"Error: {result.stderr}"
output = result.stdout.strip()
if not output:
if dry_run:
return "No matches found for rewrite"
else:
return "No changes made"
prefix = "[DRY RUN] " if dry_run else ""
return f"{prefix}{output}"
except subprocess.TimeoutExpired:
return "Error: Rewrite timed out after 120 seconds"
except subprocess.SubprocessError as e:
return f"Error executing ast-grep: {e}"
except Exception as e:
logger.exception("Unexpected error in ast_grep_rewrite")
return f"Error: {e}"
def ast_grep_scan(
path: str = ".",
rule_file: Optional[str] = None,
) -> str:
"""Scan code using YAML lint rules.
Runs ast-grep's rule-based scanning to find code patterns
defined in YAML rule files. Useful for enforcing coding
standards and finding anti-patterns.
Args:
path: Directory or file to scan. Defaults to current directory.
rule_file: Optional path to a specific YAML rule file.
If not provided, looks for sgconfig.yml in the path.
Returns:
Scan results as string.
Returns error message if ast-grep is not installed.
Example:
# Scan with default rules
result = ast_grep_scan(path="./src")
# Scan with specific rule file
result = ast_grep_scan(
path="./src",
rule_file="./rules/security.yml"
)
"""
if not is_ast_grep_available():
return _get_not_installed_message()
try:
cmd = ['sg', 'scan']
if rule_file:
cmd.extend(['--rule', rule_file])
cmd.append(path)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
# scan returns non-zero if issues found, which is expected
output = result.stdout.strip()
if result.stderr and not output:
return f"Error: {result.stderr}"
if not output:
return "No issues found"
return output
except subprocess.TimeoutExpired:
return "Error: Scan timed out after 120 seconds"
except subprocess.SubprocessError as e:
return f"Error executing ast-grep: {e}"
except Exception as e:
logger.exception("Unexpected error in ast_grep_scan")
return f"Error: {e}"
# Convenience function to get all ast-grep tools
def get_ast_grep_tools() -> List:
"""Get all ast-grep tools as a list.
Returns:
List of ast-grep tool functions.
Example:
from praisonaiagents import Agent
from praisonaiagents.tools.ast_grep_tool import get_ast_grep_tools
agent = Agent(
name="coder",
tools=get_ast_grep_tools(),
)
"""
return [ast_grep_search, ast_grep_rewrite, ast_grep_scan]
__all__ = [
'is_ast_grep_available',
'ast_grep_search',
'ast_grep_rewrite',
'ast_grep_scan',
'get_ast_grep_tools',
]
+6 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.15"
version = "1.5.16"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -83,6 +83,11 @@ auth = [
"python-multipart>=0.0.6"
]
# Autonomy mode dependencies (code intelligence tools)
autonomy = [
"ast-grep-cli>=0.30.0"
]
# Combined features
all = [
"praisonaiagents[memory]",
@@ -0,0 +1,362 @@
"""
Tests for bot framework fixes.
Tests cover:
1. WhatsApp health() uses correct session manager attribute
2. asyncio.get_running_loop() usage (not deprecated get_event_loop)
3. Rate limiter functionality
4. Background task tracking
5. Deprecation warning for legacy approval
"""
import pytest
import time
class TestRateLimiter:
"""Tests for the RateLimiter utility."""
def test_rate_limiter_import(self):
"""Test that RateLimiter can be imported."""
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
assert RateLimiter is not None
assert RateLimitConfig is not None
def test_rate_limiter_for_platform(self):
"""Test platform-specific rate limiter creation."""
from praisonai.bots._rate_limit import RateLimiter, PLATFORM_LIMITS
for platform in ["telegram", "discord", "slack", "whatsapp"]:
limiter = RateLimiter.for_platform(platform)
assert limiter is not None
assert limiter._config == PLATFORM_LIMITS[platform]
def test_rate_limiter_unknown_platform_uses_defaults(self):
"""Test that unknown platform uses default config."""
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
limiter = RateLimiter.for_platform("unknown_platform")
default = RateLimitConfig()
assert limiter._config.messages_per_second == default.messages_per_second
@pytest.mark.asyncio
async def test_rate_limiter_acquire_no_wait_with_tokens(self):
"""Test that acquire doesn't wait when tokens available."""
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
config = RateLimitConfig(messages_per_second=100, burst_size=10)
limiter = RateLimiter(config)
start = time.monotonic()
await limiter.acquire()
elapsed = time.monotonic() - start
# Should be nearly instant
assert elapsed < 0.1
@pytest.mark.asyncio
async def test_rate_limiter_acquire_waits_when_exhausted(self):
"""Test that acquire waits when tokens exhausted."""
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
config = RateLimitConfig(messages_per_second=10, burst_size=1)
limiter = RateLimiter(config)
# Exhaust the single token
await limiter.acquire()
start = time.monotonic()
await limiter.acquire()
elapsed = time.monotonic() - start
# Should wait ~0.1s for token refill (1/10 = 0.1s)
assert elapsed >= 0.05 # Allow some tolerance
@pytest.mark.asyncio
async def test_rate_limiter_per_channel_delay(self):
"""Test per-channel delay enforcement."""
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
config = RateLimitConfig(
messages_per_second=100,
per_channel_delay=0.1,
burst_size=10
)
limiter = RateLimiter(config)
channel = "test_channel"
# First message - no delay
await limiter.acquire(channel)
# Second message to same channel - should delay
start = time.monotonic()
await limiter.acquire(channel)
elapsed = time.monotonic() - start
assert elapsed >= 0.05 # Allow tolerance
def test_rate_limiter_reset(self):
"""Test rate limiter reset."""
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
config = RateLimitConfig(burst_size=5)
limiter = RateLimiter(config)
limiter._tokens = 0
limiter._channel_last_send["test"] = time.monotonic()
limiter.reset()
assert limiter._tokens == 5.0
assert len(limiter._channel_last_send) == 0
class TestAsyncioGetRunningLoop:
"""Tests to verify get_running_loop is used instead of deprecated get_event_loop."""
def test_session_manager_uses_get_running_loop(self):
"""Verify _session.py uses get_running_loop."""
import inspect
from praisonai.bots._session import BotSessionManager
source = inspect.getsource(BotSessionManager)
assert "get_running_loop" in source
assert "get_event_loop()" not in source
def test_approval_uses_get_running_loop(self):
"""Verify _approval.py uses get_running_loop."""
# Import will trigger deprecation warning, that's expected
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from praisonai.bots._approval import BotApprovalBackend
import inspect
source = inspect.getsource(BotApprovalBackend)
assert "get_running_loop" in source
class TestWhatsAppSessionManagerAttribute:
"""Tests for WhatsApp bot session manager attribute fix."""
def test_whatsapp_health_uses_session_mgr(self):
"""Verify WhatsApp health() uses _session_mgr not _session."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.health)
# Should use _session_mgr
assert "_session_mgr" in source
# Should NOT use bare _session (except as part of _session_mgr)
# Count occurrences
session_mgr_count = source.count("_session_mgr")
bare_session_count = source.count("self._session") - source.count("self._session_mgr")
assert bare_session_count == 0, "health() should not use self._session"
assert session_mgr_count > 0, "health() should use self._session_mgr"
class TestWhatsAppHttpSession:
"""Tests for WhatsApp shared HTTP session."""
def test_whatsapp_has_http_session_attribute(self):
"""Verify WhatsApp bot has _http_session attribute."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.__init__)
assert "_http_session" in source
def test_whatsapp_creates_session_on_start(self):
"""Verify WhatsApp creates ClientSession on start."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot._start_cloud_mode)
assert "self._http_session = aiohttp.ClientSession" in source
def test_whatsapp_closes_session_on_stop(self):
"""Verify WhatsApp closes ClientSession on stop."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.stop)
assert "_http_session.close()" in source
class TestWhatsAppBackgroundTasks:
"""Tests for WhatsApp background task tracking."""
def test_whatsapp_has_background_tasks_set(self):
"""Verify WhatsApp bot has _background_tasks set."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.__init__)
assert "_background_tasks" in source
def test_whatsapp_tracks_webhook_tasks(self):
"""Verify webhook processing tasks are tracked."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot._handle_webhook)
assert "self._background_tasks.add(task)" in source
assert "add_done_callback" in source
def test_whatsapp_cancels_tasks_on_stop(self):
"""Verify background tasks are cancelled on stop."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.stop)
assert "task.cancel()" in source or "for task in self._background_tasks" in source
class TestLegacyApprovalDeprecation:
"""Tests for legacy approval deprecation warning."""
def test_legacy_approval_emits_deprecation_warning(self):
"""Verify importing _approval.py emits DeprecationWarning."""
import warnings
import sys
import importlib
# Remove from cache to force re-import
if 'praisonai.bots._approval' in sys.modules:
del sys.modules['praisonai.bots._approval']
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
# Use importlib to avoid lint warning about unused import
importlib.import_module('praisonai.bots._approval')
# Check that a DeprecationWarning was raised
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
assert len(deprecation_warnings) >= 1
assert "deprecated" in str(deprecation_warnings[0].message).lower()
class TestAckReactorIntegration:
"""Tests for AckReactor integration across adapters."""
def test_discord_has_ack_reactor(self):
"""Verify Discord adapter has AckReactor."""
import inspect
from praisonai.bots.discord import DiscordBot
source = inspect.getsource(DiscordBot.__init__)
assert "_ack" in source
assert "AckReactor" in source
def test_discord_uses_ack_in_message_handling(self):
"""Verify Discord uses AckReactor in message handling."""
import inspect
from praisonai.bots.discord import DiscordBot
# Get the start method source which contains on_message
source = inspect.getsource(DiscordBot.start)
assert "self._ack.enabled" in source
assert "add_reaction" in source
assert "self._ack.done" in source
def test_slack_has_ack_reactor(self):
"""Verify Slack adapter has AckReactor."""
import inspect
from praisonai.bots.slack import SlackBot
source = inspect.getsource(SlackBot.__init__)
assert "_ack" in source
assert "AckReactor" in source
def test_slack_uses_ack_in_message_handling(self):
"""Verify Slack uses AckReactor in message handling."""
import inspect
from praisonai.bots.slack import SlackBot
source = inspect.getsource(SlackBot.start)
assert "self._ack.enabled" in source
assert "reactions_add" in source
assert "self._ack.done" in source
def test_whatsapp_has_ack_reactor(self):
"""Verify WhatsApp adapter has AckReactor."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.__init__)
assert "_ack" in source
assert "AckReactor" in source
def test_whatsapp_uses_ack_in_message_handling(self):
"""Verify WhatsApp uses AckReactor in message handling."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot._handle_incoming_message)
assert "self._ack.enabled" in source
assert "send_reaction" in source
assert "self._ack.done" in source
class TestWhatsAppRateLimiterIntegration:
"""Tests for WhatsApp rate limiter integration."""
def test_whatsapp_has_rate_limiter(self):
"""Verify WhatsApp adapter has rate limiter."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.__init__)
assert "_rate_limiter" in source
assert "RateLimiter.for_platform" in source
def test_whatsapp_send_message_uses_rate_limiter(self):
"""Verify WhatsApp send_message uses rate limiter."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.send_message)
assert "self._rate_limiter.acquire" in source
def test_whatsapp_send_template_uses_rate_limiter(self):
"""Verify WhatsApp send_template uses rate limiter."""
import inspect
from praisonai.bots.whatsapp import WhatsAppBot
source = inspect.getsource(WhatsAppBot.send_template)
assert "self._rate_limiter.acquire" in source
class TestPlatformLimits:
"""Tests for platform rate limit configurations."""
def test_telegram_limits(self):
"""Test Telegram rate limits are configured correctly."""
from praisonai.bots._rate_limit import PLATFORM_LIMITS
telegram = PLATFORM_LIMITS["telegram"]
assert telegram.messages_per_second >= 20 # ~30 per Telegram docs
assert telegram.burst_size >= 20
def test_discord_limits(self):
"""Test Discord rate limits are configured correctly."""
from praisonai.bots._rate_limit import PLATFORM_LIMITS
discord = PLATFORM_LIMITS["discord"]
assert discord.messages_per_second <= 2 # 5 per 5 seconds = 1/sec
assert discord.per_channel_delay >= 0.5
def test_slack_limits(self):
"""Test Slack rate limits are configured correctly."""
from praisonai.bots._rate_limit import PLATFORM_LIMITS
slack = PLATFORM_LIMITS["slack"]
assert slack.messages_per_second <= 2 # 1 per second per channel
assert slack.per_channel_delay >= 0.5
def test_whatsapp_limits(self):
"""Test WhatsApp rate limits are configured correctly."""
from praisonai.bots._rate_limit import PLATFORM_LIMITS
whatsapp = PLATFORM_LIMITS["whatsapp"]
assert whatsapp.messages_per_second >= 30 # ~80 per Cloud API docs
@@ -0,0 +1,437 @@
"""
TDD Tests for ast_grep tool.
Tests cover:
1. Tool availability detection (installed vs not installed)
2. Graceful fallback when not installed
3. Tool assignment to Agent
4. Autonomy mode integration
5. Search, rewrite, and scan operations
"""
import pytest
import subprocess
from unittest.mock import patch, MagicMock
class TestAstGrepToolAvailability:
"""Test ast-grep tool availability detection."""
def test_is_available_when_installed(self):
"""Tool should report available when sg binary exists."""
from praisonaiagents.tools.ast_grep_tool import is_ast_grep_available
with patch('shutil.which') as mock_which:
mock_which.return_value = '/usr/local/bin/sg'
assert is_ast_grep_available() is True
def test_is_not_available_when_not_installed(self):
"""Tool should report not available when sg binary doesn't exist."""
from praisonaiagents.tools import ast_grep_tool
from praisonaiagents.tools.ast_grep_tool import is_ast_grep_available
# Clear cache to ensure fresh check
ast_grep_tool._availability_cache = None
with patch('praisonaiagents.tools.ast_grep_tool.shutil.which') as mock_which:
mock_which.return_value = None
result = is_ast_grep_available()
assert result is False
def test_availability_cached(self):
"""Availability check should be cached for performance."""
from praisonaiagents.tools import ast_grep_tool
# Clear cache
ast_grep_tool._availability_cache = None
with patch('shutil.which') as mock_which:
mock_which.return_value = '/usr/local/bin/sg'
# First call
result1 = ast_grep_tool.is_ast_grep_available()
# Second call should use cache
result2 = ast_grep_tool.is_ast_grep_available()
# which should only be called once due to caching
assert mock_which.call_count == 1
assert result1 is True
assert result2 is True
class TestAstGrepToolGracefulFallback:
"""Test graceful fallback when ast-grep is not installed."""
def test_search_returns_error_when_not_installed(self):
"""Search should return helpful error when not installed."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = False
result = ast_grep_search("def $FN($$$)", lang="python", path=".")
assert "not installed" in result.lower() or "not available" in result.lower()
assert "pip install ast-grep-cli" in result
def test_rewrite_returns_error_when_not_installed(self):
"""Rewrite should return helpful error when not installed."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_rewrite
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = False
result = ast_grep_rewrite("def $FN($$$)", "def $FN(*args)", lang="python", path=".")
assert "not installed" in result.lower() or "not available" in result.lower()
def test_scan_returns_error_when_not_installed(self):
"""Scan should return helpful error when not installed."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_scan
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = False
result = ast_grep_scan(path=".")
assert "not installed" in result.lower() or "not available" in result.lower()
def test_tool_does_not_crash_when_not_installed(self):
"""Tool should never crash, even when not installed."""
from praisonaiagents.tools.ast_grep_tool import (
ast_grep_search, ast_grep_rewrite, ast_grep_scan
)
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = False
# None of these should raise exceptions
try:
ast_grep_search("pattern", lang="python", path=".")
ast_grep_rewrite("old", "new", lang="python", path=".")
ast_grep_scan(path=".")
except Exception as e:
pytest.fail(f"Tool crashed when not installed: {e}")
class TestAstGrepToolOperations:
"""Test ast-grep tool operations when installed."""
def test_search_executes_subprocess(self):
"""Search should execute sg CLI with correct arguments."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout='{"matches": []}',
stderr=''
)
result = ast_grep_search("def $FN($$$)", lang="python", path="/test/path")
mock_run.assert_called_once()
call_args = mock_run.call_args
cmd_list = call_args[0][0]
assert 'sg' in cmd_list[0] or cmd_list[0] == 'sg'
assert '--pattern' in cmd_list or 'def $FN($$$)' in cmd_list
assert '--lang' in cmd_list or 'python' in cmd_list
# Verify result is returned
assert result is not None
def test_search_handles_subprocess_error(self):
"""Search should handle subprocess errors gracefully."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.SubprocessError("Command failed")
result = ast_grep_search("pattern", lang="python", path=".")
assert "error" in result.lower() or "failed" in result.lower()
def test_rewrite_dry_run_by_default(self):
"""Rewrite should use dry-run by default for safety."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_rewrite
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout='No changes',
stderr=''
)
result = ast_grep_rewrite("old", "new", lang="python", path=".")
# Should not actually modify files without explicit flag
call_args = mock_run.call_args
cmd_list = call_args[0][0]
# Check that --update-all is NOT in the command (dry-run mode)
assert '--update-all' not in cmd_list
assert mock_run.called
assert result is not None
class TestAstGrepToolAgentIntegration:
"""Test ast-grep tool integration with Agent."""
def test_tool_can_be_assigned_to_agent(self):
"""Tool should be assignable to Agent via tools parameter."""
from praisonaiagents import Agent
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
# Should not raise
agent = Agent(
name="test",
instructions="Test agent",
tools=[ast_grep_search],
)
assert ast_grep_search in agent.tools or any(
getattr(t, '__name__', '') == 'ast_grep_search' for t in agent.tools
)
def test_tool_works_when_not_installed_but_assigned(self):
"""Agent should work even if ast-grep tool is assigned but not installed."""
from praisonaiagents import Agent
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = False
# Should not raise during agent creation
agent = Agent(
name="test",
instructions="Test agent",
tools=[ast_grep_search],
)
# Agent should be created successfully
assert agent is not None
def test_tool_import_via_lazy_loading(self):
"""Tool should be importable via lazy loading from tools package."""
# This tests the TOOL_MAPPINGS integration
from praisonaiagents.tools import ast_grep_search
assert callable(ast_grep_search)
def test_all_ast_grep_tools_importable(self):
"""All ast-grep tools should be importable."""
from praisonaiagents.tools import (
ast_grep_search,
ast_grep_rewrite,
ast_grep_scan,
)
assert callable(ast_grep_search)
assert callable(ast_grep_rewrite)
assert callable(ast_grep_scan)
class TestAstGrepToolAutonomyIntegration:
"""Test ast-grep tool integration with autonomy mode."""
def test_autonomy_config_has_default_tools_field(self):
"""AutonomyConfig should have default_tools field."""
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig()
assert hasattr(config, 'default_tools')
def test_autonomy_default_tools_includes_ast_grep(self):
"""Autonomy default_tools should include ast_grep when available."""
from praisonaiagents.tools.ast_grep_tool import get_ast_grep_tools
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
tools = get_ast_grep_tools()
# Should include ast_grep tools
tool_names = [getattr(t, '__name__', str(t)) for t in tools]
assert any('ast_grep' in name for name in tool_names)
def test_autonomy_default_tools_graceful_fallback(self):
"""get_ast_grep_tools should return tools even when ast-grep not installed."""
from praisonaiagents.tools.ast_grep_tool import get_ast_grep_tools
# get_ast_grep_tools always returns the tools - they handle unavailability internally
tools = get_ast_grep_tools()
# Should return a list with the 3 ast-grep tools
assert isinstance(tools, list)
assert len(tools) == 3
def test_agent_with_autonomy_gets_default_tools(self):
"""Agent with autonomy enabled should get default tools injected."""
from praisonaiagents import Agent
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
agent = Agent(
name="test",
instructions="Test agent",
autonomy=True,
)
# Agent should have ast-grep tools injected
assert agent is not None
assert len(agent.tools) > 0, "Agent with autonomy=True should have default tools"
# Verify ast_grep_search is in the tools
tool_names = [getattr(t, '__name__', str(t)) for t in agent.tools]
assert 'ast_grep_search' in tool_names, f"ast_grep_search should be in agent.tools, got: {tool_names}"
def test_agent_with_custom_default_tools(self):
"""Agent with AutonomyConfig(default_tools=[X]) should use custom tools."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
def custom_tool():
"""A custom tool for testing."""
return "custom"
config = AutonomyConfig(default_tools=[custom_tool])
agent = Agent(
name="test",
instructions="Test agent",
autonomy=config,
)
# Agent should have the custom tool
assert custom_tool in agent.tools, "Custom default_tools should be injected"
def test_agent_autonomy_default_tools_in_config_dict(self):
"""AutonomyConfig.default_tools should be serialized to agent.autonomy_config dict."""
from praisonaiagents import Agent
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
agent = Agent(
name="test",
instructions="Test agent",
autonomy=True,
)
# default_tools should be in the autonomy_config dict
assert 'default_tools' in agent.autonomy_config, "default_tools should be in autonomy_config dict"
class TestAstGrepToolSchema:
"""Test ast-grep tool schema generation for LLM."""
def test_search_has_docstring(self):
"""Search tool should have proper docstring for LLM."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
assert ast_grep_search.__doc__ is not None
assert len(ast_grep_search.__doc__) > 50
def test_rewrite_has_docstring(self):
"""Rewrite tool should have proper docstring for LLM."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_rewrite
assert ast_grep_rewrite.__doc__ is not None
assert len(ast_grep_rewrite.__doc__) > 50
def test_scan_has_docstring(self):
"""Scan tool should have proper docstring for LLM."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_scan
assert ast_grep_scan.__doc__ is not None
assert len(ast_grep_scan.__doc__) > 50
class TestAstGrepToolEdgeCases:
"""Test edge cases and error handling."""
def test_empty_pattern_handled(self):
"""Empty pattern should be handled gracefully."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout='', stderr='Invalid pattern')
result = ast_grep_search("", lang="python", path=".")
# Should not crash, should return error message
assert isinstance(result, str)
def test_invalid_language_handled(self):
"""Invalid language should be handled gracefully."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=1, stdout='', stderr='Unknown language'
)
result = ast_grep_search("pattern", lang="invalid_lang", path=".")
assert isinstance(result, str)
def test_nonexistent_path_handled(self):
"""Nonexistent path should be handled gracefully."""
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
mock_avail.return_value = True
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=1, stdout='', stderr='Path not found'
)
result = ast_grep_search("pattern", lang="python", path="/nonexistent/path")
assert isinstance(result, str)
class TestAstGrepToolImportPerformance:
"""Test that tool import doesn't impact performance."""
def test_lazy_import_no_subprocess_on_import(self):
"""Importing the tool should not execute subprocess."""
with patch('subprocess.run') as mock_run:
# Re-import to test
import importlib
import praisonaiagents.tools.ast_grep_tool as module
importlib.reload(module)
# subprocess.run should not be called on import
mock_run.assert_not_called()
def test_availability_not_checked_on_import(self):
"""Availability should not be checked on module import."""
with patch('shutil.which') as mock_which:
import importlib
import praisonaiagents.tools.ast_grep_tool as module
# Clear cache to force fresh check
module._availability_cache = None
importlib.reload(module)
# which should not be called on import
mock_which.assert_not_called()
+21 -2
View File
@@ -145,6 +145,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
]
[[package]]
name = "ast-grep-cli"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/9c/4b5fbc28a606d1d3217891d41c8428518de8a52e8f83564daabbd4d8ba18/ast_grep_cli-0.41.0.tar.gz", hash = "sha256:9aee19fa53d582f4408ace5cffe3ff5635dff954c7ab78a15530c4acf5893a0e", size = 220762, upload-time = "2026-02-22T19:06:01.576Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/e2/e7604406b8487d43928a99fcfb66ac3aa9fba787527e901b96e65dbbd6d6/ast_grep_cli-0.41.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93085ee153855fa9b1f486884f8aad788fb111f1e6a96bcfde240071d5033199", size = 14488134, upload-time = "2026-02-22T19:05:54.26Z" },
{ url = "https://files.pythonhosted.org/packages/65/b5/1f9ad69e28e2b98e0e99a4a7f981982937072c2a667f5ae808d8ffefa193/ast_grep_cli-0.41.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8db8e877b365a553f83a2a0e158d8cfa17fc33f8033774ee3248665449dcad1b", size = 7261908, upload-time = "2026-02-22T19:06:02.5Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8b/ac2e7946e2b566483fb0005ed715767110663fb85a666030b38e43dee10f/ast_grep_cli-0.41.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c10f70cd6e45ded98773022d9ae0bebfc497b8b2e85ae17e15abca174e7d0f93", size = 7106987, upload-time = "2026-02-22T19:05:59.003Z" },
{ url = "https://files.pythonhosted.org/packages/f8/00/90cc9f094fad9116f29076c1a5393df50fba7d7b25c402d2611d96f7e3f9/ast_grep_cli-0.41.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:92d384608e5fd98a301a03a380e0ce011d46adcd5f6bcf53b0b9ba125faa43ad", size = 7417585, upload-time = "2026-02-22T19:06:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/be/1b/390fd8ab3164e0f9377bd272196612f5047bfed35eb704df1c1e84776075/ast_grep_cli-0.41.0-py3-none-win32.whl", hash = "sha256:a7a4682cde7a1e1a8083dd8feed6f968d8f90a109e7c0917e1d5066807dfa46e", size = 6974015, upload-time = "2026-02-22T19:05:55.956Z" },
{ url = "https://files.pythonhosted.org/packages/0a/f5/71984cca3d3533c7e0a7dfe1784b7f6b12931463f6c5ce95ec36d5169219/ast_grep_cli-0.41.0-py3-none-win_amd64.whl", hash = "sha256:00570076cbf30e9c4c93f5577137c4d63245974420eff9e1fb3e3c6a9abefe97", size = 7413122, upload-time = "2026-02-22T19:05:57.586Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ee/2add23700fa89661e84301c458a9a3218836edd5bbaeea49a7a283f4269a/ast_grep_cli-0.41.0-py3-none-win_arm64.whl", hash = "sha256:b8e0eeeec751fa213abc57ea20af4fdd8ae955a43e390914df85985f6933eb53", size = 7089061, upload-time = "2026-02-22T19:05:52.28Z" },
]
[[package]]
name = "async-timeout"
version = "4.0.3"
@@ -2549,7 +2564,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.15"
version = "1.5.16"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -2589,6 +2604,9 @@ auth = [
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
]
autonomy = [
{ name = "ast-grep-cli" },
]
graph = [
{ name = "chromadb" },
{ name = "mem0ai", extra = ["graph"] },
@@ -2629,6 +2647,7 @@ telemetry = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.8.0" },
{ name = "ast-grep-cli", marker = "extra == 'autonomy'", specifier = ">=0.30.0" },
{ name = "chonkie", marker = "extra == 'knowledge'", specifier = ">=1.0.2" },
{ name = "chromadb", marker = "extra == 'graph'", specifier = ">=1.0.0" },
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
@@ -2669,7 +2688,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "all"]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "all"]
[[package]]
name = "propcache"
+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.5.15.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.15.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.16.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.16.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+10 -1
View File
@@ -9,6 +9,15 @@ from __future__ import annotations
import asyncio
import logging
# Module-level deprecation warning
import warnings as _deprecation_warnings
_deprecation_warnings.warn(
"BotApprovalBackend is deprecated. Use platform-specific approval backends "
"(SlackApproval, TelegramApproval, DiscordApproval, WebhookApproval, HTTPApproval) instead.",
DeprecationWarning,
stacklevel=2
)
import time
from typing import Any, Dict
@@ -77,7 +86,7 @@ class BotApprovalBackend:
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()
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
self._pending[request_id] = future
+141
View File
@@ -0,0 +1,141 @@
"""
Rate Limiting Utilities for Bot Adapters.
Provides platform-aware rate limiting to prevent 429 errors from messaging APIs.
Platform limits (as of 2024):
- Telegram: ~30 messages/second to different users, stricter per-chat
- Discord: 5 messages per 5 seconds per channel (1 msg/sec effective)
- Slack: 1 message per second per channel
- WhatsApp: ~80 messages/second (Cloud API), varies for Web
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
import logging
logger = logging.getLogger(__name__)
@dataclass
class RateLimitConfig:
"""Configuration for rate limiting.
Attributes:
messages_per_second: Maximum messages per second (global)
per_channel_delay: Minimum delay between messages to same channel (seconds)
burst_size: Number of messages allowed in a burst before throttling
"""
messages_per_second: float = 1.0
per_channel_delay: float = 1.0
burst_size: int = 5
# Platform-specific defaults
PLATFORM_LIMITS: Dict[str, RateLimitConfig] = {
"telegram": RateLimitConfig(messages_per_second=25.0, per_channel_delay=0.05, burst_size=30),
"discord": RateLimitConfig(messages_per_second=1.0, per_channel_delay=1.0, burst_size=5),
"slack": RateLimitConfig(messages_per_second=1.0, per_channel_delay=1.0, burst_size=1),
"whatsapp": RateLimitConfig(messages_per_second=50.0, per_channel_delay=0.1, burst_size=80),
}
class RateLimiter:
"""Async rate limiter with per-channel tracking.
Uses token bucket algorithm for global rate and per-channel delays.
Example:
limiter = RateLimiter.for_platform("telegram")
async def send(channel_id, msg):
await limiter.acquire(channel_id)
await api.send_message(channel_id, msg)
"""
def __init__(self, config: Optional[RateLimitConfig] = None):
"""Initialize rate limiter.
Args:
config: Rate limit configuration. Defaults to 1 msg/sec.
"""
self._config = config or RateLimitConfig()
self._tokens = float(self._config.burst_size)
self._last_refill = time.monotonic()
self._channel_last_send: Dict[str, float] = {}
self._lock = asyncio.Lock()
@classmethod
def for_platform(cls, platform: str) -> "RateLimiter":
"""Create a rate limiter with platform-specific defaults.
Args:
platform: Platform name (telegram, discord, slack, whatsapp)
Returns:
Configured RateLimiter instance
"""
config = PLATFORM_LIMITS.get(platform.lower(), RateLimitConfig())
return cls(config)
async def acquire(self, channel_id: Optional[str] = None) -> None:
"""Wait until rate limit allows sending.
Args:
channel_id: Optional channel ID for per-channel limiting
"""
async with self._lock:
now = time.monotonic()
# Refill tokens based on elapsed time
elapsed = now - self._last_refill
self._tokens = min(
self._config.burst_size,
self._tokens + elapsed * self._config.messages_per_second
)
self._last_refill = now
# Wait for global token
if self._tokens < 1.0:
wait_time = (1.0 - self._tokens) / self._config.messages_per_second
logger.debug(f"Rate limit: waiting {wait_time:.3f}s for global token")
await asyncio.sleep(wait_time)
self._tokens = 1.0
# Consume token
self._tokens -= 1.0
# Per-channel delay
if channel_id:
last_send = self._channel_last_send.get(channel_id, 0.0)
channel_elapsed = now - last_send
if channel_elapsed < self._config.per_channel_delay:
wait_time = self._config.per_channel_delay - channel_elapsed
logger.debug(f"Rate limit: waiting {wait_time:.3f}s for channel {channel_id}")
await asyncio.sleep(wait_time)
self._channel_last_send[channel_id] = time.monotonic()
def reset(self) -> None:
"""Reset rate limiter state."""
self._tokens = float(self._config.burst_size)
self._last_refill = time.monotonic()
self._channel_last_send.clear()
def get_rate_limiter(platform: str) -> RateLimiter:
"""Get a rate limiter for the specified platform.
This is a convenience function that creates a new limiter each time.
For persistent limiting, create and store a RateLimiter instance.
Args:
platform: Platform name
Returns:
Configured RateLimiter
"""
return RateLimiter.for_platform(platform)
+1 -1
View File
@@ -145,7 +145,7 @@ class BotSessionManager:
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()
loop = asyncio.get_running_loop()
user_history = await loop.run_in_executor(
None, self._load_history, user_id
)
+24
View File
@@ -27,6 +27,7 @@ from praisonaiagents.bots import (
from ._commands import format_status, format_help
from ._session import BotSessionManager
from ._debounce import InboundDebouncer
from ._ack import AckReactor
logger = logging.getLogger(__name__)
@@ -81,6 +82,10 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
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,
)
@property
def is_running(self) -> bool:
@@ -186,6 +191,22 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
if self._agent:
user_id = str(message.author.id)
# Ack reaction - show processing indicator
ack_ctx = None
if self._ack.enabled:
async def _discord_react(emoji, **kw):
try:
await message.add_reaction(emoji)
except Exception:
pass # Reactions may not be supported
async def _discord_unreact(emoji, **kw):
try:
await message.remove_reaction(emoji, self._client.user)
except Exception:
pass
ack_ctx = await self._ack.ack(react_fn=_discord_react)
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)
@@ -196,6 +217,9 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
return
await self._send_long_message(message.channel, send_result["content"], reference=message)
self.fire_message_sent(str(message.channel.id), send_result["content"])
# Done reaction - show completion
if ack_ctx:
await self._ack.done(ack_ctx, react_fn=_discord_react, unreact_fn=_discord_unreact)
if self.config.typing_indicator:
async with message.channel.typing():
+38 -1
View File
@@ -29,6 +29,7 @@ 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__)
@@ -94,6 +95,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
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
self._stt_enabled: bool = False
@@ -194,6 +199,35 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
should_respond = True
if should_respond and self._agent:
channel_id = event.get("channel", "")
msg_ts = event.get("ts", "")
# Ack reaction - show processing indicator
ack_ctx = None
if self._ack.enabled and self._client:
async def _slack_react(emoji, **kw):
try:
# Slack uses emoji names without colons
emoji_name = emoji.strip(":")
await self._client.reactions_add(
channel=channel_id,
timestamp=msg_ts,
name=emoji_name,
)
except Exception:
pass # Reactions may fail silently
async def _slack_unreact(emoji, **kw):
try:
emoji_name = emoji.strip(":")
await self._client.reactions_remove(
channel=channel_id,
timestamp=msg_ts,
name=emoji_name,
)
except Exception:
pass
ack_ctx = await self._ack.ack(react_fn=_slack_react)
try:
user_id = event.get("user", "unknown")
logger.info(f"Message received: {text[:100]}...")
@@ -201,7 +235,6 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
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
@@ -219,6 +252,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
channel_id, say, response, thread_ts=thread_ts
)
self.fire_message_sent(channel_id, response)
# Done reaction - show completion
if ack_ctx and self._client:
await self._ack.done(ack_ctx, react_fn=_slack_react, unreact_fn=_slack_unreact)
except Exception as e:
logger.error(f"Agent error: {e}")
error_msg = str(e)
+57 -12
View File
@@ -45,6 +45,8 @@ from praisonaiagents.bots import (
from ._commands import format_status, format_help
from ._session import BotSessionManager
from ._rate_limit import RateLimiter
from ._ack import AckReactor
logger = logging.getLogger(__name__)
@@ -129,6 +131,13 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._runner: Any = None
self._site: Any = None
self._http_session: Any = None # Shared aiohttp session for connection pooling
self._background_tasks: set = set() # Track background tasks to prevent silent failures
self._rate_limiter = RateLimiter.for_platform("whatsapp") # Rate limiting for API calls
self._ack: AckReactor = AckReactor(
ack_emoji=self.config.ack_emoji,
done_emoji=self.config.done_emoji,
)
# Web mode adapter (lazy initialized)
self._web_adapter: Any = None
@@ -216,6 +225,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
async def _start_cloud_mode(self) -> None:
"""Start the Cloud API webhook server."""
try:
import aiohttp
from aiohttp import web
except ImportError:
raise ImportError("aiohttp is required for WhatsApp bot. Install with: pip install aiohttp")
@@ -231,6 +241,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
self._is_running = True
self._started_at = time.time()
self._http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
self._bot_user = BotUser(
user_id=self._phone_number_id,
username="whatsapp_bot",
@@ -257,6 +268,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
⚠️ EXPERIMENTAL: Uses reverse-engineered protocol.
"""
import aiohttp
from ._whatsapp_web_adapter import WhatsAppWebAdapter
async def _on_web_message(event: Any) -> None:
@@ -447,6 +459,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
self._is_running = True
self._started_at = time.time()
self._http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
self._bot_user = BotUser(
user_id="web_user",
username="whatsapp_web_bot",
@@ -507,6 +520,15 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
await self._web_adapter.disconnect()
except Exception as e:
logger.warning(f"Web adapter disconnect error: {e}")
# Cancel pending background tasks
for task in self._background_tasks:
task.cancel()
if self._background_tasks:
await asyncio.gather(*self._background_tasks, return_exceptions=True)
self._background_tasks.clear()
if self._http_session:
await self._http_session.close()
self._http_session = None
logger.info("WhatsApp bot stopped")
# ── Webhook handlers ────────────────────────────────────────────
@@ -561,7 +583,10 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
return web.Response(status=400, text="Bad request")
# Process asynchronously so we respond 200 immediately
asyncio.create_task(self._process_webhook_data(data))
# Track task to prevent silent failures
task = asyncio.create_task(self._process_webhook_data(data))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Always respond 200 to acknowledge receipt
return web.Response(status=200, text="OK")
@@ -681,6 +706,22 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
# Route to agent
if self._agent and content:
# Ack reaction - show processing indicator
ack_ctx = None
if self._ack.enabled:
async def _wa_react(emoji, **kw):
try:
await self.send_reaction(sender_id, msg_id, emoji)
except Exception:
pass
async def _wa_unreact(emoji, **kw):
try:
# WhatsApp doesn't have unreact, send empty reaction to clear
await self.send_reaction(sender_id, msg_id, "")
except Exception:
pass
ack_ctx = await self._ack.ack(react_fn=_wa_react)
try:
response = await self._session_mgr.chat(
self._agent, sender_id, content
@@ -690,6 +731,9 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
if not send_result["cancel"]:
await self.send_message(sender_id, send_result["content"])
self.fire_message_sent(sender_id, send_result["content"])
# Done reaction - show completion
if ack_ctx:
await self._ack.done(ack_ctx, react_fn=_wa_react, unreact_fn=_wa_unreact)
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.")
@@ -733,9 +777,11 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
if reply_to:
payload["context"] = {"message_id": reply_to}
# Rate limit before sending
await self._rate_limiter.acquire(to)
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
result = await resp.json()
if "error" in result:
logger.error(f"WhatsApp send error: {result['error']}")
@@ -783,9 +829,11 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
if components:
payload["template"]["components"] = components
# Rate limit before sending
await self._rate_limiter.acquire(to)
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
result = await resp.json()
if "error" in result:
logger.error(f"WhatsApp template error: {result['error']}")
@@ -820,8 +868,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
await resp.json()
except Exception as e:
logger.error(f"Failed to send reaction: {e}")
@@ -845,8 +892,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
await resp.json()
except Exception as e:
logger.error(f"Failed to mark as read: {e}")
@@ -883,8 +929,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
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:
async with self._http_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()
@@ -904,7 +949,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
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
session_count = len(self._session_mgr._histories) if hasattr(self._session_mgr, '_histories') else 0
return HealthResult(
ok=self._is_running and probe_result.ok, platform="whatsapp",
is_running=self._is_running, uptime_seconds=uptime,
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.5.15 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.16 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.15"
__version__ = "4.5.16"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.15",
"praisonaiagents>=1.5.16",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
+4 -4
View File
@@ -3731,7 +3731,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=1.5.15" },
{ name = "praisonaiagents", specifier = ">=1.5.16" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
@@ -3789,7 +3789,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.15"
version = "1.5.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -3798,9 +3798,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/46/959510dcc4e73c50be214f956ea2e1df75232120da162b1a3fc68d32a840/praisonaiagents-1.5.15.tar.gz", hash = "sha256:e710f3f4dfe91239dc7cd21b59bcc548f6a964ff31a5e7e70959748b2070c984", size = 1209876, upload-time = "2026-02-18T10:35:21.989Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/7e/683674c8d6d450926c33e2d5789fd1dbbe3cb7a02810684dce704d3cb2e9/praisonaiagents-1.5.16.tar.gz", hash = "sha256:9c5054184d66d330fca89765f49ec5a6c7c16c0fddf2165d4f2cae4af94b78c0", size = 1213443, upload-time = "2026-02-27T04:34:43.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/96/8e4d217730b6e61855811424ff312443d546459d81eea03bce0448939a39/praisonaiagents-1.5.15-py3-none-any.whl", hash = "sha256:65a0679a00fa35a9a19902364edadfdd6b5d7b9afbdf2c7eaa7b7774c5e19bcc", size = 1208597, upload-time = "2026-02-18T10:35:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/ec/5f/63de8fdb7f3e44362e5d5124847eb6be0f76ef9daa62538f7dc28b98729c/praisonaiagents-1.5.16-py3-none-any.whl", hash = "sha256:92a5e6c8eed210fcf3bd23d71e2c821f85396c199135a24bde5c7a37c4cc40c8", size = 1212613, upload-time = "2026-02-27T04:34:41.561Z" },
]
[[package]]