Files
crewAI/lib/crewai/tests/utilities/test_tool_files.py
Devin AI 90f4021071 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
2026-05-09 02:38:28 +00:00

113 lines
4.1 KiB
Python

"""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]