mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-13 05:01:35 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7c274489e | ||
|
|
23f6c00e21 | ||
|
|
a293999e8b | ||
|
|
0b14ae49ef | ||
|
|
b33c649de5 | ||
|
|
02a1977651 |
No files matched your search
+3
-1
@@ -91,4 +91,6 @@ issue_details.json
|
||||
target/
|
||||
Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
*.pdb
|
||||
.windsurf
|
||||
.agent
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Generated
+21
-2
@@ -145,6 +145,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-grep-cli"
|
||||
version = "0.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/9c/4b5fbc28a606d1d3217891d41c8428518de8a52e8f83564daabbd4d8ba18/ast_grep_cli-0.41.0.tar.gz", hash = "sha256:9aee19fa53d582f4408ace5cffe3ff5635dff954c7ab78a15530c4acf5893a0e", size = 220762, upload-time = "2026-02-22T19:06:01.576Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e2/e7604406b8487d43928a99fcfb66ac3aa9fba787527e901b96e65dbbd6d6/ast_grep_cli-0.41.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93085ee153855fa9b1f486884f8aad788fb111f1e6a96bcfde240071d5033199", size = 14488134, upload-time = "2026-02-22T19:05:54.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/b5/1f9ad69e28e2b98e0e99a4a7f981982937072c2a667f5ae808d8ffefa193/ast_grep_cli-0.41.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8db8e877b365a553f83a2a0e158d8cfa17fc33f8033774ee3248665449dcad1b", size = 7261908, upload-time = "2026-02-22T19:06:02.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8b/ac2e7946e2b566483fb0005ed715767110663fb85a666030b38e43dee10f/ast_grep_cli-0.41.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c10f70cd6e45ded98773022d9ae0bebfc497b8b2e85ae17e15abca174e7d0f93", size = 7106987, upload-time = "2026-02-22T19:05:59.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/00/90cc9f094fad9116f29076c1a5393df50fba7d7b25c402d2611d96f7e3f9/ast_grep_cli-0.41.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:92d384608e5fd98a301a03a380e0ce011d46adcd5f6bcf53b0b9ba125faa43ad", size = 7417585, upload-time = "2026-02-22T19:06:00.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/1b/390fd8ab3164e0f9377bd272196612f5047bfed35eb704df1c1e84776075/ast_grep_cli-0.41.0-py3-none-win32.whl", hash = "sha256:a7a4682cde7a1e1a8083dd8feed6f968d8f90a109e7c0917e1d5066807dfa46e", size = 6974015, upload-time = "2026-02-22T19:05:55.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/f5/71984cca3d3533c7e0a7dfe1784b7f6b12931463f6c5ce95ec36d5169219/ast_grep_cli-0.41.0-py3-none-win_amd64.whl", hash = "sha256:00570076cbf30e9c4c93f5577137c4d63245974420eff9e1fb3e3c6a9abefe97", size = 7413122, upload-time = "2026-02-22T19:05:57.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ee/2add23700fa89661e84301c458a9a3218836edd5bbaeea49a7a283f4269a/ast_grep_cli-0.41.0-py3-none-win_arm64.whl", hash = "sha256:b8e0eeeec751fa213abc57ea20af4fdd8ae955a43e390914df85985f6933eb53", size = 7089061, upload-time = "2026-02-22T19:05:52.28Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "4.0.3"
|
||||
@@ -2549,7 +2564,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.15"
|
||||
version = "1.5.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"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -9,6 +9,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
# Module-level deprecation warning
|
||||
import warnings as _deprecation_warnings
|
||||
_deprecation_warnings.warn(
|
||||
"BotApprovalBackend is deprecated. Use platform-specific approval backends "
|
||||
"(SlackApproval, TelegramApproval, DiscordApproval, WebhookApproval, HTTPApproval) instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -77,7 +86,7 @@ class BotApprovalBackend:
|
||||
msg = f"🔐 Approval required:\n\nTool: {tool_name}\nArgs: {args_preview}\n\nReply 'yes' to approve or 'no' to deny."
|
||||
|
||||
# Create a future to wait for the response
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[request_id] = future
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Rate Limiting Utilities for Bot Adapters.
|
||||
|
||||
Provides platform-aware rate limiting to prevent 429 errors from messaging APIs.
|
||||
|
||||
Platform limits (as of 2024):
|
||||
- Telegram: ~30 messages/second to different users, stricter per-chat
|
||||
- Discord: 5 messages per 5 seconds per channel (1 msg/sec effective)
|
||||
- Slack: 1 message per second per channel
|
||||
- WhatsApp: ~80 messages/second (Cloud API), varies for Web
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limiting.
|
||||
|
||||
Attributes:
|
||||
messages_per_second: Maximum messages per second (global)
|
||||
per_channel_delay: Minimum delay between messages to same channel (seconds)
|
||||
burst_size: Number of messages allowed in a burst before throttling
|
||||
"""
|
||||
messages_per_second: float = 1.0
|
||||
per_channel_delay: float = 1.0
|
||||
burst_size: int = 5
|
||||
|
||||
|
||||
# Platform-specific defaults
|
||||
PLATFORM_LIMITS: Dict[str, RateLimitConfig] = {
|
||||
"telegram": RateLimitConfig(messages_per_second=25.0, per_channel_delay=0.05, burst_size=30),
|
||||
"discord": RateLimitConfig(messages_per_second=1.0, per_channel_delay=1.0, burst_size=5),
|
||||
"slack": RateLimitConfig(messages_per_second=1.0, per_channel_delay=1.0, burst_size=1),
|
||||
"whatsapp": RateLimitConfig(messages_per_second=50.0, per_channel_delay=0.1, burst_size=80),
|
||||
}
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Async rate limiter with per-channel tracking.
|
||||
|
||||
Uses token bucket algorithm for global rate and per-channel delays.
|
||||
|
||||
Example:
|
||||
limiter = RateLimiter.for_platform("telegram")
|
||||
|
||||
async def send(channel_id, msg):
|
||||
await limiter.acquire(channel_id)
|
||||
await api.send_message(channel_id, msg)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||
"""Initialize rate limiter.
|
||||
|
||||
Args:
|
||||
config: Rate limit configuration. Defaults to 1 msg/sec.
|
||||
"""
|
||||
self._config = config or RateLimitConfig()
|
||||
self._tokens = float(self._config.burst_size)
|
||||
self._last_refill = time.monotonic()
|
||||
self._channel_last_send: Dict[str, float] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def for_platform(cls, platform: str) -> "RateLimiter":
|
||||
"""Create a rate limiter with platform-specific defaults.
|
||||
|
||||
Args:
|
||||
platform: Platform name (telegram, discord, slack, whatsapp)
|
||||
|
||||
Returns:
|
||||
Configured RateLimiter instance
|
||||
"""
|
||||
config = PLATFORM_LIMITS.get(platform.lower(), RateLimitConfig())
|
||||
return cls(config)
|
||||
|
||||
async def acquire(self, channel_id: Optional[str] = None) -> None:
|
||||
"""Wait until rate limit allows sending.
|
||||
|
||||
Args:
|
||||
channel_id: Optional channel ID for per-channel limiting
|
||||
"""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
|
||||
# Refill tokens based on elapsed time
|
||||
elapsed = now - self._last_refill
|
||||
self._tokens = min(
|
||||
self._config.burst_size,
|
||||
self._tokens + elapsed * self._config.messages_per_second
|
||||
)
|
||||
self._last_refill = now
|
||||
|
||||
# Wait for global token
|
||||
if self._tokens < 1.0:
|
||||
wait_time = (1.0 - self._tokens) / self._config.messages_per_second
|
||||
logger.debug(f"Rate limit: waiting {wait_time:.3f}s for global token")
|
||||
await asyncio.sleep(wait_time)
|
||||
self._tokens = 1.0
|
||||
|
||||
# Consume token
|
||||
self._tokens -= 1.0
|
||||
|
||||
# Per-channel delay
|
||||
if channel_id:
|
||||
last_send = self._channel_last_send.get(channel_id, 0.0)
|
||||
channel_elapsed = now - last_send
|
||||
if channel_elapsed < self._config.per_channel_delay:
|
||||
wait_time = self._config.per_channel_delay - channel_elapsed
|
||||
logger.debug(f"Rate limit: waiting {wait_time:.3f}s for channel {channel_id}")
|
||||
await asyncio.sleep(wait_time)
|
||||
self._channel_last_send[channel_id] = time.monotonic()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset rate limiter state."""
|
||||
self._tokens = float(self._config.burst_size)
|
||||
self._last_refill = time.monotonic()
|
||||
self._channel_last_send.clear()
|
||||
|
||||
|
||||
def get_rate_limiter(platform: str) -> RateLimiter:
|
||||
"""Get a rate limiter for the specified platform.
|
||||
|
||||
This is a convenience function that creates a new limiter each time.
|
||||
For persistent limiting, create and store a RateLimiter instance.
|
||||
|
||||
Args:
|
||||
platform: Platform name
|
||||
|
||||
Returns:
|
||||
Configured RateLimiter
|
||||
"""
|
||||
return RateLimiter.for_platform(platform)
|
||||
@@ -145,7 +145,7 @@ class BotSessionManager:
|
||||
agent_lock = self._get_agent_lock(agent)
|
||||
async with user_lock:
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
user_history = await loop.run_in_executor(
|
||||
None, self._load_history, user_id
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ from praisonaiagents.bots import (
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,6 +82,10 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -186,6 +191,22 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
if self._agent:
|
||||
user_id = str(message.author.id)
|
||||
|
||||
# Ack reaction - show processing indicator
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
async def _discord_react(emoji, **kw):
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
except Exception:
|
||||
pass # Reactions may not be supported
|
||||
async def _discord_unreact(emoji, **kw):
|
||||
try:
|
||||
await message.remove_reaction(emoji, self._client.user)
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await self._ack.ack(react_fn=_discord_react)
|
||||
|
||||
async def _send_agent_response():
|
||||
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
|
||||
response = await self._session.chat(self._agent, user_id, text_to_send)
|
||||
@@ -196,6 +217,9 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
return
|
||||
await self._send_long_message(message.channel, send_result["content"], reference=message)
|
||||
self.fire_message_sent(str(message.channel.id), send_result["content"])
|
||||
# Done reaction - show completion
|
||||
if ack_ctx:
|
||||
await self._ack.done(ack_ctx, react_fn=_discord_react, unreact_fn=_discord_unreact)
|
||||
|
||||
if self.config.typing_indicator:
|
||||
async with message.channel.typing():
|
||||
|
||||
@@ -29,6 +29,7 @@ from .media import split_media_from_output, is_audio_file
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +95,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
# Audio capabilities
|
||||
self._stt_enabled: bool = False
|
||||
@@ -194,6 +199,35 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
should_respond = True
|
||||
|
||||
if should_respond and self._agent:
|
||||
channel_id = event.get("channel", "")
|
||||
msg_ts = event.get("ts", "")
|
||||
|
||||
# Ack reaction - show processing indicator
|
||||
ack_ctx = None
|
||||
if self._ack.enabled and self._client:
|
||||
async def _slack_react(emoji, **kw):
|
||||
try:
|
||||
# Slack uses emoji names without colons
|
||||
emoji_name = emoji.strip(":")
|
||||
await self._client.reactions_add(
|
||||
channel=channel_id,
|
||||
timestamp=msg_ts,
|
||||
name=emoji_name,
|
||||
)
|
||||
except Exception:
|
||||
pass # Reactions may fail silently
|
||||
async def _slack_unreact(emoji, **kw):
|
||||
try:
|
||||
emoji_name = emoji.strip(":")
|
||||
await self._client.reactions_remove(
|
||||
channel=channel_id,
|
||||
timestamp=msg_ts,
|
||||
name=emoji_name,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await self._ack.ack(react_fn=_slack_react)
|
||||
|
||||
try:
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"Message received: {text[:100]}...")
|
||||
@@ -201,7 +235,6 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
response = await self._session.chat(self._agent, user_id, text)
|
||||
logger.info(f"Response sent: {response[:100]}...")
|
||||
|
||||
channel_id = event.get("channel", "")
|
||||
send_result = self.fire_message_sending(channel_id, str(response))
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
@@ -219,6 +252,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
channel_id, say, response, thread_ts=thread_ts
|
||||
)
|
||||
self.fire_message_sent(channel_id, response)
|
||||
|
||||
# Done reaction - show completion
|
||||
if ack_ctx and self._client:
|
||||
await self._ack.done(ack_ctx, react_fn=_slack_react, unreact_fn=_slack_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
error_msg = str(e)
|
||||
|
||||
@@ -45,6 +45,8 @@ from praisonaiagents.bots import (
|
||||
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._rate_limit import RateLimiter
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -129,6 +131,13 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._runner: Any = None
|
||||
self._site: Any = None
|
||||
self._http_session: Any = None # Shared aiohttp session for connection pooling
|
||||
self._background_tasks: set = set() # Track background tasks to prevent silent failures
|
||||
self._rate_limiter = RateLimiter.for_platform("whatsapp") # Rate limiting for API calls
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
# Web mode adapter (lazy initialized)
|
||||
self._web_adapter: Any = None
|
||||
@@ -216,6 +225,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
async def _start_cloud_mode(self) -> None:
|
||||
"""Start the Cloud API webhook server."""
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
raise ImportError("aiohttp is required for WhatsApp bot. Install with: pip install aiohttp")
|
||||
@@ -231,6 +241,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
|
||||
self._bot_user = BotUser(
|
||||
user_id=self._phone_number_id,
|
||||
username="whatsapp_bot",
|
||||
@@ -257,6 +268,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
⚠️ EXPERIMENTAL: Uses reverse-engineered protocol.
|
||||
"""
|
||||
import aiohttp
|
||||
from ._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
|
||||
async def _on_web_message(event: Any) -> None:
|
||||
@@ -447,6 +459,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
|
||||
self._bot_user = BotUser(
|
||||
user_id="web_user",
|
||||
username="whatsapp_web_bot",
|
||||
@@ -507,6 +520,15 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
await self._web_adapter.disconnect()
|
||||
except Exception as e:
|
||||
logger.warning(f"Web adapter disconnect error: {e}")
|
||||
# Cancel pending background tasks
|
||||
for task in self._background_tasks:
|
||||
task.cancel()
|
||||
if self._background_tasks:
|
||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
self._background_tasks.clear()
|
||||
if self._http_session:
|
||||
await self._http_session.close()
|
||||
self._http_session = None
|
||||
logger.info("WhatsApp bot stopped")
|
||||
|
||||
# ── Webhook handlers ────────────────────────────────────────────
|
||||
@@ -561,7 +583,10 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
return web.Response(status=400, text="Bad request")
|
||||
|
||||
# Process asynchronously so we respond 200 immediately
|
||||
asyncio.create_task(self._process_webhook_data(data))
|
||||
# Track task to prevent silent failures
|
||||
task = asyncio.create_task(self._process_webhook_data(data))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
# Always respond 200 to acknowledge receipt
|
||||
return web.Response(status=200, text="OK")
|
||||
@@ -681,6 +706,22 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
# Route to agent
|
||||
if self._agent and content:
|
||||
# Ack reaction - show processing indicator
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
async def _wa_react(emoji, **kw):
|
||||
try:
|
||||
await self.send_reaction(sender_id, msg_id, emoji)
|
||||
except Exception:
|
||||
pass
|
||||
async def _wa_unreact(emoji, **kw):
|
||||
try:
|
||||
# WhatsApp doesn't have unreact, send empty reaction to clear
|
||||
await self.send_reaction(sender_id, msg_id, "")
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await self._ack.ack(react_fn=_wa_react)
|
||||
|
||||
try:
|
||||
response = await self._session_mgr.chat(
|
||||
self._agent, sender_id, content
|
||||
@@ -690,6 +731,9 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
if not send_result["cancel"]:
|
||||
await self.send_message(sender_id, send_result["content"])
|
||||
self.fire_message_sent(sender_id, send_result["content"])
|
||||
# Done reaction - show completion
|
||||
if ack_ctx:
|
||||
await self._ack.done(ack_ctx, react_fn=_wa_react, unreact_fn=_wa_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent chat error: {e}")
|
||||
await self.send_message(sender_id, "Sorry, I encountered an error processing your message.")
|
||||
@@ -733,9 +777,11 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
if reply_to:
|
||||
payload["context"] = {"message_id": reply_to}
|
||||
|
||||
# Rate limit before sending
|
||||
await self._rate_limiter.acquire(to)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
result = await resp.json()
|
||||
if "error" in result:
|
||||
logger.error(f"WhatsApp send error: {result['error']}")
|
||||
@@ -783,9 +829,11 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
if components:
|
||||
payload["template"]["components"] = components
|
||||
|
||||
# Rate limit before sending
|
||||
await self._rate_limiter.acquire(to)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
result = await resp.json()
|
||||
if "error" in result:
|
||||
logger.error(f"WhatsApp template error: {result['error']}")
|
||||
@@ -820,8 +868,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
await resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reaction: {e}")
|
||||
@@ -845,8 +892,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
async with self._http_session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
await resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark as read: {e}")
|
||||
@@ -883,8 +929,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
import aiohttp
|
||||
url = f"{GRAPH_API_BASE}/{self._phone_number_id}"
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
async with self._http_session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
elapsed = (time.time() - started) * 1000
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
@@ -904,7 +949,7 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
from praisonaiagents.bots import HealthResult
|
||||
probe_result = await self.probe()
|
||||
uptime = (time.time() - self._started_at) if self._started_at else None
|
||||
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
|
||||
session_count = len(self._session_mgr._histories) if hasattr(self._session_mgr, '_histories') else 0
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok, platform="whatsapp",
|
||||
is_running=self._is_running, uptime_seconds=uptime,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "4.5.15"
|
||||
__version__ = "4.5.20"
|
||||
@@ -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:
|
||||
|
||||
Generated
+16
-97
@@ -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 = [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
Reference in new issue
Block a user