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:
Devin AI
2025-08-18 13:56:38 +00:00
parent 947c9552f0
commit 95d3b5dbc3
5 changed files with 271 additions and 20 deletions

View File

@@ -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(

View File

@@ -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

View File

@@ -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(

View File

@@ -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
View 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"