From ed1b51e4b44cf46fc14be315fa7d0db9f91df4f8 Mon Sep 17 00:00:00 2001 From: Renato Nitta Date: Wed, 6 May 2026 14:00:48 -0300 Subject: [PATCH] feat: add list_tools() to Crew and Flow for static tool enumeration --- lib/crewai/src/crewai/crew.py | 105 ++++++++++++ lib/crewai/src/crewai/flow/flow.py | 28 +++ lib/crewai/tests/test_list_tools_crew.py | 207 +++++++++++++++++++++++ lib/crewai/tests/test_list_tools_flow.py | 132 +++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 lib/crewai/tests/test_list_tools_crew.py create mode 100644 lib/crewai/tests/test_list_tools_flow.py diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index 60f163155..2fccc3df1 100644 --- a/lib/crewai/src/crewai/crew.py +++ b/lib/crewai/src/crewai/crew.py @@ -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::*"`` / + ``"app:[: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. diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index d22794873..8217b5462 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -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. diff --git a/lib/crewai/tests/test_list_tools_crew.py b/lib/crewai/tests/test_list_tools_crew.py new file mode 100644 index 000000000..68d8b4057 --- /dev/null +++ b/lib/crewai/tests/test_list_tools_crew.py @@ -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"] diff --git a/lib/crewai/tests/test_list_tools_flow.py b/lib/crewai/tests/test_list_tools_flow.py new file mode 100644 index 000000000..d60de1117 --- /dev/null +++ b/lib/crewai/tests/test_list_tools_flow.py @@ -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"]}}