mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-15 23:31:05 +00:00
* refactor(file_store): simplify ChromaDB client initialization and improve file truncation logic - Remove shutil import and _create_chroma_client method from chroma_file_store.py - Directly initialize ChromaDB PersistentClient in start method without retry logic - Reduce DEFAULT_MAX_BYTES from 100KB to 50KB in file_utils.py - Update truncation notice format to provide clearer continuation instructions - Add _truncate_fresh and _retruncate functions for better text truncation handling - Replace inline truncation logic with dedicated function calls in file_utils.py - Rename skills_tool_ids to md_file_tool_ids in tool_result_compactor.py - Update file detection logic to identify any .md files instead of only skill.md - Create comprehensive unit tests for truncation functionality in test_truncate_text_output.py * chore(version): bump version to 0.3.1.6 - Update __version__ from 0.3.1.5 to 0.3.1.6 in __init__.py
360 lines
11 KiB
Python
360 lines
11 KiB
Python
"""File I/O operations with a configurable working directory."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from agentscope.message import TextBlock
|
|
from agentscope.tool import ToolResponse
|
|
|
|
from ..utils import read_file_safe, truncate_text_output, 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: Optional[int] = None,
|
|
end_line: Optional[int] = 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 = 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:
|
|
with open(file_path, "w", encoding="utf-8") as file:
|
|
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 = 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:
|
|
with open(file_path, "a", encoding="utf-8") as file:
|
|
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}",
|
|
),
|
|
],
|
|
)
|