Make declarative refs work across flows and crews (#6326)

Declarative flows already used `module:qualname` refs for runtime
objects, but crew JSON tools still had their own lookup path. That meant
examples like `project_tools:LookupTool` were treated as named
`crewai_tools` lookups and failed with guidance that only mentioned
`SerperDevTool` or `custom:<name>`. Invalid refs such as
`not_tools:NotATool` also missed the same BaseTool validation used by
flow tool actions.

Move ref resolution into a shared declarative helper, use it from flow
tool actions and crew JSON loading, and require tool refs to resolve to
`BaseTool` classes before instantiation. Validation still checks tool
refs structurally, so validating a crew does not import or execute
project code.
This commit is contained in:
Vinicius Brasil
2026-06-24 15:11:59 -07:00
committed by GitHub
parent 156b3500b4
commit 7738a1d30c
6 changed files with 196 additions and 51 deletions

View File

@@ -123,7 +123,6 @@ from crewai.flow.human_feedback import (
from crewai.flow.input_provider import InputProvider
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.runtime._actions import FlowScriptExecutionDisabledError, build_action
from crewai.flow.runtime._refs import resolve_instance_ref, resolve_ref
from crewai.flow.types import (
FlowExecutionData,
FlowMethodName,
@@ -137,6 +136,7 @@ from crewai.state.checkpoint_config import (
_coerce_checkpoint,
apply_checkpoint,
)
from crewai.utilities.declarative_refs import InvalidRefError, resolve_ref
if TYPE_CHECKING:
@@ -289,6 +289,18 @@ def _resolve_persistence(value: Any) -> Any:
return value
def _resolve_instance_ref(ref: str, *, field: str) -> Any:
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e
def _serialize_persistence(value: Any) -> dict[str, Any] | None:
if value is None:
return None
@@ -304,7 +316,7 @@ def _validate_input_provider(value: Any) -> Any:
if value is None or isinstance(value, InputProvider):
return value
if isinstance(value, str) and ":" in value:
resolved = resolve_instance_ref(value, field="input_provider")
resolved = _resolve_instance_ref(value, field="input_provider")
else:
from crewai.types.callback import _dotted_path_to_instance
@@ -3605,7 +3617,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
) -> Any:
provider = feedback_definition.provider
if isinstance(provider, str):
provider = resolve_instance_ref(provider, field="human_feedback.provider")
provider = _resolve_instance_ref(provider, field="human_feedback.provider")
if provider is None:
from crewai.flow.flow_config import flow_config

View File

@@ -24,7 +24,11 @@ from crewai.flow.flow_definition import (
FlowToolActionDefinition,
)
from crewai.flow.runtime._outputs import outputs_by_name
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
from crewai.utilities.declarative_refs import (
InvalidRefError,
resolve_class_ref,
resolve_ref,
)
if TYPE_CHECKING:
@@ -103,16 +107,17 @@ class ToolAction:
)
def _build_tool(self) -> Any:
target = resolve_ref(self.definition.ref, field="do")
from crewai.tools import BaseTool
if not (inspect.isclass(target) and issubclass(target, BaseTool)):
raise InvalidRefError(
f"invalid tool ref {self.definition.ref!r}; expected a BaseTool class"
)
tool_cls = cast(
Callable[[], BaseTool],
resolve_class_ref(
self.definition.ref,
field="do",
base_class=BaseTool,
),
)
try:
tool_cls = cast(Callable[[], BaseTool], target)
return tool_cls()
except Exception as e:
raise InvalidRefError(

View File

@@ -1,38 +0,0 @@
"""Resolution of ``module:qualname`` refs into live Python objects."""
from __future__ import annotations
import importlib
import inspect
from operator import attrgetter
from typing import Any
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live object."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Import the object a definition's `module:qualname` ref points to."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_instance_ref(ref: str, *, field: str) -> Any:
"""Resolve a ref, auto-instantiating a no-arg class into an instance."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e

View File

@@ -16,6 +16,8 @@ from urllib.parse import unquote, urlparse
from pydantic import BaseModel, ValidationError
from crewai.utilities.declarative_refs import InvalidRefError, resolve_class_ref
logger = logging.getLogger(__name__)
@@ -1820,6 +1822,9 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
if tool_def.startswith("custom:"):
tools.append(_resolve_custom_tool(tool_def[7:], project_root=project_root))
continue
if ":" in tool_def:
tools.append(_instantiate_tool_import_ref(tool_def))
continue
try:
tool_cls = _find_tool_class(tool_def)
except Exception as e:
@@ -1827,8 +1832,10 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
if tool_cls is None:
raise JSONProjectError(
f"Unknown tool '{tool_def}'. Tool names must match a class from "
f"the 'crewai_tools' package (e.g. 'SerperDevTool') or use the "
f"'custom:<name>' prefix for a tool defined in tools/<name>.py."
f"the 'crewai_tools' package (e.g. 'SerperDevTool'), use a "
f"'module:ClassName' import ref (e.g. 'crewai_tools:SerperDevTool'), "
f"or use the 'custom:<name>' prefix for a tool defined in "
f"tools/<name>.py."
)
try:
tools.append(tool_cls())
@@ -1839,6 +1846,32 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
return tools
def _instantiate_tool_import_ref(ref: str) -> Any:
from crewai.tools import BaseTool
try:
tool_cls = cast(
Callable[[], BaseTool],
resolve_class_ref(ref, field="tool", base_class=BaseTool),
)
except InvalidRefError as e:
message = str(e)
if (
message.startswith("unresolvable ")
or "expected 'module:qualname'" in message
):
raise JSONProjectError(str(e)) from e
raise JSONProjectError(
f"invalid tool ref {ref!r}; expected a BaseTool class"
) from e
try:
return tool_cls()
except Exception as e:
raise JSONProjectError(
f"cannot instantiate tool ref {ref!r} without arguments: {e}"
) from e
_tool_class_cache: dict[str, type | None] = {}

View File

@@ -0,0 +1,69 @@
"""Resolve Python refs used in project definitions.
A ref must use this form: ``module:qualname``. ``module`` must name a Python
module we can import. ``qualname`` must name something inside that module. For
example, ``crewai_tools:SerperDevTool`` imports ``crewai_tools`` and returns
``SerperDevTool`` from it. Dots in ``qualname`` mean nested attributes.
Examples:
- ``crewai_tools:SerperDevTool`` imports ``crewai_tools`` and returns
``SerperDevTool``.
- ``my_app.tools:Factory.build`` imports ``my_app.tools``, gets ``Factory``,
then gets ``build`` from ``Factory``.
- ``crewai_tools`` is invalid because it has no ``:``.
- ``crewai_tools:`` is invalid because it has no ``qualname``.
These helpers are the shared contract for YAML/JSON definitions:
- ``resolve_ref()`` checks the ref, imports the module, and returns the symbol
as-is.
- ``resolve_class_ref()`` does the same work, then checks that the symbol is a
class. It can also check that the class extends a base class. It does not
create an object.
These helpers import user code. Code that must avoid that should check the raw
string shape instead.
"""
from __future__ import annotations
import importlib
import inspect
from operator import attrgetter
from typing import Any
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live Python symbol."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Return the Python symbol named by a project definition field."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_class_ref(
ref: str,
*,
field: str,
base_class: type[Any] | None = None,
) -> type[Any]:
"""Return the named class, with an optional base class check."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
raise InvalidRefError(f"invalid {field} ref {ref!r}; expected a class")
if base_class is not None and not issubclass(target, base_class):
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected a subclass of "
f"{base_class.__module__}.{base_class.__name__}"
)
return target

View File

@@ -385,12 +385,52 @@ class TestLoadAgentFromDefinition:
class TestResolveTools:
def test_import_ref_tool_resolves(self, tmp_path, monkeypatch):
from crewai.project.json_loader import _resolve_tools
(tmp_path / "project_tools.py").write_text(
"from crewai.tools.base_tool import BaseTool\n"
"\n"
"class LookupTool(BaseTool):\n"
" name: str = 'lookup'\n"
" description: str = 'lookup input'\n"
"\n"
" def _run(self, text: str) -> str:\n"
" return text\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
tools = _resolve_tools(["project_tools:LookupTool"])
assert len(tools) == 1
assert tools[0].name == "lookup"
def test_unknown_tool_raises_with_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
with pytest.raises(JSONProjectError, match="Unknown tool 'NotARealToolXYZ'"):
_resolve_tools(["NotARealToolXYZ"])
def test_import_ref_tool_must_resolve_to_basetool_class(
self, tmp_path, monkeypatch
):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
(tmp_path / "not_tools.py").write_text(
"class NotATool:\n"
" pass\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(JSONProjectError, match="expected a BaseTool class"):
_resolve_tools(["not_tools:NotATool"])
def test_unresolvable_import_ref_tool_raises_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
with pytest.raises(JSONProjectError, match="unresolvable tool ref"):
_resolve_tools(["not_a_real_module:MissingTool"])
def test_missing_custom_tool_raises(self, tmp_path, monkeypatch):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
@@ -505,6 +545,30 @@ class TestValidationDoesNotExecuteTools:
assert not sentinel.exists(), "validation must not import Python refs"
def test_validate_does_not_import_tool_refs(
self, tmp_path, monkeypatch: pytest.MonkeyPatch
):
from crewai.project.json_loader import validate_crew_project
sentinel = tmp_path / "tool_ref_executed.txt"
(tmp_path / "project_tools.py").write_text(
"from pathlib import Path\n"
f"Path({str(sentinel)!r}).write_text('boom')\n"
"from crewai.tools.base_tool import BaseTool\n"
"class LookupTool(BaseTool):\n"
" name: str = 'lookup'\n"
" description: str = 'lookup input'\n"
" def _run(self, text: str) -> str:\n"
" return text\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
sys.modules.pop("project_tools", None)
crew_path = self._write_project(tmp_path, tool_line='"project_tools:LookupTool"')
validate_crew_project(crew_path, tmp_path / "agents")
assert not sentinel.exists(), "validation must not import tool refs"
def test_validate_reports_missing_custom_tool_file(self, tmp_path):
from crewai.project.json_loader import (
JSONProjectValidationError,