Skip to content

Commit 7efd007

Browse files
praisonai-triage-agent[bot]MervinPraisonCopilot
authored
fix: resolve architectural gaps in src/praisonai/praisonai (#1658)
* fix: resolve architectural gaps in src/praisonai/praisonai (fixes #1657) - Fix framework adapter dispatch kwargs mismatch by adding required keyword-only arguments to AutoGenAdapter, AutoGenV4Adapter, and AG2Adapter run() methods - Fix InteractiveRuntime lifecycle management by keeping runtime alive during agent execution instead of stopping it prematurely - Consolidate tools.py loading paths by making ToolResolver the single source of truth and replacing duplicate loading logic in agents_generator.py - Remove unused load_tools_from_tools_py() method - Update ToolResolver to use _safe_loader for consistent security model Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com> * fix: define tools_path in ToolResolver local loader logging Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/bee41da9-2153-41cd-9834-439aa753a848 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * fix: resolve critical NameError and event loop leak - Fix NameError in ToolResolver._load_local_tools() for undefined tools_path variable - Fix event loop leak in AgentsGenerator when InteractiveRuntime startup fails - Change autogen_v4/ag2 adapters to raise NotImplementedError instead of returning strings 🤖 Generated with Claude Code Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> --------- Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com>
1 parent 9ec9036 commit 7efd007

3 files changed

Lines changed: 127 additions & 78 deletions

File tree

src/praisonai/praisonai/agents_generator.py

Lines changed: 20 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -448,36 +448,6 @@ def load_tools_from_package(self, package_path):
448448
tools_dict[name] = obj
449449
return tools_dict
450450

451-
def load_tools_from_tools_py(self):
452-
"""
453-
Imports and returns all contents from tools.py file.
454-
Uses the tool registry instead of global namespace pollution.
455-
456-
Returns:
457-
list: A list of callable functions with proper formatting
458-
"""
459-
tools_list = []
460-
try:
461-
# Try to import tools.py from current directory using safe loading
462-
from ._safe_loader import load_user_module
463-
module = load_user_module("tools.py", name="tools")
464-
if module is None:
465-
self.logger.debug("tools.py not found or local tools loading disabled")
466-
return tools_list
467-
468-
# Register functions in the tool registry instead of globals()
469-
registered_tools = self.tool_registry.register_from_module(module)
470-
tools_list = [self.tool_registry.get_function(name) for name in registered_tools]
471-
472-
self.logger.debug(f"Loaded {len(tools_list)} tool functions from tools.py")
473-
self.logger.debug(f"Registered tools: {registered_tools}")
474-
475-
except FileNotFoundError:
476-
self.logger.debug("tools.py not found in current directory")
477-
except Exception as e:
478-
self.logger.warning(f"Error loading tools from tools.py: {e}")
479-
480-
return tools_list
481451

482452
def generate_crew_and_kickoff(self):
483453
"""
@@ -587,12 +557,10 @@ def generate_crew_and_kickoff(self):
587557
tools_py_path = os.path.join(root_directory, 'tools.py')
588558
tools_dir_path = Path(root_directory) / 'tools'
589559

560+
# Use consolidated ToolResolver for tools.py loading
561+
tools_dict.update(self.tool_resolver.get_local_tool_classes())
590562
if os.path.isfile(tools_py_path):
591-
from ._safe_loader import load_user_module
592-
module = load_user_module(tools_py_path, name="tools_module")
593-
if module is not None:
594-
tools_dict.update(self._extract_tool_classes(module))
595-
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
563+
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
596564
elif tools_dir_path.is_dir():
597565
from ._safe_loader import load_user_module
598566
for py_file in tools_dir_path.glob("*.py"):
@@ -1195,8 +1163,8 @@ def _run_praisonai(self, config, topic, tools_dict):
11951163

11961164
# Use existing tool resolver instance
11971165

1198-
# Load tools from local tools.py (backward compat)
1199-
tools_list = self.load_tools_from_tools_py()
1166+
# Load tools from local tools.py (backward compat) - use consolidated ToolResolver
1167+
tools_list = self.tool_resolver.get_local_callables()
12001168
self.logger.debug(f"Loaded tools from tools.py: {tools_list}")
12011169

12021170
# Initialize InteractiveRuntime for ACP/LSP if enabled globally
@@ -1224,25 +1192,24 @@ def _run_praisonai(self, config, topic, tools_dict):
12241192

12251193
# Create a scoped event loop instead of modifying process globals
12261194
interactive_loop = asyncio.new_event_loop()
1227-
try:
1228-
interactive_loop.run_until_complete(interactive_runtime.start())
1229-
1230-
centric_tools = create_agent_centric_tools(interactive_runtime)
1231-
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
1232-
tools_list.extend(centric_tools)
1233-
1234-
finally:
1235-
try:
1236-
interactive_loop.run_until_complete(interactive_runtime.stop())
1237-
except Exception as stop_error:
1238-
self.logger.warning(f"Error stopping InteractiveRuntime: {stop_error}")
1239-
finally:
1240-
interactive_loop.close()
1195+
1196+
# Start the runtime but keep it alive for agent execution
1197+
interactive_loop.run_until_complete(interactive_runtime.start())
1198+
1199+
centric_tools = create_agent_centric_tools(interactive_runtime)
1200+
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
1201+
tools_list.extend(centric_tools)
12411202

12421203
except ImportError as e:
12431204
self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
1205+
interactive_runtime = None
1206+
interactive_loop = None
12441207
except Exception as e:
12451208
self.logger.error(f"Error starting InteractiveRuntime: {e}")
1209+
if 'interactive_loop' in locals() and interactive_loop is not None:
1210+
interactive_loop.close()
1211+
interactive_runtime = None
1212+
interactive_loop = None
12461213

12471214
# Create agents from config
12481215
for role, details in config['roles'].items():
@@ -1482,6 +1449,8 @@ def _run_praisonai(self, config, topic, tools_dict):
14821449
interactive_loop.run_until_complete(interactive_runtime.stop())
14831450
except Exception as e:
14841451
self.logger.error(f"Error stopping InteractiveRuntime: {e}")
1452+
finally:
1453+
interactive_loop.close()
14851454

14861455
if AGENTOPS_AVAILABLE:
14871456
import agentops

src/praisonai/praisonai/framework_adapters/autogen_adapter.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
import logging
8-
from typing import Dict, List, Any
8+
from typing import Dict, List, Any, Optional, Callable
99
from .base import BaseFrameworkAdapter
1010

1111
logger = logging.getLogger(__name__)
@@ -26,14 +26,28 @@ def is_available(self) -> bool:
2626
except ImportError:
2727
return False
2828

29-
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
29+
def run(
30+
self,
31+
config: Dict[str, Any],
32+
llm_config: List[Dict],
33+
topic: str,
34+
*,
35+
tools_dict: Optional[Dict[str, Any]] = None,
36+
agent_callback: Optional[Callable] = None,
37+
task_callback: Optional[Callable] = None,
38+
cli_config: Optional[Dict[str, Any]] = None,
39+
) -> str:
3040
"""
3141
Run AutoGen v0.2 with given configuration.
3242
3343
Args:
3444
config: AutoGen configuration with agents
3545
llm_config: LLM configuration list
3646
topic: Topic for the tasks
47+
tools_dict: Available tools dictionary
48+
agent_callback: Callback for agent events
49+
task_callback: Callback for task events
50+
cli_config: CLI configuration
3751
3852
Returns:
3953
Execution result as string
@@ -110,14 +124,28 @@ def is_available(self) -> bool:
110124
except ImportError:
111125
return False
112126

113-
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
127+
def run(
128+
self,
129+
config: Dict[str, Any],
130+
llm_config: List[Dict],
131+
topic: str,
132+
*,
133+
tools_dict: Optional[Dict[str, Any]] = None,
134+
agent_callback: Optional[Callable] = None,
135+
task_callback: Optional[Callable] = None,
136+
cli_config: Optional[Dict[str, Any]] = None,
137+
) -> str:
114138
"""
115139
Run AutoGen v0.4 with given configuration.
116140
117141
Args:
118142
config: AutoGen v0.4 configuration with agents
119143
llm_config: LLM configuration list
120144
topic: Topic for the tasks
145+
tools_dict: Available tools dictionary
146+
agent_callback: Callback for agent events
147+
task_callback: Callback for task events
148+
cli_config: CLI configuration
121149
122150
Returns:
123151
Execution result as string
@@ -128,7 +156,7 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str
128156
# For now, return a proper error message instead of delegating
129157
# TODO: Implement full AutoGen v0.4 adapter logic
130158
logger.warning("AutoGen v0.4 adapter is not yet fully implemented")
131-
return "### AutoGen v0.4 Output ###\nAutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support."
159+
raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.")
132160

133161

134162
class AG2Adapter(BaseFrameworkAdapter):
@@ -148,14 +176,28 @@ def is_available(self) -> bool:
148176
except Exception:
149177
return False
150178

151-
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
179+
def run(
180+
self,
181+
config: Dict[str, Any],
182+
llm_config: List[Dict],
183+
topic: str,
184+
*,
185+
tools_dict: Optional[Dict[str, Any]] = None,
186+
agent_callback: Optional[Callable] = None,
187+
task_callback: Optional[Callable] = None,
188+
cli_config: Optional[Dict[str, Any]] = None,
189+
) -> str:
152190
"""
153191
Run AG2 with given configuration.
154192
155193
Args:
156194
config: AG2 configuration with agents
157195
llm_config: LLM configuration list
158196
topic: Topic for the tasks
197+
tools_dict: Available tools dictionary
198+
agent_callback: Callback for agent events
199+
task_callback: Callback for task events
200+
cli_config: CLI configuration
159201
160202
Returns:
161203
Execution result as string
@@ -166,4 +208,4 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str
166208
# For now, return a proper error message instead of delegating
167209
# TODO: Implement full AG2 adapter logic
168210
logger.warning("AG2 adapter is not yet fully implemented")
169-
return "### AG2 Output ###\nAG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support."
211+
raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.")

src/praisonai/praisonai/tool_resolver.py

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from pathlib import Path
3131
from typing import Any, Callable, Dict, List, Mapping, Optional
3232
from types import MappingProxyType
33+
from ._safe_loader import load_user_module
3334

3435
logger = logging.getLogger(__name__)
3536

@@ -74,31 +75,16 @@ def _load_local_tools(self) -> Mapping[str, Callable]:
7475
if self._local_tools_loaded: # Double-check inside lock
7576
return self._local_tools_cache
7677

77-
# Security: Require explicit opt-in for local tools loading
78-
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
79-
logger.debug("Local tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.")
80-
self._local_tools_cache = MappingProxyType({})
81-
self._local_tools_loaded = True
82-
return self._local_tools_cache
83-
8478
tools_path = Path(self._tools_py_path)
85-
if not tools_path.exists():
86-
logger.debug(f"No local tools.py found at {tools_path}")
87-
self._local_tools_cache = MappingProxyType({})
88-
self._local_tools_loaded = True
89-
return self._local_tools_cache
90-
9179
try:
92-
spec = importlib.util.spec_from_file_location("tools", str(tools_path))
93-
if spec is None or spec.loader is None:
94-
logger.warning(f"Could not load spec for {tools_path}")
80+
# Use the same safe loader as other tools.py loading paths
81+
module = load_user_module(self._tools_py_path, name="tools")
82+
if module is None:
83+
logger.debug(f"Local tools loading disabled or tools.py not found at {self._tools_py_path}")
9584
self._local_tools_cache = MappingProxyType({})
9685
self._local_tools_loaded = True
9786
return self._local_tools_cache
9887

99-
module = importlib.util.module_from_spec(spec)
100-
spec.loader.exec_module(module)
101-
10288
# Build cache locally, then freeze
10389
cache: Dict[str, Callable] = {}
10490
for name, obj in inspect.getmembers(module):
@@ -108,13 +94,13 @@ def _load_local_tools(self) -> Mapping[str, Callable]:
10894
cache[name] = obj
10995
logger.debug(f"Loaded local tool: {name}")
11096

111-
logger.info(f"Loaded {len(cache)} tools from {tools_path}")
97+
logger.info(f"Loaded {len(cache)} tools from {self._tools_py_path}")
11298

11399
# Create immutable view to prevent concurrent modification
114100
self._local_tools_cache = MappingProxyType(cache)
115101

116102
except Exception as e:
117-
logger.warning(f"Error loading tools from {tools_path}: {e}")
103+
logger.warning(f"Error loading tools from {self._tools_py_path}: {e}")
118104
self._local_tools_cache = MappingProxyType({})
119105

120106
self._local_tools_loaded = True
@@ -381,6 +367,58 @@ def clear_cache(self) -> None:
381367
with self._local_tools_lock:
382368
self._local_tools_cache = MappingProxyType({})
383369
self._local_tools_loaded = False
370+
371+
def get_local_callables(self) -> List[Callable]:
372+
"""Get functions exposed by tools.py (path A semantics).
373+
374+
Returns:
375+
List of callable functions from tools.py
376+
"""
377+
local_tools = self._load_local_tools()
378+
return list(local_tools.values())
379+
380+
def get_local_tool_classes(self) -> Dict[str, Any]:
381+
"""Get BaseTool/langchain class instances from tools.py (path B semantics).
382+
383+
Returns:
384+
Dictionary mapping class names to instantiated tool objects
385+
"""
386+
try:
387+
# Use the same safe loader to get the module
388+
module = load_user_module(self._tools_py_path, name="tools_module")
389+
if module is None:
390+
return {}
391+
392+
# Import the necessary classes (matching agents_generator.py logic)
393+
BaseTool = None
394+
PRAISONAI_TOOLS_AVAILABLE = False
395+
try:
396+
from praisonai_tools import BaseTool
397+
PRAISONAI_TOOLS_AVAILABLE = True
398+
except ImportError:
399+
try:
400+
from praisonai.tools import BaseTool
401+
PRAISONAI_TOOLS_AVAILABLE = True
402+
except ImportError:
403+
pass
404+
405+
result = {}
406+
for name, obj in inspect.getmembers(module,
407+
lambda x: inspect.isclass(x) and (
408+
x.__module__.startswith('langchain_community.tools') or
409+
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
410+
) and x is not BaseTool):
411+
try:
412+
result[name] = obj()
413+
logger.debug(f"Loaded local tool class: {name}")
414+
except Exception as e:
415+
logger.warning(f"Error instantiating tool class {name}: {e}")
416+
continue
417+
418+
return result
419+
except Exception as e:
420+
logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
421+
return {}
384422

385423

386424
# Convenience functions that construct resolver explicitly (no global singleton)

0 commit comments

Comments
 (0)