Compare commits

..

1 Commits

Author SHA1 Message Date
Renato Nitta
ed1b51e4b4 feat: add list_tools() to Crew and Flow for static tool enumeration 2026-05-06 14:00:48 -03:00
22 changed files with 491 additions and 520 deletions

View File

@@ -4,29 +4,6 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="7 مايو 2026">
## v1.14.5a3
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a3)
## ما الذي تغير
### إصلاحات الأخطاء
- إصلاح مسار نقطة النهاية للحالة من /{kickoff_id}/status إلى /status/{kickoff_id}
- تحديث تبعية gitpython إلى الإصدار >=3.1.47 للامتثال الأمني
### إعادة هيكلة
- استخراج واجهة سطر الأوامر إلى حزمة crewai-cli المستقلة
### الوثائق
- تحديث سجل التغييرات والإصدار للإصدار v1.14.5a2
## المساهمون
@greysonlalonde, @iris-clawd
</Update>
<Update label="4 مايو 2026">
## v1.14.5a2

View File

@@ -4,29 +4,6 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="May 07, 2026">
## v1.14.5a3
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a3)
## What's Changed
### Bug Fixes
- Fix status endpoint path from /{kickoff_id}/status to /status/{kickoff_id}
- Bump gitpython dependency to version >=3.1.47 for security compliance
### Refactoring
- Extract CLI into standalone crewai-cli package
### Documentation
- Update changelog and version for v1.14.5a2
## Contributors
@greysonlalonde, @iris-clawd
</Update>
<Update label="May 04, 2026">
## v1.14.5a2

View File

@@ -4,29 +4,6 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 5월 7일">
## v1.14.5a3
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a3)
## 변경 사항
### 버그 수정
- 상태 엔드포인트 경로를 /{kickoff_id}/status에서 /status/{kickoff_id}로 수정
- 보안 준수를 위해 gitpython 의존성을 버전 >=3.1.47로 업데이트
### 리팩토링
- CLI를 독립형 crewai-cli 패키지로 분리
### 문서
- v1.14.5a2에 대한 변경 로그 및 버전 업데이트
## 기여자
@greysonlalonde, @iris-clawd
</Update>
<Update label="2026년 5월 4일">
## v1.14.5a2

View File

@@ -4,29 +4,6 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="07 mai 2026">
## v1.14.5a3
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a3)
## O que Mudou
### Correções de Bugs
- Corrigir o caminho do endpoint de status de /{kickoff_id}/status para /status/{kickoff_id}
- Atualizar a dependência gitpython para a versão >=3.1.47 para conformidade de segurança
### Refatoração
- Extrair CLI para o pacote independente crewai-cli
### Documentação
- Atualizar o changelog e a versão para v1.14.5a2
## Contributors
@greysonlalonde, @iris-clawd
</Update>
<Update label="04 mai 2026">
## v1.14.5a2

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.5a3",
"crewai-core>=1.14.5a2",
"click~=8.1.7",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",
@@ -22,8 +22,6 @@ dependencies = [
"packaging>=23.0",
"python-dotenv>=1.2.2,<2",
"uv~=0.11.6",
"textual>=7.5.0",
"certifi",
]
[project.urls]

View File

@@ -1 +1 @@
__version__ = "1.14.5a3"
__version__ = "1.14.5a2"

View File

