mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-06 02:48:22 +00:00
- Introduce BaseAsTokenCounter and EstimatedAsTokenCounter for token estimation - Add AsMsgStat and AsBlockStat schema for message statistics tracking - Implement FileIO class with read/write/append/edit operations - Create file utility functions for safe async file reading and truncation - Add MemorySearch component for semantic search in memory files - Register new component types in ComponentEnum and update imports - Add constants for default host, port, and truncation limits - Create BaseService abstract base class for service implementations - Implement BaseStep with component accessors and lifecycle management - Add proper __all__ exports for all new modules and components
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""File I/O operations with a configurable working directory."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import aiofiles
|
|
from agentscope.message import TextBlock
|
|
from agentscope.tool import ToolResponse
|
|
|
|
from .file_utils import async_read_file_safe, truncate_text_output
|
|
from ..constants import TRUNCATION_NOTICE_MARKER
|
|
|
|
|
|
class FileIO:
|
|
"""File I/O operations with a configurable working directory."""
|
|
|
|
def __init__(self, working_dir: str | Path):
|
|
"""Initialize FileIO with a working directory.
|
|
|
|
Args:
|
|
working_dir (`str`):
|
|
The working directory for resolving relative paths.
|
|
"""
|
|
self.working_dir = Path(working_dir)
|
|
|
|
def _resolve_file_path(self, file_path: str) -> str:
|
|
"""Resolve file path: use absolute path as-is,
|
|
resolve relative path from working_dir.
|
|
|
|
Args:
|
|
file_path: The input file path (absolute or relative).
|
|
|
|
Returns:
|
|
The resolved absolute file path as string.
|
|
"""
|
|
path = Path(file_path).expanduser()
|
|
if path.is_absolute():
|
|
return str(path)
|
|
else:
|
|
return str(self.working_dir / file_path)
|
|
|
|
async def read_file( # pylint: disable=too-many-return-statements
|
|
self,
|
|
file_path: str,
|
|
start_line: int | None = None,
|
|
end_line: int | None = None,
|
|
) -> ToolResponse:
|
|
"""Read a file. Relative paths resolve from WORKING_DIR.
|
|
|
|
Use start_line/end_line to read a specific line range (output includes
|
|
line numbers). Omit both to read the full file.
|
|
|
|
Args:
|
|
file_path (`str`):
|
|
Path to the file.
|
|
start_line (`int`, optional):
|
|
First line to read (1-based, inclusive).
|
|
end_line (`int`, optional):
|
|
Last line to read (1-based, inclusive).
|
|
"""
|
|
|
|
# Convert start_line/end_line to int if they are strings
|
|
if start_line is not None:
|
|
try:
|
|
start_line = int(start_line)
|
|
except (ValueError, TypeError):
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: start_line must be an integer, got {start_line!r}.",
|
|
),
|
|
],
|
|
)
|
|
|
|
if end_line is not None:
|
|
try:
|
|
end_line = int(end_line)
|
|
except (ValueError, TypeError):
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: end_line must be an integer, got {end_line!r}.",
|
|
),
|
|
],
|
|
)
|
|
|
|
file_path = self._resolve_file_path(file_path)
|
|
|
|
if not os.path.exists(file_path):
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: The file {file_path} does not exist.",
|
|
),
|
|
],
|
|
)
|
|
|
|
if not os.path.isfile(file_path):
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: The path {file_path} is not a file.",
|
|
),
|
|
],
|
|
)
|
|
|
|
try:
|
|
content = await async_read_file_safe(file_path)
|
|
all_lines = content.split("\n")
|
|
total = len(all_lines)
|
|
|
|
# Determine read range
|
|
s = max(1, start_line if start_line is not None else 1)
|
|
e = min(total, end_line if end_line is not None else total)
|
|
|
|
if s > total:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: start_line {s} exceeds file length ({total} lines).",
|
|
),
|
|
],
|
|
)
|
|
|
|
if s > e:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: start_line ({s}) > end_line ({e}).",
|
|
),
|
|
],
|
|
)
|
|
|
|
# Extract selected lines
|
|
selected_content = "\n".join(all_lines[s - 1: e])
|
|
|
|
# Apply smart truncation (consistent with shell output format)
|
|
text = truncate_text_output(
|
|
selected_content,
|
|
start_line=s,
|
|
total_lines=total,
|
|
file_path=file_path,
|
|
)
|
|
|
|
# Add continuation hint if partial read without truncation.
|
|
# Use TRUNCATION_NOTICE_MARKER format so ToolResultCompactor can
|
|
# re-truncate with the correct start_line when compacting old messages.
|
|
if text == selected_content and e < total:
|
|
content_bytes = len(text.encode("utf-8"))
|
|
notice = (
|
|
TRUNCATION_NOTICE_MARKER +
|
|
f"\nThe output above was truncated."
|
|
f"\nThe full content is saved to the file "
|
|
f"and contains {total} lines in total."
|
|
f"\nThis excerpt starts at line {s} and "
|
|
f"covers the next {content_bytes} bytes."
|
|
"\nIf the current content is not enough, "
|
|
f"call `read_file` with file_path={file_path} start_line={e + 1} to read more."
|
|
)
|
|
text = text + notice
|
|
|
|
return ToolResponse(
|
|
content=[TextBlock(type="text", text=text)],
|
|
)
|
|
|
|
except Exception as e:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: Read file failed due to \n{e}",
|
|
),
|
|
],
|
|
)
|
|
|
|
async def write_file(
|
|
self,
|
|
file_path: str,
|
|
content: str,
|
|
) -> ToolResponse:
|
|
"""Create or overwrite a file. Relative paths resolve from working_dir.
|
|
|
|
Args:
|
|
file_path (`str`):
|
|
Path to the file.
|
|
content (`str`):
|
|
Content to write.
|
|
"""
|
|
if not file_path:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text="Error: No `file_path` provided.",
|
|
),
|
|
],
|
|
)
|
|
|
|
file_path = self._resolve_file_path(file_path)
|
|
|
|
try:
|
|
async with aiofiles.open(file_path, "w", encoding="utf-8") as file:
|
|
await file.write(content)
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Wrote {len(content)} bytes to {file_path}.",
|
|
),
|
|
],
|
|
)
|
|
except Exception as e:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: Write file failed due to \n{e}",
|
|
),
|
|
],
|
|
)
|
|
|
|
# pylint: disable=too-many-return-statements
|
|
async def edit_file(
|
|
self,
|
|
file_path: str,
|
|
old_text: str,
|
|
new_text: str,
|
|
) -> ToolResponse:
|
|
"""Find-and-replace text in a file. All occurrences of old_text are
|
|
replaced with new_text. Relative paths resolve from working_dir.
|
|
|
|
Args:
|
|
file_path (`str`):
|
|
Path to the file.
|
|
old_text (`str`):
|
|
Exact text to find.
|
|
new_text (`str`):
|
|
Replacement text.
|
|
"""
|
|
if not file_path:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text="Error: No `file_path` provided.",
|
|
),
|
|
],
|
|
)
|
|
|
|
resolved_path = self._resolve_file_path(file_path)
|
|
|
|
if not os.path.exists(resolved_path):
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: The file {resolved_path} does not exist.",
|
|
),
|
|
],
|
|
)
|
|
|
|
if not os.path.isfile(resolved_path):
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: The path {resolved_path} is not a file.",
|
|
),
|
|
],
|
|
)
|
|
|
|
try:
|
|
content = await async_read_file_safe(resolved_path)
|
|
except Exception as e:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: Read file failed due to \n{e}",
|
|
),
|
|
],
|
|
)
|
|
|
|
if old_text not in content:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: The text to replace was not found in {file_path}.",
|
|
),
|
|
],
|
|
)
|
|
|
|
new_content = content.replace(old_text, new_text)
|
|
write_response = await self.write_file(file_path=resolved_path, content=new_content)
|
|
|
|
if write_response.content and len(write_response.content) > 0:
|
|
write_text = write_response.content[0].get("text", "")
|
|
if write_text.startswith("Error:"):
|
|
return write_response
|
|
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Successfully replaced text in {file_path}.",
|
|
),
|
|
],
|
|
)
|
|
|
|
async def append_file(
|
|
self,
|
|
file_path: str,
|
|
content: str,
|
|
) -> ToolResponse:
|
|
"""Append content to the end of a file. Relative paths resolve from
|
|
working_dir.
|
|
|
|
Args:
|
|
file_path (`str`):
|
|
Path to the file.
|
|
content (`str`):
|
|
Content to append.
|
|
"""
|
|
if not file_path:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text="Error: No `file_path` provided.",
|
|
),
|
|
],
|
|
)
|
|
|
|
file_path = self._resolve_file_path(file_path)
|
|
|
|
try:
|
|
async with aiofiles.open(file_path, "a", encoding="utf-8") as file:
|
|
await file.write(content)
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Appended {len(content)} bytes to {file_path}.",
|
|
),
|
|
],
|
|
)
|
|
except Exception as e:
|
|
return ToolResponse(
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: Append file failed due to \n{e}",
|
|
),
|
|
],
|
|
)
|