mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 11:10:43 +08:00
fix: Add dynamic tool mode descriptions for agent integration (#10743)
* fix file as tool when the prompt is generic * [autofix.ci] apply automated fixes * ruff fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ad55a3ac10
commit
0a168035e8
@ -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")
|
||||
|
||||
@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user