mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-08 15:48:29 +00:00
feat: Add support for hosted/server-side tools in agents
- Allow agents to accept both CrewAI tools and raw tool definitions (dicts) - Raw tool definitions are passed through unchanged to LLM providers - Maintain backward compatibility with existing CrewAI tools - Update BaseAgent.validate_tools() to handle dict tool definitions - Update parse_tools() to preserve raw tool definitions - Update utility functions to handle mixed tool types - Add comprehensive tests for new functionality Fixes #3338 Co-Authored-By: João <joao@crewai.com>
This commit is contained in:
@@ -537,14 +537,14 @@ class Agent(BaseAgent):
|
||||
)["output"]
|
||||
|
||||
def create_agent_executor(
|
||||
self, tools: Optional[List[BaseTool]] = None, task=None
|
||||
self, tools: Optional[List[Union[BaseTool, dict]]] = None, task=None
|
||||
) -> None:
|
||||
"""Create an agent executor for the agent.
|
||||
|
||||
Returns:
|
||||
An instance of the CrewAgentExecutor class.
|
||||
"""
|
||||
raw_tools: List[BaseTool] = tools or self.tools or []
|
||||
raw_tools: List[Union[BaseTool, dict]] = tools or self.tools or []
|
||||
parsed_tools = parse_tools(raw_tools)
|
||||
|
||||
prompt = Prompts(
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from copy import copy as shallow_copy
|
||||
from hashlib import md5
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
|
||||
|
||||
from pydantic import (
|
||||
UUID4,
|
||||
@@ -108,7 +108,7 @@ class BaseAgent(ABC, BaseModel):
|
||||
default=False,
|
||||
description="Enable agent to delegate and ask questions among each other.",
|
||||
)
|
||||
tools: Optional[List[BaseTool]] = Field(
|
||||
tools: Optional[List[Union[BaseTool, dict]]] = Field(
|
||||
default_factory=list, description="Tools at agents' disposal"
|
||||
)
|
||||
max_iter: int = Field(
|
||||
@@ -168,13 +168,12 @@ class BaseAgent(ABC, BaseModel):
|
||||
|
||||
@field_validator("tools")
|
||||
@classmethod
|
||||
def validate_tools(cls, tools: List[Any]) -> List[BaseTool]:
|
||||
def validate_tools(cls, tools: List[Any]) -> List[Union[BaseTool, dict]]:
|
||||
"""Validate and process the tools provided to the agent.
|
||||
|
||||
This method ensures that each tool is either an instance of BaseTool
|
||||
or an object with 'name', 'func', and 'description' attributes. If the
|
||||
tool meets these criteria, it is processed and added to the list of
|
||||
tools. Otherwise, a ValueError is raised.
|
||||
This method ensures that each tool is either an instance of BaseTool,
|
||||
an object with 'name', 'func', and 'description' attributes, or a
|
||||
raw tool definition (dict) for hosted/server-side tools.
|
||||
"""
|
||||
if not tools:
|
||||
return []
|
||||
@@ -184,13 +183,19 @@ class BaseAgent(ABC, BaseModel):
|
||||
for tool in tools:
|
||||
if isinstance(tool, BaseTool):
|
||||
processed_tools.append(tool)
|
||||
elif isinstance(tool, dict):
|
||||
if "name" not in tool:
|
||||
raise ValueError(
|
||||
f"Raw tool definition must have a 'name' field: {tool}"
|
||||
)
|
||||
processed_tools.append(tool)
|
||||
elif all(hasattr(tool, attr) for attr in required_attrs):
|
||||
# Tool has the required attributes, create a Tool instance
|
||||
processed_tools.append(Tool.from_langchain(tool))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid tool type: {type(tool)}. "
|
||||
"Tool must be an instance of BaseTool or "
|
||||
"Tool must be an instance of BaseTool, a dict for hosted tools, or "
|
||||
"an object with 'name', 'func', and 'description' attributes."
|
||||
)
|
||||
return processed_tools
|
||||
|
||||
@@ -48,7 +48,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
agent: BaseAgent,
|
||||
prompt: dict[str, str],
|
||||
max_iter: int,
|
||||
tools: List[CrewStructuredTool],
|
||||
tools: List[Union[CrewStructuredTool, dict]],
|
||||
tools_names: str,
|
||||
stop_words: List[str],
|
||||
tools_description: str,
|
||||
@@ -84,8 +84,8 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
self.messages: List[Dict[str, str]] = []
|
||||
self.iterations = 0
|
||||
self.log_error_after = 3
|
||||
self.tool_name_to_tool_map: Dict[str, Union[CrewStructuredTool, BaseTool]] = {
|
||||
tool.name: tool for tool in self.tools
|
||||
self.tool_name_to_tool_map: Dict[str, Union[CrewStructuredTool, BaseTool, dict]] = {
|
||||
tool.name if hasattr(tool, 'name') else tool.get('name', 'unknown'): tool for tool in self.tools
|
||||
}
|
||||
existing_stop = self.llm.stop or []
|
||||
self.llm.stop = list(
|
||||
|
||||
@@ -25,26 +25,28 @@ from crewai.cli.config import Settings
|
||||
|
||||
console = Console()
|
||||
|
||||
def parse_tools(tools: List[BaseTool]) -> List[CrewStructuredTool]:
|
||||
def parse_tools(tools: List[Union[BaseTool, dict]]) -> List[Union[CrewStructuredTool, dict]]:
|
||||
"""Parse tools to be used for the task."""
|
||||
tools_list = []
|
||||
|
||||
for tool in tools:
|
||||
if isinstance(tool, CrewAITool):
|
||||
if isinstance(tool, dict):
|
||||
tools_list.append(tool)
|
||||
elif isinstance(tool, BaseTool):
|
||||
tools_list.append(tool.to_structured_tool())
|
||||
else:
|
||||
raise ValueError("Tool is not a CrewStructuredTool or BaseTool")
|
||||
raise ValueError(f"Tool is not a CrewStructuredTool, BaseTool, or raw tool definition (dict): {type(tool)}")
|
||||
|
||||
return tools_list
|
||||
|
||||
|
||||
def get_tool_names(tools: Sequence[Union[CrewStructuredTool, BaseTool]]) -> str:
|
||||
def get_tool_names(tools: Sequence[Union[CrewStructuredTool, BaseTool, dict]]) -> str:
|
||||
"""Get the names of the tools."""
|
||||
return ", ".join([t.name for t in tools])
|
||||
return ", ".join([t.name if hasattr(t, 'name') else t.get('name', 'unknown') for t in tools])
|
||||
|
||||
|
||||
def render_text_description_and_args(
|
||||
tools: Sequence[Union[CrewStructuredTool, BaseTool]],
|
||||
tools: Sequence[Union[CrewStructuredTool, BaseTool, dict]],
|
||||
) -> str:
|
||||
"""Render the tool name, description, and args in plain text.
|
||||
|
||||
@@ -54,7 +56,12 @@ def render_text_description_and_args(
|
||||
"""
|
||||
tool_strings = []
|
||||
for tool in tools:
|
||||
tool_strings.append(tool.description)
|
||||
if hasattr(tool, 'description'):
|
||||
tool_strings.append(tool.description)
|
||||
elif isinstance(tool, dict) and 'description' in tool:
|
||||
tool_strings.append(f"Tool name: {tool.get('name', 'unknown')}\nTool description:\n{tool['description']}")
|
||||
else:
|
||||
tool_strings.append(f"Tool name: {tool.get('name', 'unknown') if isinstance(tool, dict) else 'unknown'}")
|
||||
|
||||
return "\n".join(tool_strings)
|
||||
|
||||
|
||||
239
tests/test_hosted_tools.py
Normal file
239
tests/test_hosted_tools.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from crewai import Agent, Task, Crew
|
||||
from crewai.tools import BaseTool
|
||||
from crewai.llm import LLM
|
||||
|
||||
|
||||
class MockTool(BaseTool):
|
||||
name: str = "mock_tool"
|
||||
description: str = "A mock tool for testing"
|
||||
|
||||
def _run(self, query: str) -> str:
|
||||
return f"Mock result for: {query}"
|
||||
|
||||
|
||||
def test_agent_with_crewai_tools_only():
|
||||
"""Test backward compatibility with CrewAI tools only."""
|
||||
mock_tool = MockTool()
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[mock_tool]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert isinstance(agent.tools[0], BaseTool)
|
||||
|
||||
|
||||
def test_agent_with_raw_tools_only():
|
||||
"""Test agent with raw tool definitions only."""
|
||||
raw_tool = {
|
||||
"name": "hosted_search",
|
||||
"description": "Search the web using hosted search",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[raw_tool]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert isinstance(agent.tools[0], dict)
|
||||
assert agent.tools[0]["name"] == "hosted_search"
|
||||
|
||||
|
||||
def test_agent_with_mixed_tools():
|
||||
"""Test agent with both CrewAI tools and raw tool definitions."""
|
||||
mock_tool = MockTool()
|
||||
raw_tool = {
|
||||
"name": "hosted_calculator",
|
||||
"description": "Hosted calculator tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[mock_tool, raw_tool]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 2
|
||||
assert isinstance(agent.tools[0], BaseTool)
|
||||
assert isinstance(agent.tools[1], dict)
|
||||
|
||||
|
||||
def test_invalid_raw_tool_definition():
|
||||
"""Test error handling for invalid raw tool definitions."""
|
||||
invalid_tool = {"description": "Missing name field"}
|
||||
|
||||
with pytest.raises(ValueError, match="Raw tool definition must have a 'name' field"):
|
||||
Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[invalid_tool]
|
||||
)
|
||||
|
||||
|
||||
def test_parse_tools_with_mixed_types():
|
||||
"""Test parse_tools function with mixed tool types."""
|
||||
from crewai.utilities.agent_utils import parse_tools
|
||||
|
||||
mock_tool = MockTool()
|
||||
raw_tool = {
|
||||
"name": "hosted_tool",
|
||||
"description": "A hosted tool",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
|
||||
parsed = parse_tools([mock_tool, raw_tool])
|
||||
|
||||
assert len(parsed) == 2
|
||||
assert hasattr(parsed[0], 'name')
|
||||
assert parsed[0].name == "mock_tool"
|
||||
assert isinstance(parsed[1], dict)
|
||||
assert parsed[1]["name"] == "hosted_tool"
|
||||
|
||||
|
||||
def test_get_tool_names_with_mixed_types():
|
||||
"""Test get_tool_names function with mixed tool types."""
|
||||
from crewai.utilities.agent_utils import get_tool_names
|
||||
|
||||
mock_tool = MockTool()
|
||||
raw_tool = {"name": "hosted_tool", "description": "A hosted tool"}
|
||||
|
||||
names = get_tool_names([mock_tool, raw_tool])
|
||||
assert "mock_tool" in names
|
||||
assert "hosted_tool" in names
|
||||
|
||||
|
||||
def test_render_text_description_with_mixed_types():
|
||||
"""Test render_text_description_and_args function with mixed tool types."""
|
||||
from crewai.utilities.agent_utils import render_text_description_and_args
|
||||
|
||||
mock_tool = MockTool()
|
||||
raw_tool = {"name": "hosted_tool", "description": "A hosted tool"}
|
||||
|
||||
description = render_text_description_and_args([mock_tool, raw_tool])
|
||||
assert "A mock tool for testing" in description
|
||||
assert "A hosted tool" in description
|
||||
|
||||
|
||||
def test_agent_executor_with_mixed_tools():
|
||||
"""Test CrewAgentExecutor initialization with mixed tool types."""
|
||||
mock_tool = MockTool()
|
||||
raw_tool = {"name": "hosted_tool", "description": "A hosted tool"}
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[mock_tool, raw_tool]
|
||||
)
|
||||
|
||||
agent.create_agent_executor()
|
||||
|
||||
assert len(agent.agent_executor.tool_name_to_tool_map) == 2
|
||||
assert "mock_tool" in agent.agent_executor.tool_name_to_tool_map
|
||||
assert "hosted_tool" in agent.agent_executor.tool_name_to_tool_map
|
||||
|
||||
|
||||
def test_empty_tools_list():
|
||||
"""Test agent with empty tools list."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 0
|
||||
|
||||
|
||||
def test_none_tools():
|
||||
"""Test agent with None tools."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=None
|
||||
)
|
||||
|
||||
assert agent.tools == []
|
||||
|
||||
|
||||
def test_raw_tool_without_description():
|
||||
"""Test raw tool definition without description field."""
|
||||
raw_tool = {"name": "minimal_tool"}
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[raw_tool]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert isinstance(agent.tools[0], dict)
|
||||
assert agent.tools[0]["name"] == "minimal_tool"
|
||||
|
||||
|
||||
def test_complex_raw_tool_definition():
|
||||
"""Test complex raw tool definition with nested parameters."""
|
||||
raw_tool = {
|
||||
"name": "complex_search",
|
||||
"description": "Advanced search with multiple parameters",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_range": {"type": "string"},
|
||||
"category": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
tools=[raw_tool]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert isinstance(agent.tools[0], dict)
|
||||
assert agent.tools[0]["name"] == "complex_search"
|
||||
assert "parameters" in agent.tools[0]
|
||||
assert agent.tools[0]["parameters"]["type"] == "object"
|
||||
Reference in New Issue
Block a user