diff --git a/tests/tools/test_read_extract.py b/tests/tools/test_read_extract.py index 525da60bf1960..4384cc5af229d 100644 --- a/tests/tools/test_read_extract.py +++ b/tests/tools/test_read_extract.py @@ -477,14 +477,64 @@ class TestReadFileToolIntegration(unittest.TestCase): self.assertIn("print(1)", res["content"]) - def test_corrupt_docx_falls_through_to_binary_guard(self): + def test_corrupt_docx_surfaces_extraction_error(self): p = os.path.join(self.tmp, "bad.docx") with open(p, "wb") as fh: fh.write(b"not a zip") res = json.loads(read_file_tool(p)) - # Should NOT crash; falls through to the binary-extension guard. + # Should NOT crash; the binary guard fires but surfaces the + # specific extraction failure instead of the generic message. self.assertIn("error", res) - self.assertIn("binary", res["error"].lower()) + self.assertIn("extraction failed", res["error"].lower()) + self.assertIn("docx", res["error"].lower()) + + def test_oversized_anydoc_read_surfaces_size_error(self): + import tools.read_extract as rex + + saved_cap = rex.MAX_ANYDOC_BYTES + saved_module = rex._anydoc_module + + class _FakeAnydoc: + def to_markdown(self, path): # pragma: no cover - must not be called + raise AssertionError("conversion should be rejected before call") + + rex._anydoc_module = _FakeAnydoc() + rex.MAX_ANYDOC_BYTES = 10 + try: + p = os.path.join(self.tmp, "big.pdf") + with open(p, "wb") as fh: + fh.write(b"x" * 11) + res = json.loads(read_file_tool(p)) + self.assertIn("error", res) + self.assertIn("too large", res["error"].lower()) + # The size hint reaches the agent instead of a generic binary error. + self.assertNotIn("cannot read binary file", res["error"].lower()) + finally: + rex.MAX_ANYDOC_BYTES = saved_cap + rex._anydoc_module = saved_module + + def test_unavailable_converter_falls_back_to_raw_read(self): + import time + + import tools.read_extract as rex + + saved_module = rex._anydoc_module + saved_failed_at = rex._anydoc_failed_at + # Simulate "converter unavailable and in cooldown": _anydoc() returns + # None, the .pdf is not treated as extractable, and read_file keeps + # its historical raw-read fallthrough (no extraction error surfaced). + rex._anydoc_module = None + rex._anydoc_failed_at = time.monotonic() + try: + p = os.path.join(self.tmp, "doc.pdf") + with open(p, "wb") as fh: + fh.write(b"%PDF-1.4 fake") + res = json.loads(read_file_tool(p)) + self.assertNotIn("error", res) + self.assertIn("%PDF-1.4 fake", res.get("content", "")) + finally: + rex._anydoc_module = saved_module + rex._anydoc_failed_at = saved_failed_at def test_docx_read_extracts(self): p = os.path.join(self.tmp, "d.docx") diff --git a/tools/file_tools.py b/tools/file_tools.py index 146a2626f92a3..0a8f08cb9b0bc 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -1535,6 +1535,8 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 2000, task_id: str = # Try before the binary-extension guard so .docx/.xlsx can render as text. # Malformed documents fall through to the normal path/binary guard. from tools.read_extract import ( + ANYDOC_EXTENSIONS, + EXTRACTABLE_EXTENSIONS, MAX_DOCUMENT_BYTES, ExtractionError, extract_document_bytes, @@ -1555,8 +1557,31 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 2000, task_id: str = extracted_text = extract_document_bytes( document_bytes, str(_resolved) ) - except (ExtractionError, ValueError, base64.binascii.Error): + except (ExtractionError, ValueError, base64.binascii.Error) as exc: logger.debug("document extraction failed for %s", path, exc_info=True) + # For binary document formats, surface the specific failure + # (size cap, encrypted, malformed…) instead of falling through + # — the fallthrough path can only produce a generic + # binary-file error or garbage raw bytes, hiding the + # actionable reason (e.g. "Document too large to convert"). + # .ipynb stays on the fallthrough path: it is plain JSON text + # and a raw read is genuinely useful. Byte-transport issues + # (ValueError / binascii) keep the fallthrough too — only a + # specific ExtractionError carries an actionable reason. + _doc_ext = _resolved.suffix.lower() + _binary_doc = _doc_ext in ANYDOC_EXTENSIONS or ( + _doc_ext in EXTRACTABLE_EXTENSIONS and _doc_ext != ".ipynb" + ) + if ( + _binary_doc + and isinstance(exc, ExtractionError) + and not str(exc).startswith("Unsupported document type") + ): + return tool_error( + f"Cannot read '{path}' ({_doc_ext}): document " + f"extraction failed — {exc}. Use terminal utilities " + "to inspect or convert the file." + ) else: lines = extracted_text.splitlines() total_lines = len(lines)