From 90f40210718f6da2aec1e89e3a698cfc04a5e2cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:38:28 +0000 Subject: [PATCH] feat(tools): allow tools to return File instances for multimodal context Closes #5758 Tools can now return crewai_files.FileInput instances (or lists/dicts of them) from their _run method to dynamically extend the agent's multimodal context. The framework detects file returns, replaces the raw return with a confirmation message, and attaches the files to the most recent user message so subsequent LLM calls include them. - Add extract_files_from_tool_result helper - Extend ToolResult dataclass with optional files field - Detect FileInput returns in ToolUsage._use / _ause - Propagate files through execute_tool_and_check_finality - Attach files to messages in CrewAgentExecutor (ReAct + native tool flows) - Mirror file attachment in LiteAgent ReAct loop - Add comprehensive unit tests --- .../src/crewai/agents/crew_agent_executor.py | 40 +++ lib/crewai/src/crewai/lite_agent.py | 29 ++ lib/crewai/src/crewai/tools/tool_types.py | 23 +- lib/crewai/src/crewai/tools/tool_usage.py | 26 ++ lib/crewai/src/crewai/utilities/tool_files.py | 138 +++++++ lib/crewai/src/crewai/utilities/tool_utils.py | 6 +- .../tests/tools/test_tool_file_returns.py | 336 ++++++++++++++++++ lib/crewai/tests/utilities/test_tool_files.py | 112 ++++++ 8 files changed, 706 insertions(+), 4 deletions(-) create mode 100644 lib/crewai/src/crewai/utilities/tool_files.py create mode 100644 lib/crewai/tests/tools/test_tool_file_returns.py create mode 100644 lib/crewai/tests/utilities/test_tool_files.py diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index 1dac07606..a6fd5aba2 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -937,6 +937,17 @@ class CrewAgentExecutor(BaseAgentExecutor): try: raw_result = available_functions[func_name](**(args_dict or {})) + from crewai.utilities.tool_files import ( + extract_files_from_tool_result, + ) + + extracted_files, files_message = extract_files_from_tool_result( + raw_result + ) + if extracted_files is not None: + self._attach_tool_files_to_messages(extracted_files) + raw_result = files_message + if self.tools_handler and self.tools_handler.cache: should_cache = True if ( @@ -947,6 +958,8 @@ class CrewAgentExecutor(BaseAgentExecutor): should_cache = original_tool.cache_function( args_dict or {}, raw_result ) + if extracted_files is not None: + should_cache = False if should_cache: self.tools_handler.cache.add( tool=func_name, input=input_str, output=raw_result @@ -1409,6 +1422,9 @@ class CrewAgentExecutor(BaseAgentExecutor): Returns: Updated action or final answer. """ + if tool_result.files: + self._attach_tool_files_to_messages(tool_result.files) + add_image_tool = I18N_DEFAULT.tools("add_image") if ( isinstance(add_image_tool, dict) @@ -1426,6 +1442,30 @@ class CrewAgentExecutor(BaseAgentExecutor): show_logs=self._show_logs, ) + def _attach_tool_files_to_messages(self, files: dict[str, Any]) -> None: + """Attach files returned by a tool to the most recent user message. + + Tools may return ``crewai_files.FileInput`` instances (or lists/dicts + of them) to dynamically extend the agent's multimodal context. This + method merges those files into the existing ``files`` mapping on the + most recent user message so the next LLM call includes them. + + Args: + files: Mapping of file names to ``FileInput`` instances. + """ + if not files: + return + + for i in range(len(self.messages) - 1, -1, -1): + msg = self.messages[i] + if msg.get("role") == "user": + existing = msg.get("files") or {} + merged = {**existing, **files} + msg["files"] = merged + return + + self.messages.append({"role": "user", "content": "", "files": dict(files)}) + def _invoke_step_callback( self, formatted_answer: AgentAction | AgentFinish ) -> None: diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index cd9823e15..fa478e131 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -920,6 +920,9 @@ class LiteAgent(FlowTrackable, BaseModel): except Exception as e: raise e + if tool_result.files: + self._attach_tool_files_to_messages(tool_result.files) + formatted_answer = handle_agent_action_core( formatted_answer=formatted_answer, tool_result=tool_result, @@ -970,6 +973,32 @@ class LiteAgent(FlowTrackable, BaseModel): self._show_logs(formatted_answer) return formatted_answer + def _attach_tool_files_to_messages(self, files: dict[str, FileInput]) -> None: + """Attach files returned by a tool to the most recent user message. + + Tools may return ``crewai_files.FileInput`` instances (or lists/dicts + of them) to dynamically extend the agent's multimodal context. This + method merges those files into the existing ``files`` mapping on the + most recent user message so the next LLM call includes them. + + Args: + files: Mapping of file names to ``FileInput`` instances. + """ + if not files: + return + + for i in range(len(self._messages) - 1, -1, -1): + msg = self._messages[i] + if msg.get("role") == "user": + existing = cast(dict[str, FileInput], msg.get("files") or {}) + merged = {**existing, **files} + msg["files"] = merged + return + + self._messages.append( + cast(LLMMessage, {"role": "user", "content": "", "files": dict(files)}) + ) + def _show_logs(self, formatted_answer: AgentAction | AgentFinish) -> None: """Show logs for the agent's execution.""" crewai_event_bus.emit( diff --git a/lib/crewai/src/crewai/tools/tool_types.py b/lib/crewai/src/crewai/tools/tool_types.py index 3e37fed2f..17a88db77 100644 --- a/lib/crewai/src/crewai/tools/tool_types.py +++ b/lib/crewai/src/crewai/tools/tool_types.py @@ -1,9 +1,28 @@ -from dataclasses import dataclass +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from crewai_files import FileInput @dataclass class ToolResult: - """Result of tool execution.""" + """Result of tool execution. + + Attributes: + result: Textual result of the tool, used as the LLM-facing tool + output. + result_as_answer: Whether the tool's result should be treated as + the agent's final answer. + files: Optional mapping of file names to ``crewai_files.FileInput`` + instances returned by the tool. When set, the executor will + attach these files to the agent's conversation as multimodal + context for subsequent LLM calls. + """ result: str result_as_answer: bool = False + files: dict[str, FileInput] | None = field(default=None) diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index 0a004059a..62f898613 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -31,6 +31,7 @@ from crewai.utilities.agent_utils import ( from crewai.utilities.converter import Converter from crewai.utilities.i18n import I18N_DEFAULT from crewai.utilities.string_utils import sanitize_tool_name +from crewai.utilities.tool_files import extract_files_from_tool_result if TYPE_CHECKING: @@ -106,6 +107,7 @@ class ToolUsage: self.action = action self.function_calling_llm = function_calling_llm self.fingerprint_context = fingerprint_context or {} + self._last_extracted_files: dict[str, Any] | None = None # Set the maximum parsing attempts for bigger models if ( @@ -339,6 +341,13 @@ class ToolUsage: else: result = await tool.ainvoke(input={}, config=fingerprint_config) + extracted_files, files_message = extract_files_from_tool_result( + result + ) + if extracted_files is not None: + result = files_message + self._last_extracted_files = extracted_files + if self.tools_handler: should_cache = True # Check cache_function on original tool (for tools converted via to_structured_tool) @@ -352,6 +361,9 @@ class ToolUsage: if cache_func: should_cache = cache_func(calling.arguments, result) + if extracted_files is not None: + should_cache = False + self.tools_handler.on_tool_use( calling=calling, output=result, should_cache=should_cache ) @@ -367,6 +379,8 @@ class ToolUsage: "tool_name": sanitize_tool_name(tool.name), "tool_args": calling.arguments, } + if extracted_files is not None: + data["files"] = extracted_files if ( hasattr(available_tool, "result_as_answer") @@ -572,6 +586,13 @@ class ToolUsage: else: result = tool.invoke(input={}, config=fingerprint_config) + extracted_files, files_message = extract_files_from_tool_result( + result + ) + if extracted_files is not None: + result = files_message + self._last_extracted_files = extracted_files + if self.tools_handler: should_cache = True # Check cache_function on original tool (for tools converted via to_structured_tool) @@ -585,6 +606,9 @@ class ToolUsage: if cache_func: should_cache = cache_func(calling.arguments, result) + if extracted_files is not None: + should_cache = False + self.tools_handler.on_tool_use( calling=calling, output=result, should_cache=should_cache ) @@ -600,6 +624,8 @@ class ToolUsage: "tool_name": sanitize_tool_name(tool.name), "tool_args": calling.arguments, } + if extracted_files is not None: + data["files"] = extracted_files if ( hasattr(available_tool, "result_as_answer") diff --git a/lib/crewai/src/crewai/utilities/tool_files.py b/lib/crewai/src/crewai/utilities/tool_files.py new file mode 100644 index 000000000..4ab0f53ea --- /dev/null +++ b/lib/crewai/src/crewai/utilities/tool_files.py @@ -0,0 +1,138 @@ +"""Helpers for detecting file returns from tools. + +Tools can return file objects (instances of ``crewai_files.BaseFile`` or +subclasses such as ``File``, ``PDFFile``, ``ImageFile``, etc.) and the +agent executor will automatically attach those files to the conversation +so they become available as multimodal context for subsequent LLM calls. + +This module exposes the pure helpers used by the executor to detect such +returns and convert them into a normalized representation. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from crewai_files import BaseFile, FileInput + + +def _is_base_file(value: Any) -> bool: + """Return True if ``value`` is a ``crewai_files.BaseFile`` subclass instance.""" + try: + from crewai_files import BaseFile + except ImportError: + return False + return isinstance(value, BaseFile) + + +def _file_key(file: BaseFile, used: set[str], default_index: int) -> str: + """Pick a unique key for ``file`` when adding it to a files dict. + + Prefers the file's filename stem, falling back to ``file_`` if no + filename is available or if the chosen key collides with one already in + use. + """ + filename = file.filename + base = Path(filename).stem if filename else f"file_{default_index}" + candidate = base or f"file_{default_index}" + if candidate not in used: + return candidate + + counter = 1 + while True: + candidate = f"{base}_{counter}" if base else f"file_{default_index}_{counter}" + if candidate not in used: + return candidate + counter += 1 + + +def extract_files_from_tool_result( + result: Any, +) -> tuple[dict[str, FileInput] | None, str | None]: + """Inspect a tool's return value and extract any ``FileInput`` instances. + + Tools may return: + + - A single ``BaseFile`` instance (``File``, ``PDFFile``, ``ImageFile``, + ``TextFile``, ``AudioFile``, ``VideoFile``). + - A list/tuple of ``BaseFile`` instances. + - A dict mapping names to ``BaseFile`` instances. + + When any of these shapes are detected this returns a tuple + ``(files, message)`` where ``files`` is a dict suitable for the + multimodal ``files`` slot on a user message and ``message`` is a short + confirmation string describing what was added (intended to be shown to + the LLM as the textual tool result). + + For any other return type the helper returns ``(None, None)`` so the + caller can keep the existing string-based behavior unchanged. + + Args: + result: The raw return value of a tool's ``run`` / ``_run`` method. + + Returns: + A ``(files, message)`` tuple. ``files`` is ``None`` when no files + were detected. + """ + files: dict[str, BaseFile] = {} + + if _is_base_file(result): + key = _file_key(result, used=set(), default_index=0) + files[key] = result + elif isinstance(result, Mapping) and result: + if not all(_is_base_file(value) for value in result.values()): + return None, None + used: set[str] = set() + for raw_key, value in result.items(): + key = str(raw_key) + if not key or key in used: + key = _file_key(value, used=used, default_index=len(used)) + used.add(key) + files[key] = value + elif ( + isinstance(result, Sequence) + and not isinstance(result, (str, bytes, bytearray)) + and result + ): + if not all(_is_base_file(value) for value in result): + return None, None + used = set() + for index, value in enumerate(result): + key = _file_key(value, used=used, default_index=index) + used.add(key) + files[key] = value + else: + return None, None + + if not files: + return None, None + + message = _format_files_message(files) + return files, message + + +def _format_files_message(files: Mapping[str, BaseFile]) -> str: + """Build a confirmation string describing the files that were added.""" + descriptions: list[str] = [] + for key, file in files.items(): + filename = file.filename + try: + content_type = file.content_type + except Exception: # pragma: no cover - defensive + content_type = "unknown" + if filename: + descriptions.append(f"'{key}' ({filename}, {content_type})") + else: + descriptions.append(f"'{key}' ({content_type})") + + count = len(files) + suffix = "" if count == 1 else "s" + return ( + f"Added {count} file{suffix} to the agent context: " + + ", ".join(descriptions) + + ". They are available for subsequent reasoning and tool calls." + ) diff --git a/lib/crewai/src/crewai/utilities/tool_utils.py b/lib/crewai/src/crewai/utilities/tool_utils.py index c7a469468..e92990ff8 100644 --- a/lib/crewai/src/crewai/utilities/tool_utils.py +++ b/lib/crewai/src/crewai/utilities/tool_utils.py @@ -116,6 +116,7 @@ async def aexecute_tool_and_check_finality( logger.log("error", f"Error in before_tool_call hook: {e}") tool_result = await tool_usage.ause(tool_calling, agent_action.text) + extracted_files = tool_usage._last_extracted_files after_hook_context = ToolCallHookContext( tool_name=sanitized_tool_name, @@ -138,7 +139,7 @@ async def aexecute_tool_and_check_finality( except Exception as e: logger.log("error", f"Error in after_tool_call hook: {e}") - return ToolResult(modified_result, tool.result_as_answer) + return ToolResult(modified_result, tool.result_as_answer, files=extracted_files) tool_result = I18N_DEFAULT.errors("wrong_tool_name").format( tool=sanitized_tool_name, @@ -234,6 +235,7 @@ def execute_tool_and_check_finality( logger.log("error", f"Error in before_tool_call hook: {e}") tool_result = tool_usage.use(tool_calling, agent_action.text) + extracted_files = tool_usage._last_extracted_files after_hook_context = ToolCallHookContext( tool_name=sanitized_tool_name, @@ -257,7 +259,7 @@ def execute_tool_and_check_finality( except Exception as e: logger.log("error", f"Error in after_tool_call hook: {e}") - return ToolResult(modified_result, tool.result_as_answer) + return ToolResult(modified_result, tool.result_as_answer, files=extracted_files) tool_result = I18N_DEFAULT.errors("wrong_tool_name").format( tool=sanitized_tool_name, diff --git a/lib/crewai/tests/tools/test_tool_file_returns.py b/lib/crewai/tests/tools/test_tool_file_returns.py new file mode 100644 index 000000000..494c4372c --- /dev/null +++ b/lib/crewai/tests/tools/test_tool_file_returns.py @@ -0,0 +1,336 @@ +"""Tests for tools returning ``FileInput`` instances. + +Verifies that when a tool's ``_run`` method returns a ``BaseFile`` instance +(or a collection of them), the framework: + 1. Detects the file(s) via ``extract_files_from_tool_result``. + 2. Replaces the raw return with a confirmation message string. + 3. Propagates the files through ``ToolResult.files``. + 4. Attaches the files to the agent's message history. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from crewai_files import File, ImageFile, TextFile + +from crewai.agents.crew_agent_executor import CrewAgentExecutor +from crewai.agents.parser import AgentAction +from crewai.tools import BaseTool +from crewai.tools.tool_calling import ToolCalling +from crewai.tools.tool_types import ToolResult +from crewai.tools.tool_usage import ToolUsage +from crewai.utilities.tool_utils import execute_tool_and_check_finality +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Fixture tools +# --------------------------------------------------------------------------- + + +class SingleFileToolInput(BaseModel): + path: str = Field(description="Path to get file from") + + +class SingleFileTool(BaseTool): + name: str = "Get Document" + description: str = "Returns a single file" + args_schema: type[BaseModel] = SingleFileToolInput + + def _run(self, path: str, **kwargs) -> TextFile: + return TextFile(source=b"hello world") + + +class MultiFileToolInput(BaseModel): + query: str = Field(description="Query to search for") + + +class MultiFileTool(BaseTool): + name: str = "Search Files" + description: str = "Returns multiple files" + args_schema: type[BaseModel] = MultiFileToolInput + + def _run(self, query: str, **kwargs) -> list[TextFile]: + return [ + TextFile(source=b"file one"), + TextFile(source=b"file two"), + ] + + +class DictFileToolInput(BaseModel): + name: str = Field(description="Name") + + +class DictFileTool(BaseTool): + name: str = "Get Named Files" + description: str = "Returns a dict of named files" + args_schema: type[BaseModel] = DictFileToolInput + + def _run(self, name: str, **kwargs) -> dict[str, TextFile]: + return { + "notes": TextFile(source=b"notes content"), + "report": TextFile(source=b"report content"), + } + + +class RegularTool(BaseTool): + name: str = "Regular Tool" + description: str = "Returns a string" + + def _run(self, **kwargs) -> str: + return "just a string" + + +# --------------------------------------------------------------------------- +# Tests: ToolUsage file extraction +# --------------------------------------------------------------------------- + + +class TestToolUsageFileExtraction: + """Test that ToolUsage._use extracts files from tool returns.""" + + def _make_tool_usage(self, tool: BaseTool) -> ToolUsage: + structured = tool.to_structured_tool() + mock_agent = MagicMock() + mock_agent.key = "agent_key" + mock_agent.role = "agent_role" + mock_agent._original_role = "agent_role" + mock_agent.verbose = False + mock_agent.fingerprint = None + mock_agent.tools_results = [] + + mock_task = MagicMock() + mock_task.delegations = 0 + mock_task.name = "Test" + mock_task.description = "Test" + mock_task.id = "t-id" + + mock_action = MagicMock() + mock_action.tool = tool.name + mock_action.tool_input = "{}" + + return ToolUsage( + tools_handler=MagicMock(cache=None, last_used_tool=None), + tools=[structured], + task=mock_task, + function_calling_llm=None, + agent=mock_agent, + action=mock_action, + ) + + def test_single_file_tool_extracts_files(self) -> None: + """When a tool returns a single BaseFile, _last_extracted_files is set.""" + tool = SingleFileTool() + tool_usage = self._make_tool_usage(tool) + calling = ToolCalling(tool_name="get_document", arguments={"path": "/a"}) + + result = tool_usage.use(calling=calling, tool_string="Action: get_document") + + assert tool_usage._last_extracted_files is not None + assert len(tool_usage._last_extracted_files) == 1 + assert "Added 1 file" in result + + def test_multi_file_tool_extracts_files(self) -> None: + """When a tool returns a list of BaseFile, all are extracted.""" + tool = MultiFileTool() + tool_usage = self._make_tool_usage(tool) + calling = ToolCalling(tool_name="search_files", arguments={"query": "q"}) + + result = tool_usage.use(calling=calling, tool_string="Action: search_files") + + assert tool_usage._last_extracted_files is not None + assert len(tool_usage._last_extracted_files) == 2 + assert "Added 2 files" in result + + def test_dict_file_tool_extracts_files(self) -> None: + """When a tool returns a dict of BaseFile, keys are preserved.""" + tool = DictFileTool() + tool_usage = self._make_tool_usage(tool) + calling = ToolCalling(tool_name="get_named_files", arguments={"name": "test"}) + + result = tool_usage.use(calling=calling, tool_string="Action: get_named_files") + + assert tool_usage._last_extracted_files is not None + assert "notes" in tool_usage._last_extracted_files + assert "report" in tool_usage._last_extracted_files + + def test_regular_tool_no_files_extracted(self) -> None: + """Regular string-returning tools don't trigger file extraction.""" + tool = RegularTool() + tool_usage = self._make_tool_usage(tool) + calling = ToolCalling(tool_name="regular_tool", arguments={}) + + result = tool_usage.use(calling=calling, tool_string="Action: regular_tool") + + assert tool_usage._last_extracted_files is None + assert "just a string" in result + + +# --------------------------------------------------------------------------- +# Tests: execute_tool_and_check_finality propagates files +# --------------------------------------------------------------------------- + + +class TestToolUtilsFilePropagation: + """Test that execute_tool_and_check_finality propagates files in ToolResult.""" + + def test_file_tool_returns_tool_result_with_files(self) -> None: + """ToolResult.files should be set when tool returns a file.""" + tool = SingleFileTool() + structured = tool.to_structured_tool() + action = AgentAction( + thought="Need a doc", + tool="get_document", + tool_input='{"path": "/a"}', + text='Action: get_document\nAction Input: {"path": "/a"}', + ) + + result = execute_tool_and_check_finality( + agent_action=action, + tools=[structured], + agent_key="k", + agent_role="r", + ) + + assert isinstance(result, ToolResult) + assert result.files is not None + assert len(result.files) == 1 + assert "Added 1 file" in result.result + + def test_regular_tool_returns_tool_result_without_files(self) -> None: + """ToolResult.files should be None for regular tools.""" + tool = RegularTool() + structured = tool.to_structured_tool() + action = AgentAction( + thought="Run a tool", + tool="regular_tool", + tool_input="{}", + text="Action: regular_tool\nAction Input: {}", + ) + + result = execute_tool_and_check_finality( + agent_action=action, + tools=[structured], + agent_key="k", + agent_role="r", + ) + + assert isinstance(result, ToolResult) + assert result.files is None + assert "just a string" in result.result + + +# --------------------------------------------------------------------------- +# Tests: CrewAgentExecutor._attach_tool_files_to_messages +# --------------------------------------------------------------------------- + + +class TestAttachToolFilesToMessages: + """Test that _attach_tool_files_to_messages correctly attaches files.""" + + def _make_executor(self, messages: list[dict]) -> CrewAgentExecutor: + """Create a minimal executor with pre-set messages. + + Uses ``model_construct`` to skip pydantic validation so tests can + exercise ``_attach_tool_files_to_messages`` without supplying a + complete agent/task wiring. + """ + executor = CrewAgentExecutor.model_construct(messages=messages) + return executor + + def test_attaches_files_to_last_user_message(self) -> None: + """Files are merged into the most recent user message.""" + messages = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "working..."}, + ] + executor = self._make_executor(messages) + test_file = TextFile(source=b"content") + executor._attach_tool_files_to_messages({"doc": test_file}) + + user_msg = messages[1] + assert "files" in user_msg + assert user_msg["files"]["doc"] is test_file + + def test_merges_with_existing_files(self) -> None: + """New files are merged into existing files on the user message.""" + existing_file = ImageFile(source=b"\x89PNG\r\n\x1a\n") + messages = [ + {"role": "user", "content": "go", "files": {"img": existing_file}}, + ] + executor = self._make_executor(messages) + new_file = TextFile(source=b"text") + executor._attach_tool_files_to_messages({"doc": new_file}) + + user_msg = messages[0] + assert user_msg["files"]["img"] is existing_file + assert user_msg["files"]["doc"] is new_file + + def test_creates_user_message_if_none_exists(self) -> None: + """If no user message exists, a new one is appended.""" + messages = [ + {"role": "system", "content": "prompt"}, + {"role": "assistant", "content": "answer"}, + ] + executor = self._make_executor(messages) + test_file = TextFile(source=b"content") + executor._attach_tool_files_to_messages({"doc": test_file}) + + assert len(messages) == 3 + user_msg = messages[-1] + assert user_msg["role"] == "user" + assert user_msg["files"]["doc"] is test_file + + def test_no_op_for_empty_files(self) -> None: + """Empty files dict doesn't modify messages.""" + messages = [{"role": "user", "content": "hi"}] + executor = self._make_executor(messages) + executor._attach_tool_files_to_messages({}) + + assert "files" not in messages[0] + + +# --------------------------------------------------------------------------- +# Tests: _handle_agent_action with files +# --------------------------------------------------------------------------- + + +class TestHandleAgentActionWithFiles: + """Test that _handle_agent_action calls _attach_tool_files_to_messages.""" + + def test_handle_agent_action_attaches_files(self) -> None: + """When ToolResult has files, they get attached to messages.""" + messages = [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "task"}, + ] + + executor = CrewAgentExecutor.model_construct( + messages=messages, + step_callback=None, + task=None, + crew=None, + ) + + action = AgentAction( + thought="Run tool", + tool="tool", + tool_input="{}", + text="Action: tool\nAction Input: {}", + ) + test_file = TextFile(source=b"data") + tool_result = ToolResult( + result="Added 1 file to the agent context: 'doc' (text/plain).", + result_as_answer=False, + files={"doc": test_file}, + ) + + with patch.object(executor, "_show_logs"): + executor._handle_agent_action(action, tool_result) + + user_msg = messages[1] + assert "files" in user_msg + assert user_msg["files"]["doc"] is test_file diff --git a/lib/crewai/tests/utilities/test_tool_files.py b/lib/crewai/tests/utilities/test_tool_files.py new file mode 100644 index 000000000..dc0067fbc --- /dev/null +++ b/lib/crewai/tests/utilities/test_tool_files.py @@ -0,0 +1,112 @@ +"""Tests for ``crewai.utilities.tool_files``.""" + +from __future__ import annotations + +from crewai_files import File, ImageFile, PDFFile, TextFile + +from crewai.utilities.tool_files import extract_files_from_tool_result + + +def test_extract_files_returns_none_for_string() -> None: + """Plain strings are not files and should be passed through unchanged.""" + files, message = extract_files_from_tool_result("hello") + assert files is None + assert message is None + + +def test_extract_files_returns_none_for_dict_without_files() -> None: + """Dicts that don't only contain ``BaseFile`` values are left alone.""" + files, message = extract_files_from_tool_result({"role": "user", "content": "hi"}) + assert files is None + assert message is None + + +def test_extract_files_returns_none_for_empty_collections() -> None: + """Empty collections should not be treated as file containers.""" + assert extract_files_from_tool_result([]) == (None, None) + assert extract_files_from_tool_result(()) == (None, None) + assert extract_files_from_tool_result({}) == (None, None) + + +def test_extract_files_single_file() -> None: + """A single ``BaseFile`` instance is wrapped into a one-entry dict.""" + text_file = TextFile(source=b"hello", mode="auto") + files, message = extract_files_from_tool_result(text_file) + assert files is not None + assert message is not None + assert len(files) == 1 + assert next(iter(files.values())) is text_file + assert "Added 1 file" in message + + +def test_extract_files_uses_filename_stem_as_key() -> None: + """The filename stem (without extension) is used as the dict key.""" + text_file = TextFile( + source=b"hello", + ) + text_file.source.filename = "report.txt" # type: ignore[union-attr] + files, _ = extract_files_from_tool_result(text_file) + assert files is not None + assert "report" in files + + +def test_extract_files_list_of_files() -> None: + """Lists of ``BaseFile`` instances are extracted into a dict.""" + file_a = TextFile(source=b"a") + file_b = TextFile(source=b"b") + files, message = extract_files_from_tool_result([file_a, file_b]) + assert files is not None + assert message is not None + assert len(files) == 2 + assert file_a in files.values() + assert file_b in files.values() + assert "Added 2 files" in message + + +def test_extract_files_tuple_of_files() -> None: + """Tuples of ``BaseFile`` instances are also extracted.""" + file_a = ImageFile(source=b"\x89PNG\r\n\x1a\n") + files, _ = extract_files_from_tool_result((file_a,)) + assert files is not None + assert len(files) == 1 + assert file_a in files.values() + + +def test_extract_files_dict_of_files() -> None: + """Dicts mapping names to ``BaseFile`` instances are extracted as-is.""" + file_a = TextFile(source=b"a") + file_b = PDFFile(source=b"%PDF-1.4 content") + raw = {"notes": file_a, "report": file_b} + files, message = extract_files_from_tool_result(raw) + assert files is not None + assert message is not None + assert files["notes"] is file_a + assert files["report"] is file_b + + +def test_extract_files_mixed_list_returns_none() -> None: + """Heterogeneous lists with non-files are not treated as file containers.""" + files, message = extract_files_from_tool_result([TextFile(source=b"a"), "string"]) + assert files is None + assert message is None + + +def test_extract_files_generic_file_class() -> None: + """The generic ``File`` class also works as a file return type.""" + generic = File(source=b"plain text") + files, _ = extract_files_from_tool_result(generic) + assert files is not None + assert generic in files.values() + + +def test_extract_files_with_duplicate_filenames() -> None: + """When two files share a filename stem the keys are de-duplicated.""" + file_a = TextFile(source=b"a") + file_b = TextFile(source=b"b") + file_a.source.filename = "shared.txt" # type: ignore[union-attr] + file_b.source.filename = "shared.txt" # type: ignore[union-attr] + files, _ = extract_files_from_tool_result([file_a, file_b]) + assert files is not None + assert len(files) == 2 + keys = list(files) + assert keys[0] != keys[1]