Skip to content

Commit 9c3bb7d

Browse files
fix: address critical issues found in code review
- Fix CLI tools write-back regression in main.py (silent tool dropping) - Improve error handling in Agent toolset resolution (fail fast on invalid toolsets) - Fix mutable state leakage in ToolsetRegistry (defensive copying) - Fix cache collision in ToolRegistry (use registry keys not tool.name) - Fix NameError in CLI commands (missing toolset parameters) - Add deduplication in tool resolution - Improve test reliability with cleanup and assertions - Filter empty toolset names from CLI input Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent aa2fcdd commit 9c3bb7d

8 files changed

Lines changed: 135 additions & 51 deletions

File tree

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,12 +1664,24 @@ def __init__(
16641664
try:
16651665
from ..toolsets import resolve_toolsets
16661666
toolset_tool_names = resolve_toolsets(toolsets)
1667-
toolset_tools = self._resolve_tool_names(toolset_tool_names)
1667+
# Remove duplicates with existing tools
1668+
existing_tool_names = set()
1669+
for tool in self.tools:
1670+
name = getattr(tool, 'name', getattr(tool, '__name__', str(tool)))
1671+
existing_tool_names.add(name)
1672+
1673+
unique_tool_names = [name for name in toolset_tool_names if name not in existing_tool_names]
1674+
toolset_tools = self._resolve_tool_names(unique_tool_names)
16681675
self.tools.extend(toolset_tools)
16691676
logging.debug(f"Resolved toolsets {toolsets} to {len(toolset_tools)} tools: {[getattr(t, '__name__', str(t)) for t in toolset_tools]}")
1670-
except Exception as e:
1677+
except (ValueError, KeyError) as e:
1678+
raise ValueError(
1679+
f"Agent '{getattr(self, 'display_name', 'unknown')}' failed to resolve toolsets {toolsets}: {e}. "
1680+
"Verify names with praisonaiagents.toolsets.list_toolsets()."
1681+
) from e
1682+
except ImportError as e:
16711683
logging.warning(f"Failed to resolve toolsets {toolsets}: {e}")
1672-
# Continue without toolsets rather than failing
1684+
# Continue without toolsets if module is unavailable
16731685

16741686
# Inject default tools for autonomy mode (after self.tools is initialized)
16751687
# ONLY inject if caller didn't provide tools - avoid duplicates with CLI/wrapper tools

src/praisonai-agents/praisonaiagents/tools/registry.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,13 @@ def unregister(self, name: str) -> bool:
111111
with self._lock:
112112
if name in self._tools:
113113
del self._tools[name]
114+
# Evict cache entry to prevent stale growth
115+
self._availability_cache.pop(name, None)
114116
return True
115117
if name in self._functions:
116118
del self._functions[name]
119+
# Evict cache entry to prevent stale growth
120+
self._availability_cache.pop(name, None)
117121
return True
118122
return False
119123

@@ -179,11 +183,11 @@ def list_available_tools(
179183
current_time = time.time()
180184

181185
# Check BaseTool instances
182-
for tool in self._tools.values():
186+
for registered_name, tool in self._tools.items():
183187
# Check if tool has availability checking capability
184188
if hasattr(tool, 'check_availability'):
185-
# Check cache first
186-
cache_entry = self._availability_cache.get(tool.name)
189+
# Check cache first using registry key (not tool.name)
190+
cache_entry = self._availability_cache.get(registered_name)
187191
if cache_entry is not None:
188192
cached_result, cached_time = cache_entry
189193
if current_time - cached_time < ttl_seconds:
@@ -195,17 +199,17 @@ def list_available_tools(
195199
# Cache miss or expired - perform availability check
196200
try:
197201
is_available, reason = tool.check_availability()
198-
# Cache the result
199-
self._availability_cache[tool.name] = (is_available, current_time)
202+
# Cache the result using registry key
203+
self._availability_cache[registered_name] = (is_available, current_time)
200204

201205
if is_available:
202206
available.append(tool)
203207
elif reason:
204-
logging.debug(f"Tool '{tool.name}' unavailable: {reason}")
208+
logging.debug(f"Tool '{registered_name}' unavailable: {reason}")
205209
except Exception as e:
206-
logging.warning(f"Availability check failed for tool '{tool.name}': {e}")
210+
logging.warning(f"Availability check failed for tool '{registered_name}': {e}")
207211
# Cache as unavailable on error
208-
self._availability_cache[tool.name] = (False, current_time)
212+
self._availability_cache[registered_name] = (False, current_time)
209213
else:
210214
# No availability check = always available
211215
available.append(tool)

src/praisonai-agents/praisonaiagents/toolsets.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ def register_toolset(
7979

8080
toolset = ToolsetSpec(
8181
name=name,
82-
tools=tools or [],
83-
includes=includes or [],
82+
tools=list(tools) if tools else [],
83+
includes=list(includes) if includes else [],
8484
description=description
8585
)
8686
self._toolsets[name] = toolset
@@ -113,7 +113,16 @@ def get_toolset(self, name: str) -> Optional[ToolsetSpec]:
113113
"""
114114
with self._lock:
115115
self._ensure_prebuilt_loaded()
116-
return self._toolsets.get(name)
116+
spec = self._toolsets.get(name)
117+
if spec is None:
118+
return None
119+
# Return defensive copy to prevent external mutation
120+
return ToolsetSpec(
121+
name=spec.name,
122+
tools=list(spec.tools),
123+
includes=list(spec.includes),
124+
description=spec.description,
125+
)
117126

118127
def list_toolsets(self) -> List[str]:
119128
"""List all registered toolset names."""
@@ -138,7 +147,15 @@ def resolve_toolset(self, name: str) -> List[str]:
138147
"""
139148
with self._lock:
140149
self._ensure_prebuilt_loaded()
141-
return self._resolve_toolset_recursive(name, set())
150+
tools = self._resolve_toolset_recursive(name, set())
151+
# Ensure uniqueness while preserving order
152+
seen = set()
153+
unique_tools = []
154+
for tool in tools:
155+
if tool not in seen:
156+
seen.add(tool)
157+
unique_tools.append(tool)
158+
return unique_tools
142159

143160
def resolve_toolsets(self, names: List[str]) -> List[str]:
144161
"""Resolve multiple toolset names to a flat list of tool names.

src/praisonai/praisonai/cli/commands/chat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ def _run_legacy_terminal_chat(
286286
verbose: bool = False,
287287
memory: bool = False,
288288
tools: Optional[str] = None,
289+
toolset: Optional[str] = None,
289290
user_id: Optional[str] = None,
290291
session_id: Optional[str] = None,
291292
continue_session: bool = False,

src/praisonai/praisonai/cli/commands/run.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def run_main(
125125
trace=trace,
126126
memory=memory,
127127
tools=tools,
128+
toolset=toolset,
128129
max_tokens=max_tokens,
129130
output_mode=output_mode,
130131
approval=approval,
@@ -188,6 +189,7 @@ def _run_prompt(
188189
trace: bool = False,
189190
memory: bool = False,
190191
tools: Optional[str] = None,
192+
toolset: Optional[str] = None,
191193
max_tokens: int = 16000,
192194
output_mode: Optional[str] = None,
193195
approval: Optional[str] = None,

src/praisonai/praisonai/cli/main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4356,15 +4356,19 @@ def handle_direct_prompt(self, prompt):
43564356
existing_tools = agent_config.get('tools', [])
43574357
if isinstance(existing_tools, list):
43584358
existing_tools.extend(tools_list)
4359+
agent_config["tools"] = existing_tools
4360+
else:
4361+
agent_config["tools"] = tools_list
43594362

43604363
# Load toolsets if specified (--toolset flag)
43614364
if getattr(self.args, 'toolset', None):
4362-
toolset_names = [name.strip() for name in self.args.toolset.split(',')]
4363-
toolset_tools = self._load_toolsets(toolset_names)
4365+
toolset_names = [name.strip() for name in self.args.toolset.split(',') if name.strip()]
4366+
toolset_tools = self._load_toolsets(toolset_names) if toolset_names else []
43644367
if toolset_tools:
43654368
existing_tools = agent_config.get('tools', [])
43664369
if isinstance(existing_tools, list):
43674370
existing_tools.extend(toolset_tools)
4371+
agent_config["tools"] = existing_tools
43684372
else:
43694373
agent_config["tools"] = toolset_tools
43704374
print(f"[bold cyan]Toolsets loaded: {len(toolset_tools)} tool(s) from {self.args.toolset}[/bold cyan]")

src/praisonai/praisonai/tool_resolver.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -541,9 +541,14 @@ def resolve_toolsets(self, toolset_names: List[str]) -> List[Callable]:
541541
# Resolve tool names to callables
542542
return self.resolve_many(tool_names)
543543

544-
except Exception as e:
545-
logger.warning(f"Failed to resolve toolsets {toolset_names}: {e}")
544+
except ImportError as e:
545+
logger.warning(f"Toolset support unavailable: {e}")
546546
return []
547+
except (ValueError, KeyError) as e:
548+
raise ValueError(
549+
f"Failed to resolve toolsets {toolset_names}: {e}. "
550+
"Check toolset names and includes."
551+
) from e
547552

548553
def resolve_tools_and_toolsets(
549554
self,
@@ -569,7 +574,16 @@ def resolve_tools_and_toolsets(
569574
if toolset_names:
570575
all_tools.extend(self.resolve_toolsets(toolset_names))
571576

572-
return all_tools
577+
# Deduplicate while preserving order
578+
deduped: List[Callable] = []
579+
seen: set[int] = set()
580+
for tool in all_tools:
581+
marker = id(tool)
582+
if marker in seen:
583+
continue
584+
seen.add(marker)
585+
deduped.append(tool)
586+
return deduped
573587

574588

575589
# Context-local resolver for multi-project safety

test_toolsets.py

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,50 @@ def test_toolset_basic():
2020
)
2121
print("✓ Successfully imported toolset functions")
2222

23-
# Test registering a custom toolset
24-
register_toolset(
25-
"test_toolset",
26-
tools=["tool1", "tool2"],
27-
description="Test toolset"
28-
)
29-
print("✓ Successfully registered custom toolset")
30-
31-
# Test resolving the toolset
32-
tools = resolve_toolset("test_toolset")
33-
assert tools == ["tool1", "tool2"]
34-
print("✓ Successfully resolved toolset")
35-
36-
# Test listing toolsets (should include prebuilt ones)
37-
toolset_list = list_toolsets()
38-
assert "test_toolset" in toolset_list
39-
assert "web" in toolset_list
40-
assert "research" in toolset_list
41-
print(f"✓ Found {len(toolset_list)} toolsets: {toolset_list}")
42-
43-
# Test composition via includes
44-
register_toolset(
45-
"composed_test",
46-
tools=["tool3"],
47-
includes=["test_toolset"]
48-
)
49-
composed_tools = resolve_toolset("composed_test")
50-
assert "tool1" in composed_tools
51-
assert "tool2" in composed_tools
52-
assert "tool3" in composed_tools
53-
print("✓ Successfully tested toolset composition")
23+
# Test registering a custom toolset with unique names to avoid pollution
24+
import uuid
25+
test_toolset_name = f"test_toolset_{uuid.uuid4().hex[:8]}"
26+
composed_toolset_name = f"composed_test_{uuid.uuid4().hex[:8]}"
27+
28+
try:
29+
register_toolset(
30+
test_toolset_name,
31+
tools=["tool1", "tool2"],
32+
description="Test toolset"
33+
)
34+
print("✓ Successfully registered custom toolset")
35+
36+
# Test resolving the toolset
37+
tools = resolve_toolset(test_toolset_name)
38+
assert tools == ["tool1", "tool2"]
39+
print("✓ Successfully resolved toolset")
40+
41+
# Test listing toolsets (should include prebuilt ones)
42+
toolset_list = list_toolsets()
43+
assert test_toolset_name in toolset_list
44+
assert "web" in toolset_list
45+
assert "research" in toolset_list
46+
print(f"✓ Found {len(toolset_list)} toolsets: {toolset_list}")
47+
48+
# Test composition via includes
49+
register_toolset(
50+
composed_toolset_name,
51+
tools=["tool3"],
52+
includes=[test_toolset_name]
53+
)
54+
composed_tools = resolve_toolset(composed_toolset_name)
55+
assert "tool1" in composed_tools
56+
assert "tool2" in composed_tools
57+
assert "tool3" in composed_tools
58+
print("✓ Successfully tested toolset composition")
59+
finally:
60+
# Cleanup to avoid registry pollution
61+
from praisonaiagents.toolsets import unregister_toolset
62+
try:
63+
unregister_toolset(composed_toolset_name)
64+
unregister_toolset(test_toolset_name)
65+
except Exception:
66+
pass
5467

5568
print("All basic toolset tests passed!\n")
5669
return True
@@ -95,6 +108,11 @@ def test_agent_integration():
95108

96109
print(f"✓ Agent has {len(agent.tools)} tools: {tool_names}")
97110

111+
# Verify expected tools are present
112+
assert "internet_search" in tool_names, f"internet_search was not resolved from toolset. Available: {tool_names}"
113+
assert "read_file" in tool_names, f"read_file was not resolved from toolset. Available: {tool_names}"
114+
print("✓ Verified expected tools are present")
115+
98116
# Test mixing tools and toolsets
99117
agent2 = Agent(
100118
name="test_agent2",
@@ -104,6 +122,18 @@ def test_agent_integration():
104122
)
105123
print("✓ Successfully created Agent with both tools and toolsets")
106124

125+
# Check tools in agent2
126+
tool_names2 = []
127+
for tool in agent2.tools:
128+
if hasattr(tool, '__name__'):
129+
tool_names2.append(tool.__name__)
130+
elif hasattr(tool, 'name'):
131+
tool_names2.append(tool.name)
132+
133+
assert "write_file" in tool_names2, f"explicit tool write_file missing. Available: {tool_names2}"
134+
assert "internet_search" in tool_names2, f"toolset tool internet_search missing. Available: {tool_names2}"
135+
print("✓ Verified mixed tools and toolsets work correctly")
136+
107137
print("All agent integration tests passed!\n")
108138
return True
109139

0 commit comments

Comments
 (0)