Compare commits

...

13 Commits

Author SHA1 Message Date
Renato Nitta
6a41e6f972 fix: distinguish explicit name via _name_was_explicit flag 2026-04-21 13:10:26 -03:00
Renato Nitta
6a714c42dd Merge branch 'main' into rn/con-109-fix-crew-name-in-traces 2026-04-21 12:56:02 -03:00
Renato Nitta
051e9e4fed fix: preserve explicit name inside @crew-decorated methods 2026-04-21 12:49:21 -03:00
Renato Nitta
4b018a2b5b refactor: resolve name via model_validator 2026-04-21 12:31:06 -03:00
Renato Nitta
402f45078a test: alias InternalCrew as type[Any] to satisfy analyzer 2026-04-20 19:43:13 -03:00
Renato Nitta
8b423d9a1b test: cast InternalCrew via Any to guide static analyzer 2026-04-20 19:38:34 -03:00
Renato Nitta
a76cef6af9 test: annotate crew_instance to guide static analyzer 2026-04-20 19:31:50 -03:00
Renato Nitta
0b92d2f69c fix: treat None crew name as missing in trace listener 2026-04-20 19:27:19 -03:00
Renato Nitta
5cc6cb0a27 test: split crew decorator call to match repo pattern 2026-04-20 19:24:51 -03:00
Renato Nitta
6604f0fe3b test: parametrize crew display name tests 2026-04-20 19:07:57 -03:00
Renato Nitta
d65e0485b4 test: name crew explicitly in trace listener test and fix crew test 2026-04-20 19:03:00 -03:00
Renato Nitta
8d192c1715 refactor: extract display_name property for name fallback 2026-04-20 18:42:11 -03:00
Renato Nitta
3546a48922 fix: preserve crew name in events, fallback to class name 2026-04-20 18:39:46 -03:00
8 changed files with 147 additions and 15 deletions

View File

