chore: import upstream snapshot with attribution
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,941 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
UNSUPPORTED_BINARY_EXTENSIONS = {
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'bmp',
|
||||
'svg',
|
||||
'webp',
|
||||
'ico',
|
||||
'mp3',
|
||||
'mp4',
|
||||
'wav',
|
||||
'avi',
|
||||
'mov',
|
||||
'zip',
|
||||
'tar',
|
||||
'gz',
|
||||
'rar',
|
||||
'exe',
|
||||
'bin',
|
||||
'dll',
|
||||
'so',
|
||||
}
|
||||
|
||||
|
||||
def _build_filename_error_message(file_name: str, supported_extensions: list[str]) -> str:
|
||||
"""Build a specific error message explaining why the filename was rejected and how to fix it."""
|
||||
base = os.path.basename(file_name)
|
||||
|
||||
# Check for binary/image extension
|
||||
if '.' in base:
|
||||
_, ext = base.rsplit('.', 1)
|
||||
ext_lower = ext.lower()
|
||||
if ext_lower in UNSUPPORTED_BINARY_EXTENSIONS:
|
||||
return (
|
||||
f"Error: Cannot write binary/image file '{base}'. "
|
||||
f'The write_file tool only supports text-based files. '
|
||||
f'Supported extensions: {", ".join("." + e for e in supported_extensions)}. '
|
||||
f'For screenshots, the browser automatically captures them - do not try to save screenshots as files.'
|
||||
)
|
||||
if ext_lower not in supported_extensions:
|
||||
return (
|
||||
f"Error: Unsupported file extension '.{ext_lower}' in '{base}'. "
|
||||
f'Supported extensions: {", ".join("." + e for e in supported_extensions)}. '
|
||||
f'Please rename the file to use a supported extension.'
|
||||
)
|
||||
|
||||
# No extension or no dot
|
||||
if '.' not in base:
|
||||
return (
|
||||
f"Error: Filename '{base}' has no extension. "
|
||||
f'Please add a supported extension: {", ".join("." + e for e in supported_extensions)}.'
|
||||
)
|
||||
|
||||
return (
|
||||
f"Error: Invalid filename '{base}'. "
|
||||
f'Filenames must contain only letters, numbers, underscores, hyphens, dots, parentheses, and spaces. '
|
||||
f'Supported extensions: {", ".join("." + e for e in supported_extensions)}.'
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_FILE_SYSTEM_PATH = 'browseruse_agent_data'
|
||||
|
||||
|
||||
class FileSystemError(Exception):
|
||||
"""Custom exception for file system operations that should be shown to LLM"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseFile(BaseModel, ABC):
|
||||
"""Base class for all file types"""
|
||||
|
||||
name: str
|
||||
content: str = ''
|
||||
|
||||
# --- Subclass must define this ---
|
||||
@property
|
||||
@abstractmethod
|
||||
def extension(self) -> str:
|
||||
"""File extension (e.g. 'txt', 'md')"""
|
||||
pass
|
||||
|
||||
def write_file_content(self, content: str) -> None:
|
||||
"""Update internal content (formatted)"""
|
||||
self.update_content(content)
|
||||
|
||||
def append_file_content(self, content: str) -> None:
|
||||
"""Append content to internal content"""
|
||||
self.update_content(self.content + content)
|
||||
|
||||
# --- These are shared and implemented here ---
|
||||
|
||||
def update_content(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
def sync_to_disk_sync(self, path: Path) -> None:
|
||||
file_path = path / self.full_name
|
||||
file_path.write_text(self.content)
|
||||
|
||||
async def sync_to_disk(self, path: Path) -> None:
|
||||
file_path = path / self.full_name
|
||||
with ThreadPoolExecutor() as executor:
|
||||
await asyncio.get_event_loop().run_in_executor(executor, lambda: file_path.write_text(self.content))
|
||||
|
||||
async def write(self, content: str, path: Path) -> None:
|
||||
self.write_file_content(content)
|
||||
await self.sync_to_disk(path)
|
||||
|
||||
async def append(self, content: str, path: Path) -> None:
|
||||
self.append_file_content(content)
|
||||
await self.sync_to_disk(path)
|
||||
|
||||
def read(self) -> str:
|
||||
return self.content
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f'{self.name}.{self.extension}'
|
||||
|
||||
@property
|
||||
def get_size(self) -> int:
|
||||
return len(self.content)
|
||||
|
||||
@property
|
||||
def get_line_count(self) -> int:
|
||||
return len(self.content.splitlines())
|
||||
|
||||
|
||||
class MarkdownFile(BaseFile):
|
||||
"""Markdown file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'md'
|
||||
|
||||
|
||||
class TxtFile(BaseFile):
|
||||
"""Plain text file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'txt'
|
||||
|
||||
|
||||
class JsonFile(BaseFile):
|
||||
"""JSON file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'json'
|
||||
|
||||
|
||||
class CsvFile(BaseFile):
|
||||
"""CSV file implementation with automatic RFC 4180 normalization.
|
||||
|
||||
LLMs frequently produce malformed CSV (missing quotes around fields with commas,
|
||||
inconsistent empty fields, unescaped internal quotes). This class parses the raw
|
||||
content through Python's csv module on every write to guarantee well-formed output.
|
||||
"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'csv'
|
||||
|
||||
@staticmethod
|
||||
def _normalize_csv(raw: str) -> str:
|
||||
"""Parse and re-serialize CSV content to fix quoting, empty fields, and escaping.
|
||||
|
||||
Handles common LLM mistakes: unquoted fields containing commas,
|
||||
unescaped quotes inside fields, inconsistent empty fields,
|
||||
trailing/leading blank lines, and double-escaped JSON output
|
||||
(literal backslash-n and backslash-quote instead of real newlines/quotes).
|
||||
"""
|
||||
stripped = raw.strip('\n\r')
|
||||
if not stripped:
|
||||
return raw
|
||||
|
||||
# Detect double-escaped LLM tool call output: if the content has no real
|
||||
# newlines but contains literal \n sequences, the entire string is likely
|
||||
# double-escaped JSON. Unescape \" → " first, then \n → newline.
|
||||
if '\n' not in stripped and '\\n' in stripped:
|
||||
stripped = stripped.replace('\\"', '"')
|
||||
stripped = stripped.replace('\\n', '\n')
|
||||
|
||||
reader = csv.reader(io.StringIO(stripped))
|
||||
rows: list[list[str]] = []
|
||||
for row in reader:
|
||||
# Skip completely empty rows (artifacts of blank lines)
|
||||
if row:
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
return raw
|
||||
|
||||
out = io.StringIO()
|
||||
writer = csv.writer(out, lineterminator='\n')
|
||||
writer.writerows(rows)
|
||||
# Strip trailing newline so callers (write_file action) control line endings
|
||||
return out.getvalue().rstrip('\n')
|
||||
|
||||
def write_file_content(self, content: str) -> None:
|
||||
"""Normalize CSV content before storing."""
|
||||
self.update_content(self._normalize_csv(content))
|
||||
|
||||
def append_file_content(self, content: str) -> None:
|
||||
"""Normalize the appended CSV rows and merge with existing content."""
|
||||
normalized_new = self._normalize_csv(content)
|
||||
if not normalized_new.strip('\n\r'):
|
||||
return
|
||||
existing = self.content
|
||||
if existing and not existing.endswith('\n'):
|
||||
existing += '\n'
|
||||
combined = existing + normalized_new
|
||||
self.update_content(self._normalize_csv(combined))
|
||||
|
||||
|
||||
class JsonlFile(BaseFile):
|
||||
"""JSONL (JSON Lines) file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'jsonl'
|
||||
|
||||
|
||||
class PdfFile(BaseFile):
|
||||
"""PDF file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'pdf'
|
||||
|
||||
def sync_to_disk_sync(self, path: Path) -> None:
|
||||
# Lazy import reportlab
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
file_path = path / self.full_name
|
||||
try:
|
||||
# Create PDF document
|
||||
doc = SimpleDocTemplate(str(file_path), pagesize=letter)
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
# Convert markdown content to simple text and add to PDF
|
||||
# For basic implementation, we'll treat content as plain text
|
||||
# This avoids the AGPL license issue while maintaining functionality
|
||||
content_lines = self.content.split('\n')
|
||||
|
||||
for line in content_lines:
|
||||
if line.strip():
|
||||
# Handle basic markdown headers
|
||||
if line.startswith('# '):
|
||||
para = Paragraph(line[2:], styles['Title'])
|
||||
elif line.startswith('## '):
|
||||
para = Paragraph(line[3:], styles['Heading1'])
|
||||
elif line.startswith('### '):
|
||||
para = Paragraph(line[4:], styles['Heading2'])
|
||||
else:
|
||||
para = Paragraph(line, styles['Normal'])
|
||||
story.append(para)
|
||||
else:
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
doc.build(story)
|
||||
except Exception as e:
|
||||
raise FileSystemError(f"Error: Could not write to file '{self.full_name}'. {str(e)}")
|
||||
|
||||
async def sync_to_disk(self, path: Path) -> None:
|
||||
with ThreadPoolExecutor() as executor:
|
||||
await asyncio.get_event_loop().run_in_executor(executor, lambda: self.sync_to_disk_sync(path))
|
||||
|
||||
|
||||
class DocxFile(BaseFile):
|
||||
"""DOCX file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'docx'
|
||||
|
||||
def sync_to_disk_sync(self, path: Path) -> None:
|
||||
file_path = path / self.full_name
|
||||
try:
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
|
||||
# Convert content to DOCX paragraphs
|
||||
content_lines = self.content.split('\n')
|
||||
|
||||
for line in content_lines:
|
||||
if line.strip():
|
||||
# Handle basic markdown headers
|
||||
if line.startswith('# '):
|
||||
doc.add_heading(line[2:], level=1)
|
||||
elif line.startswith('## '):
|
||||
doc.add_heading(line[3:], level=2)
|
||||
elif line.startswith('### '):
|
||||
doc.add_heading(line[4:], level=3)
|
||||
else:
|
||||
doc.add_paragraph(line)
|
||||
else:
|
||||
doc.add_paragraph() # Empty paragraph for spacing
|
||||
|
||||
doc.save(str(file_path))
|
||||
except Exception as e:
|
||||
raise FileSystemError(f"Error: Could not write to file '{self.full_name}'. {str(e)}")
|
||||
|
||||
async def sync_to_disk(self, path: Path) -> None:
|
||||
with ThreadPoolExecutor() as executor:
|
||||
await asyncio.get_event_loop().run_in_executor(executor, lambda: self.sync_to_disk_sync(path))
|
||||
|
||||
|
||||
class HtmlFile(BaseFile):
|
||||
"""HTML file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'html'
|
||||
|
||||
|
||||
class XmlFile(BaseFile):
|
||||
"""XML file implementation"""
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return 'xml'
|
||||
|
||||
|
||||
class FileSystemState(BaseModel):
|
||||
"""Serializable state of the file system"""
|
||||
|
||||
files: dict[str, dict[str, Any]] = Field(default_factory=dict) # full filename -> file data
|
||||
base_dir: str
|
||||
extracted_content_count: int = 0
|
||||
|
||||
|
||||
class FileSystem:
|
||||
"""Enhanced file system with in-memory storage and multiple file type support"""
|
||||
|
||||
def __init__(self, base_dir: str | Path, create_default_files: bool = True):
|
||||
# Handle the Path conversion before calling super().__init__
|
||||
self.base_dir = Path(base_dir) if isinstance(base_dir, str) else base_dir
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create and use a dedicated subfolder for all operations
|
||||
self.data_dir = self.base_dir / DEFAULT_FILE_SYSTEM_PATH
|
||||
if self.data_dir.exists():
|
||||
# clean the data directory
|
||||
shutil.rmtree(self.data_dir)
|
||||
self.data_dir.mkdir(exist_ok=True)
|
||||
|
||||
self._file_types: dict[str, type[BaseFile]] = {
|
||||
'md': MarkdownFile,
|
||||
'txt': TxtFile,
|
||||
'json': JsonFile,
|
||||
'jsonl': JsonlFile,
|
||||
'csv': CsvFile,
|
||||
'pdf': PdfFile,
|
||||
'docx': DocxFile,
|
||||
'html': HtmlFile,
|
||||
'xml': XmlFile,
|
||||
}
|
||||
|
||||
self.files = {}
|
||||
if create_default_files:
|
||||
self.default_files = ['todo.md']
|
||||
self._create_default_files()
|
||||
|
||||
self.extracted_content_count = 0
|
||||
|
||||
def get_allowed_extensions(self) -> list[str]:
|
||||
"""Get allowed extensions"""
|
||||
return list(self._file_types.keys())
|
||||
|
||||
def _get_file_type_class(self, extension: str) -> type[BaseFile] | None:
|
||||
"""Get the appropriate file class for an extension."""
|
||||
return self._file_types.get(extension.lower(), None)
|
||||
|
||||
def _create_default_files(self) -> None:
|
||||
"""Create default results and todo files"""
|
||||
for full_filename in self.default_files:
|
||||
name_without_ext, extension = self._parse_filename(full_filename)
|
||||
file_class = self._get_file_type_class(extension)
|
||||
if not file_class:
|
||||
raise ValueError(f"Error: Invalid file extension '{extension}' for file '{full_filename}'.")
|
||||
|
||||
file_obj = file_class(name=name_without_ext)
|
||||
self.files[full_filename] = file_obj # Use full filename as key
|
||||
file_obj.sync_to_disk_sync(self.data_dir)
|
||||
|
||||
def _is_valid_filename(self, file_name: str) -> bool:
|
||||
"""Check if filename matches the required pattern: name.extension
|
||||
|
||||
Allows letters, numbers, underscores, hyphens, dots, parentheses, spaces, and Chinese characters
|
||||
in the name part, followed by a dot and a supported extension.
|
||||
"""
|
||||
extensions = '|'.join(self._file_types.keys())
|
||||
# Allow dots, spaces, parens in the name part - match everything up to the last dot
|
||||
pattern = rf'^[a-zA-Z0-9_\-\.\(\) \u4e00-\u9fff]+\.({extensions})$'
|
||||
file_name_base = os.path.basename(file_name)
|
||||
if not re.match(pattern, file_name_base):
|
||||
return False
|
||||
# Ensure the name part (before last dot) is non-empty
|
||||
name_part = file_name_base.rsplit('.', 1)[0]
|
||||
return len(name_part.strip()) > 0
|
||||
|
||||
@staticmethod
|
||||
def sanitize_filename(file_name: str) -> str:
|
||||
"""Sanitize a filename by replacing/removing invalid characters.
|
||||
|
||||
- Replaces spaces with hyphens
|
||||
- Removes characters that are not alphanumeric, underscore, hyphen, dot, parentheses, or Chinese
|
||||
- Preserves the extension
|
||||
- Collapses multiple consecutive hyphens
|
||||
"""
|
||||
base = os.path.basename(file_name)
|
||||
if '.' not in base:
|
||||
return base
|
||||
|
||||
name_part, ext = base.rsplit('.', 1)
|
||||
# Replace spaces with hyphens
|
||||
name_part = name_part.replace(' ', '-')
|
||||
# Remove invalid characters (keep alphanumeric, underscore, hyphen, dot, parens, Chinese)
|
||||
name_part = re.sub(r'[^a-zA-Z0-9_\-\.\(\)\u4e00-\u9fff]', '', name_part)
|
||||
# Collapse multiple hyphens
|
||||
name_part = re.sub(r'-{2,}', '-', name_part)
|
||||
# Strip leading/trailing hyphens and dots
|
||||
name_part = name_part.strip('-.')
|
||||
|
||||
if not name_part:
|
||||
name_part = 'file'
|
||||
|
||||
return f'{name_part}.{ext.lower()}'
|
||||
|
||||
def _resolve_filename(self, file_name: str) -> tuple[str, bool]:
|
||||
"""Resolve a filename, attempting sanitization if the original is invalid.
|
||||
|
||||
Normalizes to basename first to prevent directory traversal (e.g. ../secret.md).
|
||||
|
||||
Returns:
|
||||
(resolved_name, was_changed): The resolved filename and whether it differs from the input.
|
||||
If resolution fails, returns (basename, was_changed).
|
||||
"""
|
||||
base_name = os.path.basename(file_name)
|
||||
was_changed = base_name != file_name
|
||||
|
||||
if self._is_valid_filename(base_name):
|
||||
return base_name, was_changed
|
||||
|
||||
sanitized = self.sanitize_filename(base_name)
|
||||
if sanitized != base_name and self._is_valid_filename(sanitized):
|
||||
return sanitized, True
|
||||
|
||||
return base_name, was_changed
|
||||
|
||||
def _parse_filename(self, filename: str) -> tuple[str, str]:
|
||||
"""Parse filename into name and extension. Always check _is_valid_filename first."""
|
||||
name, extension = filename.rsplit('.', 1)
|
||||
return name, extension.lower()
|
||||
|
||||
def get_dir(self) -> Path:
|
||||
"""Get the file system directory"""
|
||||
return self.data_dir
|
||||
|
||||
def get_file(self, full_filename: str) -> BaseFile | None:
|
||||
"""Get a file object by full filename, trying sanitization if the name is invalid."""
|
||||
resolved, _ = self._resolve_filename(full_filename)
|
||||
if not self._is_valid_filename(resolved):
|
||||
return None
|
||||
|
||||
# Use resolved filename as key
|
||||
return self.files.get(resolved)
|
||||
|
||||
def list_files(self) -> list[str]:
|
||||
"""List all files in the system"""
|
||||
return [file_obj.full_name for file_obj in self.files.values()]
|
||||
|
||||
def display_file(self, full_filename: str) -> str | None:
|
||||
"""Display file content using file-specific display method"""
|
||||
resolved, _ = self._resolve_filename(full_filename)
|
||||
if not self._is_valid_filename(resolved):
|
||||
return None
|
||||
|
||||
file_obj = self.files.get(resolved)
|
||||
if not file_obj:
|
||||
return None
|
||||
|
||||
return file_obj.read()
|
||||
|
||||
async def read_file_structured(self, full_filename: str, external_file: bool = False) -> dict[str, Any]:
|
||||
"""Read file and return structured data including images if applicable.
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- 'message': str - The message to display
|
||||
- 'images': list[dict] | None - Image data if file is an image: [{"name": str, "data": base64_str}]
|
||||
"""
|
||||
result: dict[str, Any] = {'message': '', 'images': None}
|
||||
|
||||
if external_file:
|
||||
try:
|
||||
try:
|
||||
_, extension = self._parse_filename(full_filename)
|
||||
except Exception:
|
||||
result['message'] = (
|
||||
f'Error: Invalid filename format {full_filename}. Must be alphanumeric with a supported extension.'
|
||||
)
|
||||
return result
|
||||
|
||||
# Text-based extensions: derive from _file_types, excluding those with special readers
|
||||
_special_extensions = {'docx', 'pdf', 'jpg', 'jpeg', 'png'}
|
||||
text_extensions = [ext for ext in self._file_types if ext not in _special_extensions]
|
||||
|
||||
if extension in text_extensions:
|
||||
import anyio
|
||||
|
||||
async with await anyio.open_file(full_filename, 'r') as f:
|
||||
content = await f.read()
|
||||
result['message'] = f'Read from file {full_filename}.\n<content>\n{content}\n</content>'
|
||||
return result
|
||||
|
||||
elif extension == 'docx':
|
||||
from docx import Document
|
||||
|
||||
doc = Document(full_filename)
|
||||
content = '\n'.join([para.text for para in doc.paragraphs])
|
||||
result['message'] = f'Read from file {full_filename}.\n<content>\n{content}\n</content>'
|
||||
return result
|
||||
|
||||
elif extension == 'pdf':
|
||||
import pypdf
|
||||
|
||||
reader = pypdf.PdfReader(full_filename)
|
||||
num_pages = len(reader.pages)
|
||||
MAX_CHARS = 60000 # character-based limit
|
||||
|
||||
# Extract text from all pages with page markers
|
||||
page_texts: list[tuple[int, str]] = []
|
||||
total_chars = 0
|
||||
for i, page in enumerate(reader.pages, 1):
|
||||
text = page.extract_text() or ''
|
||||
page_texts.append((i, text))
|
||||
total_chars += len(text)
|
||||
|
||||
# If small enough, return everything
|
||||
if total_chars <= MAX_CHARS:
|
||||
content_parts = []
|
||||
for page_num, text in page_texts:
|
||||
if text.strip():
|
||||
content_parts.append(f'--- Page {page_num} ---\n{text}')
|
||||
extracted_text = '\n\n'.join(content_parts)
|
||||
result['message'] = (
|
||||
f'Read from file {full_filename} ({num_pages} pages, {total_chars:,} chars).\n'
|
||||
f'<content>\n{extracted_text}\n</content>'
|
||||
)
|
||||
return result
|
||||
|
||||
# Large PDF - use search to prioritize pages with distinctive content
|
||||
import math
|
||||
import re
|
||||
|
||||
# Extract words from each page and count which pages they appear on
|
||||
word_to_pages: dict[str, set[int]] = {}
|
||||
page_words: dict[int, set[str]] = {}
|
||||
|
||||
for page_num, text in page_texts:
|
||||
# Extract words (lowercase, 4+ chars to filter noise)
|
||||
words = set(re.findall(r'\b[a-zA-Z]{4,}\b', text.lower()))
|
||||
page_words[page_num] = words
|
||||
for word in words:
|
||||
if word not in word_to_pages:
|
||||
word_to_pages[word] = set()
|
||||
word_to_pages[word].add(page_num)
|
||||
|
||||
# Score pages using inverse document frequency (IDF)
|
||||
# words appearing on fewer pages get higher weight
|
||||
page_scores: dict[int, float] = {}
|
||||
for page_num, words in page_words.items():
|
||||
score = 0.0
|
||||
for word in words:
|
||||
pages_with_word = len(word_to_pages[word])
|
||||
# IDF: log(total_pages / pages_with_word) - higher for rarer words
|
||||
score += math.log(num_pages / pages_with_word)
|
||||
page_scores[page_num] = score
|
||||
|
||||
# Sort pages by score (highest first), always include page 1
|
||||
sorted_pages = sorted(page_scores.items(), key=lambda x: -x[1])
|
||||
priority_pages = [1]
|
||||
for page_num, _ in sorted_pages:
|
||||
if page_num not in priority_pages:
|
||||
priority_pages.append(page_num)
|
||||
|
||||
# Add remaining pages in order (for pages with no distinctive content)
|
||||
for page_num, _ in page_texts:
|
||||
if page_num not in priority_pages:
|
||||
priority_pages.append(page_num)
|
||||
|
||||
# Build content from prioritized pages, respecting char limit
|
||||
content_parts = []
|
||||
chars_used = 0
|
||||
pages_included = []
|
||||
|
||||
# First pass: add pages in priority order
|
||||
for page_num in priority_pages:
|
||||
text = page_texts[page_num - 1][1]
|
||||
if not text.strip():
|
||||
continue
|
||||
page_header = f'--- Page {page_num} ---\n'
|
||||
truncation_suffix = '\n[...truncated]'
|
||||
remaining = MAX_CHARS - chars_used
|
||||
# Need room for header + suffix + at least some content
|
||||
min_useful = len(page_header) + len(truncation_suffix) + 50
|
||||
if remaining < min_useful:
|
||||
break # no room left for meaningful content
|
||||
page_content = page_header + text
|
||||
if len(page_content) > remaining:
|
||||
# Truncate page to fit remaining budget exactly
|
||||
page_content = page_content[: remaining - len(truncation_suffix)] + truncation_suffix
|
||||
content_parts.append((page_num, page_content))
|
||||
chars_used += len(page_content)
|
||||
pages_included.append(page_num)
|
||||
if chars_used >= MAX_CHARS:
|
||||
break
|
||||
|
||||
# Sort included pages by page number for readability
|
||||
content_parts.sort(key=lambda x: x[0])
|
||||
extracted_text = '\n\n'.join(part for _, part in content_parts)
|
||||
|
||||
pages_not_shown = num_pages - len(pages_included)
|
||||
if pages_not_shown > 0:
|
||||
skipped = [p for p in range(1, num_pages + 1) if p not in pages_included]
|
||||
truncation_note = (
|
||||
f'\n\n[Showing {len(pages_included)} of {num_pages} pages. '
|
||||
f'Skipped pages: {skipped[:10]}{"..." if len(skipped) > 10 else ""}. '
|
||||
f'Use extract with start_from_char to read further into the file.]'
|
||||
)
|
||||
else:
|
||||
truncation_note = ''
|
||||
|
||||
result['message'] = (
|
||||
f'Read from file {full_filename} ({num_pages} pages, {total_chars:,} chars total).\n'
|
||||
f'<content>\n{extracted_text}{truncation_note}\n</content>'
|
||||
)
|
||||
return result
|
||||
|
||||
elif extension in ['jpg', 'jpeg', 'png']:
|
||||
import anyio
|
||||
|
||||
# Read image file and convert to base64
|
||||
async with await anyio.open_file(full_filename, 'rb') as f:
|
||||
img_data = await f.read()
|
||||
|
||||
base64_str = base64.b64encode(img_data).decode('utf-8')
|
||||
|
||||
result['message'] = f'Read image file {full_filename}.'
|
||||
result['images'] = [{'name': os.path.basename(full_filename), 'data': base64_str}]
|
||||
return result
|
||||
|
||||
else:
|
||||
result['message'] = f'Error: Cannot read file {full_filename} as {extension} extension is not supported.'
|
||||
return result
|
||||
|
||||
except FileNotFoundError:
|
||||
result['message'] = f"Error: File '{full_filename}' not found."
|
||||
return result
|
||||
except PermissionError:
|
||||
result['message'] = f"Error: Permission denied to read file '{full_filename}'."
|
||||
return result
|
||||
except Exception as e:
|
||||
result['message'] = f"Error: Could not read file '{full_filename}'. {str(e)}"
|
||||
return result
|
||||
|
||||
# For internal files, only non-image types are supported
|
||||
resolved, was_sanitized = self._resolve_filename(full_filename)
|
||||
if not self._is_valid_filename(resolved):
|
||||
result['message'] = _build_filename_error_message(full_filename, self.get_allowed_extensions())
|
||||
return result
|
||||
|
||||
file_obj = self.files.get(resolved)
|
||||
if not file_obj:
|
||||
if was_sanitized:
|
||||
result['message'] = f"File '{resolved}' not found. (Filename was auto-corrected from '{full_filename}')"
|
||||
else:
|
||||
result['message'] = f"File '{full_filename}' not found."
|
||||
return result
|
||||
|
||||
try:
|
||||
content = file_obj.read()
|
||||
sanitize_note = f"Note: filename was auto-corrected from '{full_filename}' to '{resolved}'. " if was_sanitized else ''
|
||||
result['message'] = f'{sanitize_note}Read from file {resolved}.\n<content>\n{content}\n</content>'
|
||||
return result
|
||||
except FileSystemError as e:
|
||||
result['message'] = str(e)
|
||||
return result
|
||||
except Exception as e:
|
||||
result['message'] = f"Error: Could not read file '{full_filename}'. {str(e)}"
|
||||
return result
|
||||
|
||||
async def read_file(self, full_filename: str, external_file: bool = False) -> str:
|
||||
"""Read file content using file-specific read method and return appropriate message to LLM.
|
||||
|
||||
Note: For image files, use read_file_structured() to get image data.
|
||||
"""
|
||||
result = await self.read_file_structured(full_filename, external_file)
|
||||
return result['message']
|
||||
|
||||
async def write_file(self, full_filename: str, content: str) -> str:
|
||||
"""Write content to file using file-specific write method"""
|
||||
original_filename = full_filename
|
||||
resolved, was_sanitized = self._resolve_filename(full_filename)
|
||||
if not self._is_valid_filename(resolved):
|
||||
return _build_filename_error_message(full_filename, self.get_allowed_extensions())
|
||||
full_filename = resolved
|
||||
|
||||
try:
|
||||
name_without_ext, extension = self._parse_filename(full_filename)
|
||||
file_class = self._get_file_type_class(extension)
|
||||
if not file_class:
|
||||
raise ValueError(f"Error: Invalid file extension '{extension}' for file '{full_filename}'.")
|
||||
|
||||
# Create or get existing file using full filename as key
|
||||
if full_filename in self.files:
|
||||
file_obj = self.files[full_filename]
|
||||
else:
|
||||
file_obj = file_class(name=name_without_ext)
|
||||
self.files[full_filename] = file_obj # Use full filename as key
|
||||
|
||||
# Use file-specific write method
|
||||
await file_obj.write(content, self.data_dir)
|
||||
sanitize_note = f" (auto-corrected from '{original_filename}')" if was_sanitized else ''
|
||||
return f'Data written to file {full_filename} successfully.{sanitize_note}'
|
||||
except FileSystemError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
return f"Error: Could not write to file '{full_filename}'. {str(e)}"
|
||||
|
||||
async def append_file(self, full_filename: str, content: str) -> str:
|
||||
"""Append content to file using file-specific append method"""
|
||||
original_filename = full_filename
|
||||
resolved, was_sanitized = self._resolve_filename(full_filename)
|
||||
if not self._is_valid_filename(resolved):
|
||||
return _build_filename_error_message(full_filename, self.get_allowed_extensions())
|
||||
full_filename = resolved
|
||||
|
||||
file_obj = self.files.get(full_filename)
|
||||
if not file_obj:
|
||||
if was_sanitized:
|
||||
return f"File '{full_filename}' not found. (Filename was auto-corrected from '{original_filename}')"
|
||||
return f"File '{full_filename}' not found."
|
||||
|
||||
try:
|
||||
await file_obj.append(content, self.data_dir)
|
||||
sanitize_note = f" (auto-corrected from '{original_filename}')" if was_sanitized else ''
|
||||
return f'Data appended to file {full_filename} successfully.{sanitize_note}'
|
||||
except FileSystemError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
return f"Error: Could not append to file '{full_filename}'. {str(e)}"
|
||||
|
||||
async def replace_file_str(self, full_filename: str, old_str: str, new_str: str) -> str:
|
||||
"""Replace old_str with new_str in file_name"""
|
||||
original_filename = full_filename
|
||||
resolved, was_sanitized = self._resolve_filename(full_filename)
|
||||
if not self._is_valid_filename(resolved):
|
||||
return _build_filename_error_message(full_filename, self.get_allowed_extensions())
|
||||
full_filename = resolved
|
||||
|
||||
if not old_str:
|
||||
return 'Error: Cannot replace empty string. Please provide a non-empty string to replace.'
|
||||
|
||||
file_obj = self.files.get(full_filename)
|
||||
if not file_obj:
|
||||
if was_sanitized:
|
||||
return f"File '{full_filename}' not found. (Filename was auto-corrected from '{original_filename}')"
|
||||
return f"File '{full_filename}' not found."
|
||||
|
||||
try:
|
||||
content = file_obj.read()
|
||||
content = content.replace(old_str, new_str)
|
||||
await file_obj.write(content, self.data_dir)
|
||||
sanitize_note = f" (auto-corrected from '{original_filename}')" if was_sanitized else ''
|
||||
return f'Successfully replaced all occurrences of "{old_str}" with "{new_str}" in file {full_filename}{sanitize_note}'
|
||||
except FileSystemError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
return f"Error: Could not replace string in file '{full_filename}'. {str(e)}"
|
||||
|
||||
async def save_extracted_content(self, content: str) -> str:
|
||||
"""Save extracted content to a numbered file"""
|
||||
initial_filename = f'extracted_content_{self.extracted_content_count}'
|
||||
extracted_filename = f'{initial_filename}.md'
|
||||
file_obj = MarkdownFile(name=initial_filename)
|
||||
await file_obj.write(content, self.data_dir)
|
||||
self.files[extracted_filename] = file_obj
|
||||
self.extracted_content_count += 1
|
||||
return extracted_filename
|
||||
|
||||
def describe(self) -> str:
|
||||
"""List all files with their content information using file-specific display methods"""
|
||||
DISPLAY_CHARS = 400
|
||||
description = ''
|
||||
|
||||
for file_obj in self.files.values():
|
||||
# Skip todo.md from description
|
||||
if file_obj.full_name == 'todo.md':
|
||||
continue
|
||||
|
||||
content = file_obj.read()
|
||||
|
||||
# Handle empty files
|
||||
if not content:
|
||||
description += f'<file>\n{file_obj.full_name} - [empty file]\n</file>\n'
|
||||
continue
|
||||
|
||||
lines = content.splitlines()
|
||||
line_count = len(lines)
|
||||
|
||||
# For small files, display the entire content
|
||||
whole_file_description = (
|
||||
f'<file>\n{file_obj.full_name} - {line_count} lines\n<content>\n{content}\n</content>\n</file>\n'
|
||||
)
|
||||
if len(content) < int(1.5 * DISPLAY_CHARS):
|
||||
description += whole_file_description
|
||||
continue
|
||||
|
||||
# For larger files, display start and end previews
|
||||
half_display_chars = DISPLAY_CHARS // 2
|
||||
|
||||
# Get start preview
|
||||
start_preview = ''
|
||||
start_line_count = 0
|
||||
chars_count = 0
|
||||
for line in lines:
|
||||
if chars_count + len(line) + 1 > half_display_chars:
|
||||
break
|
||||
start_preview += line + '\n'
|
||||
chars_count += len(line) + 1
|
||||
start_line_count += 1
|
||||
|
||||
# Get end preview
|
||||
end_preview = ''
|
||||
end_line_count = 0
|
||||
chars_count = 0
|
||||
for line in reversed(lines):
|
||||
if chars_count + len(line) + 1 > half_display_chars:
|
||||
break
|
||||
end_preview = line + '\n' + end_preview
|
||||
chars_count += len(line) + 1
|
||||
end_line_count += 1
|
||||
|
||||
# Calculate lines in between
|
||||
middle_line_count = line_count - start_line_count - end_line_count
|
||||
if middle_line_count <= 0:
|
||||
description += whole_file_description
|
||||
continue
|
||||
|
||||
start_preview = start_preview.strip('\n').rstrip()
|
||||
end_preview = end_preview.strip('\n').rstrip()
|
||||
|
||||
# Format output
|
||||
if not (start_preview or end_preview):
|
||||
description += f'<file>\n{file_obj.full_name} - {line_count} lines\n<content>\n{middle_line_count} lines...\n</content>\n</file>\n'
|
||||
else:
|
||||
description += f'<file>\n{file_obj.full_name} - {line_count} lines\n<content>\n{start_preview}\n'
|
||||
description += f'... {middle_line_count} more lines ...\n'
|
||||
description += f'{end_preview}\n'
|
||||
description += '</content>\n</file>\n'
|
||||
|
||||
return description.strip('\n')
|
||||
|
||||
def get_todo_contents(self) -> str:
|
||||
"""Get todo file contents"""
|
||||
todo_file = self.get_file('todo.md')
|
||||
return todo_file.read() if todo_file else ''
|
||||
|
||||
def get_state(self) -> FileSystemState:
|
||||
"""Get serializable state of the file system"""
|
||||
files_data = {}
|
||||
for full_filename, file_obj in self.files.items():
|
||||
files_data[full_filename] = {'type': file_obj.__class__.__name__, 'data': file_obj.model_dump()}
|
||||
|
||||
return FileSystemState(
|
||||
files=files_data, base_dir=str(self.base_dir), extracted_content_count=self.extracted_content_count
|
||||
)
|
||||
|
||||
def nuke(self) -> None:
|
||||
"""Delete the file system directory"""
|
||||
shutil.rmtree(self.data_dir)
|
||||
|
||||
@classmethod
|
||||
def from_state(cls, state: FileSystemState) -> 'FileSystem':
|
||||
"""Restore file system from serializable state at the exact same location"""
|
||||
# Create file system without default files
|
||||
fs = cls(base_dir=Path(state.base_dir), create_default_files=False)
|
||||
fs.extracted_content_count = state.extracted_content_count
|
||||
|
||||
# Restore all files
|
||||
for full_filename, file_data in state.files.items():
|
||||
file_type = file_data['type']
|
||||
file_info = file_data['data']
|
||||
|
||||
# Create the appropriate file object based on type
|
||||
file_type_map: dict[str, type[BaseFile]] = {
|
||||
'MarkdownFile': MarkdownFile,
|
||||
'TxtFile': TxtFile,
|
||||
'JsonFile': JsonFile,
|
||||
'JsonlFile': JsonlFile,
|
||||
'CsvFile': CsvFile,
|
||||
'PdfFile': PdfFile,
|
||||
'DocxFile': DocxFile,
|
||||
'HtmlFile': HtmlFile,
|
||||
'XmlFile': XmlFile,
|
||||
}
|
||||
|
||||
file_class = file_type_map.get(file_type)
|
||||
if not file_class:
|
||||
# Skip unknown file types
|
||||
continue
|
||||
file_obj = file_class(**file_info)
|
||||
|
||||
# Add to files dict and sync to disk
|
||||
fs.files[full_filename] = file_obj
|
||||
file_obj.sync_to_disk_sync(fs.data_dir)
|
||||
|
||||
return fs
|
||||
Reference in New Issue
Block a user