@@ -1 +1 @@
__version__ = "1.14.5a3"
__version__ = "1.14.5a2"

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.5a3"
__version__ = "1.14.5a2"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.5a3",
"crewai==1.14.5a2",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.5a3"
__version__ = "1.14.5a2"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.5a3",
"crewai-cli==1.14.5a3",
"crewai-core==1.14.5a2",
"crewai-cli==1.14.5a2",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -28,6 +28,8 @@ dependencies = [
# Authentication and Security
"python-dotenv>=1.2.2,<2",
"pyjwt>=2.9.0,<3",
# TUI
"textual>=7.5.0",
# Configuration and Utils
"click~=8.1.7",
"appdirs~=1.4.4",
@@ -54,7 +56,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.5a3",
"crewai-tools==1.14.5a2",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.5a3"
__version__ = "1.14.5a2"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -512,7 +512,7 @@ class Agent(BaseAgent):
The task prompt after memory retrieval, ready for knowledge lookup.
"""
get_env_context()
if not getattr(self.executor_class, "supports_internal_planning", False):
if self.executor_class is not AgentExecutor:
handle_reasoning(self, task)
self._inject_date_to_task(task)
@@ -1102,40 +1102,6 @@ class Agent(BaseAgent):
self.agent_executor.tools_handler = self.tools_handler
self.agent_executor.request_within_rpm_limit = rpm_limit_fn
def _resolve_kickoff_executor_class(self) -> type[AgentExecutor]:
"""Resolve the executor class to use for ``Agent.kickoff()``.
``Agent.kickoff()`` requires a Flow-based executor (one that sets
``supports_kickoff = True`` on the class). When the configured
:attr:`executor_class` does not support the kickoff flow, this method
emits a :class:`DeprecationWarning` and falls back to
:class:`~crewai.experimental.agent_executor.AgentExecutor` so that
existing default-configured agents continue to work; the warning makes
the previously silent override visible to the caller.
Returns:
The :class:`AgentExecutor` subclass to instantiate for kickoff.
"""
executor_cls = self.executor_class
supports_kickoff = getattr(executor_cls, "supports_kickoff", False)
if supports_kickoff and isinstance(executor_cls, type):
return cast("type[AgentExecutor]", executor_cls)
warnings.warn(
(
f"Agent.kickoff() requires a Flow-based AgentExecutor but "
f"executor_class={getattr(executor_cls, '__name__', executor_cls)!r} "
"does not advertise supports_kickoff=True. Falling back to "
"crewai.experimental.agent_executor.AgentExecutor for this "
"kickoff() call. Set executor_class=AgentExecutor explicitly "
"to silence this warning. The silent fallback will be removed "
"in a future release."
),
DeprecationWarning,
stacklevel=3,
)
return AgentExecutor
def get_delegation_tools(self, agents: Sequence[BaseAgent]) -> list[BaseTool]:
agent_tools = AgentTools(agents=agents)
return agent_tools.tools()
@@ -1433,8 +1399,7 @@ class Agent(BaseAgent):
executor.request_within_rpm_limit = rpm_limit_fn
executor.callbacks = [TokenCalcHandler(self._token_process)]
else:
kickoff_executor_cls = self._resolve_kickoff_executor_class()
executor = kickoff_executor_cls(
executor = AgentExecutor(
llm=cast(BaseLLM, self.llm),
agent=self,
prompt=prompt,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field, PrivateAttr
@@ -20,25 +20,6 @@ class BaseAgentExecutor(BaseModel):
model_config = {"arbitrary_types_allowed": True}
executor_type: str = "base"
supports_internal_planning: ClassVar[bool] = False
"""Whether this executor implements its own planning/replanning loop.
When True, ``Agent`` skips the external ``handle_reasoning()`` step before
task execution and lets the executor drive planning itself. This is a
class-level capability flag so callers can query it without instantiating
the executor (e.g. ``self.executor_class.supports_internal_planning``).
"""
supports_kickoff: ClassVar[bool] = False
"""Whether this executor supports the standalone ``Agent.kickoff()`` flow.
Executors that do not implement the Flow-based kickoff loop should leave
this ``False``; ``Agent.kickoff()`` will then refuse to silently fall back
to a different executor and instead emit a ``DeprecationWarning`` so the
silent override is visible to the caller.
"""
crew: Crew | None = Field(default=None, exclude=True)
agent: BaseAgent | None = Field(default=None, exclude=True)
task: Task | None = Field(default=None, exclude=True)

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

@@ -9,7 +9,7 @@ from datetime import datetime
import inspect
import json
import threading
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
from uuid import uuid4
from crewai_core.printer import PRINTER
@@ -172,10 +172,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
_skip_auto_memory: bool = True
executor_type: Literal["experimental"] = "experimental"
supports_internal_planning: ClassVar[bool] = True
supports_kickoff: ClassVar[bool] = True
suppress_flow_events: bool = True # always suppress for executor
llm: BaseLLM = Field(exclude=True)
prompt: SystemPromptResult | StandardPromptResult = Field(exclude=True)

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

@@ -1,349 +0,0 @@
"""Regression tests for issue #5736: parallel agent executors must be consistent.
The repository has historically carried two parallel ``BaseAgentExecutor``
subclasses — :class:`crewai.agents.crew_agent_executor.CrewAgentExecutor` and
:class:`crewai.experimental.agent_executor.AgentExecutor`. The issue reported
two concrete bugs caused by the split:
1. ``Agent.kickoff()`` silently ignored ``Agent.executor_class`` and always
instantiated ``AgentExecutor``, so the user-visible default
(``CrewAgentExecutor``) did not match the executor that ``kickoff()``
actually used.
2. The ``handle_reasoning()`` step was gated on a literal class identity check
(``self.executor_class is not AgentExecutor``), so reasoning was silently
skipped depending on the executor and could not be controlled via a
capability flag.
These tests pin down the new contract:
* Capability flags (``supports_internal_planning`` / ``supports_kickoff``) are
the single source of truth for executor differences.
* ``Agent.kickoff()`` honors ``executor_class`` when it is kickoff-capable and
otherwise emits a ``DeprecationWarning`` instead of silently overriding.
* ``Agent.execute_task()`` instantiates ``self.executor_class`` (not a
hard-coded class).
* ``handle_reasoning()`` is gated on the capability flag, not class identity.
"""
from __future__ import annotations
from typing import Any, ClassVar, Literal
import warnings
from unittest.mock import MagicMock, patch
import pytest
from crewai import Agent, Task
from crewai.agents.agent_builder.base_agent_executor import BaseAgentExecutor
from crewai.agents.crew_agent_executor import CrewAgentExecutor
from crewai.experimental.agent_executor import AgentExecutor
from crewai.llms.base_llm import BaseLLM
@pytest.fixture
def mock_llm() -> MagicMock:
"""Create a mock LLM that satisfies BaseLLM's protocol."""
llm = MagicMock(spec=BaseLLM)
llm.supports_stop_words.return_value = True
llm.stop = []
llm.model = "gpt-4o-mini"
return llm
@pytest.fixture
def test_agent(mock_llm: MagicMock) -> Agent:
"""A plain Agent that uses default ``executor_class``."""
return Agent(
role="Test Agent",
goal="Verify executor consistency",
backstory="An agent used by issue #5736 regression tests.",
llm=mock_llm,
verbose=False,
)
# ---------------------------------------------------------------------------
# Capability flags
# ---------------------------------------------------------------------------
class TestExecutorCapabilityFlags:
"""``supports_internal_planning`` / ``supports_kickoff`` advertise the
differences between executors so callers don't have to ``isinstance``-check
against a specific class."""
def test_base_executor_has_no_capabilities(self) -> None:
assert BaseAgentExecutor.supports_internal_planning is False
assert BaseAgentExecutor.supports_kickoff is False
def test_crew_agent_executor_has_no_capabilities(self) -> None:
"""The legacy executor neither plans internally nor handles kickoff."""
assert CrewAgentExecutor.supports_internal_planning is False
assert CrewAgentExecutor.supports_kickoff is False
def test_agent_executor_advertises_full_capabilities(self) -> None:
"""The Flow-based executor implements both plan-and-execute and kickoff."""
assert AgentExecutor.supports_internal_planning is True
assert AgentExecutor.supports_kickoff is True
# ---------------------------------------------------------------------------
# Agent.kickoff() honors executor_class
# ---------------------------------------------------------------------------
class TestKickoffRespectsExecutorClass:
"""Issue #5736 part 1: ``Agent.kickoff()`` must honor ``executor_class``."""
def test_kickoff_uses_explicit_agent_executor_without_warning(
self, mock_llm: MagicMock
) -> None:
"""When the user explicitly opts into ``AgentExecutor``, no warning fires."""
agent = Agent(
role="r",
goal="g",
backstory="b",
llm=mock_llm,
executor_class=AgentExecutor,
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
executor_cls = agent._resolve_kickoff_executor_class()
assert executor_cls is AgentExecutor
kickoff_warnings = [
w for w in caught if "Agent.kickoff()" in str(w.message)
]
assert kickoff_warnings == [], (
f"Did not expect any kickoff warning, got: {[str(w.message) for w in kickoff_warnings]}"
)
def test_kickoff_uses_kickoff_capable_subclass_of_agent_executor(
self, mock_llm: MagicMock
) -> None:
"""A user-defined subclass that opts into kickoff is honored verbatim
(rather than being silently replaced with ``AgentExecutor``)."""
class CustomAgentExecutor(AgentExecutor):
executor_type: Literal["custom"] = "custom" # type: ignore[assignment]
supports_kickoff: ClassVar[bool] = True
supports_internal_planning: ClassVar[bool] = True
agent = Agent(
role="r",
goal="g",
backstory="b",
llm=mock_llm,
executor_class=CustomAgentExecutor,
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
executor_cls = agent._resolve_kickoff_executor_class()
assert executor_cls is CustomAgentExecutor
kickoff_warnings = [
w for w in caught if "Agent.kickoff()" in str(w.message)
]
assert kickoff_warnings == []
def test_kickoff_warns_and_falls_back_when_executor_class_is_not_kickoff_capable(
self, mock_llm: MagicMock
) -> None:
"""Default-configured agents (``executor_class=CrewAgentExecutor``) used
to silently flip to ``AgentExecutor`` on ``kickoff()``. The fix keeps
the fallback for backwards compatibility but emits a
``DeprecationWarning`` so the silent override is now visible."""
agent = Agent(
role="r",
goal="g",
backstory="b",
llm=mock_llm,
executor_class=CrewAgentExecutor, # the default
)
with pytest.warns(DeprecationWarning, match=r"Agent\.kickoff\(\)"):
executor_cls = agent._resolve_kickoff_executor_class()
assert executor_cls is AgentExecutor
def test_kickoff_fallback_warning_mentions_explicit_opt_in(
self, mock_llm: MagicMock
) -> None:
"""The deprecation message should tell users how to silence it — by
setting ``executor_class=AgentExecutor`` explicitly."""
agent = Agent(
role="r",
goal="g",
backstory="b",
llm=mock_llm,
executor_class=CrewAgentExecutor,
)
with pytest.warns(DeprecationWarning) as record:
agent._resolve_kickoff_executor_class()
assert any(
"executor_class=AgentExecutor" in str(w.message) for w in record
)
def test_kickoff_constructs_executor_via_resolve_helper(
self, test_agent: Agent
) -> None:
"""``_prepare_kickoff()`` must route through ``_resolve_kickoff_executor_class``
rather than instantiating ``AgentExecutor`` directly. This guards against
regressing back to the issue #5736 hardcoded-class behavior."""
sentinel = MagicMock(name="sentinel_executor_cls")
sentinel.return_value = MagicMock(name="sentinel_executor")
with patch.object(
test_agent,
"_resolve_kickoff_executor_class",
return_value=sentinel,
) as mock_resolve, patch.object(
test_agent, "_build_execution_prompt"
) as mock_build_prompt:
mock_build_prompt.return_value = (
{"system": "sys"},
["Observation:"],
None,
)
test_agent._prepare_kickoff(messages="hello")
mock_resolve.assert_called_once_with()
sentinel.assert_called_once()
# ---------------------------------------------------------------------------
# Agent.execute_task() honors executor_class (sanity check; pre-existing
# behavior we don't want to regress)
# ---------------------------------------------------------------------------
class TestExecuteTaskRespectsExecutorClass:
"""``Agent.execute_task()`` already routed through ``self.executor_class``
— these tests pin that contract so future refactors keep both entry points
honoring the user's executor choice."""
def test_execute_task_instantiates_self_executor_class(
self, test_agent: Agent
) -> None:
"""The non-resuming branch of ``Agent.create_agent_executor`` should
call ``self.executor_class(...)``. Stubbing ``executor_class`` with a
factory lets us observe the instantiation without running the LLM
loop."""
captured: dict[str, Any] = {}
def fake_factory(*args: Any, **kwargs: Any) -> MagicMock:
captured["called"] = True
captured["kwargs"] = kwargs
return MagicMock(spec=CrewAgentExecutor)
task = Task(
description="d",
expected_output="e",
agent=test_agent,
)
test_agent.agent_executor = None
test_agent.executor_class = fake_factory # type: ignore[assignment]
with patch.object(test_agent, "_build_execution_prompt") as mock_bp:
mock_bp.return_value = ({"system": "s"}, ["Obs:"], None)
test_agent.create_agent_executor(task=task)
assert captured.get("called") is True
# The agent passed itself to its executor.
assert captured["kwargs"].get("agent") is test_agent
assert captured["kwargs"].get("task") is task
# ---------------------------------------------------------------------------
# handle_reasoning gate: capability flag, not class identity
# ---------------------------------------------------------------------------
class TestReasoningGateUsesCapabilityFlag:
"""Issue #5736 part 2: the reasoning gate must consult
``supports_internal_planning`` rather than testing class identity. This
way a ``CrewAgentExecutor`` subclass that opts into internal planning is
treated correctly — and so is an ``AgentExecutor`` subclass that doesn't."""
def test_reasoning_runs_for_executor_without_internal_planning(
self, test_agent: Agent
) -> None:
"""Default ``executor_class`` is ``CrewAgentExecutor`` which does NOT
plan internally, so the legacy ``handle_reasoning`` step must run."""
task = Task(description="d", expected_output="e", agent=test_agent)
assert test_agent.executor_class is CrewAgentExecutor
assert (
getattr(
test_agent.executor_class, "supports_internal_planning", False
)
is False
)
with patch(
"crewai.agent.core.handle_reasoning"
) as mock_handle_reasoning, patch.object(
test_agent, "_inject_date_to_task"
), patch.object(
test_agent, "_retrieve_memory_context", return_value="prompt"
):
test_agent._prepare_task_execution(task=task, context=None)
mock_handle_reasoning.assert_called_once_with(test_agent, task)
def test_reasoning_skipped_for_executor_with_internal_planning(
self, test_agent: Agent
) -> None:
"""``AgentExecutor.supports_internal_planning is True``, so the agent
should not invoke the external ``handle_reasoning`` step (the executor
runs its own ``generate_plan`` flow instead)."""
test_agent.executor_class = AgentExecutor # type: ignore[assignment]
task = Task(description="d", expected_output="e", agent=test_agent)
assert (
getattr(
test_agent.executor_class, "supports_internal_planning", False
)
is True
)
with patch(
"crewai.agent.core.handle_reasoning"
) as mock_handle_reasoning, patch.object(
test_agent, "_inject_date_to_task"
), patch.object(
test_agent, "_retrieve_memory_context", return_value="prompt"
):
test_agent._prepare_task_execution(task=task, context=None)
mock_handle_reasoning.assert_not_called()
def test_reasoning_gate_does_not_use_isinstance_or_class_identity(
self, test_agent: Agent
) -> None:
"""A ``CrewAgentExecutor`` subclass that opts into internal planning
(``supports_internal_planning = True``) must skip the legacy reasoning
step. This is the key behavioral promise of replacing the old
``self.executor_class is not AgentExecutor`` check with a flag-based
check."""
class PlanningCrewExecutor(CrewAgentExecutor):
executor_type: Literal["crew_planning"] = "crew_planning" # type: ignore[assignment]
supports_internal_planning: ClassVar[bool] = True
test_agent.executor_class = PlanningCrewExecutor # type: ignore[assignment]
task = Task(description="d", expected_output="e", agent=test_agent)
with patch(
"crewai.agent.core.handle_reasoning"
) as mock_handle_reasoning, patch.object(
test_agent, "_inject_date_to_task"
), patch.object(
test_agent, "_retrieve_memory_context", return_value="prompt"
):
test_agent._prepare_task_execution(task=task, context=None)
mock_handle_reasoning.assert_not_called()

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

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.5a3"
__version__ = "1.14.5a2"

6
uv.lock generated
View File

@@ -1304,6 +1304,7 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "textual" },
{ name = "tokenizers" },
{ name = "tomli" },
{ name = "tomli-w" },
@@ -1420,6 +1421,7 @@ requires-dist = [
{ name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = "~=1.14.3" },
{ name = "qdrant-edge-py", marker = "extra == 'qdrant-edge'", specifier = ">=0.6.0" },
{ name = "regex", specifier = "~=2026.1.15" },
{ name = "textual", specifier = ">=7.5.0" },
{ name = "tiktoken", marker = "extra == 'embeddings'", specifier = ">=0.8.0,<0.13" },
{ name = "tokenizers", specifier = ">=0.21,<1" },
{ name = "tomli", specifier = "~=2.0.2" },
@@ -1433,7 +1435,6 @@ name = "crewai-cli"
source = { editable = "lib/cli" }
dependencies = [
{ name = "appdirs" },
{ name = "certifi" },
{ name = "click" },
{ name = "crewai-core" },
{ name = "cryptography" },
@@ -1444,7 +1445,6 @@ dependencies = [
{ name = "pyjwt" },
{ name = "python-dotenv" },
{ name = "rich" },
{ name = "textual" },
{ name = "tomli" },
{ name = "tomli-w" },
{ name = "uv" },
@@ -1453,7 +1453,6 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "certifi" },
{ name = "click", specifier = "~=8.1.7" },
{ name = "crewai-core", editable = "lib/crewai-core" },
{ name = "cryptography", specifier = ">=42.0" },
@@ -1464,7 +1463,6 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.9.0,<3" },
{ name = "python-dotenv", specifier = ">=1.2.2,<2" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "textual", specifier = ">=7.5.0" },
{ name = "tomli", specifier = "~=2.0.2" },
{ name = "tomli-w", specifier = "~=1.1.0" },
{ name = "uv", specifier = "~=0.11.6" },