Spaces:
Running
Running
| """read_text tool β plain-text / line-range reader for allowed local dirs or E2B sandbox. | |
| This is the cheap host-local reader (formerly named ``read_file``): it reads a | |
| file's text directly off the host (``plugins/skills/``, ``data/``, | |
| ``/tmp/agent-outputs/`` + an explicit workspace root), with an E2B sandbox | |
| fallback for absolute paths, applies an optional 1-indexed line range and | |
| truncates at 15 KB. No subprocess, no structured parsing β for office/PDF/xlsx | |
| documents use the ``read_file`` tool (the sandbox-backed structured reader). | |
| The path-authorization gate lives in :mod:`plugins.tools._path_auth`; this | |
| module only does the reading. :func:`read_text_file` is the importable async | |
| util; :data:`read_text` is the same function exposed as a registered tool. | |
| References: | |
| - DeerFlow: sandbox/tools.py read_file_tool() | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from frontier_agent.core.tool import tool | |
| from plugins.tools._path_auth import ( | |
| _ALLOWED_ABSOLUTE_PREFIXES, | |
| _authorized_local_path, | |
| ) | |
| from plugins.tools._paths import virtual_to_physical | |
| logger = logging.getLogger(__name__) | |
| _MAX_CONTENT_BYTES = 15_000 | |
| # Document files written by write_file always land here (host-local); see | |
| # the matching prefix in :mod:`plugins.tools._path_auth`. | |
| _LOCAL_OUTPUT_DIR = "/tmp/agent-outputs" | |
| async def read_text_file(path: str, start_line: int = 0, end_line: int = 0) -> str: | |
| """Read a file's plain text content from allowed directories or E2B sandbox. | |
| Local mode: reads from plugins/skills/, data/, /tmp/agent-outputs/. | |
| Sandbox mode: reads any file from the E2B sandbox. | |
| For skills, use paths like: plugins/skills/deep-research/SKILL.md | |
| For office/PDF/spreadsheet documents, use `read_file` instead (structured). | |
| Args: | |
| path: File path (relative for local, absolute for sandbox). | |
| start_line: Optional 1-indexed start line (0 = from beginning). | |
| end_line: Optional 1-indexed end line (0 = to end of file). | |
| Returns: | |
| File content as text, or error message. | |
| """ | |
| if not path or not path.strip(): | |
| return "Error: file path is required." | |
| path = virtual_to_physical(path.strip()) | |
| def _finish(content: str) -> str: | |
| """Apply the line range, then cap the result at 15 KB.""" | |
| if start_line > 0 or end_line > 0: | |
| lines = content.splitlines(keepends=True) | |
| s = max(0, start_line - 1) if start_line > 0 else 0 | |
| e = end_line if end_line > 0 else len(lines) | |
| sliced = "".join(lines[s:e]) | |
| if s > 0 or e < len(lines): | |
| content = f"[lines {s + 1}-{min(e, len(lines))} of {len(lines)}]\n" + sliced | |
| else: | |
| content = sliced | |
| if len(content) > _MAX_CONTENT_BYTES: | |
| content = content[:_MAX_CONTENT_BYTES] + "\n\n... (truncated at 15KB)" | |
| return content | |
| # ββ Try host /tmp/agent-outputs/ first ββββββββββββββββββββββ | |
| # Check here BEFORE the sandbox to find persistent write_file outputs. | |
| if os.path.isabs(path) and not path.startswith(_LOCAL_OUTPUT_DIR): | |
| alt_resolved = Path(os.path.join(_LOCAL_OUTPUT_DIR, os.path.basename(path))).resolve() | |
| if alt_resolved.is_file(): | |
| try: | |
| return _finish(alt_resolved.read_text(encoding="utf-8")) | |
| except Exception: | |
| pass # Fall through to other strategies | |
| # ββ Try E2B sandbox for absolute paths ββββββββββββββββββββββββββββ | |
| # Skip sandbox for paths that are known to live on the host (e.g. skills). | |
| _is_host_path = any( | |
| os.path.normpath(path).startswith(os.path.normpath(p)) | |
| for p in _ALLOWED_ABSOLUTE_PREFIXES | |
| ) | |
| if os.path.isabs(path) and not _is_host_path: | |
| try: | |
| from plugins.tools._sandbox import ( | |
| aget_sandbox, | |
| arun_sandbox_cmd, | |
| sandbox_available, | |
| shell_quote, | |
| ) | |
| if sandbox_available(): | |
| sandbox = await aget_sandbox() | |
| result = await arun_sandbox_cmd( | |
| sandbox, | |
| f"cat -- {shell_quote(path)}", | |
| timeout=15, | |
| ) | |
| if result.exit_code != 0: | |
| raise FileNotFoundError(result.stderr) | |
| return _finish(result.stdout) | |
| except Exception as e: | |
| logger.debug("Sandbox read failed for '%s': %s, trying local", path, e) | |
| # ββ Local file read with security checks ββββββββββββββββββββββββββ | |
| resolved, reason = _authorized_local_path(path) | |
| if resolved is None: | |
| return f"Access denied: {reason}" | |
| if not resolved.is_file(): | |
| return f"File not found: {path}" | |
| try: | |
| return _finish(resolved.read_text(encoding="utf-8")) | |
| except UnicodeDecodeError: | |
| return f"Cannot read '{path}': not a text file" | |
| except Exception as e: | |
| logger.warning("read_text error for '%s': %s", path, e) | |
| return f"Error reading file: {e}" | |
| # The util IS the tool β decorate it directly so there is one body + docstring. | |
| read_text = tool(name="read_text")(read_text_file) | |