christlurker's picture
FrontierAgent react demo
9d0fd45
Raw
History Blame Contribute Delete
5.37 kB
"""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)