diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index 24f863c4e..4bb67a269 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -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 diff --git a/lib/crewai/src/crewai/flow/runtime/_actions.py b/lib/crewai/src/crewai/flow/runtime/_actions.py index c8f118775..9b5c8831f 100644 --- a/lib/crewai/src/crewai/flow/runtime/_actions.py +++ b/lib/crewai/src/crewai/flow/runtime/_actions.py @@ -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( diff --git a/lib/crewai/src/crewai/flow/runtime/_refs.py b/lib/crewai/src/crewai/flow/runtime/_refs.py deleted file mode 100644 index 23ddafadb..000000000 --- a/lib/crewai/src/crewai/flow/runtime/_refs.py +++ /dev/null @@ -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 diff --git a/lib/crewai/src/crewai/project/json_loader.py b/lib/crewai/src/crewai/project/json_loader.py index 0c6d7cbba..2c2a229fb 100644 --- a/lib/crewai/src/crewai/project/json_loader.py +++ b/lib/crewai/src/crewai/project/json_loader.py @@ -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:' prefix for a tool defined in tools/.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:' prefix for a tool defined in " + f"tools/.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] = {} diff --git a/lib/crewai/src/crewai/utilities/declarative_refs.py b/lib/crewai/src/crewai/utilities/declarative_refs.py new file mode 100644 index 000000000..5779fe129 --- /dev/null +++ b/lib/crewai/src/crewai/utilities/declarative_refs.py @@ -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 diff --git a/lib/crewai/tests/project/test_json_loader.py b/lib/crewai/tests/project/test_json_loader.py index a5a8f85fa..ff2d5d48c 100644 --- a/lib/crewai/tests/project/test_json_loader.py +++ b/lib/crewai/tests/project/test_json_loader.py @@ -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,