@@ -212,8 +212,9 @@ class Crew(FlowTrackable, BaseModel):
default_factory=TaskOutputStorageHandler
)
_kickoff_event_id: str | None = PrivateAttr(default=None)
_name_was_explicit: bool = PrivateAttr(default=False)
name: str | None = Field(default="crew")
name: str | None = Field(default=None)
cache: bool = Field(default=True)
tasks: list[Task] = Field(default_factory=list)
agents: Annotated[
@@ -546,6 +547,16 @@ class Crew(FlowTrackable, BaseModel):
# TODO: Improve typing
return json.loads(v) if isinstance(v, Json) else v # type: ignore
@model_validator(mode="after")
def _resolve_name(self) -> Self:
"""Fall back to the class name when no explicit name is provided."""
# Snapshot whether `name` was user-provided before the assignment below
# pollutes `model_fields_set`.
self._name_was_explicit = "name" in self.model_fields_set
if not self._name_was_explicit:
self.name = type(self).__name__
return self
@model_validator(mode="after")
def set_private_attrs(self) -> Crew:
"""set private attributes."""
@@ -581,8 +592,8 @@ class Crew(FlowTrackable, BaseModel):
"""
from crewai.memory.utils import sanitize_scope_name
# Compute sanitized crew name for root_scope
crew_name = sanitize_scope_name(self.name or "crew")
# `name` is guaranteed non-None by the `_resolve_name` validator.
crew_name = sanitize_scope_name(cast(str, self.name))
crew_root_scope = f"/crew/{crew_name}"
if self.memory is True:
@@ -889,7 +900,10 @@ class Crew(FlowTrackable, BaseModel):
except Exception as e:
crewai_event_bus.emit(
self,
CrewTrainFailedEvent(error=str(e), crew_name=self.name),
CrewTrainFailedEvent(
error=str(e),
crew_name=self.name,
),
)
self._logger.log("error", f"Training failed: {e}", color="red")
CrewTrainingHandler(TRAINING_DATA_FILE).clear()
@@ -2078,7 +2092,10 @@ class Crew(FlowTrackable, BaseModel):
except Exception as e:
crewai_event_bus.emit(
self,
CrewTestFailedEvent(error=str(e), crew_name=self.name),
CrewTestFailedEvent(
error=str(e),
crew_name=self.name,
),
)
raise

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine, Iterable, Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from opentelemetry import baggage
@@ -309,9 +309,10 @@ def prepare_kickoff(
from crewai.events.utils.console_formatter import ConsoleFormatter
fmt = ConsoleFormatter(verbose=True)
# `name` is guaranteed non-None by the `_resolve_name` validator.
content = fmt.create_status_content(
"Resuming from Checkpoint",
crew.name or "Crew",
cast(str, crew.name),
"bright_magenta",
ID=str(crew.id),
)
@@ -319,7 +320,10 @@ def prepare_kickoff(
content, "\U0001f504 Resuming from Checkpoint", "bright_magenta"
)
else:
started_event = CrewKickoffStartedEvent(crew_name=crew.name, inputs=normalized)
started_event = CrewKickoffStartedEvent(
crew_name=crew.name,
inputs=normalized,
)
crew._kickoff_event_id = started_event.event_id
future = crewai_event_bus.emit(crew, started_event)
if future is not None:

View File

@@ -715,7 +715,7 @@ class TraceCollectionListener(BaseEventListener):
"""
user_context = self._get_user_context()
execution_metadata = {
"crew_name": getattr(event, "crew_name", "Unknown Crew"),
"crew_name": getattr(event, "crew_name", None) or "Unknown Crew",
"execution_start": event.timestamp,
"crewai_version": get_crewai_version(),
}
@@ -791,7 +791,7 @@ class TraceCollectionListener(BaseEventListener):
if not self.batch_manager.is_batch_initialized():
user_context = self._get_user_context()
execution_metadata = {
"crew_name": getattr(source, "name", "Unknown Crew"),
"crew_name": getattr(source, "name", None) or "Unknown Crew",
"crewai_version": get_crewai_version(),
}
self._initialize_batch(user_context, execution_metadata)

View File

@@ -238,6 +238,12 @@ def crew(
crew_instance: Crew = _call_method(meth, self, *args, **kwargs)
# Propagate the @CrewBase class name only when the user didn't pass an
# explicit `name=` to the Crew constructor inside the factory method.
crewbase_name = getattr(self, "_crew_name", None)
if crewbase_name and not crew_instance._name_was_explicit:
crew_instance.name = crewbase_name
def callback_wrapper(
hook: Callable[Concatenate[CrewInstance, P2], R2], instance: CrewInstance
) -> Callable[P2, R2]:

View File

@@ -8,6 +8,7 @@ from concurrent.futures import Future
from hashlib import md5
import re
import sys
from typing import Any
from unittest.mock import ANY, MagicMock, call, patch
from crewai.agent import Agent
@@ -17,6 +18,7 @@ from crewai.crew import Crew
from crewai.crews.crew_output import CrewOutput
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import (
CrewKickoffStartedEvent,
CrewTestCompletedEvent,
CrewTestStartedEvent,
CrewTrainCompletedEvent,
@@ -4738,7 +4740,46 @@ def test_default_crew_name(researcher, writer):
Task(description="Task 2", expected_output="output", agent=writer),
],
)
assert crew.name == "crew"
assert crew.name == "Crew"
@pytest.mark.parametrize(
"explicit_name,expected",
[
(None, "ResearchAutomation"),
("My Research Automation", "My Research Automation"),
],
ids=["class_name_fallback", "explicit_name_preserved"],
)
def test_crew_kickoff_started_emits_display_name(
researcher, writer, explicit_name, expected
):
"""CrewKickoffStartedEvent emits crew.name when set, class name otherwise."""
from crewai.crews.utils import prepare_kickoff
class ResearchAutomation(Crew):
pass
crew_kwargs: dict[str, Any] = {
"agents": [researcher, writer],
"tasks": [
Task(description="Task 1", expected_output="output", agent=researcher),
],
}
if explicit_name is not None:
crew_kwargs["name"] = explicit_name
crew = ResearchAutomation(**crew_kwargs)
captured: list[str | None] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(CrewKickoffStartedEvent)
def _capture(_source: Any, event: CrewKickoffStartedEvent) -> None:
captured.append(event.crew_name)
prepare_kickoff(crew, inputs=None)
assert captured == [expected]
@pytest.mark.vcr()

View File

@@ -1,4 +1,4 @@
from typing import Any, ClassVar
from typing import Any, ClassVar, cast
from unittest.mock import Mock, create_autospec, patch
import pytest
@@ -261,6 +261,65 @@ def test_crew_name():
assert crew._crew_name == "InternalCrew"
def test_crew_decorator_propagates_class_name_to_instance():
"""@crew-decorated factory method should set Crew.name to the decorated class name."""
internal_crew_cls = cast(type[Any], InternalCrew)
crew_instance: Crew = cast(Any, internal_crew_cls()).crew()
assert crew_instance.name == "InternalCrew"
def test_crew_decorator_preserves_explicit_name():
"""Explicit Crew(name=...) inside @crew should win over the @CrewBase class name."""
sample_agent = Agent(role="r", goal="g", backstory="b")
sample_task = Task(description="d", expected_output="o", agent=sample_agent)
@CrewBase
class NamedCrewFactory:
agents_config = None
tasks_config = None
agents: list[BaseAgent] = [sample_agent]
tasks: list[Task] = [sample_task]
@crew
def crew(self):
return Crew(
name="My Explicit Name",
agents=[sample_agent],
tasks=[sample_task],
)
factory_cls = cast(type[Any], NamedCrewFactory)
crew_instance: Crew = cast(Any, factory_cls()).crew()
assert crew_instance.name == "My Explicit Name"
def test_crew_decorator_preserves_explicit_name_matching_class_name():
"""Explicit name that happens to equal the class-name fallback must still win."""
sample_agent = Agent(role="r", goal="g", backstory="b")
sample_task = Task(description="d", expected_output="o", agent=sample_agent)
@CrewBase
class AmbiguousFactory:
agents_config = None
tasks_config = None
agents: list[BaseAgent] = [sample_agent]
tasks: list[Task] = [sample_task]
@crew
def crew(self):
# "Crew" is the class-name fallback the validator would use, but the
# user set it explicitly — propagation must still skip the override.
return Crew(
name="Crew",
agents=[sample_agent],
tasks=[sample_task],
)
factory_cls = cast(type[Any], AmbiguousFactory)
crew_instance: Crew = cast(Any, factory_cls()).crew()
assert crew_instance.name == "Crew"
@tool
def simple_tool():
"""Return 'Hi!'"""

View File

@@ -276,7 +276,12 @@ class TestTraceListenerSetup:
expected_output="hello world",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task], verbose=True)
crew = Crew(
name="Hello World Crew",
agents=[agent],
tasks=[task],
verbose=True,
)
from crewai.events.event_bus import crewai_event_bus
@@ -304,7 +309,7 @@ class TestTraceListenerSetup:
# Verify the first completion event has proper structure
completion_event = completion_events[0]
assert "crew_name" in completion_event.event_data
assert completion_event.event_data["crew_name"] == "crew"
assert completion_event.event_data["crew_name"] == "Hello World Crew"
# Verify all events have proper structure
for call in add_event_mock.call_args_list:

2
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-18T07:00:00Z"
exclude-newer = "2026-04-18T03:00:00Z"
[manifest]
members = [