feat: add list_tools() to Crew and Flow for static tool enumeration

This commit is contained in:
Renato Nitta
2026-05-06 14:00:48 -03:00
parent 93e786d263
commit ed1b51e4b4
4 changed files with 472 additions and 0 deletions

View File

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

View File

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

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

View 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"]}}