mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-02 13:48:09 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
|
||||
69
lib/crewai/src/crewai/utilities/declarative_refs.py
Normal file
69
lib/crewai/src/crewai/utilities/declarative_refs.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user