frontier-agent-demo / plugins /tools /_reader_core.py
christlurker's picture
FrontierAgent react demo
9d0fd45
Raw
History Blame Contribute Delete
40.3 kB
"""read_file parsers · core fragment (concatenated first)."""
import hashlib
import json
import os as _os
import re as _re
import subprocess
import sys
_CACHE_DIR = "/workspace/.readdoc_cache" # bind-mounted, persists across commands; missing/unwritable → silently re-render
# Diagnostic trace: only when env READDOC_TRACE is set, record each step's routing and
# backend calls and dump them to stderr wrapped in sentinels when main() ends —
# stdout is always clean markdown (what the LLM sees); the trace is for debugging only.
_TRACE: list = []
def _trace(ev: dict) -> None:
if _os.environ.get("READDOC_TRACE"):
_TRACE.append(ev)
def _dump_trace() -> None:
if _TRACE:
sys.stderr.write("\n<<<READDOC_TRACE\n" + json.dumps(_TRACE, ensure_ascii=False)
+ "\nREADDOC_TRACE>>>\n")
def _ensure(mod: str, pkg: str) -> None:
try:
__import__(mod)
except ImportError:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", pkg],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
# Vision reading (charts / figures / text inside an image): the reader calls the gateway
# or a self-hosted VLM (OpenAI-compatible chat/completions) itself and inlines the
# result into the readout — view_image is a low-level tool not exposed to the main LLM,
# so the reader has to read and return images on its own.
# Config (env, reachable inside the sandbox): READDOC_VISION_URL (include /v1) /
# READDOC_VISION_MODEL / READDOC_VISION_KEY.
_VISION_PROMPT = (
"Reproduce ALL content of this image faithfully and completely; do not summarize or guess. "
"First, one line: what it is (chart/diagram/table/form/photo/screenshot). Then: transcribe text "
"verbatim (exact numbers/units/labels); for any table preserve rows/columns and which cell each "
"value belongs to; for a chart/diagram give title, axes, legend, series and the values/relationships "
"it conveys; for purely visual elements describe only what carries information. Keep reading order. "
"Use [illegible] rather than guessing."
)
# Figures only (for mixed pages): body text and ordinary tables were already extracted
# by OCR, so this only adds charts/figures and avoids duplicating the text.
_VISION_FIGURE_PROMPT = (
"This is a full page image that may contain charts/plots/diagrams/flowcharts/infographics, "
"possibly alongside body text and plain tables. Extract ONLY the visual figures — for EACH figure: "
"its title, axis labels and scales, legend, data series, and the concrete values or relationships it "
"conveys (read approximate values off the axes when not labeled). Do NOT transcribe ordinary paragraph "
"text, headings, or plain data tables — those are captured elsewhere. Process figures in reading order; "
"use [illegible] rather than guessing. If the page has no real figure (only text/logos), reply exactly: NO_FIGURE."
)
def _vision_read(img_bytes: bytes, mime: str = "image/png", question: str | None = None,
timeout: int = 120):
"""One image → VLM text. READDOC_VISION_URL unset or a failure → None (the caller
falls back).
timeout: lower for batch reads (45s) so the whole batch converges inside the outer
sandbox timeout."""
base = _os.environ.get("READDOC_VISION_URL", "").rstrip("/")
if not base:
return None
import base64 as _b64
import json as _json
import urllib.request as _ur
key = _os.environ.get("READDOC_VISION_KEY", "EMPTY")
model = _os.environ.get("READDOC_VISION_MODEL", "")
import time as _t
b = _b64.b64encode(img_bytes).decode("ascii")
# Reasoning models (Qwen3 and friends) burn the budget on reasoning by default →
# max_tokens exhausted, content empty, and very slow
# (measured: 130s for 4096 tok, still finish_reason=length with content=None).
# Turning thinking off answers directly — faster and more accurate.
# Most OpenAI-compatible servers ignore unknown fields; if an endpoint rejects it,
# set READDOC_VISION_THINK=1 to disable this switch.
payload = {"model": model, "max_tokens": 4096, "temperature": 0.2, "messages": [
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b}"}},
{"type": "text", "text": question or _VISION_PROMPT}]}]}
if _os.environ.get("READDOC_VISION_THINK", "0") != "1":
payload["chat_template_kwargs"] = {"enable_thinking": False}
t0 = _t.time()
try:
req = _ur.Request(base + "/chat/completions", data=_json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
r = _json.loads(_ur.urlopen(req, timeout=timeout).read())
out = r["choices"][0]["message"]["content"]
_trace({"stage": "vision", "model": model, "bytes": len(img_bytes),
"ms": int((_t.time() - t0) * 1000), "ok": bool(out),
"figure_only": question is not None})
return out
except Exception as exc:
if "chat_template_kwargs" in payload:
payload.pop("chat_template_kwargs", None)
try:
req = _ur.Request(base + "/chat/completions", data=_json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
r = _json.loads(_ur.urlopen(req, timeout=timeout).read())
out = r["choices"][0]["message"]["content"]
_trace({"stage": "vision", "model": model, "bytes": len(img_bytes),
"ms": int((_t.time() - t0) * 1000), "ok": bool(out),
"figure_only": question is not None, "retry": True})
return out
except Exception:
pass
_trace({"stage": "vision", "model": model, "bytes": len(img_bytes),
"ms": int((_t.time() - t0) * 1000), "ok": False, "error": str(exc)})
return None
def _ext(path: str) -> str:
return path.rsplit(".", 1)[-1].lower() if "." in path else ""
# ------------- Unstructured files: sniff and route (text / image·audio·video / unsupported binary) -------------
# Decided by content, not by an extension whitelist, so unlisted formats are still
# handled. Rule: never pipe binary bytes into the text channel.
# Bytes allowed in the text verdict: common control chars (BEL/BS/TAB/LF/FF/CR/ESC)
# plus 0x20-0xFF (covers utf-8 / latin-1 high bytes)
_TEXT_BYTES = bytes([7, 8, 9, 10, 12, 13, 27]) + bytes(range(0x20, 0x100))
# These formats are returned verbatim with **no line numbers**: json/yaml have to stay
# parseable and md has to stay renderable, so line numbers are pure noise;
# they only help for code / logs / plain txt (file:line references, str_replace edits).
_NO_LINENO_EXTS = {"json", "yaml", "yml", "md", "markdown"}
def _is_text(head: bytes) -> bool:
"""Does the first block look like text? A text BOM (utf-8/16/32) settles it as text
(utf-16's NUL does not count as binary);
otherwise a NUL → binary; failing that, by "share of non-text control bytes < 30%"
(the same test file(1) and git use)."""
if not head:
return True
if (head[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")
or head[:3] == b"\xef\xbb\xbf" or head[:2] in (b"\xff\xfe", b"\xfe\xff")):
return True
if b"\x00" in head:
return False
nontext = head.translate(None, _TEXT_BYTES)
return len(nontext) / len(head) < 0.30
def _magic_mime(head: bytes):
"""Binary magic bytes → mime (only to name image/audio/video); None when unknown."""
if head[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if head[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if head[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
if head[:2] == b"BM":
return "image/bmp"
if head[:4] in (b"II*\x00", b"MM\x00*"):
return "image/tiff"
if head[:4] == b"RIFF":
sub = head[8:12]
if sub == b"WEBP":
return "image/webp"
if sub == b"WAVE":
return "audio/wav"
if sub == b"AVI ":
return "video/x-msvideo"
if head[:3] == b"ID3" or head[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"):
return "audio/mpeg"
if head[:4] == b"fLaC":
return "audio/flac"
if head[:4] == b"OggS":
return "audio/ogg"
if head[4:8] == b"ftyp":
return "audio/mp4" if head[8:11] == b"M4A" else "video/mp4"
if head[:4] == b"\x1aE\xdf\xa3":
return "video/x-matroska"
if head[:3] == b"FLV":
return "video/x-flv"
return None
def _decode_bytes(raw: bytes) -> str:
"""Bytes → text, CJK-friendly. Order: BOM → strict utf-8 → charset_normalizer
detection
+ scoring of verified CJK/Cyrillic/Western candidates → utf-8 replace as the floor.
The old chain's BOM-less utf-16 attempt (any even-length byte string can "succeed"
into garbage) and its latin-1 floor
(SJIS mapped byte-by-byte into Latin mojibake) were the root cause of garbled
non-major-language text, and are gone."""
if not raw:
return ""
if raw[:3] == b"\xef\xbb\xbf":
return raw.decode("utf-8-sig", errors="replace")
if raw[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff"):
return raw.decode("utf-32", errors="replace")
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
return raw.decode("utf-16", errors="replace")
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
# Not utf-8: score the candidates. charset_normalizer's verdict competes rather than
# deciding — measured, it misreads short Shift-JIS as EUC-KR, and calls
# KOI8-R / Thai shift_jis (chaos=0 but coherence=0, i.e. no linguistic evidence at
# all). Score every strictly-decodable candidate by character range:
# full-width kana = strong Japanese signal; an all-half-width-katakana document is
# the hallmark of a single-byte encoding misread by cp932, so it scores negative;
# the normalizer candidate gets a small bonus as a tie-breaker (when it is right, it
# should win).
def _score(t: str, bonus: float = 0.0) -> float:
if not t:
return -1.0
s = 0.0
upper = lower = 0
non_ascii = 0
ascii_alpha = 0
scripts = {
"kana": 0, "hangul": 0, "cjk": 0, "latin": 0,
"greek": 0, "cyrillic": 0, "arabic": 0, "hebrew": 0, "thai": 0,
}
sample = t[:8000] # score the first 8K chars; keeps big files fast
for ch in sample:
o = ord(ch)
if o < 0x80:
if ch.isalpha():
ascii_alpha += 1
continue
non_ascii += 1
if ch.isupper():
upper += 1
elif ch.islower():
lower += 1
if 0x3040 <= o <= 0x30FF: # full-width hiragana/katakana: strong Japanese signal
s += 1
scripts["kana"] += 1
elif 0xAC00 <= o <= 0xD7A3:
s += 1
scripts["hangul"] += 1
elif 0x4E00 <= o <= 0x9FFF:
s += 1
scripts["cjk"] += 1
elif 0x00C0 <= o <= 0x024F:
s += 1
scripts["latin"] += 1
elif 0x0370 <= o <= 0x03FF:
s += 1
scripts["greek"] += 1
elif 0x0400 <= o <= 0x052F:
s += 1
scripts["cyrillic"] += 1
elif 0x0590 <= o <= 0x05FF:
s += 1
scripts["hebrew"] += 1
elif 0x0600 <= o <= 0x06FF:
s += 1
scripts["arabic"] += 1
elif 0x0E00 <= o <= 0x0E7F:
s += 1
scripts["thai"] += 1
elif 0xFF61 <= o <= 0xFF9F: # half-width katakana: mis-decode hallmark (real Japanese is mostly full-width)
s -= 2
elif o == 0xFFFD or 0xE000 <= o <= 0xF8FF or 0x80 <= o <= 0x9F:
s -= 5 # replacement char / private use / C1 control: traces of a mis-decode
# Denominator = number of non-ASCII chars: a single-byte codec misreading a
# double-byte stream produces 2x the characters and halves the ASCII-space share,
# so using total length would inflate the mis-decode's density past the correct one.
score = s / max(non_ascii, 1) + bonus
# Inverted-case penalty: the hallmark of KOI8 ↔ cp125x cross-decoding (the two
# families swap case ranges, producing "lowercase initial, rest uppercase").
# Normal text has far more lowercase than uppercase; caseless scripts are unaffected.
cased = upper + lower
if cased >= 10 and upper / cased > 0.6:
score -= 0.5
# Mixed writing systems are usually a strong signal of a misread double-byte
# encoding (e.g. GBK → CJK + Hangul).
# Real Japanese does mix kanji and kana, so treat the two as one system only when
# the kana share is high enough — one stray
# kana must not launder mojibake. Give the dominant script a small purity bonus and
# penalise the share held by the rest.
if scripts["kana"] >= 2 and scripts["kana"] * 5 >= scripts["cjk"]:
scripts["kana"] += scripts["cjk"]
scripts["cjk"] = 0
script_total = sum(scripts.values())
if script_total:
purity = max(scripts.values()) / script_total
score += 0.5 * purity - 1.5 * (1.0 - purity)
# When a double-byte stream (Big5 and similar) is misread by a single-byte
# codec, stray ASCII letters get wedged between non-Latin characters;
# a correct decode usually keeps whole CJK characters. Penalise lightly by the
# interleaving ratio, so genuinely
# ASCII/Latin-dominant Western candidates are not affected.
if scripts["latin"] == 0 and ascii_alpha:
score -= ascii_alpha / (script_total + ascii_alpha)
return score
scored: list = []
try:
_ensure("charset_normalizer", "charset-normalizer")
from charset_normalizer import from_bytes
best = from_bytes(raw).best()
if best is not None:
t = str(best)
# ``charset_normalizer`` often returns coherence=0 guesses for very short text.
# Chinese and Western single-byte short strings are especially prone to
# same-script ties
# (GB18030 "测试" → Big5 "聆彸"; cp1252 → cp1250). Let it into the tie-break only
# when coherence is genuinely non-zero, or when Korean/Japanese is detected via
# their distinctive glyphs; otherwise defer to the verified ordering below.
coherence = float(getattr(best, "coherence", 0.0) or 0.0)
language = str(getattr(best, "language", "") or "").lower()
if coherence > 0.1 or language in {"japanese", "korean"}:
scored.append((_score(t, bonus=0.1), t))
except Exception:
pass
# Verified range: multi-byte CJK + Cyrillic + Western. Inside one single-byte family
# (koi8 vs cp1251) no language model can separate them, so tie-break by
# market share. Unverified Hebrew/Arabic/Greek/Thai candidates are deliberately absent
# rather than letting tuple order masquerade as language identification.
for enc in ("cp932", "gb18030", "big5", "cp949", "euc_jp",
"cp1251", "koi8_r", "cp1252", "iso8859_2"):
try:
t = raw.decode(enc)
except (UnicodeDecodeError, UnicodeError, LookupError):
continue
scored.append((_score(t), t))
if scored:
return max(scored, key=lambda x: x[0])[1]
return raw.decode("utf-8", errors="replace")
def _text_to_md(path: str) -> str:
"""Text file → full content with line numbers (cat -n style, added by the parser);
encoding via _decode_bytes
(BOM / utf-8 / charset detection, CJK-friendly)."""
with open(path, "rb") as f:
raw = f.read()
text = _decode_bytes(raw)
if _ext(path) in _NO_LINENO_EXTS:
# json/yaml/md: verbatim (parseable / renderable), no line numbers, no header note
return text if text.strip() else "(empty file)"
head = "<!-- text readout: leading 'N\\t' line numbers are parser-added, not file content. -->"
lines = text.splitlines()
if not lines:
return head + "\n\n(empty file)"
w = len(str(len(lines)))
body = "\n".join(f"{i:>{w}}\t{ln}" for i, ln in enumerate(lines, 1))
return head + "\n" + body
def _classify_read(path: str) -> str:
"""One path for every unstructured file: text → numbered full content; image → VLM
read (type only when unconfigured);
audio/video → type only; anything else binary → unsupported (raises).
A read failure (cannot open, or an unknown binary that cannot be read as text) always
raises, and main() turns that into one
fail-closed path (stderr + non-zero exit); reporting a media type is valid output and
returns normally.
"""
name = _os.path.basename(path)
with open(path, "rb") as f: # OSError propagates up → main() catches it
head = f.read(65536)
if _is_text(head):
return _text_to_md(path)
mime = _magic_mime(head)
if mime and mime.split("/")[0] == "image":
# A standalone image goes straight to the VLM (scans, charts, screenshots, photos all get read); vision unconfigured or failing → fall back to reporting the type.
try:
with open(path, "rb") as f:
data = f.read()
vt = _vision_read(data, mime)
except Exception:
vt = None
if vt:
return f"<!-- image {name} | read via vision -->\n\n{vt}"
return (f"file: {name}\ntype: {mime}\n"
"note: image — vision unavailable (set READDOC_VISION_URL/MODEL/KEY); not read.")
if mime:
kind = mime.split("/")[0] # audio / video
return (f"file: {name}\ntype: {mime}\n"
f"note: binary {kind} — type detected only, not read as text.")
# No text and no known media type: no document content can be read — raise instead of
# returning a notice, or save_to would
# store the notice as the document and report "saved N bytes".
raise ValueError(f"unsupported binary {name} (application/octet-stream) "
"— cannot be read as text.")
# ---------------- Pagination (hard-boundary cutting + continuation notes) ----------------
# The markdown each format renders already carries hard boundary lines, so pagination is
# post-processing and the renderers stay untouched.
# One boundary marker for all: an HTML comment <!-- page/slide/sheet/... --> (not a
# heading, so it can never collide with content heading levels;
# visible in raw text, easy to match). docx injects no marker and uses the document's
# own headings as boundaries.
_BOUNDARY_RE = {
"xlsx": r"(?m)^<!-- (sheet:|range |charts)",
"pptx": r"(?m)^<!-- (slide |needs VLM)",
"pdf": r"(?m)^<!-- page ",
"docx": r"(?m)^#{1,6} ",
"image_batch": r"(?m)^===== image ", # batch image read: window on whole images, never cut a transcription in half
}
def _split_blocks(md: str, fmt: str):
"""md → list of hard-boundary block start offsets (the first block starts at 0; legend / lead-in belong to it)."""
pat = _BOUNDARY_RE.get(fmt)
starts = [m.start() for m in _re.finditer(pat, md)] if pat else []
if not starts or starts[0] != 0:
starts = [0, *starts]
return starts
def _weak_cut(md: str, start: int, hard_end: int) -> int:
"""Degraded cut point when one block exceeds the budget: line boundaries (an xlsx grid row is one line, never cut inside a cell row)."""
nl = md.rfind("\n", start + 1, hard_end)
return nl + 1 if nl > start else hard_end
def _resume_ctx(md: str, fmt: str, block_start: int, resume_at: int) -> str:
"""Rebuild context when resuming inside a block: the block's first line (sheet / slide / page title); for xlsx also the nearest column-letter header row."""
head_line = md[block_start:md.find("\n", block_start) + 1].rstrip()
# When the boundary marker is an HTML comment, put (continued) inside it so the
# comment stays valid
if head_line.endswith("-->"):
cont = head_line[:-3].rstrip() + " (continued) -->"
else:
cont = f"{head_line} (continued)"
lines = [cont]
if fmt == "xlsx":
seg = md[block_start:resume_at]
for ln in reversed(seg.splitlines()):
if ln.startswith("| |"): # grid column-letter header
lines.append(ln)
lines.append("| --- |" + " --- |" * (ln.count("|") - 2))
break
return "\n".join(lines) + "\n"
def _paginate(md: str, fmt: str, offset: int, max_chars: int) -> str:
"""Return one page of md as [offset, …]: whole blocks until the budget is spent; always
cut on a hard boundary
(sole exception: a single block larger than the budget → line boundary, noted in the
message).
Continuation notes are added at both ends; when one page holds the whole document and
offset=0, the output is byte-identical to the source (zero additions)."""
total = len(md)
if max_chars <= 0: # unset = send everything (offset still applies)
return md[offset:] if offset else md
if offset >= total:
return (f"[read_file] offset {offset} >= total {total} chars; nothing left. "
f"The document was fully covered by earlier reads.")
starts = _split_blocks(md, fmt)
# Snap the start: an offset landing mid-block (the model passed an arbitrary value, or
# the last cut was line-level) → keep the exact position and re-add context
import bisect
bi = bisect.bisect_right(starts, offset) - 1
start = max(offset, 0)
mid_block_start = start > starts[bi]
budget_end = start + max_chars
end = start
j = bi
forced_weak = False
if mid_block_start: # finish the remainder of the current block first
be = starts[j + 1] if j + 1 < len(starts) else total
if be <= budget_end:
end = be
j += 1
else:
end = _weak_cut(md, start, budget_end)
forced_weak = True
if not forced_weak:
while j < len(starts):
be = starts[j + 1] if j + 1 < len(starts) else total
if be - start > max_chars:
break
end = be
j += 1
if end == start: # the very first whole block already exceeds the budget → degrade to line boundaries
end = _weak_cut(md, start, budget_end)
forced_weak = True
body = md[start:end]
done = end >= total
if done and offset == 0:
return body # the whole document fits one page: identical to the old behaviour, zero additions
parts = []
if offset > 0:
note = f"[read_file] continued read: chars {start}-{end} of {total}."
parts.append(note + "\n")
if mid_block_start:
parts.append(_resume_ctx(md, fmt, starts[bi], start))
parts.append("\n")
parts.append(body)
if not done:
cut_desc = ("inside a section (single section exceeds max_chars; cut at a line "
"boundary)" if forced_weak else "at a section boundary")
# Map of what is left: the unread blocks' title lines + their sizes
remain = []
k = bisect.bisect_right(starts, end) - 1
if starts[k] < end:
k += 1
for idx in range(k, min(k + 10, len(starts))):
s = starts[idx]
e = starts[idx + 1] if idx + 1 < len(starts) else total
title = md[s:md.find("\n", s) + 1].strip() or "(untitled)"
remain.append(f" - {title} (~{e - max(s, end)} chars)")
more = len(starts) - k - len(remain)
if more > 0:
remain.append(f" - … and {more} more sections")
parts.append(
f"\n\n[read_file] PARTIAL READ: returned chars {start}-{end} of {total}, "
f"cut {cut_desc}. NOT finished — call read_file again with offset={end} "
f"to continue."
+ ("\nRemaining:\n" + "\n".join(remain) if remain else "")
)
return "".join(parts)
# ------------- Render cache (persists under /workspace; any read/write failure just re-renders) -------------
def _render_cached(path: str, render) -> str:
"""Cache the full render keyed by (path, size, mtime), so every continuation page comes
from the same md and parsing / recalc happens once.
Any cache read/write failure silently falls back to rendering directly."""
cf = None
try:
st = _os.stat(path)
key = hashlib.md5(f"{path}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()
cf = _os.path.join(_CACHE_DIR, key + ".md")
if _os.path.exists(cf):
return open(cf, encoding="utf-8").read()
except OSError:
cf = None
md = render()
if cf:
try:
_os.makedirs(_CACHE_DIR, exist_ok=True)
tmp = cf + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(md)
_os.replace(tmp, cf)
except OSError:
pass
return md
# Look parser functions up by name via globals() (the per-format fragments are
# concatenated after this one, so they exist at runtime).
# Legacy binary formats (doc/ppt/xls) are absent from this table: openpyxl / pandoc /
# python-pptx do not recognise OLE2, so
# main() first converts them to OOXML through _legacy_to_ooxml (the soffice bridge) and
# then enters the matching reader.
_DISPATCH_NAMES = {
"xlsx": "_xlsx_to_md", "xlsm": "_xlsx_to_md",
"csv": "_csv_to_md", "tsv": "_csv_to_md",
"docx": "_docx_to_md",
"pptx": "_pptx_to_md", "pdf": "_pdf_to_md",
}
_FMT_NORM = {"xlsm": "xlsx"}
_LEGACY_TO_OOXML = {"doc": "docx", "ppt": "pptx", "xls": "xlsx"}
def _legacy_to_ooxml(path, target_ext):
"""Legacy Office binary (doc/ppt/xls) → an OOXML temp copy (LibreOffice); None on
failure.
The copy is used only for this parse; the markdown result is still cached under the
original path via _render_cached."""
import shutil as _sh
import tempfile as _tf
if not _sh.which("soffice"):
return None
td = _tf.mkdtemp(prefix="legacy_read_")
env = dict(_os.environ)
home = env.get("HOME", "")
if not (home and _os.path.isdir(home) and _os.access(home, _os.W_OK)):
env["HOME"] = "/tmp" # /root may not exist inside the bwrap sandbox, and soffice needs a writable HOME
try:
r = subprocess.run(["soffice", "--headless", "--norestore", "--convert-to",
target_ext, "--outdir", td, _os.path.abspath(path)],
capture_output=True, timeout=90, env=env) # < the outer 120s, so this
# function's own failure message reaches the model instead of being swallowed by a generic outer timeout
out = _os.path.join(td, _os.path.splitext(_os.path.basename(path))[0]
+ "." + target_ext)
if r.returncode == 0 and _os.path.exists(out):
return out
except Exception:
pass
# Conversion failed: clean up the temp dir we created (on the success path the caller cleans up after parsing)
_sh.rmtree(td, ignore_errors=True)
return None
_IMG_EXTS = {"png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "webp"}
_IMG_MIME = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "bmp": "image/bmp", "tif": "image/tiff",
"tiff": "image/tiff", "webp": "image/webp"}
def _resolve_image_batch(path):
"""path is a glob (contains * ? [) or a comma-separated list → (existing image files,
skipped paths).
Skipped = a named path that does not exist or is not an image (a glob expansion only
contains existing entries, so skipped comes mostly from comma lists)."""
import glob as _g
cands = []
if any(c in path for c in "*?["):
cands = sorted(_g.glob(path))
elif "," in path:
cands = [q.strip() for q in path.split(",") if q.strip()]
imgs = [q for q in cands if _os.path.isfile(q) and _ext(q) in _IMG_EXTS]
skipped = [q for q in cands if q not in imgs]
return imgs, skipped
# A batch read runs at most two waves: 4 concurrent x 2 waves x 45s per image = 90s worst
# case, leaving 30s of the outer 120s for
# process start, file reads, caching and result assembly. READDOC_BATCH_MAX lowers it
# further,
# but even with lower concurrency it never exceeds two waves.
_BATCH_MAX = max(1, int(_os.environ.get("READDOC_BATCH_MAX", "8") or "8"))
_BATCH_TIMEOUT = 45
_BATCH_MAX_WAVES = 2
def _batch_concurrency():
return max(1, int(_os.environ.get("READDOC_VISION_CONCURRENCY", "4") or "4"))
def _batch_limit():
return max(1, min(_BATCH_MAX, _batch_concurrency() * _BATCH_MAX_WAVES))
def _batch_image_read(paths, skipped=(), limit=None):
"""Batch images: one vision call **per image** (keeps fidelity — never packs several
images into a single message) + parallel + reassembled in order.
Concurrency READDOC_VISION_CONCURRENCY (default 4); per-image vision timeout 45s (so
the whole batch converges inside the outer
sandbox timeout). One read_file over N rendered pages collapses N agent turns into 1."""
from concurrent.futures import ThreadPoolExecutor
conc = _batch_concurrency()
limit = max(1, _batch_limit() if limit is None else int(limit))
truncated = paths[limit:]
paths = paths[:limit]
def _one(p):
try:
with open(p, "rb") as f:
data = f.read()
return _vision_read(
data, _IMG_MIME.get(_ext(p), "image/png"), timeout=_BATCH_TIMEOUT
)
except Exception:
return None
with ThreadPoolExecutor(max_workers=min(conc, len(paths))) as ex:
vts = list(ex.map(_one, paths))
head = f"(batch image read — {len(paths)} images, one vision call per image, in parallel)"
if skipped:
head += ("\n<!-- skipped (not found / not an image): "
+ ", ".join(_os.path.basename(s) for s in skipped) + " -->")
if truncated:
head += (f"\n<!-- NOTE: {len(truncated)} more images matched but only the first "
f"{limit} were read this call. Read the rest with another read_file "
f"call, e.g. a comma-separated list starting at "
f"{_os.path.basename(truncated[0])}. -->")
out = [head]
for i, (p, vt) in enumerate(zip(paths, vts, strict=False), 1):
nm = _os.path.basename(p)
out.append(f"\n===== image {i}/{len(paths)}: {nm} =====\n"
+ (vt if vt else "(vision unavailable/failed for this image)"))
return "\n".join(out), all(vt is not None for vt in vts)
def _batch_cache_key(pattern, imgs, limit):
"""Batch-read cache key: the full match set + file state + vision endpoint/model + this call's image cap."""
parts = [
pattern,
f"vision_url={_os.environ.get('READDOC_VISION_URL', '').rstrip('/')}",
f"vision_model={_os.environ.get('READDOC_VISION_MODEL', '')}",
f"limit={limit}",
]
for p in imgs:
try:
st = _os.stat(p)
parts.append(f"{p}|{st.st_size}|{st.st_mtime_ns}")
except OSError:
parts.append(p)
return _os.path.join(_CACHE_DIR,
hashlib.md5("\n".join(parts).encode()).hexdigest() + ".md")
def main() -> None:
# argv: path [max_chars] [cell_range|-] [offset] [pdf_mode] [pages|-]
# cell_range — xlsx point lookup; pdf_mode/pages — pdf mode and range; offset — generic pagination
if len(sys.argv) < 2:
sys.stdout.write("[read_file error] usage: <path> [max_chars] [cell_range|-] "
"[offset] [pdf_mode] [pages|-]")
return
path = sys.argv[1]
max_chars = int(sys.argv[2]) if len(sys.argv) > 2 else 0 # unset = send everything
cell_range = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] != "-" else None
offset = int(sys.argv[4]) if len(sys.argv) > 4 else 0
pdf_mode = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] != "-" else "auto"
pages = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] != "-" else None
# Multi-image batch: path is a glob (build/pg-*.png) or a comma-separated list →
# vision per image in parallel, reassembled. Results: (1) enter the render cache
# (offset continuation re-calls no VLM); (2) pass through _paginate (windowed on image
# boundaries, governed by
# max_chars/offset — everything is returned at once when it fits, cut only when it
# does not, never silently truncated).
_imgs, _skipped = _resolve_image_batch(path)
if any(c in path for c in "*?[") and not _imgs:
non_image_count = len(_skipped)
sys.stdout.write(
"[read_file error] image glob matched no images "
f"({non_image_count} non-image match"
f"{'es' if non_image_count != 1 else ''}): {path}"
)
_dump_trace()
return
# 2+ images → batch read; exactly 1 but with skipped paths also goes batch (the output must report skipped, not swallow it)
if len(_imgs) >= 2 or (len(_imgs) == 1 and _skipped):
_trace({"stage": "batch_image", "n": len(_imgs), "skipped": len(_skipped)})
limit = _batch_limit()
cf = _batch_cache_key(path, _imgs, limit)
md = None
try:
if _os.path.exists(cf):
md = open(cf, encoding="utf-8").read()
except OSError:
pass
if md is None:
md, cacheable = _batch_image_read(_imgs, _skipped, limit)
if cacheable:
try:
_os.makedirs(_CACHE_DIR, exist_ok=True)
_tmp = cf + ".tmp"
with open(_tmp, "w", encoding="utf-8") as f:
f.write(md)
_os.replace(_tmp, cf)
except OSError:
pass
# max_chars is honoured exactly as passed (0 = everything). The larger default
# window for batch reads is chosen by the tool layer
# (read_file.py) when the caller did not pass one explicitly; the reader never
# overrides an explicit value.
sys.stdout.write(_paginate(md, "image_batch", offset, max_chars))
_dump_trace()
return
if len(_imgs) == 1:
# A glob / comma list matching exactly one image with nothing skipped: fall back to a plain single-file read (old behaviour)
path = _imgs[0]
ext = _ext(path)
trace_on = bool(_os.environ.get("READDOC_TRACE"))
# Legacy Office binary: the soffice bridge converts to an OOXML copy and the original
# reader runs on it; the readout header notes the conversion source.
# Cache first: the full render is cached under the **original path** (the dispatch
# section writes the same key) — a hit returns immediately, so
# continuing through a large .doc no longer re-runs soffice (seconds each) per offset.
legacy_note = ""
read_path = path
_legacy_tmp = None
if ext in _LEGACY_TO_OOXML:
tgt = _LEGACY_TO_OOXML[ext]
try:
st = _os.stat(path)
_cf0 = _os.path.join(_CACHE_DIR, hashlib.md5(
f"{path}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest() + ".md")
if _os.path.exists(_cf0):
_trace({"stage": "legacy_convert", "ext": ext, "cache": "hit"})
sys.stdout.write(_paginate(open(_cf0, encoding="utf-8").read(),
_FMT_NORM.get(tgt, tgt), offset, max_chars))
_dump_trace()
return
except OSError:
pass
_trace({"stage": "legacy_convert", "ext": ext, "to": tgt})
conv = _legacy_to_ooxml(path, tgt)
if conv is None:
sys.stdout.write(f"[read_file error] legacy .{ext} requires LibreOffice "
"conversion, which is unavailable/failed in this sandbox.")
_dump_trace()
return
legacy_note = f"`converted from .{ext} via LibreOffice; fidelity best-effort`\n\n"
read_path, ext = conv, tgt
_legacy_tmp = _os.path.dirname(conv) # converted-copy dir, cleaned after parsing (keeps /tmp from growing)
if ext not in _DISPATCH_NAMES:
# Unstructured document: sniff and route — text → numbered content (paginated); image/audio/video → type only; anything else binary → unsupported
_trace({"stage": "dispatch", "ext": ext, "fmt": "text", "reader": "_classify_read"})
try:
md = _render_cached(path, lambda: _classify_read(path))
except Exception as e:
# Fail closed like the structured route below: any read failure
# (bad open / unsupported binary) goes to stderr + exit non-zero,
# else under read_file's save_to (`... > <file>`) the error text is
# captured AS the saved document and reported as a success. Media
# type notices are valid output — _classify_read returns those.
sys.stderr.write(f"[read_file error] {type(e).__name__}: {e}")
_dump_trace()
sys.exit(1)
sys.stdout.write(_paginate(md, "text", offset, max_chars))
_dump_trace()
return
fmt = _FMT_NORM.get(ext, ext)
fn = globals()[_DISPATCH_NAMES[ext]]
_trace({"stage": "dispatch", "ext": ext, "fmt": fmt, "reader": _DISPATCH_NAMES[ext]})
# Parameterised reads (output varies with the parameters) skip the full-text cache;
# only a plain full read is cached.
# Tracing also bypasses the cache: a cache hit skips the routing code, leaving the
# trace empty.
pdf_param = ext == "pdf" and (pdf_mode != "auto" or pages)
try:
# Parse read_path (the converted copy for legacy formats); the cache key stays the original path
if cell_range and ext in ("xlsx", "xlsm"):
md = fn(read_path, cell_range)
elif pdf_param:
md = fn(read_path, pdf_mode, pages)
elif ext == "pdf":
md = fn(read_path, "auto", None) if trace_on else \
_render_cached(path, lambda: fn(read_path, "auto", None))
else:
if trace_on:
md = fn(read_path)
elif legacy_note:
# Cache the exact string that pagination indexes. The first
# page and continuation reads share this cache key, so omitting
# the conversion note here shifts every later offset by its
# length and silently skips document content.
md = _render_cached(path, lambda: legacy_note + fn(read_path))
legacy_note = ""
else:
md = _render_cached(path, lambda: fn(read_path))
except Exception as e:
# Parse error must NOT go to stdout: under read_file's ``save_to`` the
# command is ``... > <file>``, so an error written to stdout would be
# captured AS the saved document and read_file would report "saved N
# bytes" (a silent success on a failed parse). Emit to stderr and exit
# non-zero so read_file's ``exit_code != 0`` branch surfaces the error.
sys.stderr.write(f"[read_file error parsing .{ext}] {type(e).__name__}: {e}")
_dump_trace()
sys.exit(1)
finally:
if _legacy_tmp: # the legacy converted copy is removed as soon as it is used, so the sandbox /tmp does not grow per read
import shutil as _sh
_sh.rmtree(_legacy_tmp, ignore_errors=True)
sys.stdout.write(_paginate(legacy_note + md, fmt, offset, max_chars))
_dump_trace()