mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 04:35:58 -10:00
* 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:
15 files changed
+1725
-21
No files matched your search
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user