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]
|
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):
|
class Crew(FlowTrackable, BaseModel):
|
||||||
"""
|
"""
|
||||||
Represents a group of agents, defining how they should collaborate and the
|
Represents a group of agents, defining how they should collaborate and the
|
||||||
@@ -1927,6 +1957,81 @@ class Crew(FlowTrackable, BaseModel):
|
|||||||
|
|
||||||
return required_inputs
|
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]
|
def copy(self) -> Crew: # type: ignore[override]
|
||||||
"""
|
"""
|
||||||
Creates a deep copy of the Crew instance.
|
Creates a deep copy of the Crew instance.
|
||||||
|
|||||||
@@ -1864,6 +1864,34 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
|||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
return "" # Safely handle any unexpected attribute access issues
|
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:
|
def _initialize_state(self, inputs: dict[str, Any]) -> None:
|
||||||
"""Initialize or update flow state with new inputs.
|
"""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