"""read_file tool — parse file formats (office / PDF) into structured markdown for the LLM.""" from __future__ import annotations import hashlib import logging import os import shlex from frontier_agent.core.tool import tool from plugins.tools._deliverable_policy import spill_write_error from plugins.tools._doc_reader import reader_src from plugins.tools._sandbox import ( aget_sandbox, arun_sandbox_cmd, resolve_mount_dirs, resolve_runtime_path, resolve_sandbox_mode, ) logger = logging.getLogger(__name__) def _rejected_save_to(save_to: str) -> str | None: """Reject a ``save_to`` that lands in one of the task's shared mounts. Only enforced in container mode, where those mounts are real. ``/outputs`` is world-writable there and is diffed after every main-agent tool result to build ``file_delta`` events and ``final.deliverables`` — so a reader saving its markdown there would silently publish a deliverable. This tool renders documents; declaring deliverables belongs to the write tools. ``/inputs`` is the read-only source mount, so writing into it is a mistake worth naming rather than an OS-level permission error. Everything else stays allowed, including ``/tmp``: ``_build_tool_env`` hands model commands ``TMPDIR=/tmp`` on purpose and several skills tell the model to put files there, so it is ordinary scratch space, not a deliverable path. Symlinks are resolved before the comparison. A lexical check is not enough: with ``/workspace/link -> /outputs``, ``/workspace/link/report.md`` reads as a workspace path but the shell redirection lands in ``/outputs``. Both the fully resolved target and its resolved parent are checked, since the leaf itself usually does not exist yet. This is a guard against the model publishing a deliverable by accident, not a security boundary: the write happens later, in the sandbox, so a symlink created in between would not be seen here. Anything that needs to be airtight has to be enforced where the write occurs. """ if error := spill_write_error(save_to): return f"Error: {error}" if resolve_sandbox_mode() != "container": return None workspace_dir, outputs_dir, inputs_dir = resolve_mount_dirs() # Relative paths resolve against the sandbox cwd, which IS the workspace. absolute = ( save_to if os.path.isabs(save_to) else os.path.join(workspace_dir, save_to) ) # realpath() resolves the components that exist and leaves the rest alone, # so this works for a leaf that has not been created yet. target = os.path.realpath(absolute) parent = os.path.realpath(os.path.dirname(absolute) or ".") for reserved, why in ( (outputs_dir, "is for deliverables written by the write tools"), (inputs_dir, "is a read-only mount"), ): resolved_reserved = os.path.realpath(reserved) if any( candidate == resolved_reserved or candidate.startswith(resolved_reserved + os.sep) for candidate in (target, parent) ): return ( f"Error: save_to cannot write into {reserved} — it {why} " f"(got {save_to!r}). Save under {workspace_dir} instead." ) return None def _dump_readout(path: str, content: str) -> None: """When READDOC_DUMP_DIR is set, persist what read_file read out (host-side) so it can be inspected. Does NOT alter the in-sandbox read path.""" d = os.environ.get("READDOC_DUMP_DIR") if not d: return try: os.makedirs(d, exist_ok=True) base = os.path.basename(path) or "doc" h = hashlib.md5(path.encode()).hexdigest()[:8] with open(os.path.join(d, f"{base}.{h}.readout.md"), "w", encoding="utf-8") as f: f.write(f"\n\n{content}") except Exception as e: logger.warning("read_file dump failed for '%s': %s", path, e) # The parsing logic is split by file type into _reader_{core,xlsx,docx,pptx,pdf}.py; here they # are concatenated into one # self-contained script, piped into the sandbox over stdin and run with `python3 -`. _READER_SRC = reader_src() #: The comma-separated form of ``path`` is documented for images only, so a #: comma anywhere else is far likelier to be part of a filename. _BATCH_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff", ".webp") def _looks_like_batch(path: str) -> bool: """True when ``path`` is a comma-separated list of image paths. Every member has to look like an image: one segment that does not is enough to make ``a, b`` a single filename containing a comma, which splitting would corrupt (the space after the comma is stripped). """ if "," not in path: return False parts = [item.strip() for item in path.split(",")] return all( part and part.lower().endswith(_BATCH_SUFFIXES) for part in parts ) # Feed the reader to python3 via stdin in a SINGLE sandbox command (no temp # file). Under bwrap each command gets a fresh /tmp tmpfs, so a reader written # to /tmp in one command is gone by the next — same reason run_python_code # pipes its code via stdin. Writing+running as two commands silently breaks # read_file under the per-task BwrapSandbox. # Critical: never base64 the bundle and echo it into argv — the bundle is already ~95KB # (~145KB base64), # while execve's per-argument limit MAX_ARG_STRLEN is 128KB, so echoing it onto the command line gives E2BIG # ("Argument list too long"). stdin is a data stream with no such limit. _TIMEOUT = 120 @tool async def read_file( path: str, max_chars: int | None = None, offset: int = 0, cell_range: str | None = None, save_to: str | None = None, pdf_mode: str = "auto", pages: str | None = None, ) -> str: """Read a document. Structured parsing of office/PDF/csv files into markdown. Use this to READ any file under /inputs (or anywhere) — do NOT write parsing scripts to read them: it renders office/PDF into high-signal markdown and READS IMAGES for you. IMAGES: pass an image path (png/jpg/jpeg/gif/bmp/tif/tiff/webp) and the tool SEES it — charts, diagrams, screenshots, photos, scanned pages are transcribed (title, axes, legend, series values, all visible text) straight into the reply. Use it to check your own rendered output too: after producing charts/PDF pages/images or PPT file(when the design and layout matter), read it back to confirm labels, layouts, numbers and non-ASCII text render (e.g., a missing font shows up as boxes, or overlapping text). Several images at once: see `path` below. Office/PDF (xlsx/docx/pptx/pdf) are parsed into structured markdown; csv/tsv become a relational table + column meta. Legacy Office binaries (doc/ppt/xls) are converted via LibreOffice then parsed the same way (readout notes the conversion). Any other file is sniffed in-sandbox: text (txt/md/json/yaml/code/log/…) is returned with line numbers; audio/video report only the detected file type; unsupported binary is reported as such (never dumped). Parses inside the sandbox (xlsx→openpyxl+LibreOffice recalc, docx→pandoc, pptx→python-pptx, pdf→pdftotext) and returns markdown: spreadsheets→coordinate grid + formulas/styles meta, docs→headings+tables, slides→per-slide rich text, pdf→per-page text. Large documents are paged at SECTION boundaries (sheet/slide/page/heading — never mid-table-row). A partial read ends with "[read_file] PARTIAL READ … continue with offset=N" plus a map of remaining sections; pass that offset back to continue reading. Rendered output is cached, so continuation reads are cheap. Args: path: Absolute path to the file in the sandbox. For MANY images at once (multi-page renders, several charts), pass a GLOB (e.g. "/workspace/pg-*.png") or a comma-separated list of image paths — all of them are read in parallel and returned image-by-image in ONE call (up to 8 per call; don't read them one at a time). For a PDF, prefer pdf_mode="image" with a pages range to render+read many pages at once. max_chars: Page size budget per call. Omit for the default (8000 chars; 20000 when reading a batch of images via glob / comma-separated paths, since one image transcript alone is 2-6K). Any value you pass explicitly is used as-is. The ToolMessage budget leaves headroom above this so the trailing "PARTIAL READ … continue with offset=N" hint always survives. offset: Continue a previous partial read from this char position (use the exact offset given in the previous PARTIAL READ note). cell_range: xlsx only — A1-style range like "Sheet1!A3:D15" to dump just that range (second read of pivot output or a large sheet region). save_to: If set, write the FULL markdown (no paging) to this sandbox path and return a one-line confirmation. Useful for large documents: save once, then inspect selectively via read_file/grep. Use a scratch path — this is a reader, not a way to publish a deliverable. pdf_mode: pdf only — "auto" (default: text + mark image-only pages), "text" (pdftotext -layout, faithful, blanks left blank), or "image" (render the requested pages and READ them via vision — use it for scans, figures and stamped pages that carry no extractable text). pages: pdf only — which pages to read, 1-based like "1-5,12,40-" (empty = all). Use it to scope a large PDF or to render just the image-only pages flagged by an earlier auto read. Returns: Markdown text (or a save confirmation), or an error/hint message. """ if not path or not path.strip(): return "Error: path is required." # A comma-separated image batch is part of this tool's public contract; # resolve each member independently so native aliases do not become one # malformed physical path. Only split when every segment really looks like # a batch member — ``report, final.pdf`` is one filename, and splitting it # would strip the space after the comma and read a path that is not there. if _looks_like_batch(path): path = ",".join(resolve_runtime_path(item.strip()) for item in path.split(",")) else: path = resolve_runtime_path(path) if save_to: rejected = _rejected_save_to(save_to) if rejected: return rejected save_to = resolve_runtime_path(save_to) # Every file goes through the sandbox reader: structured documents → markdown; the rest # are sniffed and routed inside the bundle # (text → numbered content; image/audio/video → type only; unsupported binary → said so explicitly). try: sandbox = await aget_sandbox() except RuntimeError as e: return f"Error: {e}" try: # argv = path max_chars [cell_range|-] [offset] [pdf_mode] [pages|-] # max_chars=None is the "not passed explicitly" sentinel (a literal 8000 cannot be used # — that could not distinguish # "explicitly passed 8000" from "used the default", and an explicit 8000 would be wrongly raised to 20000). # Default windows: 8K for an ordinary read; 20K for a batch image read (one image # transcribes to 2-6K, so 8K holds only 1-2 and loses the "N turns collapse into 1" win). # Any explicitly passed value is honoured exactly. if max_chars is None: is_batch = any(c in path for c in "*?[") or "," in path eff_max = 20_000 if is_batch else 8_000 else: eff_max = int(max_chars) if save_to: eff_max = 10**9 # save_to = write the whole thing to disk, no pagination eff_off = 0 if save_to else int(offset) # Positional arguments: trim from the right by their defaults argv = [ shlex.quote(cell_range) if cell_range else "-", str(eff_off), # Whitelist + quote: pdf_mode is the only argv element that was # interpolated raw, so an LLM value like `auto; touch /x` reached # the sandbox shell. Coerce to a known literal, then quote. shlex.quote(pdf_mode if pdf_mode in ("auto", "text", "image") else "auto"), shlex.quote(pages) if pages else "-", ] defaults = ["-", "0", "auto", "-"] while argv and argv[-1] == defaults[len(argv) - 1]: argv.pop() tail = (" " + " ".join(argv)) if argv else "" # The reader source goes over stdin (input=_READER_SRC), leaving only `python3 - ` on the command line (short) cmd = f"python3 - {shlex.quote(path)} {eff_max}{tail}" if save_to: # Store to a sandbox file instead of returning the whole text (large documents: store once, then fetch on demand with read_file/grep/cell_range) q = shlex.quote(save_to) d = shlex.quote(os.path.dirname(save_to) or ".") cmd = f"mkdir -p {d} && {cmd} > {q} && wc -c < {q}" # The parser is trusted harness infrastructure: allow_net opens networking so it can # reach READDOC_OCR/VISION, and # env_allow passes through those two services' URL/KEY. Sandbox env is deny-all by # default (see _sandbox_env), # so READDOC_* has to be declared here explicitly; the model's bash in the same sandbox does not inherit these credentials. result = await arun_sandbox_cmd(sandbox, cmd, timeout=_TIMEOUT, input=_READER_SRC, allow_net=True, env_allow=("READDOC_",)) if result.exit_code != 0: return f"[read_file error] exit={result.exit_code}: {result.stderr or result.stdout}" if save_to: size = (result.stdout or "?").strip() return (f"[read_file] markdown saved to {save_to} ({size} bytes). " "Inspect with read_file/grep; for xlsx use cell_range for targeted re-reads.") out = result.stdout or "(empty)" _dump_readout(path, out) return out except TimeoutError: return f"Error: read_file timed out after {_TIMEOUT}s" except Exception as e: logger.warning("read_file error for '%s': %s", path, e) return f"[read_file error] {type(e).__name__}: {e}"