mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-13 18:48:28 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a1977651 |
No files matched your search
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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()
|
||||
Generated
+21
-2
@@ -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"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "4.5.15"
|
||||
__version__ = "4.5.16"
|
||||
@@ -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",
|
||||
|
||||
Generated
+4
-4
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user