Compare commits

...

3 Commits

Author SHA1 Message Date
Devin AI
9229b78fe5 fix(tools): annotate files dict as dict[str, FileInput] for mypy 2026-05-09 02:47:36 +00:00
Devin AI
280e9103a1 fix(tools): tighten _is_base_file return type to TypeGuard[FileInput]
Mypy could not infer that BaseFile values are valid FileInput entries.
Using TypeGuard[FileInput] makes the type narrowing explicit.
2026-05-09 02:43:30 +00:00
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
8 changed files with 710 additions and 4 deletions

View File

@@ -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:

View File

@@ -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(

View File

@@ -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)

View File

@@ -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")

View File

@@ -0,0 +1,142 @@
"""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, TypeGuard
if TYPE_CHECKING:
from crewai_files import BaseFile, FileInput
def _is_base_file(value: Any) -> TypeGuard[FileInput]:
"""Return True if ``value`` is a ``crewai_files.BaseFile`` subclass instance.
``FileInput`` is the union of all concrete ``BaseFile`` subclasses, so any
``BaseFile`` instance is by definition a ``FileInput``.
"""
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_<index>`` 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, FileInput] = {}
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."
)

View File

@@ -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,

View File

@@ -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

View File

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