mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-08 02:29:00 +00:00
feat: add list_tools() to Crew and Flow for static tool enumeration
This commit is contained in:
@@ -156,6 +156,36 @@ def _resolve_agents(value: Any, info: Any) -> Any:
|
||||
return [_resolve_agent(a, info) for a in value]
|
||||
|
||||
|
||||
def _mcp_label(mcp: Any) -> str:
|
||||
if isinstance(mcp, str):
|
||||
return mcp
|
||||
url = getattr(mcp, "url", None)
|
||||
if url:
|
||||
return str(url)
|
||||
command = getattr(mcp, "command", None)
|
||||
if command:
|
||||
return str(command)
|
||||
return type(mcp).__name__
|
||||
|
||||
|
||||
def _app_placeholder(app: Any) -> str:
|
||||
raw = app if isinstance(app, str) else str(app)
|
||||
if "#" in raw:
|
||||
base, action = raw.split("#", 1)
|
||||
return f"app:{base}:{action}"
|
||||
return f"app:{raw}:*"
|
||||
|
||||
|
||||
def _dedupe_preserve_order(values: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for v in values:
|
||||
if v not in seen:
|
||||
seen.add(v)
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
class Crew(FlowTrackable, BaseModel):
|
||||
"""
|
||||
Represents a group of agents, defining how they should collaborate and the
|
||||
@@ -1927,6 +1957,81 @@ class Crew(FlowTrackable, BaseModel):
|
||||
|
||||
return required_inputs
|
||||
|
||||
def list_tools(self) -> dict[str, list[str]]:
|
||||
"""Enumerate tool names available to each agent in this Crew.
|
||||
|
||||
Mirrors the runtime tool resolution in ``_prepare_tools`` without
|
||||
performing any I/O: tools sourced from external services (MCP
|
||||
servers, platform apps) are returned as ``"mcp:<id>:*"`` /
|
||||
``"app:<id>[:action]"`` placeholders since their concrete tool
|
||||
names require live fetches.
|
||||
|
||||
Returns:
|
||||
Mapping of agent role to a deduplicated list of tool names.
|
||||
In hierarchical mode the manager agent is included as an
|
||||
extra entry keyed by its role.
|
||||
"""
|
||||
tasks_by_agent: dict[int, list[Task]] = {}
|
||||
for task in self.tasks:
|
||||
if task.agent is not None:
|
||||
tasks_by_agent.setdefault(id(task.agent), []).append(task)
|
||||
|
||||
result: dict[str, list[str]] = {}
|
||||
|
||||
for agent in self.agents:
|
||||
candidates: list[str] = [tool.name for tool in agent.tools or []]
|
||||
|
||||
for task in tasks_by_agent.get(id(agent), []):
|
||||
candidates.extend(tool.name for tool in task.tools or [])
|
||||
|
||||
if (
|
||||
self.process != Process.hierarchical
|
||||
and getattr(agent, "allow_delegation", False)
|
||||
and len(self.agents) > 1
|
||||
):
|
||||
candidates.append("Delegate work to coworker")
|
||||
candidates.append("Ask question to coworker")
|
||||
|
||||
if getattr(agent, "multimodal", False):
|
||||
llm = getattr(agent, "llm", None)
|
||||
if not (isinstance(llm, BaseLLM) and llm.supports_multimodal()):
|
||||
candidates.append("Add image to content")
|
||||
|
||||
if getattr(agent, "memory", None) is not None or self._memory is not None:
|
||||
candidates.append("Search memory")
|
||||
candidates.append("Save to memory")
|
||||
|
||||
candidates.extend(
|
||||
f"mcp:{_mcp_label(mcp)}:*" for mcp in getattr(agent, "mcps", None) or []
|
||||
)
|
||||
candidates.extend(
|
||||
_app_placeholder(app) for app in getattr(agent, "apps", None) or []
|
||||
)
|
||||
|
||||
for task in tasks_by_agent.get(id(agent), []):
|
||||
if get_all_files(self.id, task.id):
|
||||
candidates.append("read_file")
|
||||
break
|
||||
|
||||
result[agent.role] = _dedupe_preserve_order(candidates)
|
||||
|
||||
if self.process == Process.hierarchical:
|
||||
if self.manager_agent is not None:
|
||||
mgr_candidates: list[str] = [
|
||||
tool.name for tool in self.manager_agent.tools or []
|
||||
]
|
||||
mgr_role = self.manager_agent.role
|
||||
else:
|
||||
mgr_candidates = []
|
||||
mgr_role = get_i18n(prompt_file=self.prompt_file).retrieve(
|
||||
"hierarchical_manager_agent", "role"
|
||||
)
|
||||
mgr_candidates.append("Delegate work to coworker")
|
||||
mgr_candidates.append("Ask question to coworker")
|
||||
result[mgr_role] = _dedupe_preserve_order(mgr_candidates)
|
||||
|
||||
return result
|
||||
|
||||
def copy(self) -> Crew: # type: ignore[override]
|
||||
"""
|
||||
Creates a deep copy of the Crew instance.
|
||||
|
||||
@@ -1864,6 +1864,34 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
except (AttributeError, TypeError):
|
||||
return "" # Safely handle any unexpected attribute access issues
|
||||
|
||||
def list_tools(self) -> dict[str, dict[str, list[str]]]:
|
||||
"""Enumerate tools available across the Crews attached to this Flow.
|
||||
|
||||
Inspects public instance attributes for :class:`~crewai.crew.Crew`
|
||||
instances and lists/tuples of Crews, delegating to
|
||||
:meth:`Crew.list_tools` for each. Crews instantiated lazily inside
|
||||
flow methods are not discovered — to opt them in, store them as
|
||||
instance attributes (typically in ``__init__``).
|
||||
|
||||
Returns:
|
||||
Mapping of crew identifier (the attribute name, with
|
||||
``[index]`` suffix when stored in a list/tuple) to that
|
||||
Crew's ``list_tools()`` output.
|
||||
"""
|
||||
from crewai.crew import Crew
|
||||
|
||||
result: dict[str, dict[str, list[str]]] = {}
|
||||
for attr_name, value in vars(self).items():
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
if isinstance(value, Crew):
|
||||
result[attr_name] = value.list_tools()
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for i, item in enumerate(value):
|
||||
if isinstance(item, Crew):
|
||||
result[f"{attr_name}[{i}]"] = item.list_tools()
|
||||
return result
|
||||
|
||||
def _initialize_state(self, inputs: dict[str, Any]) -> None:
|
||||
"""Initialize or update flow state with new inputs.
|
||||
|
||||
|
||||
207
lib/crewai/tests/test_list_tools_crew.py
Normal file
207
lib/crewai/tests/test_list_tools_crew.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Tests for Crew.list_tools()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.agent import Agent
|
||||
from crewai.crew import Crew
|
||||
from crewai.process import Process
|
||||
from crewai.task import Task
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
|
||||
class _Args(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
def _tool(tool_name: str) -> BaseTool:
|
||||
class _T(BaseTool):
|
||||
name: str = tool_name
|
||||
description: str = "test tool"
|
||||
args_schema: type = _Args
|
||||
|
||||
def _run(self, **_: Any) -> str:
|
||||
return ""
|
||||
|
||||
return _T()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def writer():
|
||||
return Agent(role="writer", goal="g", backstory="b", tools=[_tool("search")])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor():
|
||||
return Agent(role="editor", goal="g", backstory="b")
|
||||
|
||||
|
||||
def test_lists_user_defined_agent_tools(writer):
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
assert crew.list_tools() == {"writer": ["search"]}
|
||||
|
||||
|
||||
def test_includes_task_level_tool_overrides(writer):
|
||||
extra = _tool("calculator")
|
||||
task = Task(description="d", expected_output="e", agent=writer, tools=[extra])
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
assert crew.list_tools() == {"writer": ["search", "calculator"]}
|
||||
|
||||
|
||||
def test_dedupes_when_agent_and_task_share_a_tool(writer):
|
||||
duplicate = _tool("search")
|
||||
task = Task(description="d", expected_output="e", agent=writer, tools=[duplicate])
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
assert crew.list_tools() == {"writer": ["search"]}
|
||||
|
||||
|
||||
def test_peer_delegation_adds_delegate_and_ask_tools(writer, editor):
|
||||
writer.allow_delegation = True
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer, editor], tasks=[task])
|
||||
|
||||
tools = crew.list_tools()
|
||||
assert "Delegate work to coworker" in tools["writer"]
|
||||
assert "Ask question to coworker" in tools["writer"]
|
||||
assert "Delegate work to coworker" not in tools["editor"]
|
||||
|
||||
|
||||
def test_peer_delegation_skipped_when_only_one_agent(writer):
|
||||
writer.allow_delegation = True
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
assert "Delegate work to coworker" not in crew.list_tools()["writer"]
|
||||
|
||||
|
||||
def test_hierarchical_includes_default_manager(writer, editor):
|
||||
writer.allow_delegation = True
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(
|
||||
agents=[writer, editor],
|
||||
tasks=[task],
|
||||
process=Process.hierarchical,
|
||||
manager_llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
tools = crew.list_tools()
|
||||
assert "writer" in tools
|
||||
assert "Delegate work to coworker" not in tools["writer"]
|
||||
# Default manager role from i18n.
|
||||
manager_keys = [k for k in tools if k not in {"writer", "editor"}]
|
||||
assert len(manager_keys) == 1
|
||||
manager_role = manager_keys[0]
|
||||
assert tools[manager_role] == [
|
||||
"Delegate work to coworker",
|
||||
"Ask question to coworker",
|
||||
]
|
||||
|
||||
|
||||
def test_hierarchical_uses_user_provided_manager_role(writer, editor):
|
||||
manager = Agent(role="Chief", goal="g", backstory="b", allow_delegation=True)
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(
|
||||
agents=[writer, editor],
|
||||
tasks=[task],
|
||||
process=Process.hierarchical,
|
||||
manager_agent=manager,
|
||||
manager_llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
tools = crew.list_tools()
|
||||
assert "Chief" in tools
|
||||
assert tools["Chief"] == [
|
||||
"Delegate work to coworker",
|
||||
"Ask question to coworker",
|
||||
]
|
||||
|
||||
|
||||
def test_multimodal_added_when_llm_does_not_support_it(writer):
|
||||
writer.multimodal = True
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
with patch.object(type(writer.llm), "supports_multimodal", return_value=False):
|
||||
tools = crew.list_tools()
|
||||
|
||||
assert "Add image to content" in tools["writer"]
|
||||
|
||||
|
||||
def test_multimodal_skipped_when_llm_supports_it(writer):
|
||||
writer.multimodal = True
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
with patch.object(type(writer.llm), "supports_multimodal", return_value=True):
|
||||
tools = crew.list_tools()
|
||||
|
||||
assert "Add image to content" not in tools["writer"]
|
||||
|
||||
|
||||
def test_crew_level_memory_adds_search_and_save(writer):
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task], memory=True)
|
||||
|
||||
tools = crew.list_tools()
|
||||
assert "Search memory" in tools["writer"]
|
||||
assert "Save to memory" in tools["writer"]
|
||||
|
||||
|
||||
def test_no_memory_means_no_memory_tools(writer):
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task]) # memory defaults to False
|
||||
|
||||
tools = crew.list_tools()
|
||||
assert "Search memory" not in tools["writer"]
|
||||
assert "Save to memory" not in tools["writer"]
|
||||
|
||||
|
||||
def test_mcp_emits_placeholder_per_server():
|
||||
a = Agent(role="r", goal="g", backstory="b", mcps=["github", "slack"])
|
||||
task = Task(description="d", expected_output="e", agent=a)
|
||||
crew = Crew(agents=[a], tasks=[task])
|
||||
|
||||
assert crew.list_tools()["r"] == ["mcp:github:*", "mcp:slack:*"]
|
||||
|
||||
|
||||
def test_apps_emit_placeholder_with_action_split():
|
||||
a = Agent(
|
||||
role="r",
|
||||
goal="g",
|
||||
backstory="b",
|
||||
apps=["gmail", "slack#send_message"],
|
||||
)
|
||||
task = Task(description="d", expected_output="e", agent=a)
|
||||
crew = Crew(agents=[a], tasks=[task])
|
||||
|
||||
assert crew.list_tools()["r"] == ["app:gmail:*", "app:slack:send_message"]
|
||||
|
||||
|
||||
def test_file_reader_added_when_task_has_input_files(writer):
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
sentinel_files = {"foo.txt": object()}
|
||||
with patch("crewai.crew.get_all_files", return_value=sentinel_files):
|
||||
tools = crew.list_tools()
|
||||
|
||||
assert "read_file" in tools["writer"]
|
||||
|
||||
|
||||
def test_file_reader_not_added_when_no_input_files(writer):
|
||||
task = Task(description="d", expected_output="e", agent=writer)
|
||||
crew = Crew(agents=[writer], tasks=[task])
|
||||
|
||||
with patch("crewai.crew.get_all_files", return_value={}):
|
||||
tools = crew.list_tools()
|
||||
|
||||
assert "read_file" not in tools["writer"]
|
||||
132
lib/crewai/tests/test_list_tools_flow.py
Normal file
132
lib/crewai/tests/test_list_tools_flow.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Tests for Flow.list_tools()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.agent import Agent
|
||||
from crewai.crew import Crew
|
||||
from crewai.flow.flow import Flow, start
|
||||
from crewai.task import Task
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
|
||||
class _Args(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
def _tool(tool_name: str) -> BaseTool:
|
||||
class _T(BaseTool):
|
||||
name: str = tool_name
|
||||
description: str = "test"
|
||||
args_schema: type = _Args
|
||||
|
||||
def _run(self, **_: Any) -> str:
|
||||
return ""
|
||||
|
||||
return _T()
|
||||
|
||||
|
||||
def _crew(role: str = "writer", tool_name: str = "search") -> Crew:
|
||||
agent = Agent(role=role, goal="g", backstory="b", tools=[_tool(tool_name)])
|
||||
task = Task(description="d", expected_output="e", agent=agent)
|
||||
return Crew(agents=[agent], tasks=[task])
|
||||
|
||||
|
||||
def test_empty_flow_returns_empty_dict():
|
||||
class EmptyFlow(Flow):
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return None
|
||||
|
||||
assert EmptyFlow().list_tools() == {}
|
||||
|
||||
|
||||
def test_crew_attribute_keyed_by_attribute_name():
|
||||
class SingleCrewFlow(Flow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.poem_crew = _crew()
|
||||
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return self.poem_crew.kickoff()
|
||||
|
||||
assert SingleCrewFlow().list_tools() == {"poem_crew": {"writer": ["search"]}}
|
||||
|
||||
|
||||
def test_list_of_crews_keyed_with_index_suffix():
|
||||
class ListFlow(Flow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.research_crews = [_crew("a", "t1"), _crew("b", "t2")]
|
||||
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return None
|
||||
|
||||
tools = ListFlow().list_tools()
|
||||
assert tools == {
|
||||
"research_crews[0]": {"a": ["t1"]},
|
||||
"research_crews[1]": {"b": ["t2"]},
|
||||
}
|
||||
|
||||
|
||||
def test_tuple_of_crews_supported():
|
||||
class TupleFlow(Flow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.crews_tuple = (_crew("a", "t1"),)
|
||||
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return None
|
||||
|
||||
assert TupleFlow().list_tools() == {"crews_tuple[0]": {"a": ["t1"]}}
|
||||
|
||||
|
||||
def test_underscore_prefixed_attributes_ignored():
|
||||
class HiddenFlow(Flow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._private_crew = _crew("a", "t1")
|
||||
self.public_crew = _crew("b", "t2")
|
||||
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return None
|
||||
|
||||
tools = HiddenFlow().list_tools()
|
||||
assert "_private_crew" not in tools
|
||||
assert tools == {"public_crew": {"b": ["t2"]}}
|
||||
|
||||
|
||||
def test_non_crew_attributes_skipped():
|
||||
class MixedFlow(Flow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.label = "some-string"
|
||||
self.config = {"k": "v"}
|
||||
self.poem_crew = _crew()
|
||||
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return None
|
||||
|
||||
assert MixedFlow().list_tools() == {"poem_crew": {"writer": ["search"]}}
|
||||
|
||||
|
||||
def test_list_with_non_crew_items_filtered():
|
||||
class PartialFlow(Flow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.things = [_crew("a", "t1"), "not a crew", 42]
|
||||
|
||||
@start()
|
||||
def kickoff(self):
|
||||
return None
|
||||
|
||||
assert PartialFlow().list_tools() == {"things[0]": {"a": ["t1"]}}
|
||||
Reference in New Issue
Block a user