Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 37 additions & 42 deletions src/praisonai/praisonai/agents_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,7 @@
# Compatibility imports - now handled by centralized detection
# (inbuilt_tools still defines these but they're read-only compatibility)

# Import BaseTool for tools handling
BaseTool = None
try:
from praisonai_tools import BaseTool
except ImportError:
try:
from praisonai.tools import BaseTool
except ImportError:
pass
# BaseTool import is now handled centrally by ToolResolver
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/praisonai/praisonai/agents_generator.py"

echo "BaseTool references:"
rg -n '\bBaseTool\b' "$file"

echo
echo "BaseTool imports or assignments:"
rg -n 'from .* import BaseTool|BaseTool\s*=' "$file" || true

Repository: MervinPraison/PraisonAI

Length of output: 406


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/praisonai/praisonai/agents_generator.py"

echo "=== Imports / top of file (lines 1-120) ==="
nl -ba "$file" | sed -n '1,120p'

echo
echo "=== generate_crew_and_kickoff() BaseTool usage (lines 560-660) ==="
nl -ba "$file" | sed -n '560,660p'

Repository: MervinPraison/PraisonAI

Length of output: 172


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/praisonai/praisonai/agents_generator.py"

echo "=== Imports / top of file (lines 1-120) ==="
awk 'NR>=1 && NR<=120 {printf "%4d:%s\n", NR, $0}' "$file"

echo
echo "=== generate_crew_and_kickoff() BaseTool usage (lines 560-660) ==="
awk 'NR>=560 && NR<=660 {printf "%4d:%s\n", NR, $0}' "$file"

Repository: MervinPraison/PraisonAI

Length of output: 10497


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/praisonai/praisonai/agents_generator.py"

echo "=== Context around BaseTool mention (lines 440-520) ==="
awk 'NR>=440 && NR<=520 {printf "%4d:%s\n", NR, $0}' "$file"

Repository: MervinPraison/PraisonAI

Length of output: 4253


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/praisonai/praisonai/agents_generator.py"

rg -n --hidden --no-ignore-vcs '\bBaseTool\b' "$file"
echo
echo "=== Any assignments to BaseTool? ==="
rg -n '\bBaseTool\s*=' "$file" || true
echo
echo "=== Any imports of BaseTool? ==="
rg -n 'import .*BaseTool|from .* import .*BaseTool' "$file" || true

Repository: MervinPraison/PraisonAI

Length of output: 423


Fix unresolved BaseTool in generate_crew_and_kickoff()

