Compare commits

...
Author SHA1 Message Date
Mervin Praison d7c274489e Release v4.5.20 2026-03-03 11:08:28 +00:00
Mervin Praison 23f6c00e21 Release v4.5.19 2026-03-03 09:37:24 +00:00
Mervin Praison a293999e8b Release v4.5.18 2026-03-03 07:22:28 +00:00
Mervin Praison 0b14ae49ef update 2026-03-01 07:12:47 +00:00
Mervin Praison b33c649de5 Release v4.5.17 2026-02-28 08:47:18 +00:00
Mervin Praison 02a1977651 Release v4.5.16 2026-02-27 04:43:28 +00:00
38 changed files with 3281 additions and 428 deletions

No files matched your search

+3 -1
View File
@@ -91,4 +91,6 @@ issue_details.json
target/
Cargo.lock
**/*.rs.bk
*.pdb
*.pdb
.windsurf
.agent
+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.20" \
"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.20" \
"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.20" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -438,6 +438,7 @@ class Agent:
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
):
"""Initialize an Agent instance.
@@ -1343,6 +1344,37 @@ class Agent:
self.tools = list(tools)
else:
self.tools = tools or []
# Inject default tools for autonomy mode (after self.tools is initialized)
# ONLY inject if caller didn't provide tools - avoid duplicates with CLI/wrapper tools
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 via AutonomyConfig
self.tools.extend(config.default_tools)
elif not self.tools:
# Only inject AUTONOMY_PROFILE if no tools were provided by caller
# This prevents duplicates when CLI/wrapper already provides tools
try:
from ..tools.profiles import AUTONOMY_PROFILE
from .. import tools as tools_module
resolved_tools = []
for tool_name in AUTONOMY_PROFILE.tools:
try:
tool = getattr(tools_module, tool_name)
if tool is not None:
resolved_tools.append(tool)
except AttributeError:
pass # Tool not available, skip
self.tools.extend(resolved_tools)
except ImportError:
# Fallback to ast-grep tools if profiles not available
try:
from ..tools.ast_grep_tool import get_ast_grep_tools
self.tools.extend(get_ast_grep_tools())
except ImportError:
pass # No default tools available
self.max_iter = max_iter
self.max_rpm = max_rpm
self.max_execution_time = max_execution_time
@@ -1463,6 +1495,9 @@ Your Goal: {self.goal}
# Pending approvals for async (non-blocking) mode
self._pending_approvals = {}
# P8/G11: Tool timeout - prevent slow tools from blocking
self._tool_timeout = tool_timeout
# Cache for system prompts and formatted tools with lazy thread-safe lock
self._system_prompt_cache = {}
self._formatted_tools_cache = {}
@@ -1962,6 +1997,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):
@@ -2270,13 +2306,16 @@ Summary:"""
)
# Take initial snapshot before autonomous execution starts
if self._file_snapshot is not None:
# NOTE: Snapshot tracking is disabled by default for performance (G12 fix)
# FileSnapshot.track() walks entire project and is too slow for large codebases
# Enable with autonomy={"snapshot": True} if needed
if self._file_snapshot is not None and self.autonomy_config.get("snapshot", False):
try:
snap_info = self._file_snapshot.track(message="pre-autonomous")
self._snapshot_stack.append(snap_info.commit_hash)
self._redo_stack.clear()
except Exception as e:
logger.debug(f"Pre-autonomous snapshot failed: {e}")
logging.debug(f"Pre-autonomous snapshot failed: {e}")
start_time = time_module.time()
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -2303,11 +2342,21 @@ Summary:"""
# Reset doom loop tracker for new task
self._reset_doom_loop()
# P3/G2: Import callback helper for autonomy events
from ..main import execute_sync_callback
try:
# Execute the autonomous loop
while iterations < effective_max_iter:
iterations += 1
# P3/G2: Emit autonomy_iteration callback for CLI visibility
execute_sync_callback('autonomy_iteration',
iteration=iterations,
max_iterations=effective_max_iter,
stage=stage
)
# Check timeout
if timeout_seconds and (time_module.time() - start_time) > timeout_seconds:
return AutonomyResult(
@@ -2324,6 +2373,13 @@ Summary:"""
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
if self._is_doom_loop():
recovery = self._get_doom_recovery()
# P3/G2: Emit doom loop callback for CLI visibility
execute_sync_callback('autonomy_doom_loop',
iteration=iterations,
recovery_action=recovery
)
obs = getattr(self, '_observability_hooks', None)
if obs is not None:
from ..escalation.observability import EventType as _EvtType
@@ -2337,6 +2393,12 @@ Summary:"""
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
continue
elif recovery == "escalate_model":
# Give the agent one more try with explicit error guidance
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
continue
elif recovery == "request_help":
return AutonomyResult(
success=False,
@@ -2380,8 +2442,13 @@ Summary:"""
response_str = str(response)
# Record the action for doom loop tracking (G1 fix: was missing)
# Use iteration number + response hash to avoid false positives
# when same prompt is re-injected (G8 fix: doom loop false positive)
self._record_action(
"chat", {"prompt": prompt}, response_str[:200], True
"chat",
{"iteration": iterations, "response_hash": hash(response_str[:500])},
response_str[:200],
True
)
# Record for action history
@@ -2417,14 +2484,28 @@ Summary:"""
stage_order = ["direct", "heuristic", "planned", "autonomous"]
idx = stage_order.index(stage) if stage in stage_order else 0
if idx < len(stage_order) - 1:
prev_stage = stage
stage = stage_order[idx + 1]
# P3/G2: Emit stage change callback for CLI visibility
execute_sync_callback('autonomy_stage_change',
from_stage=prev_stage,
to_stage=stage
)
if obs is not None:
obs.emit(_EvtType.STAGE_ESCALATE, {"from": stage_order[idx], "to": stage})
obs.emit(_EvtType.STAGE_ESCALATE, {"from": prev_stage, "to": stage})
# Check for completion promise FIRST (structured signal)
if effective_promise:
promise_tag = f"<promise>{effective_promise}</promise>"
if promise_tag in response_str:
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="promise",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=True,
output=response_str,
@@ -2438,6 +2519,12 @@ Summary:"""
# Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
if self._is_completion_signal(response_str):
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="goal",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=True,
output=response_str,
@@ -2451,6 +2538,12 @@ Summary:"""
# For DIRECT stage, complete after first response
if stage == "direct":
# P3/G2: Emit completion callback
execute_sync_callback('autonomy_complete',
completion_reason="goal",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=True,
output=response_str,
@@ -2467,6 +2560,12 @@ Summary:"""
self.clear_history()
# Max iterations reached
# P3/G2: Emit completion callback for max iterations
execute_sync_callback('autonomy_complete',
completion_reason="max_iterations",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=False,
output="Max iterations reached",
@@ -2620,6 +2719,11 @@ Summary:"""
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
continue
elif recovery == "escalate_model":
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.clear_actions()
continue
elif recovery == "request_help":
return AutonomyResult(
success=False,
@@ -2663,8 +2767,13 @@ Summary:"""
response_str = str(response)
# Record the action for doom loop tracking (G2 fix: was missing)
# Use iteration number + response hash to avoid false positives
# when same prompt is re-injected (G8 fix: doom loop false positive)
self._record_action(
"chat", {"prompt": prompt}, response_str[:200], True
"chat",
{"iteration": iterations, "response_hash": hash(response_str[:500])},
response_str[:200],
True
)
# Record for action history
@@ -4274,8 +4383,26 @@ Your Goal: {self.goal}"""
try:
sig = inspect.signature(func)
valid_params = set(sig.parameters.keys()) - {'self'}
casted_args = {}
# Sanitize argument names: strip trailing '=', whitespace, and
# other invalid chars that LLMs sometimes hallucinate in kwarg names
sanitized = {}
for raw_name, arg_value in arguments.items():
clean = raw_name.strip().rstrip('=').strip()
# If the cleaned name matches a valid param, use it;
# otherwise try case-insensitive match
if clean in valid_params:
sanitized[clean] = arg_value
elif clean.lower() in {p.lower() for p in valid_params}:
# Case-insensitive fuzzy match
matched = next(p for p in valid_params if p.lower() == clean.lower())
sanitized[matched] = arg_value
else:
sanitized[clean] = arg_value
arguments = sanitized
for param_name, arg_value in arguments.items():
if param_name in sig.parameters:
param = sig.parameters[param_name]
@@ -4362,7 +4489,19 @@ Your Goal: {self.goal}"""
arguments.update(res.output.modified_data)
with with_injection_context(state):
result = self._execute_tool_impl(function_name, arguments)
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
if tool_timeout and tool_timeout > 0:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(self._execute_tool_impl, function_name, arguments)
try:
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
else:
result = self._execute_tool_impl(function_name, arguments)
# Apply tool output truncation to prevent context overflow
# Uses context manager budget if enabled, otherwise applies default limit
@@ -4415,6 +4554,13 @@ Your Goal: {self.goal}"""
)
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
# G10 fix: Mark progress after successful tool execution
# This prevents false doom loop detection when tools succeed
if self._doom_loop_tracker is not None and result is not None:
is_error = isinstance(result, dict) and result.get('error')
if not is_error:
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
return result
except Exception as e:
# Emit tool call end with error
@@ -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"),
)
@@ -592,6 +592,18 @@ Respond with ONLY a valid JSON tool call in this format:
f"waiting {retry_delay:.1f}s before retry..."
)
# P5: Emit retry callback for CLI visibility
try:
from ..main import execute_sync_callback
execute_sync_callback('retry',
attempt=attempt + 1,
max_attempts=self._max_retries + 1,
error=error_str[:200],
retry_in_seconds=retry_delay
)
except ImportError:
pass
if self._rate_limiter is not None:
self._rate_limiter.wait_for_retry(retry_delay)
else:
@@ -103,6 +103,20 @@ __all__ = [
'display_self_reflection',
]
# P3/G2: Supported callback types documentation
# Standard callbacks: tool_call, interaction, error, llm_start, llm_end
# Autonomy callbacks (PraisonAI unique):
# - autonomy_iteration: {iteration, max_iterations, stage}
# - autonomy_stage_change: {from_stage, to_stage}
# - autonomy_doom_loop: {iteration, recovery_action}
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
# - retry: {attempt, max_attempts, error, retry_in_seconds}
SUPPORTED_CALLBACK_TYPES = [
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end',
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
'retry',
]
def register_display_callback(display_type: str, callback_fn, is_async: bool = False):
"""Register a synchronous or asynchronous callback function for a specific display type.
@@ -80,6 +80,7 @@ class StatusOutput:
use_color: bool = True,
show_timestamps: bool = True, # NEW: control timestamp display
show_metrics: bool = False, # Enable metrics display for debug mode
show_tool_output: bool = True, # Show tool result output inline
):
self._file = file or sys.stderr # Use stderr to not interfere with agent output
self._format = format
@@ -87,6 +88,7 @@ class StatusOutput:
self._use_color = use_color
self._show_timestamps = show_timestamps
self._show_metrics = show_metrics
self._show_tool_output = show_tool_output
self._console = None
self._tool_start_times: Dict[str, float] = {}
self._lock = threading.Lock() # Per-sink lock for thread safety
@@ -120,12 +122,12 @@ class StatusOutput:
parts = []
for k, v in (redacted or {}).items():
if isinstance(v, str):
v_str = f'"{_truncate(v, 30)}"'
v_str = f'"{_truncate(v, 80)}"'
else:
v_str = _truncate(str(v), 30)
v_str = _truncate(str(v), 80)
parts.append(f"{k}={v_str}")
return _truncate(", ".join(parts), 80)
return _truncate(", ".join(parts), 200)
def agent_start(self, agent_name: str) -> None:
"""Record agent start."""
@@ -185,8 +187,8 @@ class StatusOutput:
"""Record LLM call end with optional metrics for debug mode."""
ts = time.time()
# Use latency_ms if provided, otherwise calculate from start time
if latency_ms is not None:
# Use latency_ms if provided and positive, otherwise calculate from start time
if latency_ms is not None and latency_ms > 0:
duration_ms = latency_ms
elif duration_ms is None and hasattr(self, '_llm_start_time'):
start_ts = self._llm_start_time
@@ -267,9 +269,13 @@ class StatusOutput:
args_str = self._format_args(self._pending_tool_args)
result_str = ""
if result_summary:
result_str = f" → {_truncate(result_summary, 50)}"
if self._show_tool_output:
if result_summary:
result_str = f" → {_truncate(result_summary, 50)}"
elif error_message:
result_str = f" → ✗ {_truncate(error_message, 50)}"
elif error_message:
# Always show errors even when output is hidden
result_str = f" → ✗ {_truncate(error_message, 50)}"
color = "cyan" if status == "ok" else "red"
@@ -285,9 +291,9 @@ class StatusOutput:
separator = "─" * 50
self._emit_text(separator, ts, "dim", show_timestamp=False)
self._emit_text("Final Output:", ts, "bold", show_timestamp=False)
# Print actual content without truncation for final output
# Print actual content to stdout (not stderr) so users see it
with self._lock: # Thread-safe output
print(content, file=self._file)
print(content)
def _emit_text(self, message: str, ts: float, style: str = None, show_timestamp: bool = True) -> None:
"""Emit a text line. Thread-safe for multi-agent execution."""
@@ -320,6 +326,7 @@ def enable_status_output(
use_color: bool = True,
show_timestamps: bool = True,
show_metrics: bool = False, # Enable metrics for debug mode
show_tool_output: bool = True, # Show tool result output inline
) -> StatusOutput:
"""
Enable actions output mode globally.
@@ -334,6 +341,7 @@ def enable_status_output(
use_color: Whether to use colored output (default: True)
show_timestamps: Whether to show timestamps (default: True)
show_metrics: Whether to show token/cost metrics (default: False)
show_tool_output: Whether to show tool result output inline (default: True)
Returns:
StatusOutput instance for programmatic access
@@ -350,6 +358,7 @@ def enable_status_output(
use_color=use_color,
show_timestamps=show_timestamps,
show_metrics=show_metrics,
show_tool_output=show_tool_output,
)
_status_output_enabled = True
@@ -155,12 +155,44 @@ 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
# Profile exports (lazy loaded)
_PROFILE_EXPORTS = frozenset({
'ToolProfile', 'AUTONOMY_PROFILE', 'BUILTIN_PROFILES',
'register_profile', 'get_profile', 'resolve_profiles', 'list_profiles'
})
def __getattr__(name: str) -> Any:
"""Smart lazy loading of tools with class method support."""
"""Smart lazy loading of tools and profiles."""
# Handle profile imports first
if name in _PROFILE_EXPORTS:
from .profiles import (
ToolProfile, AUTONOMY_PROFILE, BUILTIN_PROFILES,
register_profile, get_profile, resolve_profiles, list_profiles
)
_profile_map = {
'ToolProfile': ToolProfile,
'AUTONOMY_PROFILE': AUTONOMY_PROFILE,
'BUILTIN_PROFILES': BUILTIN_PROFILES,
'register_profile': register_profile,
'get_profile': get_profile,
'resolve_profiles': resolve_profiles,
'list_profiles': list_profiles,
}
return _profile_map[name]
# Handle tool mappings
if name not in TOOL_MAPPINGS:
raise AttributeError(f"module '{__package__}' has no attribute '{name}'")
@@ -188,10 +220,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:
@@ -214,4 +247,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
# Validation and retry protocols
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
'RetryPolicy', 'FallbackChain', 'ToolExecutionConfig',
# Tool profiles (DRY tool sets for autonomy/interactive modes)
'ToolProfile', 'AUTONOMY_PROFILE', 'BUILTIN_PROFILES',
'register_profile', 'get_profile', 'resolve_profiles', 'list_profiles',
]
@@ -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',
]
@@ -93,6 +93,12 @@ class FileTools:
safe_path = FileTools._validate_path(filepath)
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
# Auto-coerce non-string content (LLMs sometimes pass dicts/lists)
if not isinstance(content, str):
if isinstance(content, (dict, list)):
content = json.dumps(content, indent=2, ensure_ascii=False)
else:
content = str(content)
with open(safe_path, 'w', encoding=encoding) as f:
f.write(content)
return True
@@ -0,0 +1,185 @@
"""
ToolProfile system for composable, extensible tool sets.
Provides named profiles of tools that can be combined and extended.
This eliminates DRY violations across interactive/autonomy modes.
Usage:
from praisonaiagents.tools.profiles import resolve_profiles, AUTONOMY_PROFILE
# Get tools for autonomy mode
tools = resolve_profiles("autonomy")
# Combine multiple profiles
tools = resolve_profiles("file_ops", "web", "shell")
# Register custom profile (wrapper/plugins)
from praisonaiagents.tools.profiles import register_profile, ToolProfile
register_profile(ToolProfile(name="custom", tools=["my_tool"]))
"""
from dataclasses import dataclass, field
from typing import Dict, List
@dataclass
class ToolProfile:
"""Named, extensible collection of tool names.
Attributes:
name: Profile identifier (e.g., "file_ops", "shell", "autonomy")
tools: List of tool name strings (resolved via TOOL_MAPPINGS)
description: Human-readable description of the profile
Example:
profile = ToolProfile(
name="file_ops",
tools=["read_file", "write_file", "list_files"],
description="File system operations"
)
"""
name: str
tools: List[str] = field(default_factory=list)
description: str = ""
# Built-in profiles (SDK ships with these)
BUILTIN_PROFILES: Dict[str, ToolProfile] = {
"code_intelligence": ToolProfile(
name="code_intelligence",
tools=["ast_grep_search", "ast_grep_rewrite", "ast_grep_scan"],
description="Structural code search and rewrite using ast-grep",
),
"file_ops": ToolProfile(
name="file_ops",
tools=[
"read_file", "write_file", "list_files",
"copy_file", "move_file", "delete_file", "get_file_info"
],
description="File system operations",
),
"shell": ToolProfile(
name="shell",
tools=["execute_command", "list_processes", "get_system_info"],
description="Shell and system tools",
),
"web": ToolProfile(
name="web",
tools=["internet_search", "search_web", "web_crawl"],
description="Web search and crawling",
),
"code_exec": ToolProfile(
name="code_exec",
tools=["execute_code", "analyze_code", "format_code", "lint_code"],
description="Python code execution and analysis",
),
"schedule": ToolProfile(
name="schedule",
tools=["schedule_add", "schedule_list", "schedule_remove"],
description="Agent-centric scheduling",
),
}
def _build_autonomy_tools() -> List[str]:
"""Build the autonomy profile tools list (deduped)."""
tools = []
for profile_name in ["code_intelligence", "file_ops", "shell", "web"]:
profile = BUILTIN_PROFILES[profile_name]
for tool in profile.tools:
if tool not in tools:
tools.append(tool)
return tools
# Composite autonomy profile - combines multiple profiles
AUTONOMY_PROFILE = ToolProfile(
name="autonomy",
tools=_build_autonomy_tools(),
description="Default tools for autonomous agents (file_ops + shell + web + code_intelligence)",
)
# Extension registry for custom profiles (wrapper/plugins use this)
_custom_profiles: Dict[str, ToolProfile] = {}
def register_profile(profile: ToolProfile) -> None:
"""Register a custom tool profile.
Custom profiles override built-in profiles with the same name.
This allows the wrapper to extend or replace SDK profiles.
Args:
profile: ToolProfile to register
Example:
# Wrapper registers ACP/LSP tools
register_profile(ToolProfile(
name="acp",
tools=["acp_create_file", "acp_edit_file"],
))
"""
_custom_profiles[profile.name] = profile
def get_profile(name: str) -> ToolProfile:
"""Get a tool profile by name.
Custom profiles take precedence over built-in profiles.
Args:
name: Profile name
Returns:
ToolProfile instance
Raises:
KeyError: If profile not found
"""
if name in _custom_profiles:
return _custom_profiles[name]
if name in BUILTIN_PROFILES:
return BUILTIN_PROFILES[name]
if name == "autonomy":
return AUTONOMY_PROFILE
raise KeyError(f"Unknown tool profile: {name}")
def resolve_profiles(*names: str) -> List[str]:
"""Resolve multiple profile names to a flat tool list.
Tools are deduplicated - each tool appears only once.
Args:
*names: Profile names to resolve
Returns:
List of tool name strings (deduped)
Raises:
KeyError: If any profile not found
Example:
# Get tools for autonomy + custom ACP
tools = resolve_profiles("autonomy", "acp")
"""
tools: List[str] = []
for name in names:
profile = get_profile(name)
for tool in profile.tools:
if tool not in tools:
tools.append(tool)
return tools
def list_profiles() -> List[str]:
"""List all available profile names.
Returns:
List of profile names (built-in + custom + autonomy)
"""
names = set(BUILTIN_PROFILES.keys())
names.update(_custom_profiles.keys())
names.add("autonomy")
return sorted(names)
+6 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.15"
version = "1.5.20"
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()
@@ -0,0 +1,363 @@
"""
TDD Tests for ToolProfile system.
Tests the ToolProfile dataclass, built-in profiles, and profile resolution.
"""
import pytest
class TestToolProfileDataclass:
"""Test ToolProfile dataclass structure."""
def test_tool_profile_has_required_fields(self):
"""ToolProfile should have name, tools, and description fields."""
from praisonaiagents.tools.profiles import ToolProfile
profile = ToolProfile(
name="test",
tools=["tool1", "tool2"],
description="Test profile"
)
assert profile.name == "test"
assert profile.tools == ["tool1", "tool2"]
assert profile.description == "Test profile"
def test_tool_profile_description_optional(self):
"""ToolProfile description should default to empty string."""
from praisonaiagents.tools.profiles import ToolProfile
profile = ToolProfile(name="test", tools=["tool1"])
assert profile.description == ""
def test_tool_profile_is_dataclass(self):
"""ToolProfile should be a dataclass."""
from dataclasses import is_dataclass
from praisonaiagents.tools.profiles import ToolProfile
assert is_dataclass(ToolProfile)
class TestBuiltinProfiles:
"""Test built-in profiles are defined correctly."""
def test_builtin_profiles_dict_exists(self):
"""BUILTIN_PROFILES dict should exist."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert isinstance(BUILTIN_PROFILES, dict)
assert len(BUILTIN_PROFILES) > 0
def test_code_intelligence_profile_exists(self):
"""code_intelligence profile should include ast-grep tools."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert "code_intelligence" in BUILTIN_PROFILES
profile = BUILTIN_PROFILES["code_intelligence"]
assert "ast_grep_search" in profile.tools
assert "ast_grep_rewrite" in profile.tools
assert "ast_grep_scan" in profile.tools
def test_file_ops_profile_exists(self):
"""file_ops profile should include file operation tools."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert "file_ops" in BUILTIN_PROFILES
profile = BUILTIN_PROFILES["file_ops"]
assert "read_file" in profile.tools
assert "write_file" in profile.tools
assert "list_files" in profile.tools
def test_shell_profile_exists(self):
"""shell profile should include shell tools."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert "shell" in BUILTIN_PROFILES
profile = BUILTIN_PROFILES["shell"]
assert "execute_command" in profile.tools
def test_web_profile_exists(self):
"""web profile should include web search tools."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert "web" in BUILTIN_PROFILES
profile = BUILTIN_PROFILES["web"]
assert "internet_search" in profile.tools or "search_web" in profile.tools
def test_code_exec_profile_exists(self):
"""code_exec profile should include code execution tools."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert "code_exec" in BUILTIN_PROFILES
profile = BUILTIN_PROFILES["code_exec"]
assert "execute_code" in profile.tools
def test_schedule_profile_exists(self):
"""schedule profile should include schedule tools."""
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
assert "schedule" in BUILTIN_PROFILES
profile = BUILTIN_PROFILES["schedule"]
assert "schedule_add" in profile.tools
assert "schedule_list" in profile.tools
assert "schedule_remove" in profile.tools
class TestAutonomyProfile:
"""Test the composite AUTONOMY_PROFILE."""
def test_autonomy_profile_exists(self):
"""AUTONOMY_PROFILE should exist."""
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
assert AUTONOMY_PROFILE is not None
assert AUTONOMY_PROFILE.name == "autonomy"
def test_autonomy_profile_includes_file_ops(self):
"""AUTONOMY_PROFILE should include file_ops tools."""
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
assert "read_file" in AUTONOMY_PROFILE.tools
assert "write_file" in AUTONOMY_PROFILE.tools
def test_autonomy_profile_includes_shell(self):
"""AUTONOMY_PROFILE should include shell tools."""
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
assert "execute_command" in AUTONOMY_PROFILE.tools
def test_autonomy_profile_includes_web(self):
"""AUTONOMY_PROFILE should include web tools."""
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
assert "internet_search" in AUTONOMY_PROFILE.tools or "search_web" in AUTONOMY_PROFILE.tools
def test_autonomy_profile_includes_code_intelligence(self):
"""AUTONOMY_PROFILE should include code_intelligence tools."""
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
assert "ast_grep_search" in AUTONOMY_PROFILE.tools
def test_autonomy_profile_has_many_tools(self):
"""AUTONOMY_PROFILE should have 15+ tools (not just 3)."""
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
assert len(AUTONOMY_PROFILE.tools) >= 15, f"Expected 15+ tools, got {len(AUTONOMY_PROFILE.tools)}"
class TestProfileRegistry:
"""Test profile registration and retrieval."""
def test_get_profile_returns_builtin(self):
"""get_profile should return built-in profiles."""
from praisonaiagents.tools.profiles import get_profile
profile = get_profile("file_ops")
assert profile.name == "file_ops"
assert "read_file" in profile.tools
def test_get_profile_raises_for_unknown(self):
"""get_profile should raise KeyError for unknown profiles."""
from praisonaiagents.tools.profiles import get_profile
with pytest.raises(KeyError):
get_profile("nonexistent_profile")
def test_register_profile_adds_custom(self):
"""register_profile should add custom profiles."""
from praisonaiagents.tools.profiles import (
ToolProfile, register_profile, get_profile, _custom_profiles
)
# Clean up any previous test registrations
if "test_custom" in _custom_profiles:
del _custom_profiles["test_custom"]
custom = ToolProfile(
name="test_custom",
tools=["custom_tool1", "custom_tool2"],
description="Test custom profile"
)
register_profile(custom)
retrieved = get_profile("test_custom")
assert retrieved.name == "test_custom"
assert "custom_tool1" in retrieved.tools
# Cleanup
del _custom_profiles["test_custom"]
def test_custom_profile_overrides_builtin(self):
"""Custom profiles should override built-in profiles with same name."""
from praisonaiagents.tools.profiles import (
ToolProfile, register_profile, get_profile, _custom_profiles
)
# Register a custom profile with same name as builtin
custom = ToolProfile(
name="file_ops",
tools=["custom_read", "custom_write"],
description="Custom file ops"
)
register_profile(custom)
retrieved = get_profile("file_ops")
assert "custom_read" in retrieved.tools
# Cleanup - restore original
del _custom_profiles["file_ops"]
class TestResolveProfiles:
"""Test resolve_profiles function."""
def test_resolve_single_profile(self):
"""resolve_profiles should return tools from a single profile."""
from praisonaiagents.tools.profiles import resolve_profiles
tools = resolve_profiles("file_ops")
assert "read_file" in tools
assert "write_file" in tools
def test_resolve_multiple_profiles(self):
"""resolve_profiles should combine tools from multiple profiles."""
from praisonaiagents.tools.profiles import resolve_profiles
tools = resolve_profiles("file_ops", "shell")
assert "read_file" in tools
assert "execute_command" in tools
def test_resolve_profiles_deduplicates(self):
"""resolve_profiles should deduplicate tools."""
from praisonaiagents.tools.profiles import resolve_profiles
# autonomy includes file_ops, so calling both should not duplicate
tools = resolve_profiles("autonomy")
# Count occurrences of read_file
count = tools.count("read_file")
assert count == 1, f"Expected 1 occurrence of read_file, got {count}"
def test_resolve_profiles_returns_list(self):
"""resolve_profiles should return a list."""
from praisonaiagents.tools.profiles import resolve_profiles
tools = resolve_profiles("shell")
assert isinstance(tools, list)
def test_resolve_profiles_raises_for_unknown(self):
"""resolve_profiles should raise KeyError for unknown profiles."""
from praisonaiagents.tools.profiles import resolve_profiles
with pytest.raises(KeyError):
resolve_profiles("nonexistent")
class TestAgentAutonomyWithProfiles:
"""Test that Agent with autonomy=True uses AUTONOMY_PROFILE tools."""
def test_agent_autonomy_gets_profile_tools_when_no_tools_provided(self):
"""Agent(autonomy=True) with no tools should get AUTONOMY_PROFILE tools."""
from praisonaiagents import Agent
# No tools provided - should inject AUTONOMY_PROFILE
agent = Agent(
name="test",
instructions="Test agent",
autonomy=True,
)
# Should have more than just 3 ast-grep tools
assert len(agent.tools) >= 15, f"Expected 15+ tools, got {len(agent.tools)}"
# Check for file_ops tools
tool_names = [getattr(t, '__name__', str(t)) for t in agent.tools]
# At least one of these should be present
has_file_tool = any(name in tool_names for name in ['read_file', 'write_file', 'list_files'])
assert has_file_tool or len(agent.tools) >= 15, f"Expected file tools, got: {tool_names}"
def test_agent_autonomy_does_not_inject_when_tools_provided(self):
"""Agent(autonomy=True) with tools should NOT inject AUTONOMY_PROFILE (avoid duplicates)."""
from praisonaiagents import Agent
def my_tool():
"""My custom tool."""
return "custom"
# Tools provided - should NOT inject AUTONOMY_PROFILE
agent = Agent(
name="test",
instructions="Test agent",
tools=[my_tool],
autonomy=True,
)
# Should only have the user-provided tool, not AUTONOMY_PROFILE
assert len(agent.tools) == 1, f"Expected 1 tool (user-provided), got {len(agent.tools)}"
assert my_tool in agent.tools
def test_agent_custom_default_tools_override(self):
"""Agent with custom default_tools should use those instead."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
def custom_tool():
"""Custom tool."""
return "custom"
config = AutonomyConfig(default_tools=[custom_tool])
agent = Agent(
name="test",
instructions="Test agent",
autonomy=config,
)
# Should have only the custom tool
assert custom_tool in agent.tools
# Should NOT have the full autonomy profile
assert len(agent.tools) == 1
def test_agent_empty_default_tools_disables_injection(self):
"""Agent with default_tools=[] should have no auto-injected tools."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig(default_tools=[])
agent = Agent(
name="test",
instructions="Test agent",
autonomy=config,
)
# Should have no tools
assert len(agent.tools) == 0
class TestProfileExports:
"""Test that profiles are properly exported."""
def test_import_from_tools_package(self):
"""ToolProfile should be importable from praisonaiagents.tools."""
from praisonaiagents.tools import ToolProfile
assert ToolProfile is not None
def test_import_register_profile(self):
"""register_profile should be importable from praisonaiagents.tools."""
from praisonaiagents.tools import register_profile
assert callable(register_profile)
def test_import_resolve_profiles(self):
"""resolve_profiles should be importable from praisonaiagents.tools."""
from praisonaiagents.tools import resolve_profiles
assert callable(resolve_profiles)
def test_import_autonomy_profile(self):
"""AUTONOMY_PROFILE should be importable from praisonaiagents.tools."""
from praisonaiagents.tools import AUTONOMY_PROFILE
assert AUTONOMY_PROFILE is not None
+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.20"
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.20.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.20.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,
@@ -58,7 +58,6 @@ def _execute_core(
agent_config = {
"name": request.agent_name,
"verbose": False,
}
if request.agent_instructions:
@@ -183,7 +182,6 @@ async def execute_async_stream(
agent_config = {
"name": request.agent_name,
"verbose": False,
}
if request.agent_instructions:
@@ -293,11 +293,18 @@ def create_agent_centric_tools(
JSON string with list of symbols and their locations
"""
async def _list():
result = await router.handle_query(
f"list all functions and classes in {file_path}",
file_path=file_path
)
return json.dumps(result.to_dict())
# Add 10s timeout to prevent LSP from blocking autonomy
try:
result = await asyncio.wait_for(
router.handle_query(
f"list all functions and classes in {file_path}",
file_path=file_path
),
timeout=10.0
)
return json.dumps(result.to_dict())
except asyncio.TimeoutError:
return json.dumps({"error": "LSP list_symbols timed out (10s)", "success": False})
return run_async(_list())
@@ -317,8 +324,15 @@ def create_agent_centric_tools(
if file_path:
query += f" in {file_path}"
result = await router.handle_query(query, file_path=file_path)
return json.dumps(result.to_dict())
# Add 10s timeout to prevent LSP from blocking autonomy
try:
result = await asyncio.wait_for(
router.handle_query(query, file_path=file_path),
timeout=10.0
)
return json.dumps(result.to_dict())
except asyncio.TimeoutError:
return json.dumps({"error": "LSP find_definition timed out (10s)", "success": False})
return run_async(_find())
@@ -338,8 +352,15 @@ def create_agent_centric_tools(
if file_path:
query += f" in {file_path}"
result = await router.handle_query(query, file_path=file_path)
return json.dumps(result.to_dict())
# Add 10s timeout to prevent LSP from blocking autonomy
try:
result = await asyncio.wait_for(
router.handle_query(query, file_path=file_path),
timeout=10.0
)
return json.dumps(result.to_dict())
except asyncio.TimeoutError:
return json.dumps({"error": "LSP find_references timed out (10s)", "success": False})
return run_async(_find())
@@ -358,8 +379,15 @@ def create_agent_centric_tools(
if file_path:
query += f" in {file_path}"
result = await router.handle_query(query, file_path=file_path)
return json.dumps(result.to_dict())
# Add 10s timeout to prevent LSP from blocking autonomy
try:
result = await asyncio.wait_for(
router.handle_query(query, file_path=file_path),
timeout=10.0
)
return json.dumps(result.to_dict())
except asyncio.TimeoutError:
return json.dumps({"error": "LSP diagnostics timed out (10s)", "success": False})
return run_async(_get())
@@ -0,0 +1,173 @@
"""
JSONL Display Mode for PraisonAI CLI.
Provides structured JSONL output matching Gemini CLI's STREAM_JSON format.
Each event is a single JSON line written to stderr.
Usage:
praisonai "prompt" --display jsonl 2>/tmp/events.jsonl
Event Types:
- init: Session start with model info
- tool_use: Tool call with parameters
- tool_result: Tool completion with status/output
- llm_start: LLM call start
- llm_end: LLM call end with metrics
- autonomy_step: Autonomy iteration info (PraisonAI unique)
- error: Error event
- result: Final result with stats
"""
import json
import sys
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional, TextIO
class JsonlDisplay:
"""JSONL event emitter matching Gemini CLI's STREAM_JSON format.
Thread-safe for multi-agent execution.
"""
def __init__(self, file: TextIO = None):
self.file = file or sys.stderr
self._start_time = time.time()
self._tool_calls = 0
self._tokens_in = 0
self._tokens_out = 0
def emit(self, event_type: str, **kwargs) -> None:
"""Emit a JSONL event. Thread-safe."""
event = {
"type": event_type,
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
# Filter None values
event.update({k: v for k, v in kwargs.items() if v is not None})
try:
self.file.write(json.dumps(event, default=str) + "\n")
self.file.flush()
except Exception:
pass # Silently ignore write errors
def on_init(self, model: str = None, session_id: str = None) -> None:
"""Emit init event at session start."""
self.emit("init", model=model, session_id=session_id)
def on_tool_call(
self,
message: str = None,
tool_name: str = None,
tool_input: Dict[str, Any] = None,
tool_output: str = None,
**kwargs
) -> None:
"""Callback for tool calls - emits tool_use or tool_result."""
if tool_name:
if tool_output is None:
# Tool starting
self._tool_calls += 1
self.emit("tool_use", tool=tool_name, args=tool_input)
else:
# Tool completed
output_str = str(tool_output)[:500] if tool_output else None
self.emit("tool_result", tool=tool_name, status="ok", output=output_str)
def on_llm_start(self, model: str = None, agent_name: str = None, **kwargs) -> None:
"""Callback for LLM call start."""
self.emit("llm_start", model=model, agent=agent_name)
def on_llm_end(
self,
model: str = None,
tokens_in: int = 0,
tokens_out: int = 0,
cost: float = None,
latency_ms: float = None,
**kwargs
) -> None:
"""Callback for LLM call end with metrics."""
self._tokens_in += tokens_in
self._tokens_out += tokens_out
self.emit(
"llm_end",
model=model,
tokens_in=tokens_in,
tokens_out=tokens_out,
cost=cost,
latency_ms=latency_ms
)
def on_error(self, message: str = None, **kwargs) -> None:
"""Callback for errors."""
if message:
self.emit("error", severity="error", message=str(message))
def on_interaction(self, response: str = None, agent_name: str = None, **kwargs) -> None:
"""Callback for agent response."""
if response:
# Truncate for JSONL
self.emit("message", role="assistant", content=response[:2000], agent=agent_name)
# P3/G2: Autonomy-specific events (PraisonAI unique!)
def on_autonomy_iteration(
self,
iteration: int = None,
max_iterations: int = None,
stage: str = None,
**kwargs
) -> None:
"""Callback for autonomy iteration."""
self.emit("autonomy_step", iteration=iteration, max=max_iterations, stage=stage)
def on_autonomy_stage_change(
self,
from_stage: str = None,
to_stage: str = None,
**kwargs
) -> None:
"""Callback for autonomy stage escalation."""
self.emit("autonomy_stage", from_stage=from_stage, to_stage=to_stage)
def on_autonomy_doom_loop(
self,
iteration: int = None,
recovery_action: str = None,
**kwargs
) -> None:
"""Callback for doom loop detection."""
self.emit("autonomy_doom_loop", iteration=iteration, recovery=recovery_action)
def on_complete(
self,
reason: str = None,
iterations: int = None,
duration_ms: float = None,
**kwargs
) -> None:
"""Emit final result event with stats."""
duration = duration_ms or (time.time() - self._start_time) * 1000
self.emit(
"result",
status="success" if reason in ("goal", "promise") else "stopped",
reason=reason,
iterations=iterations,
duration_ms=duration,
stats={
"total_tokens": self._tokens_in + self._tokens_out,
"input_tokens": self._tokens_in,
"output_tokens": self._tokens_out,
"tool_calls": self._tool_calls,
}
)
def create_jsonl_display(file: TextIO = None) -> JsonlDisplay:
"""Create a JSONL display instance."""
return JsonlDisplay(file=file)
__all__ = ["JsonlDisplay", "create_jsonl_display"]
@@ -0,0 +1,516 @@
"""
Editor-Style Display for PraisonAI CLI.
Provides a user-friendly display format that is easy to understand:
- Clear action descriptions with icons
- Human-readable tool names
- Progress indicators
- Summarized results (no raw JSON)
- Step-by-step progress
Designed for non-programmers and beginners to understand what the agent is doing.
Usage:
from praisonai.cli.features.editor_display import EditorDisplay
display = EditorDisplay()
display.action("Searching the web for information...")
display.step(1, 10, "Finding Django latest version")
display.success("Found 5 results")
"""
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from enum import Enum
class BlockType(Enum):
"""Types of display blocks."""
NARRATIVE = "narrative"
COMMAND = "command"
SUMMARY = "summary"
ACTION = "action"
CODE = "code"
LIST = "list"
@dataclass
class DisplayBlock:
"""A single display block."""
type: BlockType
content: str
title: Optional[str] = None
items: List[str] = field(default_factory=list)
output: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
# Human-readable tool name mappings
TOOL_LABELS = {
'internet_search': ('🔍', 'Searching the web'),
'search_web': ('🔍', 'Searching the web'),
'web_search': ('🔍', 'Searching the web'),
'read_file': ('📖', 'Reading file'),
'write_file': ('📝', 'Writing file'),
'create_file': ('📄', 'Creating file'),
'acp_create_file': ('📄', 'Creating file'),
'acp_edit_file': ('✏️', 'Editing file'),
'acp_delete_file': ('🗑️', 'Deleting file'),
'execute_command': ('⚡', 'Running command'),
'acp_execute_command': ('⚡', 'Running command'),
'list_files': ('📂', 'Listing files'),
'get_system_info': ('💻', 'Getting system info'),
'lsp_get_diagnostics': ('🔬', 'Analyzing code'),
'lsp_list_symbols': ('🔎', 'Finding code symbols'),
'lsp_find_definition': ('📍', 'Finding definition'),
'lsp_find_references': ('🔗', 'Finding references'),
}
class EditorDisplay:
"""
User-friendly display for CLI output.
Designed for non-programmers and beginners with:
- Clear action descriptions with icons
- Human-readable tool names (not technical names)
- Progress indicators (Step X of Y)
- Summarized results (no raw JSON)
- Success/failure indicators
"""
def __init__(self, console=None, use_rich: bool = True):
"""
Initialize the display.
Args:
console: Optional Rich console instance
use_rich: Whether to use Rich formatting (default: True)
"""
self._use_rich = use_rich
self._console = console
self._blocks: List[DisplayBlock] = []
self._start_time = time.time()
self._step_count = 0 # Track number of steps for progress
if use_rich and console is None:
try:
from rich.console import Console
self._console = Console()
except ImportError:
self._use_rich = False
def narrative(self, text: str):
"""
Display a narrative block (agent thinking/explanation).
Args:
text: The narrative text
"""
block = DisplayBlock(type=BlockType.NARRATIVE, content=text)
self._blocks.append(block)
self._render_narrative(text)
def command(self, cmd: str, output: str = None, exit_code: int = 0):
"""
Display a command execution block with output.
Args:
cmd: The command that was executed
output: The command output
exit_code: Exit code (0 = success)
"""
block = DisplayBlock(
type=BlockType.COMMAND,
content=cmd,
output=output,
metadata={"exit_code": exit_code}
)
self._blocks.append(block)
self._render_command(cmd, output, exit_code)
def summary(self, title: str, items: List[str] = None, content: str = None):
"""
Display a summary section with header.
Args:
title: Section title (e.g., "Problem Identified", "Current Status")
items: Optional list of bullet points
content: Optional paragraph content
"""
block = DisplayBlock(
type=BlockType.SUMMARY,
title=title,
content=content or "",
items=items or []
)
self._blocks.append(block)
self._render_summary(title, items, content)
def action(self, title: str, details: List[str] = None):
"""
Display an action summary block.
Args:
title: Action title (e.g., "Changes applied")
details: List of detail items
"""
block = DisplayBlock(
type=BlockType.ACTION,
title=title,
items=details or []
)
self._blocks.append(block)
self._render_action(title, details)
def code(self, code: str, language: str = ""):
"""
Display a code block.
Args:
code: The code content
language: Programming language for syntax highlighting
"""
block = DisplayBlock(
type=BlockType.CODE,
content=code,
metadata={"language": language}
)
self._blocks.append(block)
self._render_code(code, language)
def list_items(self, items: List[str], title: str = None):
"""
Display a bulleted list.
Args:
items: List items
title: Optional title
"""
block = DisplayBlock(
type=BlockType.LIST,
title=title,
items=items
)
self._blocks.append(block)
self._render_list(items, title)
def tool_call(self, tool_name: str, args: Dict[str, Any] = None, result: str = None, duration: float = None):
"""
Display a tool call in user-friendly format.
Args:
tool_name: Name of the tool
args: Tool arguments
result: Tool result
duration: Execution duration in seconds
"""
# Increment step counter
self._step_count += 1
# Get human-readable label
icon, label = TOOL_LABELS.get(tool_name, ('🔧', f'Using {tool_name}'))
# Format the action description with step number
step_prefix = f"Step {self._step_count}: "
if args:
# Extract key argument for context
if 'query' in args:
action = f"{step_prefix}{icon} {label} for \"{args['query']}\""
elif 'filepath' in args:
action = f"{step_prefix}{icon} {label}: {args['filepath']}"
elif 'file_path' in args:
action = f"{step_prefix}{icon} {label}: {args['file_path']}"
elif 'directory' in args:
action = f"{step_prefix}{icon} {label} in {args['directory']}"
elif 'command' in args:
action = f"{step_prefix}{icon} {label}: {args['command']}"
elif 'content' in args:
action = f"{step_prefix}{icon} {label}"
else:
action = f"{step_prefix}{icon} {label}"
else:
action = f"{step_prefix}{icon} {label}"
# Format result for display (hide raw JSON, show user-friendly summary)
display_result = None
if result:
result_str = str(result)
# Check if it's JSON and summarize
if result_str.startswith('[') or result_str.startswith('{'):
try:
import json
data = json.loads(result_str)
if isinstance(data, list):
# List of items - show count
display_result = f"✓ Found {len(data)} items"
elif isinstance(data, dict):
# Dict - check for common patterns
if data.get('success') is True:
if data.get('file_created'):
display_result = f"✓ Created: {data['file_created']}"
else:
display_result = "✓ Success"
elif data.get('success') is False:
display_result = f"⚠ Failed: {data.get('error', 'Unknown error')[:80]}"
elif data.get('error'):
display_result = f"⚠ {data['error'][:80]}"
elif data.get('stdout'):
# Command output - show first line
stdout = data['stdout'].strip()
first_line = stdout.split('\n')[0][:80]
display_result = f"✓ {first_line}"
elif data.get('exit_code') == 0:
display_result = "✓ Command completed"
else:
display_result = "✓ Done"
except (json.JSONDecodeError, TypeError, ValueError):
# Not valid JSON - don't show raw data
display_result = "✓ Done"
elif result_str.lower() == 'true':
display_result = "✓ Success"
elif result_str.lower() == 'false':
display_result = "⚠ Failed"
else:
# Plain text result - show if short
if len(result_str) <= 80 and '\n' not in result_str:
display_result = result_str
else:
# Long text - just confirm done
display_result = "✓ Done"
self.command(action, display_result)
def thinking(self, text: str):
"""
Display agent thinking/reasoning.
Args:
text: The thinking text
"""
self.narrative(text)
def result(self, text: str):
"""
Display final result.
Args:
text: The result text
"""
if self._use_rich:
self._console.print()
self._console.print(text)
self._console.print()
else:
print()
print(text)
print()
def elapsed_time(self) -> float:
"""Get elapsed time since display started."""
return time.time() - self._start_time
def get_blocks(self) -> List[DisplayBlock]:
"""Get all display blocks."""
return self._blocks.copy()
def to_markdown(self) -> str:
"""
Export all blocks as Markdown.
Returns:
Markdown string
"""
lines = []
for block in self._blocks:
if block.type == BlockType.NARRATIVE:
lines.append(block.content)
lines.append("")
elif block.type == BlockType.COMMAND:
lines.append("```")
lines.append(block.content)
if block.output:
lines.append(block.output)
lines.append("```")
lines.append("")
elif block.type == BlockType.SUMMARY:
if block.title:
lines.append(f"## {block.title}")
if block.content:
lines.append(block.content)
for item in block.items:
lines.append(f"- {item}")
lines.append("")
elif block.type == BlockType.ACTION:
if block.title:
lines.append(f"**{block.title}**")
for item in block.items:
lines.append(f"- {item}")
lines.append("")
elif block.type == BlockType.CODE:
lang = block.metadata.get("language", "")
lines.append(f"```{lang}")
lines.append(block.content)
lines.append("```")
lines.append("")
elif block.type == BlockType.LIST:
if block.title:
lines.append(f"**{block.title}**")
for item in block.items:
lines.append(f"- {item}")
lines.append("")
return "\n".join(lines)
# =========================================================================
# Private rendering methods
# =========================================================================
def _render_narrative(self, text: str):
"""Render narrative block."""
if self._use_rich:
self._console.print()
self._console.print(text)
else:
print()
print(text)
def _render_command(self, cmd: str, output: str = None, exit_code: int = 0):
"""Render command block."""
if self._use_rich:
# Command line in dim style
self._console.print(f"[dim]{cmd}[/dim]")
# Output if present
if output:
# Truncate very long output
lines = output.split("\n")
if len(lines) > 20:
output = "\n".join(lines[:20]) + f"\n... ({len(lines) - 20} more lines)"
self._console.print(output)
else:
print(cmd)
if output:
print(output)
def _render_summary(self, title: str, items: List[str] = None, content: str = None):
"""Render summary block."""
if self._use_rich:
self._console.print()
self._console.print(f"[bold]{title}[/bold]")
if content:
self._console.print(content)
if items:
for item in items:
self._console.print(f"- {item}")
else:
print()
print(title)
print("-" * len(title))
if content:
print(content)
if items:
for item in items:
print(f"- {item}")
def _render_action(self, title: str, details: List[str] = None):
"""Render action block."""
if self._use_rich:
self._console.print()
self._console.print(f"[bold green]{title}[/bold green]")
if details:
for detail in details:
self._console.print(f" {detail}")
else:
print()
print(title)
if details:
for detail in details:
print(f" {detail}")
def _render_code(self, code: str, language: str = ""):
"""Render code block."""
if self._use_rich:
try:
from rich.syntax import Syntax
syntax = Syntax(code, language or "text", theme="monokai", line_numbers=False)
self._console.print(syntax)
except Exception:
self._console.print(f"```{language}")
self._console.print(code)
self._console.print("```")
else:
print(f"```{language}")
print(code)
print("```")
def _render_list(self, items: List[str], title: str = None):
"""Render list block."""
if self._use_rich:
if title:
self._console.print(f"[bold]{title}[/bold]")
for item in items:
self._console.print(f"- {item}")
else:
if title:
print(title)
for item in items:
print(f"- {item}")
def create_editor_callbacks(display: EditorDisplay) -> Dict[str, Callable]:
"""
Create SDK callbacks that use EditorDisplay.
Args:
display: EditorDisplay instance
Returns:
Dict of callback functions to register with SDK
"""
def tool_call_callback(message=None, tool_name=None, tool_input=None, tool_output=None, **kwargs):
"""Callback for tool calls."""
if tool_name:
display.tool_call(
tool_name=tool_name,
args=tool_input,
result=str(tool_output)[:500] if tool_output else None
)
def thinking_callback(content=None, **kwargs):
"""Callback for agent thinking."""
if content:
display.thinking(content)
def response_callback(content=None, **kwargs):
"""Callback for agent response."""
if content:
display.result(content)
def autonomy_iteration_callback(iteration=None, max_iterations=None, stage=None, **kwargs):
"""Callback for autonomy iterations."""
if iteration and max_iterations:
display.narrative(f"Iteration {iteration}/{max_iterations}: {stage or 'processing'}...")
return {
"tool_call": tool_call_callback,
"thinking": thinking_callback,
"response": response_callback,
"autonomy_iteration": autonomy_iteration_callback,
}
@@ -18,6 +18,7 @@ class OutputMode(Enum):
COMPACT = "compact"
VERBOSE = "verbose"
QUIET = "quiet"
EDITOR = "editor" # User-friendly conversation format
@dataclass
@@ -42,6 +43,8 @@ def get_default_mode() -> OutputMode:
return OutputMode.COMPACT
elif env_mode == "quiet":
return OutputMode.QUIET
elif env_mode == "editor":
return OutputMode.EDITOR
return OutputMode.VERBOSE
+5
View File
@@ -99,11 +99,16 @@ def is_legacy_invocation(argv: List[str]) -> bool:
'-p', '--autonomy', '--trust',
'--sandbox', '--external-agent', '--compare', '--interval', '--timeout',
'--max-cost', '--rpm', '--tpm', '--temperature',
# Display flags (verbosity ladder + machine output)
'--output', '--flow',
]
for arg in argv:
if arg in legacy_flags:
return True
# Handle -vv, -qq, -vvv etc. (count flags not in Typer)
if len(arg) > 2 and arg.startswith('-') and not arg.startswith('--') and arg[1:] in ('vv', 'qq', 'vvv', 'qqq'):
return True
# Check if first arg is a legacy command not in Typer
if first_arg in LEGACY_COMMANDS and first_arg not in TYPER_COMMANDS:
+274 -259
View File
@@ -260,8 +260,15 @@ class PraisonAI:
try:
# Check if stdin is not a terminal (i.e., has piped input)
if not sys.stdin.isatty():
stdin_content = sys.stdin.read().strip()
return stdin_content if stdin_content else None
import select
# Non-blocking check: only read if data is actually available.
# Without this, sys.stdin.read() blocks forever in non-TTY
# environments (subprocesses, CI/CD, IDE terminals, Docker)
# where stdin is a pipe with no EOF.
if select.select([sys.stdin], [], [], 0.0)[0]:
stdin_content = sys.stdin.read().strip()
return stdin_content if stdin_content else None
return None
except Exception:
# If there's any error reading stdin, ignore it
pass
@@ -831,7 +838,14 @@ class PraisonAI:
parser.add_argument("--no-acp", action="store_true", help="Disable ACP tools (agentic file operations with plan/approve/apply)")
parser.add_argument("--no-lsp", action="store_true", help="Disable LSP tools (code intelligence: symbols, definitions, references)")
parser.add_argument("--save", "-s", action="store_true", help="Save research output to file (output/research/)")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output for research")
parser.add_argument("-v", "--verbose", action="count", default=0,
help="Increase verbosity (-v=verbose, -vv=debug)")
parser.add_argument("-q", "--quiet", action="count", default=0,
help="Decrease verbosity (-q=quiet, -qq=silent)")
parser.add_argument("--output", type=str, choices=["json", "jsonl", "editor"], dest="output_format",
help="Output format (json, jsonl, or editor for user-friendly conversation-style)")
parser.add_argument("--flow", action="store_true",
help="Show visual agent→tool flow chart")
parser.add_argument("--web", "--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
@@ -938,8 +952,19 @@ class PraisonAI:
# Direct prompt flag - alternative to positional command
parser.add_argument("-p", "--prompt", type=str, dest="prompt_flag", help="Direct prompt to execute (alternative to positional argument)")
# Autonomy Mode - control AI action approval
parser.add_argument("--autonomy", type=str, choices=["suggest", "auto_edit", "full_auto"], help="Set autonomy mode for AI actions")
# Autonomy Mode - full_auto by default for multi-turn autonomous execution
# ACP tools disabled by default for speed; use --acp to enable
parser.add_argument("--autonomy", type=str, choices=["suggest", "auto_edit", "full_auto", "disable"],
default=None, help="Set autonomy mode. Only use for multi-turn tasks that need iterative execution.")
parser.add_argument("--acp", action="store_true",
help="Enable ACP tools in autonomy mode (slower but more powerful file operations)")
parser.add_argument("--lsp", action="store_true",
help="Enable LSP tools in autonomy mode (slower but provides code intelligence)")
# P8/G11: Tool timeout - prevent slow tools from blocking
parser.add_argument("--tool-timeout", type=int, default=60,
help="Timeout in seconds for each tool call (default: 60)")
# Tool Approval - control tool execution approval
parser.add_argument("--trust", action="store_true", help="Auto-approve all tool executions (skip approval prompts)")
@@ -3861,15 +3886,45 @@ Provide ONLY the commit message, no explanations."""
"backstory": "You are a helpful AI assistant"
}
# Set output mode based on --verbose flag
# Uses consolidated 'output' param instead of deprecated 'verbose'
if hasattr(self, 'args') and getattr(self.args, 'verbose', False):
# Autonomy Mode - full_auto by default for multi-turn autonomous execution
# ACP tools disabled by default for speed (~3s vs 174s+); use --acp to enable
autonomy_mode = getattr(self.args, 'autonomy', None) if hasattr(self, 'args') else None
if autonomy_mode and autonomy_mode not in ('disable', None):
agent_config["autonomy"] = {"level": autonomy_mode, "enabled": True}
# Set SDK output preset based on verbosity flags
# The display dispatcher handles CLI rendering; this controls SDK-level behavior
v = getattr(self.args, 'verbose', 0) if hasattr(self, 'args') else 0
if v >= 2:
agent_config["output"] = "verbose" # SDK debug-level detail
elif v >= 1:
agent_config["output"] = "verbose"
else:
agent_config["output"] = "minimal"
# Load default tools (same as interactive mode) unless --no-tools is set
# CRITICAL: For autonomy mode, disable slow ACP and LSP tools by default
# ACP tools go through complex orchestration (174s+ delays)
# LSP tools require starting language server (176s+ delays)
# Use --acp or --lsp flags to explicitly enable them.
if not getattr(self.args, 'no_tools', False):
# Check if autonomy mode is enabled - disable ACP/LSP by default for speed
is_autonomy = autonomy_mode and autonomy_mode not in ('disable', None)
use_acp = getattr(self.args, 'acp', False) # Explicit --acp flag
use_lsp = getattr(self.args, 'lsp', False) # Explicit --lsp flag
if is_autonomy:
if not use_acp:
# Force disable ACP for autonomy mode (use basic tools only)
# This makes full_auto fast (~3s vs 174s+)
self.args.no_acp = True
logging.debug("Autonomy mode: ACP tools disabled for speed (use --acp to enable)")
if not use_lsp:
# Force disable LSP for autonomy mode
# LSP requires starting language server (176s+ delays)
self.args.no_lsp = True
logging.debug("Autonomy mode: LSP tools disabled for speed (use --lsp to enable)")
default_tools = self._load_interactive_tools()
if default_tools:
agent_config["tools"] = default_tools
@@ -3934,6 +3989,11 @@ Provide ONLY the commit message, no explanations."""
if getattr(self.args, 'planning_reasoning', False):
agent_config["planning_reasoning"] = True
# P8/G11: Tool timeout - prevent slow tools from blocking
tool_timeout = getattr(self.args, 'tool_timeout', 60)
if tool_timeout and tool_timeout > 0:
agent_config["tool_timeout"] = tool_timeout
# Memory
if getattr(self.args, 'memory', False):
agent_config["memory"] = True
@@ -4211,16 +4271,179 @@ Provide ONLY the commit message, no explanations."""
else:
result = auto_rag.chat(prompt)
else:
# Run with minimal status display when verbose=False
is_verbose = agent_config.get("verbose", False)
if not is_verbose:
result = self._run_with_status_display(agent, prompt)
else:
# Try start method first, fallback to chat
# Resolve display mode from CLI flags
display_mode = self._resolve_display_mode()
if display_mode == 'silent':
# -qq: No output at all, exit code only
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
elif display_mode == 'quiet':
# -q: Result only, no spinners or status
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
if result is not None:
output = getattr(result, 'output', None) or (str(result) if result else None)
if output:
print(output)
elif display_mode == 'verbose':
# -v: SDK StatusOutput with timestamps and metrics
try:
from praisonaiagents.output.status import enable_status_output, disable_status_output
enable_status_output(show_timestamps=True, show_metrics=True)
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
disable_status_output()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
elif display_mode == 'debug':
# -vv: SDK TraceOutput with markdown rendering
try:
from praisonaiagents.output.trace import enable_trace_output, disable_trace_output
enable_trace_output(use_markdown=True)
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
disable_trace_output()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
elif display_mode == 'jsonl':
# --output jsonl: JSONL structured output for CI/CD
from .features.display_jsonl import JsonlDisplay
from praisonaiagents.main import register_display_callback as _reg_cb
jsonl = JsonlDisplay()
jsonl.on_init(model=agent_config.get('llm'))
_reg_cb('tool_call', jsonl.on_tool_call)
_reg_cb('llm_start', jsonl.on_llm_start)
_reg_cb('autonomy_iteration', jsonl.on_autonomy_iteration)
_reg_cb('autonomy_stage_change', jsonl.on_autonomy_stage_change)
_reg_cb('autonomy_doom_loop', jsonl.on_autonomy_doom_loop)
_reg_cb('autonomy_complete', lambda **kw: jsonl.on_complete(
reason=kw.get('completion_reason'),
iterations=kw.get('iterations'),
duration_ms=(kw.get('duration_seconds') or 0) * 1000,
))
start_time = time.time()
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
# Emit final result
reason = getattr(result, 'completion_reason', None) if hasattr(result, 'completion_reason') else 'complete'
iters = getattr(result, 'iterations', None) if hasattr(result, 'iterations') else None
jsonl.on_complete(reason=reason or 'complete', iterations=iters,
duration_ms=(time.time() - start_time) * 1000)
# Print result to stdout
if hasattr(result, 'output') and result.output:
print(result.output)
elif isinstance(result, str):
print(result)
elif display_mode == 'json':
# --output json: JSON envelope output
import json as json_mod
start_time = time.time()
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
output = result.output if hasattr(result, 'output') else str(result)
envelope = {
'result': output,
'model': agent_config.get('llm'),
'duration_ms': round((time.time() - start_time) * 1000),
}
if hasattr(result, 'completion_reason'):
envelope['completion_reason'] = result.completion_reason
if hasattr(result, 'iterations'):
envelope['iterations'] = result.iterations
print(json_mod.dumps(envelope, indent=2))
elif display_mode == 'flow':
# --flow: SDK FlowDisplay - visual agent→tool chart
try:
from praisonaiagents.flow_display import track_workflow
flow = track_workflow()
flow.start()
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
flow.stop()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
elif display_mode == 'editor':
# --output editor: User-friendly conversation format
from .features.editor_display import EditorDisplay, create_editor_callbacks
from praisonaiagents.main import register_display_callback as _reg_cb
editor_display = EditorDisplay()
callbacks = create_editor_callbacks(editor_display)
# Register callbacks
for event_type, callback in callbacks.items():
_reg_cb(event_type, callback)
# Run agent
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
# Display final result
output = result.output if hasattr(result, 'output') else str(result)
if output:
editor_display.result(output)
# Show summary
elapsed = editor_display.elapsed_time()
editor_display.summary("Completed", [
f"Duration: {elapsed:.1f}s",
f"Blocks: {len(editor_display.get_blocks())}",
])
else:
# Default: SDK status output — clean inline progress
# Shows: spinner + tool calls, no panels, no timestamps
try:
from praisonaiagents.output.status import enable_status_output, disable_status_output
enable_status_output(show_timestamps=False, show_metrics=False)
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
disable_status_output()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
else:
result = agent.chat(prompt)
# ===== POST-PROCESSING WITH NEW FEATURES =====
@@ -4404,257 +4627,49 @@ Now, {final_instruction.lower()}:"""
# Return the actual result for any downstream processing
return result.output
def _run_with_status_display(self, agent, prompt):
def _resolve_display_mode(self):
"""Map CLI flags to a display mode string.
Priority: --output > --flow > --display (deprecated) > -v/-q > default.
Returns one of: 'silent', 'quiet', 'verbose', 'debug', 'json', 'jsonl', 'flow', 'status', 'cursor'.
"""
Run agent with minimal status display (spinner + tool/handoff updates).
# Machine formats take highest priority
output_fmt = getattr(self.args, 'output_format', None)
if output_fmt:
return output_fmt # "json", "jsonl", or "cursor"
Shows:
- "Generating..." with spinner while processing
- Real-time tool call notifications via registered callback
- Agent handoff notifications
"""
import threading
import time
from rich.console import Console
from rich.live import Live
from rich.text import Text
# --flow is an independent feature flag
if getattr(self.args, 'flow', False) or getattr(self.args, 'flow_display', False):
return 'flow'
console = Console()
# Import callback registration from praisonaiagents
# Store in local variable for safe access throughout the function
_sync_display_callbacks = None
_register_display_callback = None
# Check Typer global state (for Typer subcommands)
try:
from praisonaiagents import register_display_callback, sync_display_callbacks
_sync_display_callbacks = sync_display_callbacks
_register_display_callback = register_display_callback
except ImportError:
# Fallback if callbacks not available - just run agent directly
try:
result = agent.start(prompt)
except AttributeError:
# Try chat method if start not available
result = agent.chat(prompt)
if result:
console.print(result)
elif result == "" or result is None:
console.print("[dim]No response generated[/dim]")
return result
# Get tool names for display
tool_names = []
if hasattr(agent, 'tools') and agent.tools:
for tool in agent.tools:
if hasattr(tool, '__name__'):
tool_names.append(tool.__name__)
elif hasattr(tool, 'name'):
tool_names.append(tool.name)
status_info = {
'status': 'Generating...',
'tool_calls': [],
'handoffs': [],
'done': False,
'result': None,
'error': None,
'start_time': time.time(),
'available_tools': tool_names,
'approval_pending': False, # Flag to pause Live display during approval
'live_instance': None # Reference to Live instance for stopping
}
def tool_call_callback(message):
"""Callback triggered when a tool is called."""
# Extract tool name from message
if "Calling function:" in message:
# Format: "Calling function: function_name"
parts = message.split("Calling function:")
if len(parts) > 1:
tool_name = parts[1].strip()
if tool_name and tool_name not in status_info['tool_calls']:
status_info['tool_calls'].append(tool_name)
status_info['status'] = f"Using {tool_name}..."
elif "Function " in message and " returned:" in message:
# Format: "Function function_name returned: ..."
# Tool execution completed, update status
status_info['status'] = "Processing result..."
# Register callback for tool calls (use local variable)
_register_display_callback('tool_call', tool_call_callback)
def build_status_display():
"""Build the status display text."""
elapsed = time.time() - status_info['start_time']
# Main status with spinner
text = Text()
text.append("⏳ ", style="cyan")
text.append(f"{status_info['status']} ", style="bold")
text.append(f"({elapsed:.1f}s)", style="dim")
# Show available tools on first line
if status_info['available_tools'] and not status_info['tool_calls']:
tools_str = ', '.join(status_info['available_tools'][:3])
if len(status_info['available_tools']) > 3:
tools_str += f" +{len(status_info['available_tools']) - 3} more"
text.append(f"\n 🔧 Tools: {tools_str}", style="dim")
# Show recent tool calls
if status_info['tool_calls']:
text.append("\n")
for tool in status_info['tool_calls'][-3:]: # Show last 3
text.append(f" ⚙ {tool}", style="dim yellow")
text.append("\n")
# Show handoffs
if status_info['handoffs']:
for handoff in status_info['handoffs'][-2:]: # Show last 2
text.append(f" → {handoff}", style="dim cyan")
text.append("\n")
return text
def run_agent():
"""Run the agent in background thread."""
try:
status_info['result'] = agent.start(prompt)
except Exception as e:
status_info['error'] = e
finally:
status_info['done'] = True
# Set up approval callback that stops Live display before prompting
# Only if not using --trust (which auto-approves everything)
if not getattr(self.args, 'trust', False):
from praisonaiagents.approval import set_approval_callback, ApprovalDecision
from rich.prompt import Confirm
from rich.panel import Panel
def cli_approval_with_live_pause(function_name, arguments, risk_level):
"""Approval callback that stops Live display before prompting."""
# Signal to stop Live display
status_info['approval_pending'] = True
# Wait a moment for Live to stop
time.sleep(0.2)
# Now show the approval prompt
risk_colors = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "blue"}
risk_color = risk_colors.get(risk_level, "white")
tool_info = f"[bold]Function:[/] {function_name}\n"
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{risk_level.upper()}[/{risk_color}]\n"
tool_info += "[bold]Arguments:[/]\n"
for key, value in arguments.items():
str_value = str(value)[:100] + "..." if len(str(value)) > 100 else str(value)
tool_info += f" {key}: {str_value}\n"
console.print(Panel(tool_info.strip(), title="🔒 Tool Approval Required", border_style=risk_color))
try:
approved = Confirm.ask(f"[{risk_color}]Execute this {risk_level} risk tool?[/{risk_color}]", default=False)
status_info['approval_pending'] = False
if approved:
console.print("[green]✅ Approved[/green]")
return ApprovalDecision(approved=True, reason="User approved")
else:
console.print("[red]❌ Denied[/red]")
console.print("[dim]Tip: Use --trust to auto-approve all tools[/dim]")
return ApprovalDecision(approved=False, reason="User denied")
except (KeyboardInterrupt, EOFError):
status_info['approval_pending'] = False
console.print("\n[red]❌ Cancelled[/red]")
return ApprovalDecision(approved=False, reason="User cancelled")
# Only set if not already set by --approve-level
if not getattr(self.args, 'approve_level', None):
set_approval_callback(cli_approval_with_live_pause)
# Start agent in background thread
thread = threading.Thread(target=run_agent, daemon=True)
thread.start()
# Show live status while processing
# Loop handles unlimited approval interruptions (each approval
# pauses the Live display, then we restart it afterwards)
try:
while not status_info['done']:
with Live(build_status_display(), console=console, refresh_per_second=4, transient=True) as live:
status_info['live_instance'] = live
while not status_info['done']:
# Check if approval is pending - stop Live to show prompt
if status_info['approval_pending']:
break
live.update(build_status_display())
time.sleep(0.1)
# If approval was pending, wait for it to complete then loop
# back to restart the Live display
while status_info['approval_pending']:
time.sleep(0.1)
except KeyboardInterrupt:
console.print("\n[dim]Interrupted[/dim]")
# Unregister callback (use local variable with None check)
if _sync_display_callbacks is not None and 'tool_call' in _sync_display_callbacks:
del _sync_display_callbacks['tool_call']
return None
# Wait for thread to complete (generous timeout for long-running tasks)
thread.join(timeout=5.0)
# Unregister callback to avoid memory leaks (use local variable with None check)
if _sync_display_callbacks is not None and 'tool_call' in _sync_display_callbacks:
del _sync_display_callbacks['tool_call']
# Handle result
if status_info['error']:
console.print(f"[red]Error: {status_info['error']}[/red]")
return None
# Show tool calls that were made (if any)
if status_info['tool_calls']:
console.print(f"[dim]Tools used: {', '.join(status_info['tool_calls'])}[/dim]")
# Get the result and print it directly here to ensure it's displayed
result = status_info['result']
# Handle AutonomyResult from run_autonomous()
display_text = None
if result is not None:
if hasattr(result, 'output') and result.output:
display_text = result.output
elif isinstance(result, str) and result:
display_text = result
elif result:
display_text = str(result)
if display_text:
console.print(display_text)
else:
# Last resort: extract from agent's chat history
extracted = None
if hasattr(agent, 'chat_history') and agent.chat_history:
for msg in reversed(agent.chat_history):
if msg.get('role') == 'assistant' and msg.get('content'):
extracted = msg['content']
break
if extracted:
console.print(extracted)
elif status_info['tool_calls']:
console.print("[dim]Task completed (tools executed successfully)[/dim]")
else:
console.print("[dim]No response generated[/dim]")
# Cleanup LSP/ACP runtime
try:
from .features.interactive_tools import cleanup_runtime
cleanup_runtime()
except Exception:
from .app import state as typer_state
if typer_state.quiet:
return 'quiet'
if hasattr(typer_state, 'output_format'):
if typer_state.output_format.value == 'json':
return 'json'
elif typer_state.output_format.value == 'stream-json':
return 'jsonl'
if typer_state.screen_reader:
return 'verbose' # Accessible: timestamps but no spinners
except (ImportError, AttributeError):
pass
return result
# Verbosity ladder: -v/-vv/-q/-qq
v = getattr(self.args, 'verbose', 0)
q = getattr(self.args, 'quiet', 0)
if q >= 2:
return 'silent'
if q >= 1:
return 'quiet'
if v >= 2:
return 'debug'
if v >= 1:
return 'verbose'
return 'editor' # Default: User-friendly editor-style output
def _handle_serve_command(self, args, unknown_args):
"""
+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.20 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.20"
+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.20",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
@@ -166,21 +166,23 @@ class TestHandleDirectPromptWithRewrite:
mock_agent.start.return_value = "result"
with patch.object(praison, '_rewrite_query_if_enabled', return_value="rewritten prompt") as mock_rewrite:
with patch.object(praison, '_run_with_status_display', return_value="result") as mock_run:
with patch('praisonai.cli.main.PRAISONAI_AVAILABLE', True):
# Mock praisonaiagents and all its submodules
mock_praisonaiagents = MagicMock()
mock_praisonaiagents.Agent = MagicMock(return_value=mock_agent)
mock_praisonaiagents.approval = MagicMock()
with patch('praisonai.cli.main.PRAISONAI_AVAILABLE', True):
# Mock praisonaiagents and all its submodules
mock_praisonaiagents = MagicMock()
mock_agent = MagicMock()
mock_agent.start.return_value = "result"
mock_praisonaiagents.Agent = MagicMock(return_value=mock_agent)
mock_praisonaiagents.approval = MagicMock()
with patch.dict('sys.modules', {
'praisonaiagents': mock_praisonaiagents,
'praisonaiagents.approval': mock_praisonaiagents.approval,
'praisonaiagents.output.status': MagicMock(),
}):
praison.handle_direct_prompt("original prompt")
with patch.dict('sys.modules', {
'praisonaiagents': mock_praisonaiagents,
'praisonaiagents.approval': mock_praisonaiagents.approval
}):
praison.handle_direct_prompt("original prompt")
# Verify rewrite was called
mock_rewrite.assert_called_once_with("original prompt")
# Verify rewrite was called
mock_rewrite.assert_called_once_with("original prompt")
class TestToolLoading:
+16 -97
View File
@@ -178,8 +178,7 @@ dependencies = [
{ name = "networkx" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" },
{ name = "shapely" },
{ name = "trimesh" },
]
@@ -1150,8 +1149,7 @@ automl = [
{ name = "lightgbm" },
{ name = "pandas" },
{ name = "scikit-learn" },
{ name = "scipy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" },
{ name = "xgboost" },
]
@@ -1432,27 +1430,11 @@ wheels = [
name = "h11"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.11.*'",
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.2.0"
@@ -1492,35 +1474,15 @@ wheels = [
name = "httpcore"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.11.*'",
"python_full_version < '3.11'",
]
dependencies = [
{ name = "certifi", marker = "python_full_version < '3.12'" },
{ name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
dependencies = [
{ name = "certifi", marker = "python_full_version >= '3.12'" },
{ name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.6.4"
@@ -1557,8 +1519,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore", version = "1.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
@@ -1901,8 +1862,7 @@ version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "scipy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/e6/41be1f8642257e21b4170e798c9a84e4268656ebfa3019586d82bfd281c9/lightgbm-4.5.0.tar.gz", hash = "sha256:e1cd7baf0318d4e308a26575a63a4635f08df866ad3622a9d8e3d71d9637a1ba", size = 1701072, upload-time = "2024-07-26T14:39:12.398Z" }
wheels = [
@@ -3731,7 +3691,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.20" },
{ 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 +3749,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.15"
version = "1.5.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -3798,9 +3758,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/73/d4/c14c1e79b4c5628a39d45075154b08599727138c6f6dc1fddf15e203f4fd/praisonaiagents-1.5.20.tar.gz", hash = "sha256:432a4247987a8f82929e10bce8e18c97bb48c12bd2d321c4b2fac61535026c95", size = 1217493, upload-time = "2026-03-03T11:05:45.964Z" }
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/ab/6e/95722039521743ccf6dac7d9f9007b4d990cdfc5b45c8859fc12265ff286/praisonaiagents-1.5.20-py3-none-any.whl", hash = "sha256:cae32fe7dbb16484cdc5bdea517c1224007c7eb65ba558d232e4519e4468c057", size = 1216814, upload-time = "2026-03-03T11:05:43.318Z" },
]
[[package]]
@@ -4742,8 +4702,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/19/5aa2002044afc297ecaf1e3517ed07bba4aece3b5613b5160c1212995fc8/scikit_learn-1.6.0.tar.gz", hash = "sha256:9d58481f9f7499dff4196927aedd4285a0baec8caa3790efbe205f13de37dd6e", size = 7074944, upload-time = "2024-12-09T16:02:23.639Z" }
@@ -4769,12 +4728,8 @@ wheels = [
name = "scipy"
version = "1.14.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.11.*'",
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/11/4d44a1f274e002784e4dbdb81e0ea96d2de2d1045b2132d5af62cc31fd28/scipy-1.14.1.tar.gz", hash = "sha256:5a275584e726026a5699459aa72f828a610821006228e841b94275c4a7c08417", size = 58620554, upload-time = "2024-08-21T00:09:20.662Z" }
wheels = [
@@ -4804,38 +4759,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/7d/43ab67228ef98c6b5dd42ab386eae2d7877036970a0d7e3dd3eb47a0d530/scipy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ff38e22128e6c03ff73b6bb0f85f897d2362f8c052e3b8ad00532198fbdae3f", size = 44521212, upload-time = "2024-08-21T00:06:06.521Z" },
]
[[package]]
name = "scipy"
version = "1.16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/18/b06a83f0c5ee8cddbde5e3f3d0bb9b702abfa5136ef6d4620ff67df7eee5/scipy-1.16.0.tar.gz", hash = "sha256:b5ef54021e832869c8cfb03bc3bf20366cbcd426e02a58e8a58d7584dfbb8f62", size = 30581216, upload-time = "2025-06-22T16:27:55.782Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/f8/53fc4884df6b88afd5f5f00240bdc49fee2999c7eff3acf5953eb15bc6f8/scipy-1.16.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:deec06d831b8f6b5fb0b652433be6a09db29e996368ce5911faf673e78d20085", size = 36447362, upload-time = "2025-06-22T16:18:17.817Z" },
{ url = "https://files.pythonhosted.org/packages/c9/25/fad8aa228fa828705142a275fc593d701b1817c98361a2d6b526167d07bc/scipy-1.16.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d30c0fe579bb901c61ab4bb7f3eeb7281f0d4c4a7b52dbf563c89da4fd2949be", size = 28547120, upload-time = "2025-06-22T16:18:24.117Z" },
{ url = "https://files.pythonhosted.org/packages/8d/be/d324ddf6b89fd1c32fecc307f04d095ce84abb52d2e88fab29d0cd8dc7a8/scipy-1.16.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b2243561b45257f7391d0f49972fca90d46b79b8dbcb9b2cb0f9df928d370ad4", size = 20818922, upload-time = "2025-06-22T16:18:28.035Z" },
{ url = "https://files.pythonhosted.org/packages/cd/e0/cf3f39e399ac83fd0f3ba81ccc5438baba7cfe02176be0da55ff3396f126/scipy-1.16.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:e6d7dfc148135e9712d87c5f7e4f2ddc1304d1582cb3a7d698bbadedb61c7afd", size = 23409695, upload-time = "2025-06-22T16:18:32.497Z" },
{ url = "https://files.pythonhosted.org/packages/5b/61/d92714489c511d3ffd6830ac0eb7f74f243679119eed8b9048e56b9525a1/scipy-1.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90452f6a9f3fe5a2cf3748e7be14f9cc7d9b124dce19667b54f5b429d680d539", size = 33444586, upload-time = "2025-06-22T16:18:37.992Z" },
{ url = "https://files.pythonhosted.org/packages/af/2c/40108915fd340c830aee332bb85a9160f99e90893e58008b659b9f3dddc0/scipy-1.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2f0bf2f58031c8701a8b601df41701d2a7be17c7ffac0a4816aeba89c4cdac8", size = 35284126, upload-time = "2025-06-22T16:18:43.605Z" },
{ url = "https://files.pythonhosted.org/packages/d3/30/e9eb0ad3d0858df35d6c703cba0a7e16a18a56a9e6b211d861fc6f261c5f/scipy-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c4abb4c11fc0b857474241b812ce69ffa6464b4bd8f4ecb786cf240367a36a7", size = 35608257, upload-time = "2025-06-22T16:18:49.09Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ff/950ee3e0d612b375110d8cda211c1f787764b4c75e418a4b71f4a5b1e07f/scipy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b370f8f6ac6ef99815b0d5c9f02e7ade77b33007d74802efc8316c8db98fd11e", size = 38040541, upload-time = "2025-06-22T16:18:55.077Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c9/750d34788288d64ffbc94fdb4562f40f609d3f5ef27ab4f3a4ad00c9033e/scipy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:a16ba90847249bedce8aa404a83fb8334b825ec4a8e742ce6012a7a5e639f95c", size = 38570814, upload-time = "2025-06-22T16:19:00.912Z" },
{ url = "https://files.pythonhosted.org/packages/01/c0/c943bc8d2bbd28123ad0f4f1eef62525fa1723e84d136b32965dcb6bad3a/scipy-1.16.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:7eb6bd33cef4afb9fa5f1fb25df8feeb1e52d94f21a44f1d17805b41b1da3180", size = 36459071, upload-time = "2025-06-22T16:19:06.605Z" },
{ url = "https://files.pythonhosted.org/packages/99/0d/270e2e9f1a4db6ffbf84c9a0b648499842046e4e0d9b2275d150711b3aba/scipy-1.16.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:1dbc8fdba23e4d80394ddfab7a56808e3e6489176d559c6c71935b11a2d59db1", size = 28490500, upload-time = "2025-06-22T16:19:11.775Z" },
{ url = "https://files.pythonhosted.org/packages/1c/22/01d7ddb07cff937d4326198ec8d10831367a708c3da72dfd9b7ceaf13028/scipy-1.16.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7dcf42c380e1e3737b343dec21095c9a9ad3f9cbe06f9c05830b44b1786c9e90", size = 20762345, upload-time = "2025-06-22T16:19:15.813Z" },
{ url = "https://files.pythonhosted.org/packages/34/7f/87fd69856569ccdd2a5873fe5d7b5bbf2ad9289d7311d6a3605ebde3a94b/scipy-1.16.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26ec28675f4a9d41587266084c626b02899db373717d9312fa96ab17ca1ae94d", size = 23418563, upload-time = "2025-06-22T16:19:20.746Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f1/e4f4324fef7f54160ab749efbab6a4bf43678a9eb2e9817ed71a0a2fd8de/scipy-1.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:952358b7e58bd3197cfbd2f2f2ba829f258404bdf5db59514b515a8fe7a36c52", size = 33203951, upload-time = "2025-06-22T16:19:25.813Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f0/b6ac354a956384fd8abee2debbb624648125b298f2c4a7b4f0d6248048a5/scipy-1.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03931b4e870c6fef5b5c0970d52c9f6ddd8c8d3e934a98f09308377eba6f3824", size = 35070225, upload-time = "2025-06-22T16:19:31.416Z" },
{ url = "https://files.pythonhosted.org/packages/e5/73/5cbe4a3fd4bc3e2d67ffad02c88b83edc88f381b73ab982f48f3df1a7790/scipy-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:512c4f4f85912767c351a0306824ccca6fd91307a9f4318efe8fdbd9d30562ef", size = 35389070, upload-time = "2025-06-22T16:19:37.387Z" },
{ url = "https://files.pythonhosted.org/packages/86/e8/a60da80ab9ed68b31ea5a9c6dfd3c2f199347429f229bf7f939a90d96383/scipy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e69f798847e9add03d512eaf5081a9a5c9a98757d12e52e6186ed9681247a1ac", size = 37825287, upload-time = "2025-06-22T16:19:43.375Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b5/29fece1a74c6a94247f8a6fb93f5b28b533338e9c34fdcc9cfe7a939a767/scipy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:adf9b1999323ba335adc5d1dc7add4781cb5a4b0ef1e98b79768c05c796c4e49", size = 38431929, upload-time = "2025-06-22T16:19:49.385Z" },
]
[[package]]
name = "segno"
version = "1.6.6"
@@ -4862,8 +4785,7 @@ dependencies = [
{ name = "huggingface-hub" },
{ name = "pillow" },
{ name = "scikit-learn" },
{ name = "scipy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "transformers" },
@@ -5617,8 +5539,7 @@ version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
@@ -5849,8 +5770,7 @@ name = "wsproto"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" }
wheels = [
@@ -5864,8 +5784,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "nvidia-nccl-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" },
{ name = "scipy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/b0/131ffc4a15fd3acee9be3a7baa6b2fa6faa479799c51b880de9fc3ddf550/xgboost-2.1.3.tar.gz", hash = "sha256:7699ec4226156887d3afc665c63ab87469db9d46e361c702ba9fccd22535730c", size = 1090326, upload-time = "2024-11-26T10:22:20.077Z" }
wheels = [
+1
View File
@@ -0,0 +1 @@
hello