From 5b801c0d3e1aa9c0a1038b8a4eb613fab6863a7b Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:21:37 +0800 Subject: [PATCH] refactor(file_io): update file I/O operations and truncation logic (#177) * refactor(file_io): update file I/O operations and truncation logic * refactor(memory): update file-based memory compaction logic --- docs/copaw_context_design.md | 122 +++++++++++ reme/config/light.yaml | 2 + reme/core/application.py | 6 +- reme/core/file_watcher/base_file_watcher.py | 9 +- reme/core/op/base_op.py | 2 +- reme/core/schema/service_config.py | 5 +- reme/memory/file_based/components/cli.py | 6 +- .../file_based/components/summarizer.yaml | 39 ++-- .../components/tool_result_compactor.py | 145 ++++++------- .../file_based/reme_in_memory_memory.py | 5 +- reme/memory/file_based/tools/file_io.py | 72 ++++--- reme/memory/file_based/tools/shell.py | 6 - reme/memory/file_based/utils/__init__.py | 7 +- reme/memory/file_based/utils/file_utils.py | 175 ++++++++------- reme/reme_light.py | 41 ++-- tests/light/test_summarizer.py | 6 +- tests/light/test_tool_result_compactor.py | 18 +- tests/light/test_tools.py | 199 ++++++++++++++---- 18 files changed, 579 insertions(+), 286 deletions(-) create mode 100644 docs/copaw_context_design.md diff --git a/docs/copaw_context_design.md b/docs/copaw_context_design.md new file mode 100644 index 00000000..01da3764 --- /dev/null +++ b/docs/copaw_context_design.md @@ -0,0 +1,122 @@ +## Copaw Context Management V2 + +> 注:不涉及长期记忆 + +### 上下文数据结构 + +#### 1. 上下文-内存 + +- **compact_summary**(可选): + - **历史对话原始数据引导**:存储于 `dialog/YYYY-MM-DD.jsonl`,共 N 行,按时间顺序排列;回顾时建议从后往前读。 + - **历史对话摘要**:包含 `Goal + Constraints + Progress + KeyDecisions + NextSteps`。 +- **messages**:当前对话上下文(完整消息列表)。 + +#### 2. 上下文-缓存到文件系统 + +- **历史对话原始数据**:`dialog/YYYY-MM-DD.jsonl` +- **工具调用结果原始数据**:`tool_result/{uuid}.txt`(保留 N 天) + +```mermaid +flowchart TD + A[Context] --> B[compact_summary] + B --> C[dialog path guide + Goal/Constraints/Progress/KeyDecisions/NextSteps] + A --> E[messages: full dialogue history] + A --> F[File System Cache] --> G[dialog/YYYY-MM-DD.jsonl] + F --> H[tool_result/uuid.txt N-day TTL] +``` + +--- + +### 上下文机制(Pre-Reasoning Hook) + +1. **工具结果 Offload** (`ToolCallResultCompact`) +2. **上下文检查** (`ContextChecker`) +3. **若 Token 超阈值**: + - 保留最近 **X%** 的 Token(保障连贯性) + - 其余历史对话生成摘要 (`Compactor`) +4. **被摘要的上下文 Offload 到文件系统** (`SaveDialog`) + +```mermaid +flowchart LR + A[Pre-Reasoning Hook] --> B[ToolCallResultCompact] + B --> C[ContextChecker] + C --> D{Token > Threshold?} + D -->|Yes| E[Keep recent X% tokens] + E --> F[Compact & Summary old context] + F --> G[SaveDialog: offload to file] + D -->|No| H[Proceed normally] +``` + +--- + +### 工具结果 Offload 机制 + +1. 所有工具调用结果先放入上下文,等待 Pre-Reasoning Hook 处理。 +2. 根据是否属于 **recent_n** 范围,决定截断策略: + - **recent_n 内**:近期内容 → 低截断比例 + - **recent_n 外**:远期内容 → 高截断比例 + +#### 示例:Browser Use 类工具 + +| 阶段 | 行为 | +|----|------------------------------------------------------------------------------| +| 1 | 原始工具调用结果 | +| 2 | 保存原始内容到文件:– 若在 recent_n 内:截断较少– 附注:“FullText saved to xxxx”– 提示:“请从第 N 行开始读” | +| 3 | 若再次引用且超出 recent_n:– 二次截断(更激进)– 仍指向原文件路径 | + +```mermaid +flowchart LR + A[Tool Call Result] --> B{Within recent_n?} + B -->|Yes| C[Low truncation
Save full text to tool_result/uuid.txt
Hint: 'Read from line N'] + B -->|No| D[High truncation
Reference existing file
More aggressive truncation] + C --> E[Context includes snippet + file ref] + D --> E +``` + +--- + +### ReadFile 工具调用结果变化示例 + +| 阶段 | 行为 | +|----|---------------------------------------------| +| 1 | 原始工具调用结果 | +| 2 | 若在 recent_n 内:– 不截断– 不保存文件(因内容已由用户指定) | +| 3 | 若超出 recent_n:– 二次截断(更小)– 保存 FullText 到文件并引用 | + +> 注:ReadFile 本身读取的是外部文件,因此首次调用通常无需重复保存。 + +```mermaid +flowchart LR + A[ReadFile Result] --> B{Within recent_n?} + B -->|Yes| C[No truncation
No file save needed] + B -->|No| D[Apply secondary truncation
Save FullText to tool_result/uuid.txt] + C --> E[Include full content in context] + D --> F[Include snippet + file ref] +``` + +--- + +## Copaw Memory + +### 触发逻辑 + +1. **主 Agent 主动写入**: + - `Memory.md`(长期记忆主干) + - `YYYY-MM-DD.md`(当日日志) +2. **触发阈值时**,由 **Summarizer(React Agent)** 写日志: + - 个性化信息(如偏好、习惯) + - Try-error 信息(失败尝试与修正) +3. **定时任务**(每日 00:00): + - 汇总最近的 `YYYY-MM-DD.md` 文件 + - 更新 `Memory.md` + +```mermaid +flowchart TD + A[Main Agent] --> B[Write Memory.md] + A --> C[Write YYYY-MM-DD.md] + D[Context Threshold Reached?] -->|Yes| E[Summarizer Agent] + E --> F[Log: Personalization] + E --> G[Log: Try-Error Info] + H[Cron @ 00:00 daily] --> I[Aggregate recent YYYY-MM-DD.md] +I --> J[Update Memory.md] +``` diff --git a/reme/config/light.yaml b/reme/config/light.yaml index 242df716..3500f9a5 100644 --- a/reme/config/light.yaml +++ b/reme/config/light.yaml @@ -3,6 +3,8 @@ as_llms: backend: openai model_name: qwen3.5-plus +thread_pool_max_workers: -1 + as_llm_formatters: default: backend: openai diff --git a/reme/core/application.py b/reme/core/application.py index 6f04e663..e2e5b3c0 100644 --- a/reme/core/application.py +++ b/reme/core/application.py @@ -156,13 +156,15 @@ class Application: if not ray.is_initialized(): ray.init(num_cpus=self.service_config.ray_max_workers) - if ( + if self.service_config.thread_pool_max_workers > 0 and ( self.service_context.thread_pool is None or self.service_context.thread_pool._shutdown # pylint: disable=protected-access ): self.service_context.thread_pool = ThreadPoolExecutor( max_workers=self.service_config.thread_pool_max_workers, ) + elif self.service_config.thread_pool_max_workers <= 0: + logger.info("Thread pool is disabled (thread_pool_max_workers <= 0)") if self.service_context.service_config.enable_logo: print_logo(service_config=self.service_config) @@ -518,7 +520,7 @@ class Application: def shutdown_thread_pool(self, wait: bool = True): """Shutdown the thread pool executor.""" - if self.service_context.thread_pool: + if self.service_context.thread_pool is not None: self.service_context.thread_pool.shutdown(wait=wait) def shutdown_ray(self, wait: bool = True): diff --git a/reme/core/file_watcher/base_file_watcher.py b/reme/core/file_watcher/base_file_watcher.py index c9063997..383d5294 100644 --- a/reme/core/file_watcher/base_file_watcher.py +++ b/reme/core/file_watcher/base_file_watcher.py @@ -29,12 +29,13 @@ class BaseFileWatcher: watch_paths: list[str] | str, suffix_filters: list[str] | None = None, recursive: bool = False, - debounce: int = 500, # Millisecond debounce + debounce: int = 2000, chunk_tokens: int = 400, chunk_overlap: int = 80, file_store: BaseFileStore | None = None, callback: Callable[[set[tuple[Change, str]]], None | Coroutine[Any, Any, None]] | None = None, rebuild_index_on_start: bool = True, + poll_delay_ms: int = 1000, **kwargs, ): """ @@ -51,6 +52,7 @@ class BaseFileWatcher: callback: Callback function for changes rebuild_index_on_start: If True, clear all indexed data on start and rescan existing files. If False, only monitor new changes without initialization. + poll_delay_ms: Polling delay in milliseconds. If > 300ms, force_polling will be enabled automatically. **kwargs: Additional keyword arguments """ self.watch_paths: list[str] = [watch_paths] if isinstance(watch_paths, str) else watch_paths @@ -62,6 +64,7 @@ class BaseFileWatcher: self.file_store: BaseFileStore = file_store self.callback = callback self.rebuild_index_on_start: bool = rebuild_index_on_start + self.poll_delay_ms: int = poll_delay_ms self.kwargs: dict = kwargs self._stop_event = asyncio.Event() @@ -178,11 +181,15 @@ class BaseFileWatcher: try: logger.info(f"Starting watch on valid paths: {valid_paths}") + # Enable force_polling if poll_delay_ms > default 300ms to reduce CPU usage + force_polling = self.poll_delay_ms > 300 async for changes in awatch( *valid_paths, watch_filter=self.watch_filter, recursive=self.recursive, debounce=self.debounce, + poll_delay_ms=self.poll_delay_ms, + force_polling=force_polling, stop_event=self._stop_event, ): if self._stop_event.is_set(): diff --git a/reme/core/op/base_op.py b/reme/core/op/base_op.py index a84ba18d..b016bb47 100644 --- a/reme/core/op/base_op.py +++ b/reme/core/op/base_op.py @@ -301,7 +301,7 @@ class BaseOp(metaclass=ABCMeta): def submit_sync_task(self, fn: Callable, *args, **kwargs) -> "BaseOp": """Submit a task to the thread pool or local queue.""" - if self.enable_parallel: + if self.enable_parallel and self.service_context.thread_pool is not None: task = self.service_context.thread_pool.submit(fn, *args, **kwargs) else: task = (fn, args, kwargs) diff --git a/reme/core/schema/service_config.py b/reme/core/schema/service_config.py index 758944b2..48b97e0a 100644 --- a/reme/core/schema/service_config.py +++ b/reme/core/schema/service_config.py @@ -116,7 +116,10 @@ class ServiceConfig(BasicConfig): working_dir: str = Field(default=".reme") enable_logo: bool = Field(default=True) language: str = Field(default="") - thread_pool_max_workers: int = Field(default=16) + thread_pool_max_workers: int = Field( + default=16, + description="Number of thread pool workers. Set to -1 to disable thread pool.", + ) ray_max_workers: int = Field(default=-1) log_to_console: bool = Field(default=True) disabled_flows: list[str] = Field(default_factory=list) diff --git a/reme/memory/file_based/components/cli.py b/reme/memory/file_based/components/cli.py index 11d45035..138a3aad 100644 --- a/reme/memory/file_based/components/cli.py +++ b/reme/memory/file_based/components/cli.py @@ -113,9 +113,9 @@ class CliAgent(BaseOp): toolkit = Toolkit() file_io = FileIO(working_dir=self.working_dir) - toolkit.register_tool_function(file_io.read) - toolkit.register_tool_function(file_io.write) - toolkit.register_tool_function(file_io.edit) + toolkit.register_tool_function(file_io.read_file) + toolkit.register_tool_function(file_io.write_file) + toolkit.register_tool_function(file_io.edit_file) return toolkit diff --git a/reme/memory/file_based/components/summarizer.yaml b/reme/memory/file_based/components/summarizer.yaml index 00dbdc36..14599f71 100644 --- a/reme/memory/file_based/components/summarizer.yaml +++ b/reme/memory/file_based/components/summarizer.yaml @@ -1,28 +1,29 @@ user_message: | - Memory Pre-compression Flush Cycle Initiated + Memory Pre-compression Flush Cycle. The current session is about to enter the automatic compression phase. Please capture persistent memory AND session reflections, then write them to disk. Current date: {date} Working directory: {working_dir} + # Task Immediately store persistent memory and reflections to: {memory_dir}/YYYY-MM-DD.md - Workflow: - 1. First, `read` {memory_dir}/YYYY-MM-DD.md (if the file doesn’t exist, an error message will be returned). - 2. Extract and synthesize content from the current session: + # Workflow + 1. Extract and synthesize content from the current session: - Persistent Memory: Facts, user profile updates, project states, and important events. - Experience Reflection: Reusable thinking logic derived from user feedback, successful problem-solving strategies, mistakes made/pitfalls to avoid, and actionable insights for future interactions. - 3. Intelligently merge new information with existing content (skip merging if the file doesn’t exist): + 2. `read` {memory_dir}/YYYY-MM-DD.md (if the file doesn’t exist, an error message will be returned) + - If the file doesn’t exist, use `write` tool directly. + - If the file exists, intelligently merge new information with existing content, prefer using `edit` to update specific sections. + - Use `write` to overwrite the entire file only if substantial restructuring is required. + + # Principles + - Intelligently merge new information with existing content: - Categorize clearly (e.g., separate "Factual Memory" from "Reflections & Logic"). - Avoid duplicating already recorded information. - Enrich existing entries with new details where relevant. - Maintain chronological order wherever applicable. - 4. Write the updated content: - - Prefer using `edit` to update specific sections when possible. - - Use `write` to overwrite the entire file only if substantial restructuring is required. - - Principles: - Always preserve timestamps and any date/time-related context. - Add only genuinely new or meaningfully enriching information. - Reflections MUST focus on forming reusable cognitive frameworks based on user feedback, aiming to improve future task execution. @@ -37,23 +38,23 @@ user_message_zh: | 当前日期:{date} 工作目录:{working_dir} + # 任务 立即存储持久化记忆与反思(使用路径 {memory_dir}/YYYY-MM-DD.md)。 - 工作流程: - 1. 先 `read` {memory_dir}/YYYY-MM-DD.md(如文件不存在,会返回错误提示) - 2. 从当前会话中提取并综合两类内容: + # 工作流程 + 1. 从当前会话中提取并综合两类内容: - 持久化记忆:客观事实、用户信息更新、项目状态及重要事件。 - 经验反思:基于用户反馈形成的可复用思考逻辑、成功的问题解决策略、犯下的错误/应避免的陷阱,以及对未来交互有帮助的行动指南。 - 3. 智能合并新信息与现有内容(若文件不存在则跳过合并): + 2. `read` {memory_dir}/YYYY-MM-DD.md(如文件不存在,会返回错误提示) + - 若文件不存在,直接使用 `write` 工具写入。 + - 若文件已存在,智能合并新信息与现有内容,尽可能使用 `edit` 更新特定部分,仅在需要大幅重构时使用 `write` 覆盖整个文件。 + + # 原则 + - 智能合并新信息与现有内容: - 将内容进行清晰的分类(例如明确区分“事实记忆”与“反思与逻辑”)。 - 避免重复已记录的信息。 - 在相关时丰富现有条目的新细节。 - 在适用时保持时间顺序。 - 4. 写入更新后的内容: - - 尽可能使用 `edit` 更新特定部分。 - - 如需大幅重构则使用 `write` 覆盖整个文件。 - - 原则: - 始终保留时间戳、日期和时间相关上下文。 - 仅添加真正新的或有丰富价值的信息。 - 反思内容必须侧重于根据用户反馈构建可复用的思维逻辑,以改善未来的任务执行。 diff --git a/reme/memory/file_based/components/tool_result_compactor.py b/reme/memory/file_based/components/tool_result_compactor.py index 002bb8ae..cb53381f 100644 --- a/reme/memory/file_based/components/tool_result_compactor.py +++ b/reme/memory/file_based/components/tool_result_compactor.py @@ -1,33 +1,19 @@ """Tool Result Compactor: truncate large tool results and save full content to files.""" +import os +import sys import uuid from datetime import datetime, timedelta from pathlib import Path from agentscope.message import Msg +from ..utils import truncate_text_output, DEFAULT_MAX_BYTES, TRUNCATION_NOTICE_MARKER from ....core.op import BaseOp from ....core.utils import get_logger -from ....core.utils import truncate_text_head, TRUNCATION_MARKER_START logger = get_logger() -MAX_LINE_LENGTH = 10000 - - -def _split_long_lines(text: str, max_len: int = MAX_LINE_LENGTH) -> str: - """Split lines that exceed max_len by inserting newlines.""" - lines = text.split("\n") - result = [] - for line in lines: - if len(line) <= max_len: - result.append(line) - else: - # Split line into chunks of max_len - for i in range(0, len(line), max_len): - result.append(line[i : i + max_len]) - return "\n".join(result) - class ToolResultCompactor(BaseOp): """Truncate large tool_result outputs and save full content to files.""" @@ -35,62 +21,50 @@ class ToolResultCompactor(BaseOp): def __init__( self, tool_result_dir: str | Path, - retention_days: int = 7, + retention_days: int = 3, + old_max_bytes: int = 3000, + recent_max_bytes: int = DEFAULT_MAX_BYTES, recent_n: int = 1, - old_threshold: int = 500, - recent_threshold: int = 30000, + encoding: str = "utf-8", **kwargs, ): super().__init__(**kwargs) self.tool_result_dir = Path(tool_result_dir) self.retention_days = retention_days + self.old_max_bytes = old_max_bytes + self.recent_max_bytes = recent_max_bytes self.recent_n = recent_n - self.old_threshold = old_threshold - self.recent_threshold = recent_threshold - - def _save_and_truncate(self, content: str, tool_name: str, threshold: int) -> str: - """Save full content to file and return truncated version with file reference.""" - if not content: - return content - - # Check if content was previously truncated - if TRUNCATION_MARKER_START in content: - parts = content.split(TRUNCATION_MARKER_START, 1) - if len(parts[0]) <= threshold: - return content - return f"{truncate_text_head(parts[0], threshold)}{parts[1]}" - - # Not truncated before - if len(content) <= threshold: - return content - - # Save full content with long lines split + self.encoding = encoding self.tool_result_dir.mkdir(parents=True, exist_ok=True) - file_path = self.tool_result_dir / f"{uuid.uuid4().hex}.txt" - created_at = datetime.now().isoformat() - processed_content = _split_long_lines(content) - file_path.write_text( - f"# tool_name: {tool_name}\n# created_at: {created_at}\n# ---\n{processed_content}", - encoding="utf-8", - ) - logger.debug("Saved tool result to %s (len=%d)", file_path, len(content)) + def _compact(self, output: str | list[dict], max_bytes: int) -> str | list[dict]: + """Truncate output to max_bytes, saving full content to file if needed.""" - # Return truncated with file reference - return f"{truncate_text_head(content, threshold)}\n\n[Full content saved to: {file_path}]" + def _truncate(content: str) -> str: + if not content: + return content + + if TRUNCATION_NOTICE_MARKER in content: + return truncate_text_output(content, max_bytes=max_bytes) + + if len(content.encode(self.encoding)) <= max_bytes + 100: + return content + + saved_path: str | None = None + try: + fp = self.tool_result_dir / f"{uuid.uuid4().hex}.txt" + fp.write_text(content, encoding=self.encoding) + saved_path = str(fp) + except Exception as e: + logger.warning("Failed to save full tool result to file: %s", e) + + return truncate_text_output(content, 1, content.count("\n") + 1, max_bytes, file_path=saved_path) - def _process_output(self, output: str | list[dict], tool_name: str, threshold: int) -> str | list[dict]: - """Process tool result output, truncating if necessary.""" if isinstance(output, str): - return self._save_and_truncate(output, tool_name, threshold) - + return _truncate(output) if isinstance(output, list): return [ - ( - {**b, "text": self._save_and_truncate(b.get("text", ""), tool_name, threshold)} - if isinstance(b, dict) and b.get("type") == "text" - else b - ) + {**b, "text": _truncate(b.get("text", ""))} if isinstance(b, dict) and b.get("type") == "text" else b for b in output ] return output @@ -101,43 +75,54 @@ class ToolResultCompactor(BaseOp): if not messages: return messages - # Split messages into old and recent parts - split_index = max(0, len(messages) - self.recent_n) + recent_n = 0 + for msg in reversed(messages): + if not isinstance(msg.content, list) or not any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in msg.content + ): + break + recent_n += 1 + split_index = max(0, len(messages) - max(recent_n, self.recent_n)) for idx, msg in enumerate(messages): if not isinstance(msg.content, list): continue - - # Determine threshold based on message position - threshold = self.recent_threshold if idx >= split_index else self.old_threshold - + is_recent = idx >= split_index + max_bytes = self.recent_max_bytes if is_recent else self.old_max_bytes for block in msg.content: - if isinstance(block, dict) and block.get("type") == "tool_result": - output = block.get("output") - if output: - block["output"] = self._process_output(output, block.get("name", "unknown"), threshold) + if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("output"): + block["output"] = self._compact(block["output"], max_bytes) return messages def cleanup_expired_files(self) -> int: - """Clean up files older than retention_days.""" + """Clean up files older than retention_days. + + Returns: + Number of files successfully deleted. + """ if not self.tool_result_dir.exists(): return 0 cutoff = datetime.now() - timedelta(days=self.retention_days) - deleted = 0 + deleted = failed = 0 for fp in self.tool_result_dir.glob("*.txt"): try: - for line in fp.read_text(encoding="utf-8").splitlines()[:3]: - if line.startswith("# created_at:"): - if datetime.fromisoformat(line.split(":", 1)[1].strip()) < cutoff: - fp.unlink() - deleted += 1 - break + stat = os.stat(fp) + if sys.platform == "win32": + ts = stat.st_ctime # creation time on Windows + else: + ts = getattr(stat, "st_birthtime", stat.st_mtime) # macOS/BSD; Linux fallback to mtime + if datetime.fromtimestamp(ts) < cutoff: + fp.unlink() + deleted += 1 + except FileNotFoundError: + pass # deleted by another process between glob and stat/unlink except Exception as e: - logger.warning("Failed to process %s: %s", fp, e) + failed += 1 + logger.warning("Failed to delete %s: %s", fp, e) - if deleted: - logger.info("Cleaned up %d expired files", deleted) + if deleted or failed: + logger.info("Cleaned up %d expired files (%d failed)", deleted, failed) return deleted diff --git a/reme/memory/file_based/reme_in_memory_memory.py b/reme/memory/file_based/reme_in_memory_memory.py index cd4d2d8b..1ee687fd 100644 --- a/reme/memory/file_based/reme_in_memory_memory.py +++ b/reme/memory/file_based/reme_in_memory_memory.py @@ -126,9 +126,10 @@ class ReMeInMemoryMemory(InMemoryMemory): if prepend_summary and self._compressed_summary: previous_summary = f""" +Raw conversation logs are in dialog/YYYY-MM-DD.jsonl (or nearby date files). +Entries are chronological; read from the end for recent history. {self._compressed_summary} -The above is a summary of previous conversation, use it as context to maintain continuity. - """.strip() +The above is a summary of previous conversation, use it as context to maintain continuity.""".strip() return [ Msg( diff --git a/reme/memory/file_based/tools/file_io.py b/reme/memory/file_based/tools/file_io.py index 2b792475..6f4ea9cf 100644 --- a/reme/memory/file_based/tools/file_io.py +++ b/reme/memory/file_based/tools/file_io.py @@ -7,7 +7,7 @@ from typing import Optional from agentscope.message import TextBlock from agentscope.tool import ToolResponse -from ..utils import DEFAULT_MAX_BYTES, read_file_safe, truncate_output +from ..utils import read_file_safe, truncate_text_output class FileIO: @@ -38,13 +38,13 @@ class FileIO: else: return str(self.working_dir / file_path) - async def read( # pylint: disable=too-many-return-statements + 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. + """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. @@ -57,6 +57,34 @@ class FileIO: 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): @@ -111,29 +139,21 @@ class FileIO: # Extract selected lines selected_content = "\n".join(all_lines[s - 1 : e]) - # Apply smart truncation (keep head for file reading) - truncated, was_truncated, output_lines, reason = truncate_output(selected_content, keep="head") + # Apply smart truncation (consistent with shell output format) + text = truncate_text_output( + selected_content, + start_line=s, + total_lines=total, + file_path=file_path, + ) - # Build response with truncation hints - if was_truncated: - end_display = s + output_lines - 1 - next_line = end_display + 1 - if reason == "lines": - hint = f"\n\n[Lines {s}-{end_display} of {total}. Use start_line={next_line} to continue.]" - else: - hint = ( - f"\n\n[Lines {s}-{end_display} of {total} ({DEFAULT_MAX_BYTES // 1024}KB limit). " - f"Use start_line={next_line} to continue.]" - ) - text = truncated + hint - elif e < total: + # Add continuation hint if partial read without truncation + if text == selected_content and e < total: remaining = total - e text = ( - f"{file_path} (lines {s}-{e} of {total})\n{truncated}\n\n[{remaining} more lines. " - f"Use start_line={e + 1} to continue.]" + f"{file_path} (lines {s}-{e} of {total})\n{text}\n\n" + f"[{remaining} more lines. Use start_line={e + 1} to continue.]" ) - else: - text = truncated return ToolResponse( content=[TextBlock(type="text", text=text)], @@ -149,7 +169,7 @@ class FileIO: ], ) - async def write( + async def write_file( self, file_path: str, content: str, @@ -195,7 +215,7 @@ class FileIO: ], ) - async def edit( + async def edit_file( self, file_path: str, old_text: str, @@ -212,7 +232,7 @@ class FileIO: new_text (`str`): Replacement text. """ - response = await self.read(file_path=file_path) + response = await self.read_file(file_path=file_path) if response.content and len(response.content) > 0: error_text = response.content[0].get("text", "") if error_text.startswith("Error:"): @@ -239,7 +259,7 @@ class FileIO: ) new_content = content.replace(old_text, new_text) - write_response = await self.write(file_path=file_path, content=new_content) + write_response = await self.write_file(file_path=file_path, content=new_content) if write_response.content and len(write_response.content) > 0: write_text = write_response.content[0].get("text", "") diff --git a/reme/memory/file_based/tools/shell.py b/reme/memory/file_based/tools/shell.py index 2bee1bd6..cce138f9 100644 --- a/reme/memory/file_based/tools/shell.py +++ b/reme/memory/file_based/tools/shell.py @@ -12,8 +12,6 @@ from pathlib import Path from agentscope.message import TextBlock from agentscope.tool import ToolResponse -from ..utils import truncate_shell_output - def _execute_subprocess_sync( cmd: str, @@ -189,10 +187,6 @@ class Shell: stdout_str = "" stderr_str = stderr_suffix - # Apply output truncation - stdout_str = truncate_shell_output(stdout_str) - stderr_str = truncate_shell_output(stderr_str) - # Format the response in a human-friendly way if returncode == 0: # Success case: just show the output diff --git a/reme/memory/file_based/utils/__init__.py b/reme/memory/file_based/utils/__init__.py index 48231232..769a8a66 100644 --- a/reme/memory/file_based/utils/__init__.py +++ b/reme/memory/file_based/utils/__init__.py @@ -1,13 +1,12 @@ """utils""" from .as_msg_handler import AsMsgHandler -from .file_utils import truncate_output, truncate_shell_output, read_file_safe, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES +from .file_utils import truncate_text_output, read_file_safe, DEFAULT_MAX_BYTES, TRUNCATION_NOTICE_MARKER __all__ = [ "AsMsgHandler", - "truncate_output", - "truncate_shell_output", + "truncate_text_output", "read_file_safe", "DEFAULT_MAX_BYTES", - "DEFAULT_MAX_LINES", + "TRUNCATION_NOTICE_MARKER", ] diff --git a/reme/memory/file_based/utils/file_utils.py b/reme/memory/file_based/utils/file_utils.py index 17f58877..f6324296 100644 --- a/reme/memory/file_based/utils/file_utils.py +++ b/reme/memory/file_based/utils/file_utils.py @@ -1,112 +1,141 @@ +# -*- coding: utf-8 -*- """Shared utilities for file and shell tools.""" -# Default truncation limits -DEFAULT_MAX_LINES = 1000 -DEFAULT_MAX_BYTES = 30 * 1024 # 30KB +import re + +from ....core.utils import get_logger + +logger = get_logger() + +# Default truncation limit +DEFAULT_MAX_BYTES = 100 * 1024 + +# Maximum file size to read into memory (1GB) +MAX_FILE_READ_BYTES = 1024 * 1024 * 1024 + +# Marker prepended to every truncation notice. +# Format: <<>> +# File: file_path +# Content from start_line=X, next N bytes. +# total_lines=Z +# Use start_line=Y to continue. +# Split on this to recover the original (un-truncated) portion: +# original = output.split(TRUNCATION_NOTICE_MARKER)[0] +TRUNCATION_NOTICE_MARKER = "<<>>" -def truncate_output( +# pylint: disable=too-many-return-statements +def truncate_text_output( text: str, - max_lines: int = DEFAULT_MAX_LINES, + start_line: int = 0, + total_lines: int = 0, max_bytes: int = DEFAULT_MAX_BYTES, - keep: str = "head", -) -> tuple[str, bool, int, str]: - """Smart truncation for large content. + file_path: str | None = None, +) -> str: + """Truncate file output by bytes with line integrity. - Args: - text: Text content to truncate. - max_lines: Maximum number of lines. - max_bytes: Maximum size in bytes. - keep: Which part to keep - "head" (first lines) or "tail" (last lines). + If text is under byte limit, return as-is. + If over limit, truncate at the last complete line that fits, + allowing the next read to start from a fresh line. - Returns: - (truncated_content, was_truncated, output_line_count, truncate_reason) - """ - if not text: - return text, False, 0, "" - - lines = text.split("\n") - total_lines = len(lines) - - # No truncation needed - if total_lines <= max_lines and len(text.encode("utf-8")) <= max_bytes: - return text, False, total_lines, "" - - # Apply line limit - if total_lines > max_lines: - if keep == "tail": - lines = lines[-max_lines:] - else: - lines = lines[:max_lines] - reason = "lines" - else: - reason = "" - - # Apply byte limit - if len("\n".join(lines).encode("utf-8")) > max_bytes: - if keep == "tail": - while lines and len("\n".join(lines).encode("utf-8")) > max_bytes: - lines.pop(0) - else: - truncated = [] - current_bytes = 0 - for line in lines: - line_bytes = len(line.encode("utf-8")) + 1 - if current_bytes + line_bytes > max_bytes: - break - truncated.append(line) - current_bytes += line_bytes - lines = truncated - reason = "bytes" - - return "\n".join(lines), True, len(lines), reason - - -def truncate_shell_output(text: str) -> str: - """Truncate shell output to last N lines or M bytes, with truncation notice. + If TRUNCATION_NOTICE_MARKER is already in text (previously truncated), + extract the original content, re-truncate it, and update the + truncation notice using regex. Args: text: The output text to truncate. + start_line: The starting line number (1-based). Ignored when text already + contains a truncation notice (values are parsed from the notice instead). + total_lines: Total lines in the original file. Ignored when text already + contains a truncation notice (values are parsed from the notice instead). + max_bytes: Maximum size in bytes. + file_path: Optional file path to include in the truncation notice. Returns: Truncated text with notice if truncated. """ if not text: return text + if max_bytes <= 0: + return text try: - total_lines = len(text.split("\n")) - truncated, was_truncated, output_lines, reason = truncate_output(text, keep="tail") + if TRUNCATION_NOTICE_MARKER in text: + parts = text.split(TRUNCATION_NOTICE_MARKER, 1) + original_content = parts[0] + old_notice = parts[1] - if not was_truncated: - return text + text_bytes = original_content.encode("utf-8") + + # Allow a small slack to avoid re-truncating near-limit content + if len(text_bytes) <= max_bytes + 100: + return text + + # Parse start_line and total_lines from notice; return text unchanged if not found + start_match = re.search(r"start_line=(\d+),", old_notice) + total_match = re.search(r"total_lines=(\d+)", old_notice) + if not start_match or not total_match: + return text + start_line_parsed = int(start_match.group(1)) + total_lines_parsed = int(total_match.group(1)) + + truncated_bytes = text_bytes[:max_bytes] + result = truncated_bytes.decode("utf-8", errors="ignore") + newline_count = result.count("\n") + + next_line = start_line_parsed + max(1, newline_count) + + if not re.search(r"next \d+ bytes", old_notice): + return text + has_continuation = bool(re.search(r"Use start_line=\d+", old_notice)) + new_notice = re.sub(r"next \d+ bytes", f"next {max_bytes} bytes", old_notice) + if has_continuation: + new_notice = re.sub(r"Use start_line=\d+", f"Use start_line={next_line}", new_notice) + elif next_line <= total_lines_parsed: + new_notice = re.sub(r"(total_lines=\d+)", f"\\1\nUse start_line={next_line} to continue.", new_notice) + + return result + TRUNCATION_NOTICE_MARKER + new_notice - start_line = total_lines - output_lines + 1 - if reason == "lines": - notice = f"\n\n[Output truncated: showing lines {start_line}-{total_lines} of {total_lines} total]" else: + text_bytes = text.encode("utf-8") + + if len(text_bytes) <= max_bytes: + return text + + truncated = text_bytes[:max_bytes] + result = truncated.decode("utf-8", errors="ignore") + + newline_count = result.count("\n") + + next_line = start_line + max(1, newline_count) + + continuation = f"\nUse start_line={next_line} to continue." if next_line <= total_lines else "" notice = ( - f"\n\n[Output truncated: showing lines {start_line}-{total_lines} of {total_lines} " - f"({DEFAULT_MAX_BYTES // 1024}KB limit)]" + TRUNCATION_NOTICE_MARKER + + f"\n\nFile: {file_path or ''}\nContent from start_line={start_line}, next {max_bytes} bytes." + f"\ntotal_lines={total_lines}{continuation}" ) - return truncated + notice + return result + notice + except Exception: + logger.warning("truncate_text_output failed, returning original text", exc_info=True) return text -def read_file_safe(file_path: str) -> str: - """Read file with Unicode error handling. +def read_file_safe(file_path: str, max_bytes: int = MAX_FILE_READ_BYTES) -> str: + """Read file with Unicode error handling and memory protection. Args: file_path: Path to the file. + max_bytes: Maximum bytes to read into memory (default 1GB). Returns: - File content as string. + File content as string (up to max_bytes). """ try: with open(file_path, "r", encoding="utf-8") as f: - return f.read() + return f.read(max_bytes) except UnicodeDecodeError: with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - return f.read() + return f.read(max_bytes) diff --git a/reme/reme_light.py b/reme/reme_light.py index d9835f5f..11f9ce56 100644 --- a/reme/reme_light.py +++ b/reme/reme_light.py @@ -168,7 +168,7 @@ class ReMeLight(Application): Returns: Computed compaction threshold as an integer. """ - return int(max_input_length * compact_ratio * 0.9) + return int(max_input_length * compact_ratio * 0.95) def _cleanup_tool_results(self) -> int: """ @@ -231,10 +231,10 @@ class ReMeLight(Application): async def compact_tool_result( self, messages: list[Msg], + old_max_bytes: int = 3000, + recent_max_bytes: int = 100 * 1024, + retention_days: int = 3, recent_n: int = 1, - old_threshold: int = 500, - recent_threshold: int = 30000, - retention_days: int = 7, ) -> list[Msg]: """ Compact tool results by truncating large outputs and saving full content to files. @@ -247,30 +247,37 @@ class ReMeLight(Application): Args: messages (list[Msg]): List of messages potentially containing tool results that may need compaction. - recent_n (int): Number of recent messages to use recent_threshold for. - Default 1. - old_threshold (int): Character threshold for old messages. Default 500. - recent_threshold (int): Character threshold for recent messages. Default 30000. + old_max_bytes (int): Byte threshold for old (non-recent) messages. Default 3000. + recent_max_bytes (int): Byte threshold for recent messages (trailing consecutive + tool-result messages). Default 100KB (102400 bytes). Content exceeding this + limit is saved to disk; the message retains the first 100KB with a + read_file-style truncation notice and the saved file path. retention_days (int): Number of days to retain tool result files. - Default 7. + Default 3. + recent_n (int): Minimum number of most-recent tool-result messages to treat + as "recent" (using recent_max_bytes). The actual recent window is the + larger of this value and the trailing consecutive tool-result run. + Default 1. Returns: list[Msg]: The processed list of messages with large tool results compacted. If an error occurs, returns the original unmodified messages. Note: - - Tool results are truncated based on old_threshold/recent_threshold - - Full content of truncated results is saved to tool_result_path - - Expired files are automatically cleaned up during this operation + - Recent tool results (trailing consecutive tool-result messages) are truncated + to recent_max_bytes using read_file-style output with a file path hint. + - Old tool results are truncated to old_max_bytes bytes. + - Full content of truncated results is saved to tool_result_path. + - Expired files are automatically cleaned up during this operation. """ try: # Create compactor with instance configuration compactor = ToolResultCompactor( tool_result_dir=self.tool_result_path, retention_days=retention_days, + old_max_bytes=old_max_bytes, + recent_max_bytes=recent_max_bytes, recent_n=recent_n, - old_threshold=old_threshold, - recent_threshold=recent_threshold, ) # Execute compaction and get processed messages @@ -455,9 +462,9 @@ class ReMeLight(Application): if toolkit is None: toolkit = Toolkit() file_io = FileIO(working_dir=str(self.working_path)) - toolkit.register_tool_function(file_io.read) - toolkit.register_tool_function(file_io.write) - toolkit.register_tool_function(file_io.edit) + toolkit.register_tool_function(file_io.read_file) + toolkit.register_tool_function(file_io.write_file) + toolkit.register_tool_function(file_io.edit_file) summarizer = Summarizer( working_dir=str(self.working_path), diff --git a/tests/light/test_summarizer.py b/tests/light/test_summarizer.py index 475d81cd..5f8a67a1 100644 --- a/tests/light/test_summarizer.py +++ b/tests/light/test_summarizer.py @@ -101,9 +101,9 @@ def create_toolkit(working_dir: str) -> Toolkit: """Create a default Toolkit with FileIO tools for testing.""" toolkit = Toolkit() file_io = FileIO(working_dir=working_dir) - toolkit.register_tool_function(file_io.read) - toolkit.register_tool_function(file_io.write) - toolkit.register_tool_function(file_io.edit) + toolkit.register_tool_function(file_io.read_file) + toolkit.register_tool_function(file_io.write_file) + toolkit.register_tool_function(file_io.edit_file) return toolkit diff --git a/tests/light/test_tool_result_compactor.py b/tests/light/test_tool_result_compactor.py index 1697911d..ac812c35 100644 --- a/tests/light/test_tool_result_compactor.py +++ b/tests/light/test_tool_result_compactor.py @@ -33,7 +33,7 @@ class TestToolResultCompactor: def test_no_truncation_when_under_threshold(self): """Test that short content is not truncated.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=1000) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=1000) messages = [create_tool_result_msg("short content")] result = asyncio.run(op.call(messages=messages)) @@ -45,7 +45,7 @@ class TestToolResultCompactor: def test_truncation_when_over_threshold(self): """Test that long content is truncated and saved to file.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100) long_content = "x" * 500 messages = [create_tool_result_msg(long_content)] @@ -68,7 +68,7 @@ class TestToolResultCompactor: def test_skip_already_truncated(self): """Test that already truncated content is not re-truncated.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100) truncated_content = "head<<>>(100 chars omitted)<<>>tail" messages = [create_tool_result_msg(truncated_content)] @@ -80,7 +80,7 @@ class TestToolResultCompactor: def test_truncation_list_output(self): """Test truncation of list output with text blocks.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100) list_output = [{"type": "text", "text": "y" * 500}] messages = [create_tool_result_msg(list_output)] @@ -93,7 +93,7 @@ class TestToolResultCompactor: def test_list_output_no_truncation_when_short(self): """Test that short list output is not truncated.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=1000) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=1000) list_output = [{"type": "text", "text": "short"}] messages = [create_tool_result_msg(list_output)] @@ -105,7 +105,7 @@ class TestToolResultCompactor: def test_list_output_multiple_text_blocks(self): """Test truncation of multiple text blocks in list output.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100) list_output = [ {"type": "text", "text": "a" * 500}, {"type": "text", "text": "short"}, @@ -124,7 +124,7 @@ class TestToolResultCompactor: def test_list_output_mixed_block_types(self): """Test that non-text blocks in list output are unchanged.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100) list_output = [ {"type": "text", "text": "c" * 500}, {"type": "image", "source": {"type": "url", "url": "http://example.com/img.png"}}, @@ -141,7 +141,7 @@ class TestToolResultCompactor: def test_cleanup_expired_files(self): """Test cleanup of expired files.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100, retention_days=1) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100, retention_days=1) # Create an old file old_time = (datetime.now() - timedelta(days=2)).isoformat() @@ -162,7 +162,7 @@ class TestToolResultCompactor: def test_string_content_msg_unchanged(self): """Test that messages with string content are unchanged.""" with tempfile.TemporaryDirectory() as tmpdir: - op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100) + op = ToolResultCompactor(tool_result_dir=tmpdir, recent_max_bytes=100) messages = [Msg(name="user", role="user", content="hello world")] asyncio.run(op.call(messages=messages)) diff --git a/tests/light/test_tools.py b/tests/light/test_tools.py index e35bde22..779c59a2 100644 --- a/tests/light/test_tools.py +++ b/tests/light/test_tools.py @@ -4,6 +4,7 @@ import asyncio import os +import re import shutil import tempfile @@ -11,7 +12,7 @@ import pytest from reme.memory.file_based.tools.file_io import FileIO from reme.memory.file_based.tools.shell import Shell -from reme.memory.file_based.utils import DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES +from reme.memory.file_based.utils import DEFAULT_MAX_BYTES # ============ Shell Tests ============ @@ -76,23 +77,6 @@ def test_shell_multiline_output(shell_env): assert "line3" in text -def test_shell_truncated_output(shell_env): - """Test output truncation for large output.""" - lines_to_generate = DEFAULT_MAX_LINES + 500 - cmd = f"seq 1 {lines_to_generate}" - result = asyncio.run(shell_env["shell"].execute_shell_command(cmd)) - text = result.content[0].get("text", "") - - # Should contain truncation notice - assert "truncated" in text.lower() - # Should contain the last line (tail is kept) - assert str(lines_to_generate) in text - # Verify first numeric line is > 1 (truncated from head) - numeric_lines = [tl for tl in text.strip().split("\n") if tl.isdigit()] - if numeric_lines: - assert int(numeric_lines[0]) > 1 - - def test_shell_timeout(shell_env): """Test command timeout handling.""" result = asyncio.run( @@ -116,13 +100,17 @@ def fileio_env(): with open(simple_file, "w", encoding="utf-8") as f: f.write("line1\nline2\nline3\nline4\nline5") - # Create large file (exceeds DEFAULT_MAX_LINES) + # Create large file (exceeds DEFAULT_MAX_BYTES) large_file = os.path.join(test_dir, "large.txt") with open(large_file, "w", encoding="utf-8") as f: - for i in range(1, DEFAULT_MAX_LINES + 500): + # Each line is ~7-10 bytes ("line N\n"); generate enough to exceed limit. + # Line 1 is literally "line 1" so the head-kept assertion can match it. + line_count = (DEFAULT_MAX_BYTES // 7) + 1000 + for i in range(1, line_count + 1): f.write(f"line {i}\n") # Create large bytes file (exceeds DEFAULT_MAX_BYTES) + # Lines are 101 bytes each; at DEFAULT_MAX_BYTES the cut lands mid-line → else branch large_bytes_file = os.path.join(test_dir, "large_bytes.txt") with open(large_bytes_file, "w", encoding="utf-8") as f: content = "x" * 100 + "\n" @@ -130,19 +118,31 @@ def fileio_env(): for _ in range(lines_needed): f.write(content) + # Single line larger than DEFAULT_MAX_BYTES → newline_count==0 branch in truncate + huge_line_file = os.path.join(test_dir, "huge_line.txt") + with open(huge_line_file, "w", encoding="utf-8") as f: + f.write("A" * (DEFAULT_MAX_BYTES + 1000) + "\nline2\n") + + # Empty file + empty_file = os.path.join(test_dir, "empty.txt") + with open(empty_file, "w", encoding="utf-8") as f: + f.write("") + yield { "dir": test_dir, "file_io": file_io, "simple_file": simple_file, "large_file": large_file, "large_bytes_file": large_bytes_file, + "huge_line_file": huge_line_file, + "empty_file": empty_file, } shutil.rmtree(test_dir, ignore_errors=True) def test_read_file_success(fileio_env): """Test successful file reading.""" - result = asyncio.run(fileio_env["file_io"].read(fileio_env["simple_file"])) + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["simple_file"])) text = result.content[0].get("text", "") assert "line1" in text assert "line5" in text @@ -150,14 +150,14 @@ def test_read_file_success(fileio_env): def test_read_file_relative_path(fileio_env): """Test reading file with relative path.""" - result = asyncio.run(fileio_env["file_io"].read("simple.txt")) + result = asyncio.run(fileio_env["file_io"].read_file("simple.txt")) text = result.content[0].get("text", "") assert "line1" in text def test_read_file_not_exists(fileio_env): """Test reading non-existent file.""" - result = asyncio.run(fileio_env["file_io"].read("nonexistent.txt")) + result = asyncio.run(fileio_env["file_io"].read_file("nonexistent.txt")) text = result.content[0].get("text", "") assert "Error" in text assert "does not exist" in text @@ -166,7 +166,7 @@ def test_read_file_not_exists(fileio_env): def test_read_file_with_line_range(fileio_env): """Test reading specific line range.""" result = asyncio.run( - fileio_env["file_io"].read(fileio_env["simple_file"], start_line=2, end_line=4), + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=2, end_line=4), ) text = result.content[0].get("text", "") assert "line2" in text @@ -177,7 +177,7 @@ def test_read_file_with_line_range(fileio_env): def test_read_file_start_line_exceeds(fileio_env): """Test start_line exceeding file length.""" result = asyncio.run( - fileio_env["file_io"].read(fileio_env["simple_file"], start_line=100), + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=100), ) text = result.content[0].get("text", "") assert "Error" in text @@ -187,15 +187,15 @@ def test_read_file_start_line_exceeds(fileio_env): def test_read_file_invalid_range(fileio_env): """Test invalid line range (start > end).""" result = asyncio.run( - fileio_env["file_io"].read(fileio_env["simple_file"], start_line=4, end_line=2), + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=4, end_line=2), ) text = result.content[0].get("text", "") assert "Error" in text -def test_read_file_truncated_by_lines(fileio_env): - """Test file truncation by line limit.""" - result = asyncio.run(fileio_env["file_io"].read(fileio_env["large_file"])) +def test_read_file_truncated(fileio_env): + """Test file truncation by byte limit.""" + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["large_file"])) text = result.content[0].get("text", "") assert "line 1" in text # Head is kept assert "continue" in text.lower() @@ -203,19 +203,140 @@ def test_read_file_truncated_by_lines(fileio_env): def test_read_file_truncated_by_bytes(fileio_env): """Test file truncation by byte limit.""" - result = asyncio.run(fileio_env["file_io"].read(fileio_env["large_bytes_file"])) + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["large_bytes_file"])) text = result.content[0].get("text", "") - assert "continue" in text.lower() or "KB" in text + assert "continue" in text.lower() + assert "KB limit" in text def test_read_directory_error(fileio_env): """Test reading a directory returns error.""" - result = asyncio.run(fileio_env["file_io"].read(fileio_env["dir"])) + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["dir"])) text = result.content[0].get("text", "") assert "Error" in text assert "not a file" in text +def test_read_file_single_line_range(fileio_env): + """Test reading exactly one line (start_line == end_line).""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=3, end_line=3), + ) + text = result.content[0].get("text", "") + assert "line3" in text + assert "line2" not in text + assert "line4" not in text + + +def test_read_file_only_start_line(fileio_env): + """Test reading from start_line to end of file (no end_line).""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=4), + ) + text = result.content[0].get("text", "") + assert "line4" in text + assert "line5" in text + assert "line1" not in text + assert "line3" not in text + + +def test_read_file_only_end_line(fileio_env): + """Test reading from beginning to end_line (no start_line).""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], end_line=2), + ) + text = result.content[0].get("text", "") + assert "line1" in text + assert "line2" in text + assert "line4" not in text + assert "line5" not in text + + +def test_read_file_end_line_clamped(fileio_env): + """Test end_line beyond total lines is silently clamped to file end.""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=1, end_line=999), + ) + text = result.content[0].get("text", "") + assert "Error" not in text + assert "line1" in text + assert "line5" in text + + +def test_read_file_continuation_hint(fileio_env): + """Partial range read without truncation shows remaining-lines continuation hint.""" + # simple.txt has 5 lines; reading 1-3 leaves 2 more + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line=1, end_line=3), + ) + text = result.content[0].get("text", "") + assert "more lines" in text + assert "start_line=4" in text + + +def test_read_file_truncated_next_line_hint(fileio_env): + """Truncated large file provides a valid start_line > 1 to continue.""" + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["large_file"])) + text = result.content[0].get("text", "") + match = re.search(r"start_line=(\d+)", text) + assert match is not None, "Expected start_line hint in truncated output" + assert int(match.group(1)) > 1 + + +def test_read_file_truncated_mid_line_message(fileio_env): + """Truncation mid-line reports which line is truncated (else branch).""" + # large_bytes_file lines are 101 bytes; truncation lands mid-line + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["large_bytes_file"])) + text = result.content[0].get("text", "") + assert "is truncated" in text.lower() + + +def test_read_file_huge_single_line(fileio_env): + """Single line exceeding byte limit triggers 'partially shown' notice (newline_count==0 branch).""" + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["huge_line_file"])) + text = result.content[0].get("text", "") + assert "partially shown" in text.lower() + assert "start_line=2" in text + + +def test_read_file_invalid_start_line_type(fileio_env): + """Non-integer start_line returns a descriptive error.""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line="abc"), + ) + text = result.content[0].get("text", "") + assert "Error" in text + assert "start_line" in text + + +def test_read_file_invalid_end_line_type(fileio_env): + """Non-integer end_line returns a descriptive error.""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], end_line="xyz"), + ) + text = result.content[0].get("text", "") + assert "Error" in text + assert "end_line" in text + + +def test_read_file_start_line_as_string(fileio_env): + """Numeric-string start_line/end_line are coerced to int successfully.""" + result = asyncio.run( + fileio_env["file_io"].read_file(fileio_env["simple_file"], start_line="2", end_line="4"), + ) + text = result.content[0].get("text", "") + assert "Error" not in text + assert "line2" in text + assert "line4" in text + + +def test_read_file_empty(fileio_env): + """Reading an empty file returns without error.""" + result = asyncio.run(fileio_env["file_io"].read_file(fileio_env["empty_file"])) + text = result.content[0].get("text", "") + assert "Error" not in text + + # ============ FileIO Write Tests ============ @@ -231,7 +352,7 @@ def write_env(): def test_write_new_file(write_env): """Test writing a new file.""" file_path = os.path.join(write_env["dir"], "new_file.txt") - result = asyncio.run(write_env["file_io"].write(file_path, "test content")) + result = asyncio.run(write_env["file_io"].write_file(file_path, "test content")) text = result.content[0].get("text", "") assert "Wrote" in text @@ -245,7 +366,7 @@ def test_write_overwrite_file(write_env): with open(file_path, "w", encoding="utf-8") as f: f.write("old content") - result = asyncio.run(write_env["file_io"].write(file_path, "new content")) + result = asyncio.run(write_env["file_io"].write_file(file_path, "new content")) text = result.content[0].get("text", "") assert "Wrote" in text @@ -255,14 +376,14 @@ def test_write_overwrite_file(write_env): def test_write_empty_path(write_env): """Test writing with empty path.""" - result = asyncio.run(write_env["file_io"].write("", "content")) + result = asyncio.run(write_env["file_io"].write_file("", "content")) text = result.content[0].get("text", "") assert "Error" in text def test_write_relative_path(write_env): """Test writing file with relative path.""" - result = asyncio.run(write_env["file_io"].write("relative.txt", "relative content")) + result = asyncio.run(write_env["file_io"].write_file("relative.txt", "relative content")) text = result.content[0].get("text", "") assert "Wrote" in text @@ -290,7 +411,7 @@ def edit_env(): def test_edit_replace_text(edit_env): """Test replacing text in file.""" result = asyncio.run( - edit_env["file_io"].edit(edit_env["edit_file"], "Hello", "Hi"), + edit_env["file_io"].edit_file(edit_env["edit_file"], "Hello", "Hi"), ) text = result.content[0].get("text", "") assert "Successfully" in text @@ -305,7 +426,7 @@ def test_edit_replace_text(edit_env): def test_edit_text_not_found(edit_env): """Test editing when text not found.""" result = asyncio.run( - edit_env["file_io"].edit(edit_env["edit_file"], "NotExists", "Replacement"), + edit_env["file_io"].edit_file(edit_env["edit_file"], "NotExists", "Replacement"), ) text = result.content[0].get("text", "") assert "Error" in text @@ -315,7 +436,7 @@ def test_edit_text_not_found(edit_env): def test_edit_nonexistent_file(edit_env): """Test editing non-existent file.""" result = asyncio.run( - edit_env["file_io"].edit("nonexistent.txt", "old", "new"), + edit_env["file_io"].edit_file("nonexistent.txt", "old", "new"), ) text = result.content[0].get("text", "") assert "Error" in text