feat: implement named toolset groups for PraisonAI tool registry (fixes #1841) (#1850)

* feat: implement named toolset groups for PraisonAI tool registry (fixes #1841)

This implements named toolset groups functionality to address the lack of
organized tool collections in PraisonAI's tool registry system.

Core SDK Changes (praisonaiagents):
- Add toolsets.py with ToolsetSpec dataclass and ToolsetRegistry
- Implement 8 prebuilt toolsets: web, files, code, system, scraping, research, safe, development
- Support toolset composition via includes with circular dependency detection
- Add TTL availability caching (30s) to ToolRegistry.list_available_tools()
- Update Agent.__init__ to accept toolsets parameter with automatic tool resolution
- Export toolsets functionality in main package __init__.py

Wrapper Layer Changes (praisonai):
- Extend ToolResolver with resolve_toolsets() and resolve_tools_and_toolsets()
- Add --toolset CLI flag to 'praisonai run' and 'praisonai chat' commands
- Integrate toolset resolution in CLI execution pipeline

Key Features:
- Named toolset groups for organized tool management
- Progressive disclosure (safe → research → development)
- Least-privilege tool surfaces for restricted environments
- Performance improvements via TTL caching
- Full backward compatibility with existing tools parameter
- Thread-safe operations for multi-agent scenarios

Usage:
- Python: Agent(role='researcher', toolsets=['research'])
- CLI: praisonai run 'task' --toolset research
- YAML: toolsets: [research] in agent configuration

Co-authored-by: MervinPraison <[email protected]>

* 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 <[email protected]>

* fix: resolve final registry.py merge conflicts

- Fix missed conflict in list_available_tools method
- Proper TTL caching using registry keys to avoid collisions
- Clean up clear() method to remove _functions reference

Co-authored-by: Mervin Praison <[email protected]>

* feat: complete named toolsets YAML integration and testing

Wire YAML support for toolsets:
- Add 'toolsets' to known_fields in agents_generator.py
- Update validate_yaml_tools to validate toolset references
- Update praisonai_adapter to pass toolsets from YAML to Agent

Enhance testing:
- Move test_toolsets.py to tests/unit/test_toolsets.py
- Convert to pytest conventions with proper cleanup
- Add real agentic tests requiring LLM calls per AGENTS.md
- Add comprehensive test coverage including YAML validation

Fix error handling:
- Remove ImportError swallowing in Agent.__init__ for fail-fast behavior
- Add clear error messages for invalid/missing toolsets

Add documentation and examples:
- Add examples/python/toolsets_example.py with comprehensive demos
- Add examples/yaml/toolsets_workflow.yaml showing YAML usage
- Include both simple usage and complex multi-agent workflows

All changes maintain backward compatibility and follow AGENTS.md guidelines.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-06-12 14:00:39 +01:00
1 parent 74273677a8
commit b0a2aa35d4
15 files changed
+1725 -21

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
"""
Named Toolsets Example
This example demonstrates how to use named toolsets to organize and reuse
groups of tools across agents and workflows.
Toolsets provide:
- Organized tool collections (web, research, development, etc.)
- Progressive disclosure (safe → research → development)
- Composable design (toolsets can include other toolsets)
- YAML configuration support
"""
import os
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.toolsets import (
list_toolsets,
resolve_toolset,
register_toolset
)
# Set up environment (replace with your actual API key)
os.environ.setdefault("OPENAI_API_KEY", "your-openai-api-key-here")
def demo_basic_toolsets():
"""Demonstrate basic toolset functionality."""
print("=== Basic Toolsets Demo ===")
# List available toolsets
available_toolsets = list_toolsets()
print(f"Available toolsets: {available_toolsets}")
# Show what tools are in each prebuilt toolset
for toolset_name in ["safe", "web", "research"]:
tools = resolve_toolset(toolset_name)
print(f"\n{toolset_name.upper()} toolset contains: {tools}")
def demo_agent_with_toolsets():
"""Demonstrate agents using toolsets."""
print("\n=== Agent with Toolsets Demo ===")
# Create a research agent with research toolset
researcher = Agent(
name="researcher",
role="Research Specialist",
goal="Research topics thoroughly using web tools",
instructions="You are a research specialist with access to web search and file tools.",
toolsets=["research"], # includes web tools + file tools
llm="gpt-4o-mini"
)
print(f"Research agent has {len(researcher.tools)} tools available")
print(f"Tools: {[getattr(t, '__name__', str(t)) for t in researcher.tools]}")
# Create a safe agent with minimal tools
safe_agent = Agent(
name="safe_assistant",
role="Safe Assistant",
goal="Help users safely without risky operations",
instructions="You are a helpful assistant with access to only safe, read-only tools.",
toolsets=["safe"],
llm="gpt-4o-mini"
)
print(f"\nSafe agent has {len(safe_agent.tools)} tools available")
print(f"Safe tools: {[getattr(t, '__name__', str(t)) for t in safe_agent.tools]}")
def demo_custom_toolsets():
"""Demonstrate creating and using custom toolsets."""
print("\n=== Custom Toolsets Demo ===")
# Register a custom toolset for data analysis
register_toolset(
"data_analysis",
tools=["read_file", "write_file"],
includes=["safe"], # Include safe tools too
description="Tools for data analysis workflows"
)
# Create an agent with the custom toolset
analyst = Agent(
name="data_analyst",
role="Data Analyst",
goal="Analyze data files and generate reports",
instructions="You specialize in data analysis. Read files, process data, and write reports.",
toolsets=["data_analysis"],
llm="gpt-4o-mini"
)
print(f"Data analyst has {len(analyst.tools)} tools available")
print(f"Analysis tools: {[getattr(t, '__name__', str(t)) for t in analyst.tools]}")
def demo_mixed_tools_and_toolsets():
"""Demonstrate mixing explicit tools with toolsets."""
print("\n=== Mixed Tools and Toolsets Demo ===")
# Create agent with both explicit tools and toolsets
hybrid_agent = Agent(
name="hybrid_assistant",
role="Hybrid Assistant",
goal="Assist with both research and custom tasks",
instructions="You can research topics and perform custom analysis tasks.",
tools=["write_file"], # Explicit tool
toolsets=["research"], # Toolset with web + file tools
llm="gpt-4o-mini"
)
print(f"Hybrid agent has {len(hybrid_agent.tools)} tools available")
tool_names = [getattr(t, '__name__', str(t)) for t in hybrid_agent.tools]
print(f"Hybrid tools: {tool_names}")
# Note: write_file might appear twice (once explicit, once from toolset)
# The agent handles deduplication automatically
def demo_multi_agent_with_toolsets():
"""Demonstrate multi-agent workflows with different toolsets."""
print("\n=== Multi-Agent with Toolsets Demo ===")
# Create agents with different toolsets for different roles
researcher = Agent(
name="researcher",
role="Research Specialist",
goal="Research topics and gather information",
toolsets=["research"],
llm="gpt-4o-mini"
)
writer = Agent(
name="writer",
role="Content Writer",
goal="Write content based on research",
toolsets=["safe"], # Safe tools for writing
llm="gpt-4o-mini"
)
# Create tasks for each agent
research_task = Task(
name="research_task",
description="Research the benefits of renewable energy",
expected_output="A summary of key benefits of renewable energy",
agent=researcher
)
writing_task = Task(
name="writing_task",
description="Write a blog post about renewable energy based on the research",
expected_output="A well-written blog post about renewable energy benefits",
agent=writer
)
# Create team with different toolsets per agent
team = AgentTeam(
agents=[researcher, writer],
tasks=[research_task, writing_task],
process="sequential"
)
print(f"Team created with {len(team.agents)} agents using different toolsets")
print(f"Research agent tools: {len(researcher.tools)}")
print(f"Writer agent tools: {len(writer.tools)}")
def demo_yaml_config():
"""Show how toolsets work in YAML configuration."""
print("\n=== YAML Configuration Example ===")
yaml_config = """
# agents.yaml - Example using toolsets
agents:
researcher:
role: Research Specialist
goal: Research topics thoroughly
toolsets:
- research # Includes web search, file tools
llm: gpt-4o-mini
analyst:
role: Data Analyst
goal: Analyze research data
tools:
- write_file # Explicit tool
toolsets:
- safe # Safe toolset
llm: gpt-4o-mini
tasks:
research_task:
description: Research renewable energy trends
agent: researcher
expected_output: Research summary
analysis_task:
description: Analyze the research data
agent: analyst
expected_output: Analysis report
"""
print("Example YAML configuration with toolsets:")
print(yaml_config)
print("Use: praisonai run 'Research renewable energy' --toolset research")
print("Or: praisonai chat --toolset web,files")
if __name__ == "__main__":
print("🛠️ PraisonAI Named Toolsets Example")
print("=" * 50)
# Run all demos
demo_basic_toolsets()
demo_agent_with_toolsets()
demo_custom_toolsets()
demo_mixed_tools_and_toolsets()
demo_multi_agent_with_toolsets()
demo_yaml_config()
print("\n✅ Toolsets example completed!")
print("\n💡 Key Benefits:")
print(" • Organized tool collections")
print(" • Progressive disclosure (safe → research → development)")
print(" • Reusable across agents and workflows")
print(" • YAML configuration support")
print(" • Composable design with includes")
+148
View File
@@ -0,0 +1,148 @@
# Named Toolsets YAML Example
# This example demonstrates using toolsets in YAML configuration
# Global configuration
config:
llm: gpt-4o-mini
memory: true
# Define agents with different toolsets
agents:
# Research agent with research toolset (includes web tools)
researcher:
role: Senior Research Analyst
goal: Research topics thoroughly using web search and analysis tools
instructions: |
You are a senior research analyst with access to web search and file tools.
Research topics comprehensively and provide detailed findings.
toolsets:
- research # Includes: web search, file tools, basic analysis
# Writer agent with safe toolset for writing tasks
writer:
role: Content Writer
goal: Create engaging content based on research
instructions: |
You are a skilled content writer. Transform research into clear,
engaging content for the target audience.
toolsets:
- safe # Safe, read-only tools for writing
# Developer agent with development toolset
developer:
role: Software Developer
goal: Create and modify code based on requirements
instructions: |
You are a software developer with access to development tools.
Write, test, and debug code according to specifications.
toolsets:
- development # Includes: code tools, file operations, testing
# Data analyst with mixed tools and toolsets
analyst:
role: Data Analyst
goal: Analyze data and generate insights
instructions: |
You are a data analyst. Process data files and generate
actionable insights and visualizations.
tools:
- read_file # Explicit tool
- write_file # Explicit tool
toolsets:
- safe # Safe toolset for analysis
# Security-focused agent with minimal toolset
security_auditor:
role: Security Auditor
goal: Review and audit for security issues
instructions: |
You are a security auditor with minimal tool access.
Focus on reading and analyzing without making changes.
toolsets:
- safe # Minimal, safe tools only
# Define tasks for the workflow
tasks:
# Research task using research toolset
research_phase:
description: |
Research the latest trends in AI agent frameworks.
Focus on:
- Popular frameworks and their features
- Industry adoption patterns
- Security considerations
- Performance benchmarks
expected_output: |
A comprehensive research report covering AI agent frameworks,
including trends, adoption, security, and performance data.
agent: researcher
# Content creation using safe toolset
content_creation:
description: |
Create a technical blog post based on the research findings.
The post should be:
- Well-structured with clear sections
- Accessible to technical and non-technical audiences
- Include actionable insights
- 1000-1500 words
expected_output: |
A polished blog post about AI agent frameworks suitable
for publication on a technical blog.
agent: writer
# Code development using development toolset
code_examples:
description: |
Create code examples demonstrating key concepts from the research.
Include:
- Simple "hello world" style examples
- Intermediate use cases
- Best practices examples
- Proper documentation
expected_output: |
A collection of well-documented code examples that illustrate
the concepts discussed in the research and blog post.
agent: developer
# Data analysis using mixed tools/toolsets
trend_analysis:
description: |
Analyze the research data to identify trends and patterns.
Create:
- Summary statistics
- Trend analysis
- Recommendations based on findings
expected_output: |
A data analysis report with trends, patterns, and
actionable recommendations for AI framework adoption.
agent: analyst
# Security review using minimal toolset
security_review:
description: |
Review all outputs for security considerations.
Check for:
- Potential security vulnerabilities in code examples
- Security best practices in recommendations
- Privacy considerations in data handling
expected_output: |
A security review report identifying any security concerns
and recommendations for secure implementation.
agent: security_auditor
# Workflow process (sequential execution)
process: sequential
# Optional: Define workflow dependencies
# tasks:
# research_phase:
# dependencies: []
# content_creation:
# dependencies: [research_phase]
# code_examples:
# dependencies: [research_phase]
# trend_analysis:
# dependencies: [research_phase]
# security_review:
# dependencies: [content_creation, code_examples, trend_analysis]
@@ -127,6 +127,18 @@ _LAZY_IMPORTS = {
'get_tool': ('praisonaiagents.tools.registry', 'get_tool'),
'ToolRegistry': ('praisonaiagents.tools.registry', 'ToolRegistry'),
# Toolsets (named toolset groups)
'ToolsetSpec': ('praisonaiagents.toolsets', 'ToolsetSpec'),
'ToolsetRegistry': ('praisonaiagents.toolsets', 'ToolsetRegistry'),
'get_toolset_registry': ('praisonaiagents.toolsets', 'get_toolset_registry'),
'register_toolset': ('praisonaiagents.toolsets', 'register_toolset'),
'resolve_toolset': ('praisonaiagents.toolsets', 'resolve_toolset'),
'resolve_toolsets': ('praisonaiagents.toolsets', 'resolve_toolsets'),
'list_toolsets': ('praisonaiagents.toolsets', 'list_toolsets'),
'get_toolset': ('praisonaiagents.toolsets', 'get_toolset'),
'unregister_toolset': ('praisonaiagents.toolsets', 'unregister_toolset'),
'has_toolset': ('praisonaiagents.toolsets', 'has_toolset'),
# Run outcomes - typed validation and agent execution results
'AgentRunOutcome': ('praisonaiagents.run_outcome', 'AgentRunOutcome'),
'RunStatus': ('praisonaiagents.run_outcome', 'RunStatus'),
@@ -631,6 +643,11 @@ def _custom_handler(name, cache):
mod = importlib.import_module('.db', 'praisonaiagents')
cache['db'] = mod
return mod
if name == 'toolsets':
import importlib
mod = importlib.import_module('.toolsets', 'praisonaiagents')
cache['toolsets'] = mod
return mod
raise AttributeError(f"Not handled by custom_handler: {name}")
@@ -523,6 +523,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
auth: Optional[str] = None, # Subscription auth provider: "claude-code", "codex", etc.
# Tools
tools: Optional[List[Any]] = None,
toolsets: Optional[List[str]] = None, # Named toolset groups to resolve
allow_delegation: bool = False, # Deprecated: use handoffs= instead
allow_code_execution: Optional[bool] = False, # Deprecated: use execution=ExecutionConfig(code_execution=True)
code_execution_mode: Literal["safe", "unsafe"] = "safe", # Deprecated: use execution=ExecutionConfig(code_mode="safe")
@@ -1667,6 +1668,32 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
else:
self.tools = tools or []
# Handle toolsets parameter - resolve named toolset groups
if toolsets:
try:
from ..toolsets import resolve_toolsets
toolset_tool_names = resolve_toolsets(toolsets)
# Remove duplicates with existing tools
existing_tool_names = set()
for tool in self.tools:
name = getattr(tool, 'name', getattr(tool, '__name__', str(tool)))
existing_tool_names.add(name)
unique_tool_names = [name for name in toolset_tool_names if name not in existing_tool_names]
toolset_tools = self._resolve_tool_names(unique_tool_names)
self.tools.extend(toolset_tools)
logging.debug(f"Resolved toolsets {toolsets} to {len(toolset_tools)} tools: {[getattr(t, '__name__', str(t)) for t in toolset_tools]}")
except (ValueError, KeyError) as e:
raise ValueError(
f"Agent '{getattr(self, 'display_name', 'unknown')}' failed to resolve toolsets {toolsets}: {e}. "
"Verify names with praisonaiagents.toolsets.list_toolsets()."
) from e
except ImportError as e:
raise ImportError(
f"Agent '{getattr(self, 'display_name', 'unknown')}' failed to import toolsets module: {e}. "
"Ensure praisonaiagents is properly installed."
) from e
# 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'):
@@ -16,6 +16,7 @@ Usage:
import logging
import threading
import time
from typing import Any, Callable, Dict, List, Optional, Union
from dataclasses import dataclass
@@ -90,6 +91,9 @@ class ToolRegistry:
self._tools: Dict[str, ToolEntry] = {} # Unified storage for tools and functions
self._discovered: bool = False
self._lock = threading.RLock() # Thread-safe operations for multi-agent scenarios
# TTL cache for availability checks (tool_name -> (is_available, timestamp))
self._availability_cache: Dict[str, tuple[bool, float]] = {}
self._availability_cache_ttl: float = 30.0 # seconds
def register(
self,
@@ -152,6 +156,8 @@ class ToolRegistry:
with self._lock:
if name in self._tools:
del self._tools[name]
# Evict cache entry to prevent stale growth
self._availability_cache.pop(name, None)
return True
return False
@@ -230,32 +236,60 @@ class ToolRegistry:
return definitions
def list_available_tools(self, context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]:
def list_available_tools(
self,
context: Optional[Dict[str, Any]] = None,
ttl_seconds: Optional[float] = None
) -> List[Union[BaseTool, Callable]]:
"""List only currently available tools (those that pass availability checks).
Uses TTL cache to avoid expensive availability checks on every call.
Args:
context: Optional context for availability checks (unused currently)
ttl_seconds: Cache TTL override (default: 30.0 seconds)
Returns:
List of available tools (both BaseTool instances and callables)
"""
if ttl_seconds is None:
ttl_seconds = self._availability_cache_ttl
with self._lock:
available = []
current_time = time.time()
for entry in self._tools.values():
# Check tool entries with TTL availability caching
for registered_name, entry in self._tools.items():
if not entry.available:
continue
# Check if tool has availability checking capability
if hasattr(entry.tool, 'check_availability'):
# Check cache first using registry key (not tool.name) to avoid collisions
cache_entry = self._availability_cache.get(registered_name)
if cache_entry is not None:
cached_result, cached_time = cache_entry
if current_time - cached_time < ttl_seconds:
# Use cached result
if cached_result:
available.append(entry.tool)
continue
# Cache miss or expired - perform availability check
try:
is_available, reason = entry.tool.check_availability()
# Cache the result using registry key to avoid collisions
self._availability_cache[registered_name] = (is_available, current_time)
if is_available:
available.append(entry.tool)
elif reason:
logging.debug(f"Tool '{entry.name}' unavailable: {reason}")
logging.debug(f"Tool '{registered_name}' unavailable: {reason}")
except Exception as e:
logging.warning(f"Availability check failed for tool '{entry.name}': {e}")
logging.warning(f"Availability check failed for tool '{registered_name}': {e}")
# Cache as unavailable on error
self._availability_cache[registered_name] = (False, current_time)
else:
# No availability check = always available
available.append(entry.tool)
@@ -381,6 +415,7 @@ class ToolRegistry:
"""Clear all registered tools. Thread-safe."""
with self._lock:
self._tools.clear()
self._availability_cache.clear()
self._discovered = False
def __contains__(self, name: str) -> bool:
@@ -499,16 +534,20 @@ def discover_plugins() -> int:
return get_registry().discover_single_file_plugins()
def list_available_tools(context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]:
def list_available_tools(
context: Optional[Dict[str, Any]] = None,
ttl_seconds: Optional[float] = None
) -> List[Union[BaseTool, Callable]]:
"""List only currently available tools from the global registry.
Args:
context: Optional context for availability checks
ttl_seconds: Cache TTL override (default: 30.0 seconds)
Returns:
List of available tools
"""
return get_registry().list_available_tools(context)
return get_registry().list_available_tools(context, ttl_seconds)
def list_tools_with_allowed_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
@@ -0,0 +1,384 @@
"""Named toolset groups for organizing tools into reusable collections.
This module provides functionality to define named groups of tools that can be
enabled or disabled as a unit, supporting composition via includes and
scenario-specific agent deployments.
Usage:
from praisonaiagents import toolsets
# Define toolsets
toolsets.register_toolset("web", tools=["internet_search", "crawl4ai"])
toolsets.register_toolset("files", tools=["read_file", "write_file"])
toolsets.register_toolset("research", includes=["web", "files"])
# Use in agent
agent = Agent(role="researcher", toolsets=["research"])
"""
import logging
import threading
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set
logger = logging.getLogger(__name__)
@dataclass
class ToolsetSpec:
"""Specification for a named toolset group.
Attributes:
name: Unique name for the toolset
tools: List of tool names included directly in this toolset
includes: List of other toolset names to include (recursive composition)
description: Optional description of the toolset's purpose
"""
name: str
tools: List[str] = field(default_factory=list)
includes: List[str] = field(default_factory=list)
description: str = ""
class ToolsetRegistry:
"""Registry for managing named toolset groups.
Provides thread-safe registration and resolution of toolset groups,
with support for recursive composition via includes.
"""
def __init__(self):
self._toolsets: Dict[str, ToolsetSpec] = {}
self._lock = threading.RLock() # Thread-safe for multi-agent scenarios
self._prebuilt_loaded = False
def register_toolset(
self,
name: str,
tools: Optional[List[str]] = None,
includes: Optional[List[str]] = None,
description: str = "",
overwrite: bool = False
) -> None:
"""Register a named toolset.
Args:
name: Unique name for the toolset
tools: List of tool names to include directly
includes: List of other toolset names to include
description: Optional description
overwrite: If True, overwrite existing toolset with same name
Raises:
ValueError: If toolset already exists and overwrite=False
"""
with self._lock:
if name in self._toolsets and not overwrite:
logger.debug(f"Toolset '{name}' already registered, skipping")
return
toolset = ToolsetSpec(
name=name,
tools=list(tools) if tools else [],
includes=list(includes) if includes else [],
description=description
)
self._toolsets[name] = toolset
logger.debug(f"Registered toolset: {name}")
def unregister_toolset(self, name: str) -> bool:
"""Remove a toolset from the registry.
Args:
name: Toolset name to remove
Returns:
True if toolset was removed, False if not found
"""
with self._lock:
if name in self._toolsets:
del self._toolsets[name]
logger.debug(f"Unregistered toolset: {name}")
return True
return False
def get_toolset(self, name: str) -> Optional[ToolsetSpec]:
"""Get a toolset by name.
Args:
name: Toolset name
Returns:
ToolsetSpec or None if not found
"""
with self._lock:
self._ensure_prebuilt_loaded()
spec = self._toolsets.get(name)
if spec is None:
return None
# Return defensive copy to prevent external mutation
return ToolsetSpec(
name=spec.name,
tools=list(spec.tools),
includes=list(spec.includes),
description=spec.description,
)
def list_toolsets(self) -> List[str]:
"""List all registered toolset names."""
with self._lock:
self._ensure_prebuilt_loaded()
return list(self._toolsets.keys())
def resolve_toolset(self, name: str) -> List[str]:
"""Resolve a toolset name to a flat list of tool names.
Recursively expands includes to produce the final list of tools.
Handles circular dependencies by tracking visited toolsets.
Args:
name: Toolset name to resolve
Returns:
List of unique tool names
Raises:
ValueError: If toolset not found or circular dependency detected
"""
with self._lock:
self._ensure_prebuilt_loaded()
tools = self._resolve_toolset_recursive(name, set())
# Ensure uniqueness while preserving order
seen = set()
unique_tools = []
for tool in tools:
if tool not in seen:
seen.add(tool)
unique_tools.append(tool)
return unique_tools
def resolve_toolsets(self, names: List[str]) -> List[str]:
"""Resolve multiple toolset names to a flat list of tool names.
Args:
names: List of toolset names to resolve
Returns:
List of unique tool names from all toolsets
"""
with self._lock:
all_tools = []
for name in names:
tools = self.resolve_toolset(name)
all_tools.extend(tools)
# Return unique tools while preserving order
seen = set()
unique_tools = []
for tool in all_tools:
if tool not in seen:
seen.add(tool)
unique_tools.append(tool)
return unique_tools
def _resolve_toolset_recursive(self, name: str, visited: Set[str]) -> List[str]:
"""Recursive helper for toolset resolution with cycle detection."""
if name in visited:
raise ValueError(f"Circular dependency detected in toolset: {name}")
toolset = self._toolsets.get(name)
if toolset is None:
raise ValueError(f"Toolset not found: {name}")
visited.add(name)
# Collect tools from this toolset
all_tools = list(toolset.tools)
# Recursively resolve included toolsets
for include_name in toolset.includes:
included_tools = self._resolve_toolset_recursive(include_name, visited.copy())
all_tools.extend(included_tools)
return all_tools
def _ensure_prebuilt_loaded(self):
"""Ensure prebuilt toolsets are loaded exactly once."""
if not self._prebuilt_loaded:
self._load_prebuilt_toolsets()
self._prebuilt_loaded = True
def _load_prebuilt_toolsets(self):
"""Load standard prebuilt toolsets that ship with PraisonAI."""
# Web-related tools
self.register_toolset(
"web",
tools=["internet_search", "duckduckgo", "searxng_search", "tavily_search", "exa_search"],
description="Web search and crawling tools"
)
# File system tools
self.register_toolset(
"files",
tools=["read_file", "write_file", "list_files", "get_file_info", "copy_file", "move_file", "delete_file"],
description="File system operations"
)
# Code execution tools
self.register_toolset(
"code",
tools=["execute_code", "analyze_code", "format_code", "lint_code"],
description="Python code execution and analysis"
)
# System administration tools
self.register_toolset(
"system",
tools=["execute_command", "list_processes", "kill_process", "get_system_info"],
description="System administration and shell operations"
)
# Web scraping/crawling tools
self.register_toolset(
"scraping",
tools=["scrape_page", "extract_links", "crawl", "extract_text"],
description="Web page scraping and content extraction"
)
# Research workflow (composition example)
self.register_toolset(
"research",
tools=[], # No direct tools, only composed ones
includes=["web", "files", "scraping"],
description="Complete research workflow with web search, file ops, and scraping"
)
# Safe toolset for restricted environments
self.register_toolset(
"safe",
tools=["internet_search", "read_file", "tavily_search"],
description="Minimal safe toolset for restricted environments"
)
# Development workflow
self.register_toolset(
"development",
tools=[],
includes=["code", "files", "system"],
description="Complete development workflow with code execution, files, and system access"
)
logger.debug("Loaded prebuilt toolsets: web, files, code, system, scraping, research, safe, development")
def clear(self) -> None:
"""Clear all registered toolsets."""
with self._lock:
self._toolsets.clear()
self._prebuilt_loaded = False
def __contains__(self, name: str) -> bool:
with self._lock:
self._ensure_prebuilt_loaded()
return name in self._toolsets
def __len__(self) -> int:
with self._lock:
self._ensure_prebuilt_loaded()
return len(self._toolsets)
def __repr__(self) -> str:
with self._lock:
self._ensure_prebuilt_loaded()
return f"ToolsetRegistry(toolsets={len(self._toolsets)})"
# Global registry instance (protected by _registry_lock for thread safety)
_registry_lock = threading.Lock()
_global_registry: Optional[ToolsetRegistry] = None
def get_toolset_registry() -> ToolsetRegistry:
"""Get the global toolset registry instance. Thread-safe singleton."""
global _global_registry
if _global_registry is None:
with _registry_lock:
# Double-checked locking pattern
if _global_registry is None:
_global_registry = ToolsetRegistry()
return _global_registry
def register_toolset(
name: str,
tools: Optional[List[str]] = None,
includes: Optional[List[str]] = None,
description: str = "",
overwrite: bool = False
) -> None:
"""Register a named toolset with the global registry.
Args:
name: Unique name for the toolset
tools: List of tool names to include directly
includes: List of other toolset names to include
description: Optional description
overwrite: If True, overwrite existing toolset
"""
get_toolset_registry().register_toolset(name, tools, includes, description, overwrite)
def resolve_toolset(name: str) -> List[str]:
"""Resolve a toolset name to a flat list of tool names.
Args:
name: Toolset name to resolve
Returns:
List of unique tool names
"""
return get_toolset_registry().resolve_toolset(name)
def resolve_toolsets(names: List[str]) -> List[str]:
"""Resolve multiple toolset names to a flat list of tool names.
Args:
names: List of toolset names to resolve
Returns:
List of unique tool names from all toolsets
"""
return get_toolset_registry().resolve_toolsets(names)
def list_toolsets() -> List[str]:
"""List all registered toolset names."""
return get_toolset_registry().list_toolsets()
def get_toolset(name: str) -> Optional[ToolsetSpec]:
"""Get a toolset by name."""
return get_toolset_registry().get_toolset(name)
def unregister_toolset(name: str) -> bool:
"""Remove a toolset from the global registry.
Args:
name: Toolset name to remove
Returns:
True if removed, False if not found
"""
return get_toolset_registry().unregister_toolset(name)
def has_toolset(name: str) -> bool:
"""Check if a toolset is registered.
Args:
name: Toolset name to check
Returns:
True if toolset exists, False otherwise
"""
return name in get_toolset_registry()
@@ -0,0 +1,329 @@
"""
Tests for named toolsets functionality.
These tests verify that the toolset system works correctly, including:
- Basic registration and resolution
- Agent integration
- Prebuilt toolsets
- Real agentic execution
"""
import os
import uuid
import pytest
import logging
from unittest.mock import patch
# Set up minimal environment for testing
os.environ.setdefault("OPENAI_API_KEY", "test-key")
logger = logging.getLogger(__name__)
class TestToolsetsBasic:
"""Test basic toolset functionality."""
def test_toolset_imports(self):
"""Test that toolset functions can be imported."""
from praisonaiagents.toolsets import (
register_toolset,
resolve_toolset,
list_toolsets,
get_toolset,
unregister_toolset
)
# If we get here without ImportError, imports work
assert callable(register_toolset)
assert callable(resolve_toolset)
assert callable(list_toolsets)
assert callable(get_toolset)
assert callable(unregister_toolset)
def test_toolset_registration_and_resolution(self):
"""Test registering and resolving custom toolsets."""
from praisonaiagents.toolsets import (
register_toolset,
resolve_toolset,
list_toolsets,
unregister_toolset
)
# Use unique names to avoid test pollution
test_toolset_name = f"test_toolset_{uuid.uuid4().hex[:8]}"
composed_toolset_name = f"composed_test_{uuid.uuid4().hex[:8]}"
try:
# Register a simple toolset
register_toolset(
test_toolset_name,
tools=["tool1", "tool2"],
description="Test toolset"
)
# Test resolution
tools = resolve_toolset(test_toolset_name)
assert tools == ["tool1", "tool2"]
# Test listing includes our toolset
toolset_list = list_toolsets()
assert test_toolset_name in toolset_list
# Test composition via includes
register_toolset(
composed_toolset_name,
tools=["tool3"],
includes=[test_toolset_name]
)
composed_tools = resolve_toolset(composed_toolset_name)
assert "tool1" in composed_tools
assert "tool2" in composed_tools
assert "tool3" in composed_tools
finally:
# Cleanup
try:
unregister_toolset(composed_toolset_name)
unregister_toolset(test_toolset_name)
except Exception:
pass
def test_prebuilt_toolsets(self):
"""Test that prebuilt toolsets are available."""
from praisonaiagents.toolsets import resolve_toolset, list_toolsets
# Check that prebuilt toolsets exist
toolset_list = list_toolsets()
assert "web" in toolset_list
assert "research" in toolset_list
assert "safe" in toolset_list
# Test resolving prebuilt toolsets
web_tools = resolve_toolset("web")
assert isinstance(web_tools, list)
assert len(web_tools) > 0
research_tools = resolve_toolset("research")
assert isinstance(research_tools, list)
assert len(research_tools) > 0
# Research should include web tools (via includes)
for tool in web_tools:
assert tool in research_tools, f"Web tool {tool} not found in research toolset"
safe_tools = resolve_toolset("safe")
assert isinstance(safe_tools, list)
assert len(safe_tools) >= 0 # Safe might be empty, that's okay
class TestAgentIntegration:
"""Test Agent integration with toolsets."""
def test_agent_with_toolsets_parameter(self):
"""Test creating agents with toolsets parameter."""
from praisonaiagents.agent.agent import Agent
from praisonaiagents.toolsets import register_toolset, unregister_toolset
test_toolset_name = f"agent_test_{uuid.uuid4().hex[:8]}"
try:
# Register a test toolset with real tool names
register_toolset(
test_toolset_name,
tools=["internet_search", "read_file"],
description="Test toolset for agent integration"
)
# Create agent with toolsets
agent = Agent(
name="test_agent",
role="Test agent",
toolsets=[test_toolset_name]
)
# Check that tools were resolved
tool_names = []
for tool in agent.tools:
if hasattr(tool, '__name__'):
tool_names.append(tool.__name__)
elif hasattr(tool, 'name'):
tool_names.append(tool.name)
# Verify expected tools are present
assert "internet_search" in tool_names, f"internet_search not resolved. Available: {tool_names}"
assert "read_file" in tool_names, f"read_file not resolved. Available: {tool_names}"
finally:
try:
unregister_toolset(test_toolset_name)
except Exception:
pass
def test_agent_with_tools_and_toolsets(self):
"""Test agents with both explicit tools and toolsets."""
from praisonaiagents.agent.agent import Agent
from praisonaiagents.toolsets import register_toolset, unregister_toolset
test_toolset_name = f"mixed_test_{uuid.uuid4().hex[:8]}"
try:
# Register a test toolset
register_toolset(
test_toolset_name,
tools=["internet_search"],
description="Test toolset for mixed integration"
)
# Create agent with both tools and toolsets
agent = Agent(
name="mixed_agent",
role="Mixed test agent",
tools=["write_file"], # explicit tool
toolsets=[test_toolset_name] # toolset
)
# Check tools in agent
tool_names = []
for tool in agent.tools:
if hasattr(tool, '__name__'):
tool_names.append(tool.__name__)
elif hasattr(tool, 'name'):
tool_names.append(tool.name)
assert "write_file" in tool_names, f"explicit tool write_file missing. Available: {tool_names}"
assert "internet_search" in tool_names, f"toolset tool internet_search missing. Available: {tool_names}"
finally:
try:
unregister_toolset(test_toolset_name)
except Exception:
pass
def test_agent_invalid_toolset_fails_fast(self):
"""Test that agent creation fails fast with invalid toolsets."""
from praisonaiagents.agent.agent import Agent
# Should raise ValueError for invalid toolset
with pytest.raises((ValueError, KeyError, ImportError)):
Agent(
name="fail_agent",
role="Should fail",
toolsets=["nonexistent_toolset_12345"]
)
class TestAgenticExecution:
"""Test real agentic execution with toolsets (requires LLM)."""
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY") == "test-key",
reason="Real LLM test requires OPENAI_API_KEY environment variable"
)
def test_agent_real_agentic_execution_with_toolsets(self):
"""
Real agentic test: Agent runs end-to-end with LLM using toolsets.
This is a MANDATORY real agentic test per AGENTS.md requirements.
The agent MUST call the LLM and produce actual output.
"""
from praisonaiagents.agent.agent import Agent
# Create agent with safe toolset (minimal tools for testing)
agent = Agent(
name="test_agent",
instructions="You are a helpful assistant. Respond briefly and directly.",
toolsets=["safe"], # Use safe toolset for minimal functionality
llm="gpt-4o-mini" # Use cheaper model for testing
)
# REAL AGENTIC TEST: Agent must call LLM
result = agent.start("Say hello in exactly one sentence. Be brief.")
# Validate that we got a real response
assert result is not None, "Agent returned None result"
assert isinstance(result, str), f"Expected string result, got {type(result)}"
assert len(result.strip()) > 0, "Agent returned empty result"
assert len(result) < 1000, f"Response too long ({len(result)} chars), expected brief response"
# Log the result so developers can verify end-to-end behavior
print(f"\n🤖 Agent Response: {result}")
print(f"✅ Real agentic test passed - Agent successfully used toolsets and called LLM")
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY") == "test-key",
reason="Real LLM test requires OPENAI_API_KEY environment variable"
)
def test_agent_with_research_toolset_real(self):
"""
Test agent with research toolset in real execution.
This verifies that toolsets are properly resolved and available to the LLM.
"""
from praisonaiagents.agent.agent import Agent
# Create agent with research toolset
agent = Agent(
name="researcher",
instructions="You are a research assistant with access to search tools. Be concise.",
toolsets=["research"],
llm="gpt-4o-mini"
)
# Real execution - agent should respond and have tools available
result = agent.start("Briefly explain what tools you have access to.")
# Validate response
assert result is not None, "Agent returned None result"
assert isinstance(result, str), "Expected string result"
assert len(result.strip()) > 0, "Agent returned empty result"
# Print result for verification
print(f"\n🔬 Research Agent Response: {result}")
print(f"🛠️ Research Agent Tools: {[getattr(t, '__name__', str(t)) for t in agent.tools]}")
print(f"✅ Research toolset integration test passed")
class TestYAMLIntegration:
"""Test YAML configuration support for toolsets."""
def test_yaml_toolset_validation(self):
"""Test that YAML validation includes toolsets."""
from praisonai.tool_resolver import validate_yaml_tools
# Valid YAML with toolsets
valid_config = {
"agents": {
"researcher": {
"role": "Research Agent",
"goal": "Research topics",
"tools": ["internet_search"],
"toolsets": ["research"]
}
}
}
# Should not have missing tools/toolsets
missing = validate_yaml_tools(valid_config)
assert isinstance(missing, list)
# Note: May have missing tools if not available, but shouldn't crash
# Invalid YAML with nonexistent toolset
invalid_config = {
"agents": {
"researcher": {
"role": "Research Agent",
"toolsets": ["nonexistent_toolset_xyz123"]
}
}
}
missing = validate_yaml_tools(invalid_config)
assert isinstance(missing, list)
# Should have at least one missing entry for invalid toolset
toolset_errors = [m for m in missing if "toolset:" in m]
assert len(toolset_errors) > 0, "Should detect missing toolsets"
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v"])
+1 -1
View File
@@ -372,7 +372,7 @@ class AgentsGenerator:
config (dict): The parsed YAML configuration
"""
known_fields = {
'role', 'goal', 'instructions', 'backstory', 'tools', 'tasks', 'llm',
'role', 'goal', 'instructions', 'backstory', 'tools', 'toolsets', 'tasks', 'llm',
'function_calling_llm', 'allow_delegation', 'max_iter', 'max_rpm',
'max_execution_time', 'verbose', 'cache', 'system_template',
'prompt_template', 'response_template', 'tool_timeout', 'planning_tools',
@@ -60,6 +60,7 @@ def chat_main(
),
no_memory: bool = typer.Option(False, "--no-memory", help="Disable memory"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
toolset: Optional[str] = typer.Option(None, "--toolset", help="Named toolset groups (comma-separated, e.g., web,files)"),
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID to resume"),
continue_session: bool = typer.Option(False, "--continue", "-c", help="Continue last session"),
@@ -285,6 +286,7 @@ def _run_legacy_terminal_chat(
verbose: bool = False,
memory: bool = False,
tools: Optional[str] = None,
toolset: Optional[str] = None,
user_id: Optional[str] = None,
session_id: Optional[str] = None,
continue_session: bool = False,
@@ -301,6 +303,7 @@ def _run_legacy_terminal_chat(
args.verbose = verbose
args.memory = memory
args.tools = tools
args.toolset = toolset
args.user_id = user_id
args.resume_session = session_id if session_id else ('last' if continue_session else None)
args.no_acp = no_acp
@@ -26,6 +26,7 @@ def run_main(
trace: bool = typer.Option(False, "--trace", help="Enable tracing"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
toolset: Optional[str] = typer.Option(None, "--toolset", help="Named toolset groups (comma-separated, e.g., web,files)"),
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
@@ -124,6 +125,7 @@ def run_main(
trace=trace,
memory=memory,
tools=tools,
toolset=toolset,
max_tokens=max_tokens,
output_mode=output_mode,
approval=approval,
@@ -187,6 +189,7 @@ def _run_prompt(
trace: bool = False,
memory: bool = False,
tools: Optional[str] = None,
toolset: Optional[str] = None,
max_tokens: int = 16000,
output_mode: Optional[str] = None,
approval: Optional[str] = None,
@@ -245,6 +248,7 @@ def _run_prompt(
args.verbose = verbose
args.memory = memory
args.tools = tools
args.toolset = toolset
args.max_tokens = max_tokens
args.web_search = False
args.web_fetch = False
+44 -3
View File
@@ -959,6 +959,7 @@ class PraisonAI:
parser.add_argument("--expand-prompt", action="store_true", help="Expand short prompt into detailed prompt (works with any command)")
parser.add_argument("--expand-tools", type=str, help="Tools for prompt expander (e.g., 'internet_search' or path to tools.py)")
parser.add_argument("--tools", "-t", type=str, help="Path to tools.py file for research agent")
parser.add_argument("--toolset", type=str, help="Named toolset groups (comma-separated, e.g., web,files,research)")
parser.add_argument("--no-tools", action="store_true", help="Disable default built-in tools (for models that don't support tool calling)")
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)")
@@ -2161,6 +2162,34 @@ class PraisonAI:
print("[yellow]Warning: Could not import tools module[/yellow]")
return tools_list
def _load_toolsets(self, toolset_names: list) -> list:
"""
Load tools from named toolset groups.
Args:
toolset_names: List of toolset names to resolve
Returns:
List of tool functions from all toolsets
"""
tools_list = []
if not toolset_names:
return tools_list
try:
from ..tool_resolver import resolve_toolsets
tools_list = resolve_toolsets(toolset_names)
if tools_list:
print(f"[cyan]Loaded {len(tools_list)} tools from toolsets: {', '.join(toolset_names)}[/cyan]")
else:
print(f"[yellow]Warning: No tools found for toolsets: {', '.join(toolset_names)}[/yellow]")
except Exception as e:
print(f"[yellow]Warning: Failed to load toolsets {toolset_names}: {e}[/yellow]")
return tools_list
def handle_memory_command(self, action: str, action_args: list, user_id: str = None):
"""
@@ -4327,10 +4356,22 @@ Do NOT add any explanations or formatting."""
existing_tools = agent_config.get('tools', [])
if isinstance(existing_tools, list):
existing_tools.extend(tools_list)
agent_config["tools"] = existing_tools
else:
existing_tools = tools_list
agent_config['tools'] = existing_tools
print(f"[bold cyan]Tools loaded: {len(tools_list)} tool(s) available for agent[/bold cyan]")
agent_config["tools"] = tools_list
# Load toolsets if specified (--toolset flag)
if getattr(self.args, 'toolset', None):
toolset_names = [name.strip() for name in self.args.toolset.split(',') if name.strip()]
toolset_tools = self._load_toolsets(toolset_names) if toolset_names else []
if toolset_tools:
existing_tools = agent_config.get('tools', [])
if isinstance(existing_tools, list):
existing_tools.extend(toolset_tools)
agent_config["tools"] = existing_tools
else:
agent_config["tools"] = toolset_tools
print(f"[bold cyan]Toolsets loaded: {len(toolset_tools)} tool(s) from {self.args.toolset}[/bold cyan]")
# Planning Mode
if getattr(self.args, 'planning', False):
@@ -136,10 +136,13 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
agent_tools = details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
# Extract toolsets from YAML config
agent_toolsets = details.get('toolsets', [])
# Resolve per-agent LLM model
agent_model = self._resolve_agent_model(details, model_name)
# Create basic agent
# Create basic agent (pass both tools and toolsets)
agent = PraisonAgent(
name=role_filled,
role=role_filled,
@@ -149,6 +152,7 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
llm=agent_model,
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
toolsets=agent_toolsets,
)
if agent_callback:
@@ -315,10 +319,13 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
agent_tools = details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
# Extract toolsets from YAML config
agent_toolsets = details.get('toolsets', [])
# Resolve per-agent LLM model
agent_model = self._resolve_agent_model(details, model_name)
# Create basic agent
# Create basic agent (pass both tools and toolsets)
agent = PraisonAgent(
name=role_filled,
role=role_filled,
@@ -328,6 +335,7 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
llm=agent_model,
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
toolsets=agent_toolsets,
)
if agent_callback:
+125 -9
View File
@@ -412,13 +412,13 @@ class ToolResolver:
return available
def validate_yaml_tools(self, yaml_config: Dict[str, Any]) -> List[str]:
"""Validate that all tools referenced in YAML config can be resolved.
"""Validate that all tools and toolsets referenced in YAML config can be resolved.
Args:
yaml_config: Parsed YAML configuration dict
Returns:
List of tool names that could not be resolved (empty if all valid)
List of tool/toolset names that could not be resolved (empty if all valid)
"""
missing = []
@@ -431,15 +431,32 @@ class ToolResolver:
if not isinstance(role_config, dict):
continue
# Validate tools
tools = role_config.get('tools', [])
if not tools:
continue
if tools:
for tool_name in tools:
if not tool_name or not isinstance(tool_name, str):
continue
if not self.has_tool(tool_name.strip()):
missing.append(tool_name)
for tool_name in tools:
if not tool_name or not isinstance(tool_name, str):
continue
if not self.has_tool(tool_name.strip()):
missing.append(tool_name)
# Validate toolsets
toolsets = role_config.get('toolsets', [])
if toolsets:
try:
from praisonaiagents.toolsets import list_toolsets
available_toolsets = set(list_toolsets())
for toolset_name in toolsets:
if not toolset_name or not isinstance(toolset_name, str):
continue
if toolset_name.strip() not in available_toolsets:
missing.append(f"toolset:{toolset_name}")
except ImportError:
# If toolsets module not available, mark all as missing
for toolset_name in toolsets:
if toolset_name and isinstance(toolset_name, str):
missing.append(f"toolset:{toolset_name}")
return list(set(missing)) # Remove duplicates
@@ -534,6 +551,74 @@ class ToolResolver:
continue
return result
def resolve_toolsets(self, toolset_names: List[str]) -> List[Callable]:
"""Resolve named toolset groups to callables.
Expands each toolset to tool names, then resolves to callables.
Args:
toolset_names: List of toolset names to resolve
Returns:
List of resolved callables from all toolsets
"""
if not toolset_names:
return []
try:
from praisonaiagents.toolsets import resolve_toolsets
# Resolve toolset names to tool names
tool_names = resolve_toolsets(toolset_names)
logger.debug(f"Resolved toolsets {toolset_names} to tools: {tool_names}")
# Resolve tool names to callables
return self.resolve_many(tool_names)
except ImportError as e:
logger.warning(f"Toolset support unavailable: {e}")
return []
except (ValueError, KeyError) as e:
raise ValueError(
f"Failed to resolve toolsets {toolset_names}: {e}. "
"Check toolset names and includes."
) from e
def resolve_tools_and_toolsets(
self,
tool_names: Optional[List[str]] = None,
toolset_names: Optional[List[str]] = None
) -> List[Callable]:
"""Resolve both individual tools and toolset groups to callables.
Args:
tool_names: List of individual tool names
toolset_names: List of toolset names to expand
Returns:
Combined list of callables from tools and toolsets
"""
all_tools = []
# Add explicit tools
if tool_names:
all_tools.extend(self.resolve_many(tool_names))
# Add toolset tools
if toolset_names:
all_tools.extend(self.resolve_toolsets(toolset_names))
# Deduplicate while preserving order
deduped: List[Callable] = []
seen: set[int] = set()
for tool in all_tools:
marker = id(tool)
if marker in seen:
continue
seen.add(marker)
deduped.append(tool)
return deduped
# Context-local resolver for multi-project safety
@@ -639,3 +724,34 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
def resolve_toolsets(toolset_names: List[str], resolver: Optional[ToolResolver] = None) -> List[Callable]:
"""Resolve named toolset groups to callables.
Args:
toolset_names: List of toolset names to resolve
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
List of resolved callables from all toolsets
"""
return (resolver or _get_default_resolver()).resolve_toolsets(toolset_names)
def resolve_tools_and_toolsets(
tool_names: Optional[List[str]] = None,
toolset_names: Optional[List[str]] = None,
resolver: Optional[ToolResolver] = None
) -> List[Callable]:
"""Resolve both individual tools and toolset groups to callables.
Args:
tool_names: List of individual tool names
toolset_names: List of toolset names to expand
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
Combined list of callables from tools and toolsets
"""
return (resolver or _get_default_resolver()).resolve_tools_and_toolsets(tool_names, toolset_names)
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Simple test script to verify toolsets functionality works correctly.
"""
import sys
import os
# Add the praisonai-agents package to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src', 'praisonai-agents'))
def test_toolset_basic():
"""Test basic toolset functionality."""
print("Testing basic toolset functionality...")
try:
# Import toolset functionality
from praisonaiagents.toolsets import (
register_toolset, resolve_toolset, list_toolsets, get_toolset
)
print("✓ Successfully imported toolset functions")
# Test registering a custom toolset with unique names to avoid pollution
import uuid
test_toolset_name = f"test_toolset_{uuid.uuid4().hex[:8]}"
composed_toolset_name = f"composed_test_{uuid.uuid4().hex[:8]}"
try:
register_toolset(
test_toolset_name,
tools=["tool1", "tool2"],
description="Test toolset"
)
print("✓ Successfully registered custom toolset")
# Test resolving the toolset
tools = resolve_toolset(test_toolset_name)
assert tools == ["tool1", "tool2"]
print("✓ Successfully resolved toolset")
# Test listing toolsets (should include prebuilt ones)
toolset_list = list_toolsets()
assert test_toolset_name in toolset_list
assert "web" in toolset_list
assert "research" in toolset_list
print(f"✓ Found {len(toolset_list)} toolsets: {toolset_list}")
# Test composition via includes
register_toolset(
composed_toolset_name,
tools=["tool3"],
includes=[test_toolset_name]
)
composed_tools = resolve_toolset(composed_toolset_name)
assert "tool1" in composed_tools
assert "tool2" in composed_tools
assert "tool3" in composed_tools
print("✓ Successfully tested toolset composition")
finally:
# Cleanup to avoid registry pollution
from praisonaiagents.toolsets import unregister_toolset
try:
unregister_toolset(composed_toolset_name)
unregister_toolset(test_toolset_name)
except Exception:
pass
print("All basic toolset tests passed!\n")
return True
except Exception as e:
print(f"✗ Basic toolset test failed: {e}")
import traceback
traceback.print_exc()
return False
def test_agent_integration():
"""Test that Agent class can use toolsets."""
print("Testing Agent integration with toolsets...")
try:
from praisonaiagents.agent.agent import Agent
from praisonaiagents.toolsets import register_toolset
# Register a simple test toolset with real tool names
register_toolset(
"agent_test",
tools=["internet_search", "read_file"],
description="Test toolset for agent integration"
)
print("✓ Successfully registered agent test toolset")
# Create agent with toolsets (without running it)
agent = Agent(
name="test_agent",
role="Test agent",
toolsets=["agent_test"]
)
print("✓ Successfully created Agent with toolsets")
# Check that tools were resolved
tool_names = []
for tool in agent.tools:
if hasattr(tool, '__name__'):
tool_names.append(tool.__name__)
elif hasattr(tool, 'name'):
tool_names.append(tool.name)
print(f"✓ Agent has {len(agent.tools)} tools: {tool_names}")
# Verify expected tools are present
assert "internet_search" in tool_names, f"internet_search was not resolved from toolset. Available: {tool_names}"
assert "read_file" in tool_names, f"read_file was not resolved from toolset. Available: {tool_names}"
print("✓ Verified expected tools are present")
# Test mixing tools and toolsets
agent2 = Agent(
name="test_agent2",
role="Test agent 2",
tools=["write_file"], # explicit tool
toolsets=["agent_test"] # toolset
)
print("✓ Successfully created Agent with both tools and toolsets")
# Check tools in agent2
tool_names2 = []
for tool in agent2.tools:
if hasattr(tool, '__name__'):
tool_names2.append(tool.__name__)
elif hasattr(tool, 'name'):
tool_names2.append(tool.name)
assert "write_file" in tool_names2, f"explicit tool write_file missing. Available: {tool_names2}"
assert "internet_search" in tool_names2, f"toolset tool internet_search missing. Available: {tool_names2}"
print("✓ Verified mixed tools and toolsets work correctly")
print("All agent integration tests passed!\n")
return True
except Exception as e:
print(f"✗ Agent integration test failed: {e}")
import traceback
traceback.print_exc()
return False
def test_prebuilt_toolsets():
"""Test that prebuilt toolsets work correctly."""
print("Testing prebuilt toolsets...")
try:
from praisonaiagents.toolsets import resolve_toolset, list_toolsets
# Test resolving prebuilt toolsets
web_tools = resolve_toolset("web")
print(f"✓ Web toolset has {len(web_tools)} tools: {web_tools}")
research_tools = resolve_toolset("research")
print(f"✓ Research toolset has {len(research_tools)} tools: {research_tools}")
# Research should include web tools (via includes)
for tool in web_tools:
assert tool in research_tools, f"Web tool {tool} not found in research toolset"
print("✓ Research toolset correctly includes web tools")
# Test safe toolset
safe_tools = resolve_toolset("safe")
print(f"✓ Safe toolset has {len(safe_tools)} tools: {safe_tools}")
print("All prebuilt toolset tests passed!\n")
return True
except Exception as e:
print(f"✗ Prebuilt toolset test failed: {e}")
import traceback
traceback.print_exc()
return False
def main():
"""Run all tests."""
print("=" * 60)
print("Testing PraisonAI Toolsets Implementation")
print("=" * 60)
results = []
# Run tests
results.append(test_toolset_basic())
results.append(test_agent_integration())
results.append(test_prebuilt_toolsets())
# Summary
print("=" * 60)
print("Test Summary:")
print("=" * 60)
passed = sum(results)
total = len(results)
print(f"Passed: {passed}/{total}")
if passed == total:
print("🎉 All tests passed! Toolsets implementation is working correctly.")
return 0
else:
print("❌ Some tests failed. Check the output above for details.")
return 1
if __name__ == "__main__":
exit(main())
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
Simple test to verify toolsets implementation works.
"""
import sys
import os
# Add the praisonai-agents package to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src', 'praisonai-agents'))
def test_basic_functionality():
"""Test basic toolsets functionality."""
print("Testing basic toolsets functionality...")
try:
from praisonaiagents.toolsets import list_toolsets, resolve_toolset
# Test listing toolsets
toolsets = list_toolsets()
print(f"✅ Available toolsets: {toolsets}")
# Test resolving a toolset
safe_tools = resolve_toolset("safe")
print(f"✅ Safe toolset tools: {safe_tools}")
research_tools = resolve_toolset("research")
print(f"✅ Research toolset tools: {research_tools[:5]}...") # Show first 5
return True
except Exception as e:
print(f"❌ Basic functionality test failed: {e}")
import traceback
traceback.print_exc()
return False
def test_agent_integration():
"""Test agent integration with toolsets."""
print("\nTesting Agent integration...")
# Set up minimal environment
os.environ.setdefault("OPENAI_API_KEY", "test-key")
try:
from praisonaiagents import Agent
# Test creating agent with toolsets
agent = Agent(
name="test_agent",
role="Test Agent",
toolsets=["safe"]
)
print(f"✅ Agent created with {len(agent.tools)} tools")
return True
except Exception as e:
print(f"❌ Agent integration test failed: {e}")
import traceback
traceback.print_exc()
return False
def test_yaml_validation():
"""Test YAML validation for toolsets."""
print("\nTesting YAML validation...")
# Add praisonai to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src', 'praisonai'))
try:
from praisonai.tool_resolver import validate_yaml_tools
# Test valid config
config = {
"agents": {
"test_agent": {
"role": "Test Agent",
"toolsets": ["safe"]
}
}
}
missing = validate_yaml_tools(config)
print(f"✅ YAML validation working. Missing items: {missing}")
return True
except Exception as e:
print(f"❌ YAML validation test failed: {e}")
import traceback
traceback.print_exc()
return False
def test_error_handling():
"""Test error handling for invalid toolsets."""
print("\nTesting error handling...")
os.environ.setdefault("OPENAI_API_KEY", "test-key")
try:
from praisonaiagents import Agent
# This should fail fast
try:
agent = Agent(
name="fail_agent",
role="Should Fail",
toolsets=["nonexistent_toolset_xyz"]
)
print("❌ Expected agent creation to fail with invalid toolset")
return False
except (ValueError, KeyError, ImportError) as e:
print(f"✅ Agent creation correctly failed with: {type(e).__name__}")
return True
except Exception as e:
print(f"❌ Error handling test failed: {e}")
import traceback
traceback.print_exc()
return False
def main():
"""Run all tests."""
print("🛠️ Testing PraisonAI Named Toolsets Implementation")
print("=" * 60)
tests = [
test_basic_functionality,
test_agent_integration,
test_yaml_validation,
test_error_handling
]
results = []
for test in tests:
results.append(test())
print("\n" + "=" * 60)
print("Test Summary:")
print("=" * 60)
passed = sum(results)
total = len(results)
print(f"Passed: {passed}/{total}")
if passed == total:
print("🎉 All tests passed! Named toolsets implementation is working!")
return 0
else:
print("❌ Some tests failed. Check output above.")
return 1
if __name__ == "__main__":
exit(main())