mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-18 06:38:11 +00:00
Compare commits
3 Commits
ci/python-
...
devin/1778
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9229b78fe5 | ||
|
|
280e9103a1 | ||
|
|
90f4021071 |
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
142
lib/crewai/src/crewai/utilities/tool_files.py
Normal file
142
lib/crewai/src/crewai/utilities/tool_files.py
Normal 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."
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
336
lib/crewai/tests/tools/test_tool_file_returns.py
Normal file
336
lib/crewai/tests/tools/test_tool_file_returns.py
Normal 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
|
||||
112
lib/crewai/tests/utilities/test_tool_files.py
Normal file
112
lib/crewai/tests/utilities/test_tool_files.py
Normal 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]
|
||||
Reference in New Issue
Block a user