mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-14 23:21:04 +00:00
219 lines
7.6 KiB
Python
219 lines
7.6 KiB
Python
"""Read file tool with smart truncation and image support.
|
|
|
|
Features:
|
|
- Reads text files with offset/limit support
|
|
- Detects and handles image files (jpg, png, gif, webp)
|
|
- Smart truncation to prevent memory issues
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from .truncate import DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, format_size, truncate_head
|
|
from ...core.op import BaseTool
|
|
from ...core.schema import ToolCall
|
|
|
|
# Supported image extensions
|
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
|
|
|
|
def is_image_file(path: str) -> bool:
|
|
"""Check if file is a supported image type.
|
|
|
|
Args:
|
|
path: File path to check
|
|
|
|
Returns:
|
|
True if file is a supported image
|
|
"""
|
|
return Path(path).suffix.lower() in IMAGE_EXTENSIONS
|
|
|
|
|
|
class ReadTool(BaseTool):
|
|
"""Read file contents with smart truncation.
|
|
|
|
Features:
|
|
- Supports text files and images (jpg, png, gif, webp)
|
|
- Smart truncation for large files
|
|
- Offset/limit for reading specific portions
|
|
"""
|
|
|
|
def __init__(self, cwd: str | None = None):
|
|
"""Initialize read tool.
|
|
|
|
Args:
|
|
cwd: Working directory (defaults to current directory)
|
|
"""
|
|
super().__init__()
|
|
self.cwd = cwd or os.getcwd()
|
|
|
|
def _build_tool_call(self) -> ToolCall:
|
|
max_kb = DEFAULT_MAX_BYTES // 1024
|
|
return ToolCall(
|
|
**{
|
|
"description": (
|
|
f"Read the contents of a file. Supports text files and images "
|
|
f"(jpg, png, gif, webp). Images are sent as attachments. For text files, "
|
|
f"output is truncated to {DEFAULT_MAX_LINES} lines or {max_kb}KB "
|
|
f"(whichever is hit first). Use offset/limit for large files. "
|
|
f"When you need the full file, continue with offset until complete."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to the file to read (relative or absolute)",
|
|
},
|
|
"offset": {
|
|
"type": "number",
|
|
"description": "Line number to start reading from (1-indexed)",
|
|
},
|
|
"limit": {
|
|
"type": "number",
|
|
"description": "Maximum number of lines to read",
|
|
},
|
|
},
|
|
"required": ["path"],
|
|
},
|
|
},
|
|
)
|
|
|
|
async def execute(self) -> str:
|
|
"""Execute the read operation."""
|
|
path: str = self.context.path
|
|
offset: int | None = self.context.get("offset", None)
|
|
limit: int | None = self.context.get("limit", None)
|
|
|
|
# Resolve path
|
|
if not os.path.isabs(path):
|
|
absolute_path = os.path.join(self.cwd, path)
|
|
else:
|
|
absolute_path = path
|
|
absolute_path = os.path.normpath(absolute_path)
|
|
|
|
# Check file exists and is readable
|
|
if not os.path.exists(absolute_path):
|
|
raise ValueError(f"File not found: {path}")
|
|
|
|
if not os.path.isfile(absolute_path):
|
|
raise ValueError(f"Not a file: {path}")
|
|
|
|
if not os.access(absolute_path, os.R_OK):
|
|
raise ValueError(f"File not readable: {path}")
|
|
|
|
# Check if image
|
|
if is_image_file(absolute_path):
|
|
return await self._read_image(absolute_path, path)
|
|
else:
|
|
return await self._read_text(absolute_path, path, offset, limit)
|
|
|
|
@staticmethod
|
|
async def _read_image(absolute_path: str, display_path: str) -> str:
|
|
"""Read and return image file information.
|
|
|
|
Args:
|
|
absolute_path: Absolute path to image
|
|
display_path: Path to display to user
|
|
|
|
Returns:
|
|
Image information text
|
|
"""
|
|
# Get file size
|
|
file_size = os.path.getsize(absolute_path)
|
|
file_ext = Path(absolute_path).suffix.lower()
|
|
|
|
# For Python tools, we typically can't return image data directly to LLM
|
|
# So we return a descriptive message
|
|
return (
|
|
f"Read image file [{file_ext}]\n"
|
|
f"Path: {display_path}\n"
|
|
f"Size: {format_size(file_size)}\n"
|
|
f"Note: Image content cannot be displayed in text format. "
|
|
f"Use bash tool or other methods to process the image."
|
|
)
|
|
|
|
@staticmethod
|
|
async def _read_text(
|
|
absolute_path: str,
|
|
_display_path: str,
|
|
offset: int | None,
|
|
limit: int | None,
|
|
) -> str:
|
|
"""Read text file with smart truncation.
|
|
|
|
Args:
|
|
absolute_path: Absolute path to file
|
|
_display_path: Path to display to user
|
|
offset: Starting line (1-indexed)
|
|
limit: Maximum lines to read
|
|
|
|
Returns:
|
|
File contents with truncation notices
|
|
"""
|
|
# Read file
|
|
try:
|
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
except UnicodeDecodeError:
|
|
# Try with error handling for binary files
|
|
with open(absolute_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
content = f.read()
|
|
|
|
all_lines = content.split("\n")
|
|
total_file_lines = len(all_lines)
|
|
|
|
# Apply offset if specified (convert 1-indexed to 0-indexed)
|
|
start_line = max(0, (offset - 1)) if offset else 0
|
|
start_line_display = start_line + 1
|
|
|
|
# Check offset bounds
|
|
if start_line >= len(all_lines):
|
|
raise IndexError(
|
|
f"Offset {offset} is beyond end of file ({len(all_lines)} lines total)",
|
|
)
|
|
|
|
# Apply user limit if specified
|
|
if limit is not None:
|
|
end_line = min(start_line + limit, len(all_lines))
|
|
selected_content = "\n".join(all_lines[start_line:end_line])
|
|
user_limited_lines = end_line - start_line
|
|
else:
|
|
selected_content = "\n".join(all_lines[start_line:])
|
|
user_limited_lines = None
|
|
|
|
# Apply truncation
|
|
truncation = truncate_head(selected_content)
|
|
|
|
# Build output with truncation notices
|
|
if truncation.truncated:
|
|
# Truncation occurred
|
|
end_line_display = start_line_display + truncation.output_lines - 1
|
|
next_offset = end_line_display + 1
|
|
|
|
output_text = truncation.content
|
|
|
|
if truncation.truncated_by == "lines":
|
|
output_text += (
|
|
f"\n\n[Showing lines {start_line_display}-{end_line_display} "
|
|
f"of {total_file_lines}. Use offset={next_offset} to continue.]"
|
|
)
|
|
else:
|
|
max_kb = DEFAULT_MAX_BYTES // 1024
|
|
output_text += (
|
|
f"\n\n[Showing lines {start_line_display}-{end_line_display} "
|
|
f"of {total_file_lines} ({max_kb}KB limit). "
|
|
f"Use offset={next_offset} to continue.]"
|
|
)
|
|
elif user_limited_lines is not None and start_line + user_limited_lines < len(all_lines):
|
|
# User limit exceeded, but no truncation
|
|
remaining = len(all_lines) - (start_line + user_limited_lines)
|
|
next_offset = start_line + user_limited_lines + 1
|
|
|
|
output_text = truncation.content
|
|
output_text += f"\n\n[{remaining} more lines in file. " f"Use offset={next_offset} to continue.]"
|
|
else:
|
|
# No truncation or user limit exceeded
|
|
output_text = truncation.content
|
|
|
|
return output_text
|