src/praisonai/praisonai/agents_generator.py references BaseTool in generate_crew_and_kickoff() (issubclass(tool_class, BaseTool), line ~613), but this file has no BaseTool import/assignment (only a comment/docstring mention). If self.tools contains any classes, this will raise NameError before tools get registered. Replace the check with the base tool type provided by ToolResolver (or reintroduce a safe import/definition).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai/praisonai/agents_generator.py` at line 29, The code in
generate_crew_and_kickoff() uses BaseTool (issubclass(tool_class, BaseTool)) but
BaseTool is not imported; change the check to use the base tool type exposed by
ToolResolver instead: retrieve the base type once (e.g. base_tool =
ToolResolver.base_tool or base_tool = ToolResolver.get_base_tool()) and replace
issubclass(tool_class, BaseTool) with issubclass(tool_class, base_tool); if
ToolResolver does not expose a base directly add a small safe fallback (e.g.
define an empty BaseTool sentinel and assign base_tool = ToolResolver.base_tool
if present else BaseTool) before the tools loop so NameError is avoided when
iterating self.tools.


# Check for additional framework availability using centralized detection
from ._framework_availability import is_available
Expand Down Expand Up @@ -346,9 +338,31 @@ def _merge_cli_config(self, config, cli_config):
self.logger.debug(f"CLI override: lsp = {cli_config['lsp']}")

# Handle agent-level overrides using unified approach
agent_level_fields = ['tool_timeout', 'planning_tools', 'autonomy']
agent_level_fields = ['tool_timeout', 'planning_tools', 'autonomy', 'planning', 'web', 'web_fetch']
agent_overrides = {k: v for k, v in cli_config.items() if k in agent_level_fields}

# Handle handoff configuration - convert CLI flags into handoff dict
handoff_fields = ['handoff', 'handoff_policy', 'handoff_timeout', 'handoff_max_depth', 'handoff_max_concurrent', 'handoff_detect_cycles']
if any(field in cli_config for field in handoff_fields):
handoff_config = {}
if 'handoff' in cli_config:
# Convert comma-separated roles to list
handoff_roles = [role.strip() for role in cli_config['handoff'].split(',') if role.strip()]
handoff_config['to'] = handoff_roles
if 'handoff_policy' in cli_config:
handoff_config['policy'] = cli_config['handoff_policy']
if 'handoff_timeout' in cli_config:
handoff_config['timeout'] = cli_config['handoff_timeout']
if 'handoff_max_depth' in cli_config:
handoff_config['max_depth'] = cli_config['handoff_max_depth']
if 'handoff_max_concurrent' in cli_config:
handoff_config['max_concurrent'] = cli_config['handoff_max_concurrent']
if 'handoff_detect_cycles' in cli_config:
handoff_config['detect_cycles'] = cli_config['handoff_detect_cycles'].lower() == 'true'

if handoff_config:
agent_overrides['handoff'] = handoff_config

# Handle approval configuration using unified spec
approval_fields = ['trust', 'approval', 'approve_all_tools', 'approval_timeout', 'approve_level']
if any(field in cli_config for field in approval_fields):
Expand Down Expand Up @@ -399,7 +413,7 @@ def _validate_agents_config(self, config):
'max_execution_time', 'verbose', 'cache', 'system_template',
'prompt_template', 'response_template', 'tool_timeout', 'planning_tools',
'planning', 'autonomy', 'guardrails', 'streaming', 'stream',
'approval', 'skills', 'cli_backend', 'reflection'
'approval', 'skills', 'cli_backend', 'reflection', 'handoff', 'web', 'web_fetch'
}

for section_name in ('agents', 'roles'):
Expand Down Expand Up @@ -456,24 +470,6 @@ def load_tools_from_module(self, module_path):
return {}
return {name: obj for name, obj in inspect.getmembers(module, self.is_function_or_decorated)}

def _extract_tool_classes(self, module):
"""
Extract tool classes from a loaded module that inherit from BaseTool
or are part of langchain_community.tools package.
"""
result = {}
for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool):
try:
result[name] = obj()
except Exception as e:
self.logger.warning(f"Error instantiating tool class {name}: {e}")
continue
return result

def load_tools_from_module_class(self, module_path):
"""
Load BaseTool / langchain tool classes from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
Expand All @@ -482,7 +478,7 @@ def load_tools_from_module_class(self, module_path):
module = load_user_module(module_path, name="tools_module")
if module is None:
return {}
return self._extract_tool_classes(module)
return self.tool_resolver._extract_tool_classes(module)

def load_tools_from_package(self, package_path):
"""
Expand Down Expand Up @@ -612,12 +608,17 @@ def generate_crew_and_kickoff(self):
except Exception as e:
self.logger.warning(f"Error collecting YAML tool references: {e}")

# Add tools from class names
# Add tools from class names - use tool_resolver to check tool validity
for tool_class in self.tools:
if isinstance(tool_class, type) and BaseTool and issubclass(tool_class, BaseTool):
tool_name = tool_class.__name__
tools_dict[tool_name] = tool_class()
self.logger.debug(f"Added tool: {tool_name}")
if isinstance(tool_class, type):
try:
# Try to instantiate the tool to validate it
tool_instance = tool_class()
tool_name = tool_class.__name__
tools_dict[tool_name] = tool_instance
self.logger.debug(f"Added tool: {tool_name}")
except Exception as e:
self.logger.warning(f"Failed to instantiate tool class {tool_class.__name__}: {e}")

root_directory = os.getcwd()
tools_py_path = os.path.join(root_directory, 'tools.py')
Expand All @@ -628,13 +629,7 @@ def generate_crew_and_kickoff(self):
if os.path.isfile(tools_py_path):
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
elif tools_dir_path.is_dir():
from ._safe_loader import load_user_module
for py_file in tools_dir_path.glob("*.py"):
if py_file.name.startswith("__"):
continue
module = load_user_module(py_file, name=f"tools_{py_file.stem}")
if module is not None:
tools_dict.update(self._extract_tool_classes(module))
tools_dict.update(self.tool_resolver.get_local_tool_classes_from_dir(tools_dir_path))
if tools_dict:
self.logger.debug("tools folder exists in the root directory")

Expand Down
35 changes: 35 additions & 0 deletions src/praisonai/praisonai/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4117,6 +4117,16 @@ def _extract_cli_config_for_yaml(self):
planning_tools = getattr(self.args, 'planning_tools', None)
if planning_tools:
cli_config['planning_tools'] = planning_tools

# Extract --planning flag
if getattr(self.args, 'planning', False):
cli_config['planning'] = True

# Extract web flags
if getattr(self.args, 'web', False):
cli_config['web'] = True
if getattr(self.args, 'web_fetch', False):
cli_config['web_fetch'] = True

# Extract --acp flag
if getattr(self.args, 'acp', False):
Expand Down Expand Up @@ -4154,6 +4164,31 @@ def _extract_cli_config_for_yaml(self):
cli_config['stream'] = stream or stream_metrics
if stream_metrics:
cli_config['stream_metrics'] = True

# Extract handoff configuration for YAML CLI parity
handoff = getattr(self.args, 'handoff', None)
if handoff:
cli_config['handoff'] = handoff

handoff_policy = getattr(self.args, 'handoff_policy', None)
if handoff_policy is not None:
cli_config['handoff_policy'] = handoff_policy

handoff_timeout = getattr(self.args, 'handoff_timeout', None)
if handoff_timeout is not None:
cli_config['handoff_timeout'] = handoff_timeout

handoff_max_depth = getattr(self.args, 'handoff_max_depth', None)
if handoff_max_depth is not None:
cli_config['handoff_max_depth'] = handoff_max_depth

handoff_max_concurrent = getattr(self.args, 'handoff_max_concurrent', None)
if handoff_max_concurrent is not None:
cli_config['handoff_max_concurrent'] = handoff_max_concurrent

handoff_detect_cycles = getattr(self.args, 'handoff_detect_cycles', None)
if handoff_detect_cycles is not None:
cli_config['handoff_detect_cycles'] = handoff_detect_cycles

return cli_config

Expand Down
60 changes: 30 additions & 30 deletions src/praisonai/praisonai/scheduler/agent_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
from datetime import datetime
from typing import Optional, Dict, Any, Callable
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout

from .base import ScheduleParser, PraisonAgentExecutor
from .shared import backoff_delay
Expand Down Expand Up @@ -73,6 +74,7 @@ def __init__(
self._failure_count = 0
self._total_cost = 0.0
self._start_time = None
self._stats_lock = threading.Lock()

def start(
self,
Expand Down Expand Up @@ -160,17 +162,18 @@ def get_stats(self) -> Dict[str, Any]:
Returns:
Dictionary with execution stats including cost
"""
runtime = (datetime.now() - self._start_time).total_seconds() if self._start_time else 0
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
"total_cost_usd": round(self._total_cost, 4),
"runtime_seconds": round(runtime, 1),
"cost_per_execution": round(self._total_cost / self._execution_count, 4) if self._execution_count > 0 else 0
}
with self._stats_lock:
runtime = (datetime.now() - self._start_time).total_seconds() if self._start_time else 0
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
"total_cost_usd": round(self._total_cost, 4),
"runtime_seconds": round(runtime, 1),
"cost_per_execution": round(self._total_cost / self._execution_count, 4) if self._execution_count > 0 else 0
}

def _update_state_if_daemon(self):
"""Update state file with execution stats if running as daemon."""
Expand Down Expand Up @@ -232,7 +235,8 @@ def _run_schedule(self, interval: int, max_retries: int):

def _execute_with_retry(self, max_retries: int):
"""Execute agent with retry logic and timeout."""
self._execution_count += 1
with self._stats_lock:
self._execution_count += 1
success = False
result = None

Expand All @@ -242,21 +246,16 @@ def _execute_with_retry(self, max_retries: int):

# Execute with timeout if specified
if self.timeout:
import signal

def timeout_handler(signum, frame):
raise TimeoutError(f"Execution exceeded {self.timeout}s timeout")

# Set timeout alarm (Unix only)
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(self._executor.execute, self.task)
try:
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(self.timeout)
result = self._executor.execute(self.task)
signal.alarm(0) # Cancel alarm
except AttributeError:
# Windows doesn't support SIGALRM, use threading.Timer fallback
logger.warning("Timeout not supported on this platform, executing without timeout")
result = self._executor.execute(self.task)
result = future.result(timeout=self.timeout)
except FuturesTimeout as e:
future.cancel()
executor.shutdown(wait=False, cancel_futures=True)
raise TimeoutError(f"Execution exceeded {self.timeout}s timeout") from e
else:
executor.shutdown(wait=False, cancel_futures=True)
else:
result = self._executor.execute(self.task)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Expand All @@ -268,10 +267,10 @@ def timeout_handler(signum, frame):

# Estimate cost (rough: ~$0.0001 per execution for gpt-4o-mini)
estimated_cost = 0.0001 # Base cost estimate
self._total_cost += estimated_cost
with self._stats_lock:
self._total_cost += estimated_cost
self._success_count += 1
logger.debug(f"Estimated cost this run: ${estimated_cost:.4f}, Total: ${self._total_cost:.4f}")

self._success_count += 1
success = True

if self.on_success:
Expand Down Expand Up @@ -299,7 +298,8 @@ def timeout_handler(signum, frame):
return

if not success:
self._failure_count += 1
with self._stats_lock:
self._failure_count += 1
logger.error(f"Agent execution failed after {max_retries} attempts")

if self.on_failure:
Expand Down
83 changes: 56 additions & 27 deletions src/praisonai/praisonai/tool_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,37 +454,66 @@ def get_local_tool_classes(self) -> Dict[str, Any]:
module = load_user_module(self._tools_py_path, name="tools_module")
if module is None:
return {}
return self._extract_tool_classes(module)
except Exception as e:
logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
return {}

def get_local_tool_classes_from_dir(self, tools_dir: "os.PathLike|str") -> Dict[str, Any]:
"""Load BaseTool/langchain classes from every *.py in a tools/ directory.

Args:
tools_dir: Path to the tools directory

# Import the necessary classes (matching agents_generator.py logic)
BaseTool = None
PRAISONAI_TOOLS_AVAILABLE = False
Returns:
Dictionary mapping class names to instantiated tool objects
"""
from pathlib import Path
from ._safe_loader import load_user_module

classes: Dict[str, Any] = {}
for py_file in Path(tools_dir).glob("*.py"):
if py_file.name.startswith("__"):
continue
try:
from praisonai_tools import BaseTool
module = load_user_module(py_file, name=f"tools_{py_file.stem}")
if module is not None:
classes.update(self._extract_tool_classes(module))
except Exception as e:
logger.warning(f"Error loading tool classes from file {py_file}: {e}")
return classes

def _extract_tool_classes(self, module):
"""Extract tool classes from a loaded module that inherit from BaseTool
or are part of langchain_community.tools package.
"""
# Import the necessary classes (matching agents_generator.py logic)
BaseTool = None
PRAISONAI_TOOLS_AVAILABLE = False
try:
from praisonai_tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
try:
from praisonai.tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
try:
from praisonai.tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
pass

result = {}
for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool):
try:
result[name] = obj()
logger.debug(f"Loaded local tool class: {name}")
except Exception as e:
logger.warning(f"Error instantiating tool class {name}: {e}")
continue

return result
except Exception as e:
logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
return {}
pass
Comment thread
greptile-apps[bot] marked this conversation as resolved.

result = {}
for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool):
try:
result[name] = obj()
logger.debug(f"Loaded tool class: {name}")
except Exception as e:
logger.warning(f"Error instantiating tool class {name}: {e}")
continue

return result
Comment on lines +486 to +516
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Reuse this helper from get_local_tool_classes() too.

tools.py extraction still keeps a second copy of the BaseTool/langchain predicate in get_local_tool_classes(), so the "single source of truth" only covers the directory path right now.

Proposed refactor
     def get_local_tool_classes(self) -> Dict[str, Any]:
         """Get BaseTool/langchain class instances from tools.py (path B semantics).
         
         Returns:
             Dictionary mapping class names to instantiated tool objects
         """
         try:
             # Use the same safe loader to get the module
             module = load_user_module(self._tools_py_path, name="tools_module")
             if module is None:
                 return {}
-            
-            # Import the necessary classes (matching agents_generator.py logic)
-            BaseTool = None
-            PRAISONAI_TOOLS_AVAILABLE = False
-            try:
-                from praisonai_tools import BaseTool
-                PRAISONAI_TOOLS_AVAILABLE = True
-            except ImportError:
-                try:
-                    from praisonai.tools import BaseTool
-                    PRAISONAI_TOOLS_AVAILABLE = True
-                except ImportError:
-                    pass
-            
-            result = {}
-            for name, obj in inspect.getmembers(module, 
-                lambda x: inspect.isclass(x) and (
-                    x.__module__.startswith('langchain_community.tools') or 
-                    (PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
-                ) and x is not BaseTool):
-                try:
-                    result[name] = obj()
-                    logger.debug(f"Loaded local tool class: {name}")
-                except Exception as e:
-                    logger.warning(f"Error instantiating tool class {name}: {e}")
-                    continue
-            
-            return result
+            return self._extract_tool_classes(module)
         except Exception as e:
             logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
             return {}
🧰 Tools
🪛 Ruff (0.15.13)

[warning] 539-539: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai/praisonai/tool_resolver.py` around lines 513 - 543, The
get_local_tool_classes() function is duplicating the BaseTool/langchain
predicate logic; refactor it to call the existing helper
_extract_tool_classes(module) for each loaded module instead of reimplementing
the predicate. Concretely, remove the predicate/instantiation logic from
get_local_tool_classes and for each imported module pass it to
_extract_tool_classes so that BaseTool import handling
(PRAISONAI_TOOLS_AVAILABLE and BaseTool resolution) and class instantiation live
only in _extract_tool_classes; keep logging behavior consistent by letting
_extract_tool_classes emit debug/warning messages for loaded or failed classes.



# Process-level lazy singleton for performance (matches profiler.py pattern)
Expand Down
Loading
Loading