diff --git a/src/backend/tests/unit/components/files_and_knowledge/test_file_component.py b/src/backend/tests/unit/components/files_and_knowledge/test_file_component.py index d0705ab19d..89bb1d8218 100644 --- a/src/backend/tests/unit/components/files_and_knowledge/test_file_component.py +++ b/src/backend/tests/unit/components/files_and_knowledge/test_file_component.py @@ -1,6 +1,7 @@ import json from unittest.mock import MagicMock, patch +import pytest from langflow.io import Output from lfx.components.files_and_knowledge.file import FileComponent @@ -271,3 +272,298 @@ class TestFileComponentDynamicOutputs: result = component.load_files_message() assert result.text == "Content from file_path_str", "file_path_str should take priority over path" + + +class TestFileComponentToolMode: + """Tests for the tool mode functionality of FileComponent.""" + + def test_get_tool_description_without_files(self): + """Test tool description when no files are uploaded.""" + component = FileComponent() + component._attributes["path"] = None + + description = component.get_tool_description() + + assert description == "Loads and returns the content from uploaded files." + + def test_get_tool_description_with_single_file(self): + """Test tool description includes single file name.""" + component = FileComponent() + component._attributes["path"] = ["flow123/document.pdf"] + + description = component.get_tool_description() + + assert "document.pdf" in description + assert "Available files:" in description + + def test_get_tool_description_with_multiple_files(self): + """Test tool description includes all file names.""" + component = FileComponent() + component._attributes["path"] = [ + "flow123/report.pdf", + "flow123/data.csv", + "flow123/notes.txt", + ] + + description = component.get_tool_description() + + assert "report.pdf" in description + assert "data.csv" in description + assert "notes.txt" in description + assert "Available files:" in description + + def test_get_tool_description_with_empty_list(self): + """Test tool description with empty file list.""" + component = FileComponent() + component._attributes["path"] = [] + + description = component.get_tool_description() + + assert description == "Loads and returns the content from uploaded files." + + def test_description_property_returns_dynamic_description(self): + """Test that description property returns dynamic description.""" + component = FileComponent() + component._attributes["path"] = ["flow123/test.pdf"] + + # Access via property + description = component.description + + assert "test.pdf" in description + + # ==================== Edge Cases: File Names ==================== + + def test_get_tool_description_filename_with_spaces(self): + """Test handling of filenames with spaces.""" + component = FileComponent() + component._attributes["path"] = ["flow123/my document with spaces.pdf"] + + description = component.get_tool_description() + + assert "my document with spaces.pdf" in description + + def test_get_tool_description_filename_with_comma(self): + """Test handling of filenames with commas.""" + component = FileComponent() + component._attributes["path"] = ["flow123/file,with,commas.txt"] + + description = component.get_tool_description() + + assert "file,with,commas.txt" in description + + def test_get_tool_description_filename_with_multiple_dots(self): + """Test handling of filenames with multiple dots.""" + component = FileComponent() + component._attributes["path"] = ["flow123/file.name.with.dots.pdf"] + + description = component.get_tool_description() + + assert "file.name.with.dots.pdf" in description + + def test_get_tool_description_filename_with_special_characters(self): + """Test handling of filenames with special characters.""" + component = FileComponent() + component._attributes["path"] = [ + "flow123/file-with-dashes.pdf", + "flow123/file_with_underscores.txt", + "flow123/file (with) parentheses.doc", + ] + + description = component.get_tool_description() + + assert "file-with-dashes.pdf" in description + assert "file_with_underscores.txt" in description + assert "file (with) parentheses.doc" in description + + def test_get_tool_description_filename_with_unicode(self): + """Test handling of filenames with unicode characters.""" + component = FileComponent() + component._attributes["path"] = [ + "flow123/文档.pdf", + "flow123/документ.txt", + "flow123/arquivo_português.pdf", + ] + + description = component.get_tool_description() + + assert "文档.pdf" in description + assert "документ.txt" in description + assert "arquivo_português.pdf" in description + + def test_get_tool_description_filename_with_numbers(self): + """Test handling of filenames with numbers.""" + component = FileComponent() + component._attributes["path"] = [ + "flow123/report_2024_01_15.pdf", + "flow123/v1.2.3_release_notes.txt", + ] + + description = component.get_tool_description() + + assert "report_2024_01_15.pdf" in description + assert "v1.2.3_release_notes.txt" in description + + def test_get_tool_description_very_long_filename(self): + """Test handling of very long filenames.""" + component = FileComponent() + long_name = "a" * 200 + ".pdf" + component._attributes["path"] = [f"flow123/{long_name}"] + + description = component.get_tool_description() + + assert long_name in description + + def test_get_tool_description_filters_empty_paths(self): + """Test that empty paths are filtered out.""" + component = FileComponent() + component._attributes["path"] = ["flow123/valid.pdf", "", None, "flow123/another.txt"] + + description = component.get_tool_description() + + assert "valid.pdf" in description + assert "another.txt" in description + # Should not crash or include empty entries + + # ==================== _get_tools() Tests ==================== + + @pytest.mark.asyncio + async def test_get_tools_returns_tool_without_parameters(self): + """Test that _get_tools() creates a tool without file_path_str parameter.""" + component = FileComponent() + component._attributes["path"] = ["flow123/test.pdf"] + + tools = await component._get_tools() + + assert len(tools) == 1 + tool = tools[0] + assert tool.name == "load_files_message" + + # Check that args_schema has no required fields (empty schema) + schema = tool.args_schema.model_json_schema() + properties = schema.get("properties", {}) + assert len(properties) == 0, f"Tool should have no parameters, but has: {properties}" + + @pytest.mark.asyncio + async def test_get_tools_description_includes_filenames(self): + """Test that tool description includes uploaded filenames.""" + component = FileComponent() + component._attributes["path"] = ["flow123/important_document.pdf"] + + tools = await component._get_tools() + + assert len(tools) == 1 + tool = tools[0] + assert "important_document.pdf" in tool.description + + @pytest.mark.asyncio + async def test_get_tools_works_with_no_files(self): + """Test that _get_tools() works even when no files are uploaded.""" + component = FileComponent() + component._attributes["path"] = None + + tools = await component._get_tools() + + assert len(tools) == 1 + tool = tools[0] + assert tool.name == "load_files_message" + assert "Loads and returns the content from uploaded files" in tool.description + + @pytest.mark.asyncio + async def test_get_tools_metadata(self): + """Test that tool has correct metadata.""" + component = FileComponent() + component._attributes["path"] = ["flow123/test.pdf"] + + tools = await component._get_tools() + + tool = tools[0] + assert tool.metadata["display_name"] == "Read File" + assert "test.pdf" in tool.metadata["display_description"] + + @pytest.mark.asyncio + async def test_tool_execution_reads_uploaded_file(self, tmp_path): + """Test that the tool correctly reads the uploaded file when executed.""" + # Create a test file + test_file = tmp_path / "test_content.txt" + test_content = "This is the file content for tool test." + test_file.write_text(test_content) + + component = FileComponent() + component._attributes["path"] = [str(test_file)] + component.path = [str(test_file)] + + tools = await component._get_tools() + tool = tools[0] + + # Execute the tool (it should read the file without any arguments) + result = await tool.coroutine() + + assert test_content in result + + # ==================== Error Handling Tests ==================== + + @pytest.mark.asyncio + async def test_tool_execution_handles_missing_file(self): + """Test that tool handles missing file gracefully.""" + component = FileComponent() + component._attributes["path"] = ["/nonexistent/path/file.txt"] + component.path = ["/nonexistent/path/file.txt"] + + tools = await component._get_tools() + tool = tools[0] + + # Execute the tool - should return error message, not crash + result = await tool.coroutine() + + assert "Error" in result or "not found" in result.lower() + + @pytest.mark.asyncio + async def test_tool_execution_handles_empty_file_list(self): + """Test that tool handles empty file list.""" + component = FileComponent() + component._attributes["path"] = [] + component.path = [] + + tools = await component._get_tools() + tool = tools[0] + + # Execute the tool - should handle gracefully + result = await tool.coroutine() + + # Should return an error or empty result, not crash + assert result is not None + + def test_add_tool_output_is_enabled(self): + """Test that add_tool_output is True for FileComponent.""" + component = FileComponent() + + assert hasattr(component, "add_tool_output") + # Note: add_tool_output is a class attribute + assert FileComponent.add_tool_output is True + + # ==================== Integration Tests ==================== + + def test_file_path_str_input_has_tool_mode_true(self): + """Verify file_path_str input has tool_mode=True for Toolset toggle.""" + component = FileComponent() + + file_path_str_input = None + for input_field in component.inputs: + if input_field.name == "file_path_str": + file_path_str_input = input_field + break + + assert file_path_str_input is not None + assert file_path_str_input.tool_mode is True + + def test_output_has_tool_mode_true(self): + """Verify the main output has tool_mode=True.""" + component = FileComponent() + + # Check static outputs + for output in component.outputs: + if output.name == "message": + assert output.tool_mode is True + break + else: + pytest.fail("Output 'message' not found in component outputs") diff --git a/src/lfx/src/lfx/components/files_and_knowledge/file.py b/src/lfx/src/lfx/components/files_and_knowledge/file.py index 4543ebe955..e39c9b808c 100644 --- a/src/lfx/src/lfx/components/files_and_knowledge/file.py +++ b/src/lfx/src/lfx/components/files_and_knowledge/file.py @@ -36,10 +36,12 @@ class FileComponent(BaseFileComponent): """File component with optional Docling processing (isolated in a subprocess).""" display_name = "Read File" - description = "Loads content from one or more files." + # description is now a dynamic property - see get_tool_description() + _base_description = "Loads content from one or more files." documentation: str = "https://docs.langflow.org/read-file" icon = "file-text" name = "File" + add_tool_output = True # Enable tool mode toggle without requiring tool_mode inputs # Extensions that can be processed without Docling (using standard text parsing) TEXT_EXTENSIONS = TEXT_FILE_TYPES @@ -99,7 +101,7 @@ class FileComponent(BaseFileComponent): ), show=False, advanced=True, - tool_mode=True, + tool_mode=True, # Required for Toolset toggle, but _get_tools() ignores this parameter required=False, ), BoolInput( @@ -183,6 +185,84 @@ class FileComponent(BaseFileComponent): Output(display_name="Raw Content", name="message", method="load_files_message", tool_mode=True), ] + # ------------------------------ Tool description with file names -------------- + + def get_tool_description(self) -> str: + """Return a dynamic description that includes the names of uploaded files. + + This helps the Agent understand which files are available to read. + """ + base_description = "Loads and returns the content from uploaded files." + + # Get the list of uploaded file paths + file_paths = getattr(self, "path", None) + if not file_paths: + return base_description + + # Ensure it's a list + if not isinstance(file_paths, list): + file_paths = [file_paths] + + # Extract just the file names from the paths + file_names = [] + for fp in file_paths: + if fp: + name = Path(fp).name + file_names.append(name) + + if file_names: + files_str = ", ".join(file_names) + return f"{base_description} Available files: {files_str}. Call this tool to read these files." + + return base_description + + @property + def description(self) -> str: + """Dynamic description property that includes uploaded file names.""" + return self.get_tool_description() + + async def _get_tools(self) -> list: + """Override to create a tool without parameters. + + The Read File component should use the files already uploaded via UI, + not accept file paths from the Agent (which wouldn't know the internal paths). + """ + from langchain_core.tools import StructuredTool + from pydantic import BaseModel + + # Empty schema - no parameters needed + class EmptySchema(BaseModel): + """No parameters required - uses pre-uploaded files.""" + + async def read_files_tool() -> str: + """Read the content of uploaded files.""" + try: + result = self.load_files_message() + if hasattr(result, "get_text"): + return result.get_text() + if hasattr(result, "text"): + return result.text + return str(result) + except (FileNotFoundError, ValueError, OSError, RuntimeError) as e: + return f"Error reading files: {e}" + + description = self.get_tool_description() + + tool = StructuredTool( + name="load_files_message", + description=description, + coroutine=read_files_tool, + args_schema=EmptySchema, + handle_tool_error=True, + tags=["load_files_message"], + metadata={ + "display_name": "Read File", + "display_description": description, + }, + ) + + return [tool] + # ------------------------------ UI helpers -------------------------------------- def _path_value(self, template: dict) -> list[str]: