mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat: add demand-driven document retrieval (#5525)
This commit is contained in:
@@ -251,16 +251,16 @@ def _builtin_skill_read_path(path: str) -> Path | None:
|
|||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
path=StringSchema("The file path to read"),
|
path=StringSchema("The file path to read"),
|
||||||
offset=IntegerSchema(
|
offset=IntegerSchema(
|
||||||
description="Line number to start reading from (1-indexed, default 1)",
|
description="1-based text or extracted-document line (default 1)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
limit=IntegerSchema(
|
limit=IntegerSchema(
|
||||||
description="Maximum number of lines to read (default 2000)",
|
description="Maximum lines to return (default 2000)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
|
pages=StringSchema("PDF page number or range, e.g. '7' or '1-5' (max 20 pages)"),
|
||||||
force=BooleanSchema(
|
force=BooleanSchema(
|
||||||
description="Bypass same-file read deduplication and return content again.",
|
description="Return an unchanged range again",
|
||||||
default=False,
|
default=False,
|
||||||
),
|
),
|
||||||
required=["path"],
|
required=["path"],
|
||||||
@@ -282,18 +282,8 @@ class ReadFileTool(_FsTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Read a file (text, image, or document). "
|
"Read text, images, PDFs, and Office documents by path. "
|
||||||
"Text output format: LINE_NUM|CONTENT. "
|
"Text is line-numbered; use offset/limit or pages for targeted ranges."
|
||||||
"Images return visual content for analysis. "
|
|
||||||
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
|
||||||
"Uploaded non-image attachments are referenced by path; read them "
|
|
||||||
"with this tool only when their contents are needed. "
|
|
||||||
"Use find_files/list_dir first when the path is uncertain. "
|
|
||||||
"Read the relevant range before editing so replacements or patches "
|
|
||||||
"are based on current content. "
|
|
||||||
"Use offset and limit for large text files. "
|
|
||||||
"Use force=true to re-read content even if unchanged. "
|
|
||||||
"Reads exceeding ~128K chars are truncated."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -342,7 +332,7 @@ class ReadFileTool(_FsTool):
|
|||||||
|
|
||||||
# Office document support
|
# Office document support
|
||||||
if fp.suffix.lower() in {".docx", ".xlsx", ".pptx"}:
|
if fp.suffix.lower() in {".docx", ".xlsx", ".pptx"}:
|
||||||
return self._read_office_doc(fp)
|
return self._read_office_doc(fp, offset, limit)
|
||||||
|
|
||||||
raw = fp.read_bytes()
|
raw = fp.read_bytes()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -464,8 +454,8 @@ class ReadFileTool(_FsTool):
|
|||||||
max_pages=self._MAX_PDF_PAGES,
|
max_pages=self._MAX_PDF_PAGES,
|
||||||
max_chars=self._MAX_CHARS,
|
max_chars=self._MAX_CHARS,
|
||||||
)
|
)
|
||||||
except PdfPageRangeError:
|
except PdfPageRangeError as e:
|
||||||
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
|
return ToolResult.error(f"Error: Invalid page range '{pages}': {e!s}.")
|
||||||
except PdfSafetyError as e:
|
except PdfSafetyError as e:
|
||||||
return ToolResult.error(f"Error reading PDF: {e}")
|
return ToolResult.error(f"Error reading PDF: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -484,24 +474,85 @@ class ReadFileTool(_FsTool):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _read_office_doc(self, fp: Path) -> str:
|
def _read_office_doc(
|
||||||
from nanobot.utils.document import extract_text
|
self,
|
||||||
|
fp: Path,
|
||||||
|
offset: int,
|
||||||
|
limit: int | None,
|
||||||
|
) -> str:
|
||||||
|
from nanobot.utils.document import open_document_line_source
|
||||||
|
|
||||||
result = extract_text(fp)
|
offset = max(1, offset)
|
||||||
|
requested_limit = limit or self._DEFAULT_LIMIT
|
||||||
|
source_iterator = None
|
||||||
|
try:
|
||||||
|
source = open_document_line_source(fp)
|
||||||
|
if source is None:
|
||||||
|
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
|
||||||
|
source_iterator = source.lines
|
||||||
|
numbered: list[str] = []
|
||||||
|
output_chars = 0
|
||||||
|
total_seen = 0
|
||||||
|
end = offset - 1
|
||||||
|
has_more = False
|
||||||
|
line_was_clipped = False
|
||||||
|
|
||||||
if result is None:
|
for line in source_iterator:
|
||||||
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
|
total_seen = line.extracted_line
|
||||||
|
if line.extracted_line < offset:
|
||||||
|
continue
|
||||||
|
if len(numbered) >= requested_limit:
|
||||||
|
has_more = True
|
||||||
|
break
|
||||||
|
|
||||||
if result.startswith("[error:"):
|
rendered = f"{line.extracted_line}| {line.text}"
|
||||||
return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
|
extra = 1 if numbered else 0
|
||||||
|
if output_chars + extra + len(rendered) > self._MAX_CHARS:
|
||||||
|
if numbered:
|
||||||
|
has_more = True
|
||||||
|
break
|
||||||
|
prefix = f"{line.extracted_line}| "
|
||||||
|
available = max(0, self._MAX_CHARS - len(prefix) - 3)
|
||||||
|
rendered = f"{prefix}{line.text[:available]}..."
|
||||||
|
line_was_clipped = True
|
||||||
|
has_more = True
|
||||||
|
numbered.append(rendered)
|
||||||
|
output_chars += extra + len(rendered)
|
||||||
|
end = line.extracted_line
|
||||||
|
if line_was_clipped:
|
||||||
|
break
|
||||||
|
|
||||||
if not result:
|
if not numbered:
|
||||||
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
if total_seen == 0:
|
||||||
|
return (
|
||||||
|
f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
||||||
|
)
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: offset {offset} is beyond end of extracted document "
|
||||||
|
f"({total_seen} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
if len(result) > self._MAX_CHARS:
|
output = "\n".join(numbered)
|
||||||
result = result[:self._MAX_CHARS] + "\n\n(Document text truncated at ~128K chars)"
|
if has_more:
|
||||||
|
if line_was_clipped:
|
||||||
return result
|
output += (
|
||||||
|
"\n\n(Document text truncated at ~128K chars; line clipped. "
|
||||||
|
f"Use offset={end + 1} to continue.)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
output += (
|
||||||
|
f"\n\n(Showing extracted lines {offset}-{end}. "
|
||||||
|
f"Use offset={end + 1} to continue.)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
output += f"\n\n(End of document — {total_seen} extracted lines total)"
|
||||||
|
return output
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {e!s}")
|
||||||
|
finally:
|
||||||
|
close = getattr(source_iterator, "close", None)
|
||||||
|
if close is not None:
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+253
-120
@@ -7,15 +7,23 @@ from __future__ import annotations
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Iterable, TypeVar
|
from typing import Any, Iterable, Iterator, TypeVar
|
||||||
|
|
||||||
from nanobot.agent.tools.base import ToolResult
|
from nanobot.agent.tools.base import ToolResult
|
||||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||||
|
from nanobot.utils.document import (
|
||||||
|
LocatedDocumentLine,
|
||||||
|
PdfPageRangeError,
|
||||||
|
open_document_line_source,
|
||||||
|
)
|
||||||
|
|
||||||
_DEFAULT_HEAD_LIMIT = 250
|
_DEFAULT_HEAD_LIMIT = 250
|
||||||
_DEFAULT_FILE_HEAD_LIMIT = 200
|
_DEFAULT_FILE_HEAD_LIMIT = 200
|
||||||
|
_DOCUMENT_EXTENSIONS = frozenset({".pdf", ".docx", ".xlsx", ".pptx"})
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
_TYPE_GLOB_MAP = {
|
_TYPE_GLOB_MAP = {
|
||||||
"py": ("*.py", "*.pyi"),
|
"py": ("*.py", "*.pyi"),
|
||||||
@@ -41,6 +49,14 @@ _TYPE_GLOB_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PendingContextMatch:
|
||||||
|
lines: list[LocatedDocumentLine]
|
||||||
|
match_index: int
|
||||||
|
match_start: int
|
||||||
|
remaining_after: int
|
||||||
|
|
||||||
|
|
||||||
def _normalize_pattern(pattern: str) -> str:
|
def _normalize_pattern(pattern: str) -> str:
|
||||||
return pattern.strip().replace("\\", "/")
|
return pattern.strip().replace("\\", "/")
|
||||||
|
|
||||||
@@ -64,6 +80,15 @@ def _is_binary(raw: bytes) -> bool:
|
|||||||
return (non_text / len(sample)) > 0.2
|
return (non_text / len(sample)) > 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_column(index: int) -> str:
|
||||||
|
"""Return a 1-indexed spreadsheet column label without importing openpyxl."""
|
||||||
|
label = ""
|
||||||
|
while index > 0:
|
||||||
|
index, remainder = divmod(index - 1, 26)
|
||||||
|
label = chr(ord("A") + remainder) + label
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
def _paginate(items: list[T], limit: int | None, offset: int) -> tuple[list[T], bool]:
|
def _paginate(items: list[T], limit: int | None, offset: int) -> tuple[list[T], bool]:
|
||||||
if limit is None:
|
if limit is None:
|
||||||
return items[offset:], False
|
return items[offset:], False
|
||||||
@@ -133,11 +158,8 @@ class FindFilesTool(_SearchTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Find files by path fragment, glob, or file type. "
|
"Find workspace paths by name, glob, or file type. "
|
||||||
"Use this before read_file when you need to locate files, and "
|
"Returns relative paths and skips dependency/build directories."
|
||||||
"prefer it over shell find/ls for ordinary workspace discovery. "
|
|
||||||
"Returns workspace-relative paths and skips common dependency/build "
|
|
||||||
"directories."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -151,41 +173,38 @@ class FindFilesTool(_SearchTool):
|
|||||||
"properties": {
|
"properties": {
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Directory or file to search in (default '.')",
|
"description": "Search root (default '.')",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": "Case-insensitive path terms; all must match",
|
||||||
"Optional case-insensitive path fragment search. "
|
|
||||||
"Whitespace-separated terms must all be present."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"glob": {
|
"glob": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
"description": "Path filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
|
"description": "File type, e.g. 'py', 'ts', 'md', or 'json'",
|
||||||
},
|
},
|
||||||
"include_dirs": {
|
"include_dirs": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Include matching directories as well as files (default false)",
|
"description": "Include directories (default false)",
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["path", "modified"],
|
"enum": ["path", "modified"],
|
||||||
"description": "Sort by path or most recently modified first (default path)",
|
"description": "Sort order (default path)",
|
||||||
},
|
},
|
||||||
"head_limit": {
|
"head_limit": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
|
"description": "Maximum paths (default 200; 0 for all)",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
},
|
},
|
||||||
"offset": {
|
"offset": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Skip the first N results before applying head_limit",
|
"description": "Paths to skip before head_limit",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 100000,
|
"maximum": 100000,
|
||||||
},
|
},
|
||||||
@@ -280,10 +299,11 @@ class FindFilesTool(_SearchTool):
|
|||||||
|
|
||||||
|
|
||||||
class GrepTool(_SearchTool):
|
class GrepTool(_SearchTool):
|
||||||
"""Search file contents using a regex-like pattern."""
|
"""Search text and document contents using a regex-like pattern."""
|
||||||
_scopes = {"core", "subagent"}
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
_MAX_RESULT_CHARS = 128_000
|
_MAX_RESULT_CHARS = 128_000
|
||||||
|
_MAX_RENDERED_LINE_CHARS = 2_000
|
||||||
_MAX_FILE_BYTES = 2_000_000
|
_MAX_FILE_BYTES = 2_000_000
|
||||||
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
|
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
|
||||||
|
|
||||||
@@ -294,12 +314,8 @@ class GrepTool(_SearchTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Search file contents with a regex pattern. "
|
"Search text, PDF, DOCX, XLSX, and PPTX content. "
|
||||||
"Default output_mode is files_with_matches (file paths only); "
|
"Returns matches with five context lines and source locators by default."
|
||||||
"use content mode for matching lines with context. Prefer this "
|
|
||||||
"over shell grep for ordinary workspace searches. "
|
|
||||||
"Binary and file-size limits are enforced by the tool; explicit file paths "
|
|
||||||
"use a larger bounded limit than directory searches. Supports glob/type filtering."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -313,80 +329,62 @@ class GrepTool(_SearchTool):
|
|||||||
"properties": {
|
"properties": {
|
||||||
"pattern": {
|
"pattern": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Regex or plain text pattern to search for",
|
"description": "Regex, or literal text when fixed_strings=true",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "File or directory to search in (default '.')",
|
"description": "Search root (default '.')",
|
||||||
},
|
},
|
||||||
"glob": {
|
"glob": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
"description": "Path filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
|
"description": "File type, e.g. 'py', 'ts', 'md', or 'json'",
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PDF page number or range, e.g. '7' or '101-200' (max 100 pages)",
|
||||||
},
|
},
|
||||||
"case_insensitive": {
|
"case_insensitive": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Case-insensitive search (default false)",
|
"description": "Ignore case (default false)",
|
||||||
},
|
},
|
||||||
"fixed_strings": {
|
"fixed_strings": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Treat pattern as plain text instead of regex (default false)",
|
"description": "Treat pattern literally (default false)",
|
||||||
},
|
},
|
||||||
"output_mode": {
|
"output_mode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["content", "files_with_matches", "count"],
|
"enum": ["content", "files_with_matches", "count"],
|
||||||
"description": (
|
"description": (
|
||||||
"content: matching lines with optional context; "
|
"content: matches with context (default); "
|
||||||
"files_with_matches: only matching file paths; "
|
"files_with_matches: paths; count: matches per file"
|
||||||
"count: matching line counts per file. "
|
|
||||||
"Default: files_with_matches"
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"context_before": {
|
"context_before": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of lines of context before each match",
|
"description": "Context lines before a match (default 5)",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 20,
|
"maximum": 20,
|
||||||
},
|
},
|
||||||
"context_after": {
|
"context_after": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of lines of context after each match",
|
"description": "Context lines after a match (default 5)",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 20,
|
"maximum": 20,
|
||||||
},
|
},
|
||||||
"max_matches": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": (
|
|
||||||
"Legacy alias for head_limit in content mode"
|
|
||||||
),
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 1000,
|
|
||||||
},
|
|
||||||
"max_results": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": (
|
|
||||||
"Legacy alias for head_limit in files_with_matches or count mode"
|
|
||||||
),
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 1000,
|
|
||||||
},
|
|
||||||
"head_limit": {
|
"head_limit": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": (
|
"description": "Maximum matches or file entries (default 250; 0 for all)",
|
||||||
"Maximum number of results to return. In content mode this limits "
|
|
||||||
"matching line blocks; in other modes it limits file entries. "
|
|
||||||
"Default 250"
|
|
||||||
),
|
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
},
|
},
|
||||||
"offset": {
|
"offset": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Skip the first N results before applying head_limit",
|
"description": "Matches or file entries to skip before head_limit",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 100000,
|
"maximum": 100000,
|
||||||
},
|
},
|
||||||
@@ -395,19 +393,96 @@ class GrepTool(_SearchTool):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_block(
|
def _clip_rendered_line(text: str, match_start: int | None = None) -> str:
|
||||||
display_path: str,
|
limit = GrepTool._MAX_RENDERED_LINE_CHARS
|
||||||
lines: list[str],
|
if len(text) <= limit:
|
||||||
match_line: int,
|
return text
|
||||||
|
|
||||||
|
marker = "..."
|
||||||
|
available = limit - len(marker)
|
||||||
|
if match_start is None:
|
||||||
|
return text[:available] + marker
|
||||||
|
|
||||||
|
start = max(0, match_start - available // 3)
|
||||||
|
start = min(start, len(text) - available)
|
||||||
|
end = start + available
|
||||||
|
prefix = marker if start else ""
|
||||||
|
suffix = marker if end < len(text) else ""
|
||||||
|
visible = text[start:end]
|
||||||
|
if prefix and suffix:
|
||||||
|
visible = visible[: available - len(marker)]
|
||||||
|
return prefix + visible + suffix
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matching_contexts(
|
||||||
|
lines: Iterable[LocatedDocumentLine],
|
||||||
|
regex: re.Pattern[str],
|
||||||
before: int,
|
before: int,
|
||||||
after: int,
|
after: int,
|
||||||
|
) -> Iterable[tuple[list[LocatedDocumentLine], int, int]]:
|
||||||
|
history: deque[LocatedDocumentLine] = deque(maxlen=before)
|
||||||
|
pending: list[_PendingContextMatch] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if not line.searchable:
|
||||||
|
continue
|
||||||
|
|
||||||
|
still_pending: list[_PendingContextMatch] = []
|
||||||
|
for item in pending:
|
||||||
|
item.lines.append(line)
|
||||||
|
item.remaining_after -= 1
|
||||||
|
if item.remaining_after == 0:
|
||||||
|
yield item.lines, item.match_index, item.match_start
|
||||||
|
else:
|
||||||
|
still_pending.append(item)
|
||||||
|
pending = still_pending
|
||||||
|
|
||||||
|
match = regex.search(line.text)
|
||||||
|
if match is not None:
|
||||||
|
context_lines = [*history, line]
|
||||||
|
item = _PendingContextMatch(
|
||||||
|
lines=context_lines,
|
||||||
|
match_index=len(context_lines) - 1,
|
||||||
|
match_start=match.start(),
|
||||||
|
remaining_after=after,
|
||||||
|
)
|
||||||
|
if after == 0:
|
||||||
|
yield item.lines, item.match_index, item.match_start
|
||||||
|
else:
|
||||||
|
pending.append(item)
|
||||||
|
history.append(line)
|
||||||
|
|
||||||
|
for item in pending:
|
||||||
|
yield item.lines, item.match_index, item.match_start
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_block(
|
||||||
|
display_path: str,
|
||||||
|
lines: list[LocatedDocumentLine],
|
||||||
|
match_index: int,
|
||||||
|
match_start: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
start = max(1, match_line - before)
|
match_line = lines[match_index]
|
||||||
end = min(len(lines), match_line + after)
|
source_line = match_line.extracted_line
|
||||||
block = [f"{display_path}:{match_line}"]
|
match_locator = match_line.locator
|
||||||
for line_no in range(start, end + 1):
|
if match_locator.startswith("sheet="):
|
||||||
marker = ">" if line_no == match_line else " "
|
column = _excel_column(match_line.text[:match_start].count("\t") + 1)
|
||||||
block.append(f"{marker} {line_no}| {lines[line_no - 1]}")
|
row_match = re.search(r",row=(\d+)$", match_locator)
|
||||||
|
if row_match:
|
||||||
|
match_locator += f",cell={column}{row_match.group(1)}"
|
||||||
|
suffix = f" [{match_locator}]" if match_locator else ""
|
||||||
|
block = [f"{display_path}:{source_line}{suffix}"]
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
is_match = index == match_index
|
||||||
|
marker = ">" if is_match else " "
|
||||||
|
coordinate = str(line.extracted_line)
|
||||||
|
if line.locator:
|
||||||
|
coordinate += f" [{line.locator}]"
|
||||||
|
rendered = GrepTool._clip_rendered_line(
|
||||||
|
line.text,
|
||||||
|
match_start if is_match else None,
|
||||||
|
)
|
||||||
|
block.append(f"{marker} {coordinate}| {rendered}")
|
||||||
return "\n".join(block)
|
return "\n".join(block)
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -416,11 +491,12 @@ class GrepTool(_SearchTool):
|
|||||||
path: str = ".",
|
path: str = ".",
|
||||||
glob: str | None = None,
|
glob: str | None = None,
|
||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
|
pages: str | None = None,
|
||||||
case_insensitive: bool = False,
|
case_insensitive: bool = False,
|
||||||
fixed_strings: bool = False,
|
fixed_strings: bool = False,
|
||||||
output_mode: str = "files_with_matches",
|
output_mode: str = "content",
|
||||||
context_before: int = 0,
|
context_before: int = 5,
|
||||||
context_after: int = 0,
|
context_after: int = 5,
|
||||||
max_matches: int | None = None,
|
max_matches: int | None = None,
|
||||||
max_results: int | None = None,
|
max_results: int | None = None,
|
||||||
head_limit: int | None = None,
|
head_limit: int | None = None,
|
||||||
@@ -456,6 +532,8 @@ class GrepTool(_SearchTool):
|
|||||||
size_truncated = False
|
size_truncated = False
|
||||||
skipped_binary = 0
|
skipped_binary = 0
|
||||||
skipped_large = 0
|
skipped_large = 0
|
||||||
|
document_errors: list[str] = []
|
||||||
|
document_continuations: list[str] = []
|
||||||
matching_files: list[str] = []
|
matching_files: list[str] = []
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
file_mtimes: dict[str, float] = {}
|
file_mtimes: dict[str, float] = {}
|
||||||
@@ -470,61 +548,109 @@ class GrepTool(_SearchTool):
|
|||||||
continue
|
continue
|
||||||
if not _matches_type(file_path.name, type):
|
if not _matches_type(file_path.name, type):
|
||||||
continue
|
continue
|
||||||
|
display_path = self._display_path(file_path, root)
|
||||||
|
|
||||||
with file_path.open("rb") as file:
|
try:
|
||||||
raw = file.read(max_file_bytes + 1)
|
file_size = file_path.stat().st_size
|
||||||
if len(raw) > max_file_bytes:
|
except OSError:
|
||||||
skipped_large += 1
|
|
||||||
continue
|
|
||||||
if _is_binary(raw):
|
|
||||||
skipped_binary += 1
|
skipped_binary += 1
|
||||||
continue
|
continue
|
||||||
|
if file_size > max_file_bytes:
|
||||||
|
skipped_large += 1
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
mtime = file_path.stat().st_mtime
|
mtime = file_path.stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
mtime = 0.0
|
mtime = 0.0
|
||||||
|
source_iterator: Iterator[LocatedDocumentLine] | None = None
|
||||||
|
is_document = file_path.suffix.lower() in _DOCUMENT_EXTENSIONS
|
||||||
try:
|
try:
|
||||||
content = raw.decode("utf-8")
|
if is_document:
|
||||||
except UnicodeDecodeError:
|
source = open_document_line_source(file_path, pages=pages)
|
||||||
|
if source is None:
|
||||||
|
skipped_binary += 1
|
||||||
|
continue
|
||||||
|
source_iterator = source.lines
|
||||||
|
source_lines: Iterable[LocatedDocumentLine] = source_iterator
|
||||||
|
if source.continuation:
|
||||||
|
document_continuations.append(
|
||||||
|
f"({display_path}: continue PDF search with "
|
||||||
|
f"{source.continuation})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with file_path.open("rb") as file:
|
||||||
|
raw = file.read(max_file_bytes + 1)
|
||||||
|
if _is_binary(raw):
|
||||||
|
skipped_binary += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
skipped_binary += 1
|
||||||
|
continue
|
||||||
|
source_lines = (
|
||||||
|
LocatedDocumentLine(text, line_no, "")
|
||||||
|
for line_no, text in enumerate(content.splitlines(), 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
file_had_match = False
|
||||||
|
if output_mode == "content":
|
||||||
|
contexts = self._matching_contexts(
|
||||||
|
source_lines,
|
||||||
|
regex,
|
||||||
|
context_before,
|
||||||
|
context_after,
|
||||||
|
)
|
||||||
|
for context_lines, match_index, match_start in contexts:
|
||||||
|
file_had_match = True
|
||||||
|
seen_content_matches += 1
|
||||||
|
if seen_content_matches <= offset:
|
||||||
|
continue
|
||||||
|
if limit is not None and len(blocks) >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
block = self._format_block(
|
||||||
|
display_path,
|
||||||
|
context_lines,
|
||||||
|
match_index,
|
||||||
|
match_start,
|
||||||
|
)
|
||||||
|
extra_sep = 2 if blocks else 0
|
||||||
|
if result_chars + extra_sep + len(block) > self._MAX_RESULT_CHARS:
|
||||||
|
size_truncated = True
|
||||||
|
break
|
||||||
|
blocks.append(block)
|
||||||
|
result_chars += extra_sep + len(block)
|
||||||
|
else:
|
||||||
|
for line in source_lines:
|
||||||
|
if not line.searchable or regex.search(line.text) is None:
|
||||||
|
continue
|
||||||
|
file_had_match = True
|
||||||
|
if output_mode == "count":
|
||||||
|
counts[display_path] = counts.get(display_path, 0) + 1
|
||||||
|
continue
|
||||||
|
if display_path not in matching_files:
|
||||||
|
matching_files.append(display_path)
|
||||||
|
file_mtimes[display_path] = mtime
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if not is_document:
|
||||||
|
raise
|
||||||
|
if target.is_file():
|
||||||
|
if isinstance(e, PdfPageRangeError):
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: Invalid PDF page range '{pages}': {e!s}."
|
||||||
|
)
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error searching document {display_path}: {e!s}"
|
||||||
|
)
|
||||||
skipped_binary += 1
|
skipped_binary += 1
|
||||||
|
document_errors.append(f"{display_path}: {e!s}")
|
||||||
continue
|
continue
|
||||||
|
finally:
|
||||||
lines = content.splitlines()
|
close = getattr(source_iterator, "close", None)
|
||||||
display_path = self._display_path(file_path, root)
|
if close is not None:
|
||||||
file_had_match = False
|
close()
|
||||||
for idx, line in enumerate(lines, start=1):
|
|
||||||
if not regex.search(line):
|
|
||||||
continue
|
|
||||||
file_had_match = True
|
|
||||||
|
|
||||||
if output_mode == "count":
|
|
||||||
counts[display_path] = counts.get(display_path, 0) + 1
|
|
||||||
continue
|
|
||||||
if output_mode == "files_with_matches":
|
|
||||||
if display_path not in matching_files:
|
|
||||||
matching_files.append(display_path)
|
|
||||||
file_mtimes[display_path] = mtime
|
|
||||||
break
|
|
||||||
|
|
||||||
seen_content_matches += 1
|
|
||||||
if seen_content_matches <= offset:
|
|
||||||
continue
|
|
||||||
if limit is not None and len(blocks) >= limit:
|
|
||||||
truncated = True
|
|
||||||
break
|
|
||||||
block = self._format_block(
|
|
||||||
display_path,
|
|
||||||
lines,
|
|
||||||
idx,
|
|
||||||
context_before,
|
|
||||||
context_after,
|
|
||||||
)
|
|
||||||
extra_sep = 2 if blocks else 0
|
|
||||||
if result_chars + extra_sep + len(block) > self._MAX_RESULT_CHARS:
|
|
||||||
size_truncated = True
|
|
||||||
break
|
|
||||||
blocks.append(block)
|
|
||||||
result_chars += extra_sep + len(block)
|
|
||||||
if output_mode == "count" and file_had_match:
|
if output_mode == "count" and file_had_match:
|
||||||
if display_path not in matching_files:
|
if display_path not in matching_files:
|
||||||
matching_files.append(display_path)
|
matching_files.append(display_path)
|
||||||
@@ -553,8 +679,8 @@ class GrepTool(_SearchTool):
|
|||||||
key=lambda name: (-file_mtimes.get(name, 0.0), name),
|
key=lambda name: (-file_mtimes.get(name, 0.0), name),
|
||||||
)
|
)
|
||||||
ordered, truncated = _paginate(ordered_files, limit, offset)
|
ordered, truncated = _paginate(ordered_files, limit, offset)
|
||||||
lines = [f"{name}: {counts[name]}" for name in ordered]
|
count_lines = [f"{name}: {counts[name]}" for name in ordered]
|
||||||
result = "\n".join(lines)
|
result = "\n".join(count_lines)
|
||||||
else:
|
else:
|
||||||
if not blocks:
|
if not blocks:
|
||||||
result = f"No matches found for pattern '{pattern}' in {path}"
|
result = f"No matches found for pattern '{pattern}' in {path}"
|
||||||
@@ -564,10 +690,14 @@ class GrepTool(_SearchTool):
|
|||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
if output_mode == "content" and truncated:
|
if output_mode == "content" and truncated:
|
||||||
notes.append(
|
notes.append(
|
||||||
f"(pagination: limit={limit}, offset={offset})"
|
f"(pagination: limit={limit}, offset={offset}; "
|
||||||
|
f"use offset={offset + len(blocks)} to continue)"
|
||||||
)
|
)
|
||||||
elif output_mode == "content" and size_truncated:
|
elif output_mode == "content" and size_truncated:
|
||||||
notes.append("(output truncated due to size)")
|
notes.append(
|
||||||
|
"(output truncated due to size; "
|
||||||
|
f"use offset={offset + len(blocks)} to continue)"
|
||||||
|
)
|
||||||
elif truncated and output_mode in {"count", "files_with_matches"}:
|
elif truncated and output_mode in {"count", "files_with_matches"}:
|
||||||
notes.append(
|
notes.append(
|
||||||
f"(pagination: limit={limit}, offset={offset})"
|
f"(pagination: limit={limit}, offset={offset})"
|
||||||
@@ -580,6 +710,9 @@ class GrepTool(_SearchTool):
|
|||||||
notes.append(f"(skipped {skipped_binary} binary/unreadable files)")
|
notes.append(f"(skipped {skipped_binary} binary/unreadable files)")
|
||||||
if skipped_large:
|
if skipped_large:
|
||||||
notes.append(f"(skipped {skipped_large} large files)")
|
notes.append(f"(skipped {skipped_large} large files)")
|
||||||
|
if document_errors:
|
||||||
|
notes.append(f"(first document error: {document_errors[0]})")
|
||||||
|
notes.extend(document_continuations[:10])
|
||||||
if output_mode == "count" and counts:
|
if output_mode == "count" and counts:
|
||||||
notes.append(
|
notes.append(
|
||||||
f"(total matches: {sum(counts.values())} in {len(counts)} files)"
|
f"(total matches: {sum(counts.values())} in {len(counts)} files)"
|
||||||
|
|||||||
@@ -18,11 +18,9 @@
|
|||||||
|
|
||||||
## Discovery and Reading
|
## Discovery and Reading
|
||||||
|
|
||||||
- Use `find_files` or `list_dir` to locate workspace paths before `read_file` when a path is uncertain.
|
- Use `find_files` or `list_dir` for uncertain paths, `grep` for content, and `read_file` for a known path.
|
||||||
- Use `grep` for content search inside the workspace; prefer it over shell grep for ordinary searches.
|
- `grep` returns matches with five context lines by default; use `files_with_matches` for paths or `count` for totals.
|
||||||
- `grep` defaults to `output_mode="files_with_matches"`; use `output_mode="content"` for matching lines with context.
|
|
||||||
- Use `fixed_strings=true` for literal keywords containing regex characters.
|
- Use `fixed_strings=true` for literal keywords containing regex characters.
|
||||||
- Use `output_mode="count"` to size a broad search before reading full matches.
|
|
||||||
- Use `head_limit` and `offset` to page across large result sets.
|
- Use `head_limit` and `offset` to page across large result sets.
|
||||||
- Search tools enforce binary and file-size limits and report skipped files in the result.
|
- Search tools enforce binary and file-size limits and report skipped files in the result.
|
||||||
|
|
||||||
|
|||||||
+360
-117
@@ -66,6 +66,10 @@ class DocxSafetyError(Exception):
|
|||||||
"""Raised when a DOCX table exceeds a parser safety boundary."""
|
"""Raised when a DOCX table exceeds a parser safety boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentExtractionError(Exception):
|
||||||
|
"""Raised when a document cannot be opened for incremental extraction."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PdfExtraction:
|
class PdfExtraction:
|
||||||
text: str
|
text: str
|
||||||
@@ -74,6 +78,24 @@ class PdfExtraction:
|
|||||||
end_page: int
|
end_page: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LocatedDocumentLine:
|
||||||
|
"""One searchable document line with a stable, human-readable locator."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
extracted_line: int
|
||||||
|
locator: str
|
||||||
|
searchable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentLineSource:
|
||||||
|
"""Incremental document lines plus an optional next PDF page range."""
|
||||||
|
|
||||||
|
lines: Iterator[LocatedDocumentLine]
|
||||||
|
continuation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def extract_text(path: str | Path) -> str | None:
|
def extract_text(path: str | Path) -> str | None:
|
||||||
"""Extract text from a file.
|
"""Extract text from a file.
|
||||||
|
|
||||||
@@ -85,13 +107,8 @@ def extract_text(path: str | Path) -> str | None:
|
|||||||
or error string for failures.
|
or error string for failures.
|
||||||
"""
|
"""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if not path.exists():
|
if error := _extraction_path_error(path):
|
||||||
return f"[error: file not found: {path}]"
|
return error
|
||||||
try:
|
|
||||||
if path.stat().st_size > _MAX_EXTRACT_FILE_SIZE:
|
|
||||||
return f"[error: file exceeds {_MAX_EXTRACT_FILE_SIZE // (1024 * 1024)} MB limit]"
|
|
||||||
except OSError as e:
|
|
||||||
return f"[error: failed to inspect file: {e!s}]"
|
|
||||||
|
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
|
|
||||||
@@ -115,6 +132,303 @@ def extract_text(path: str | Path) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def open_document_line_source(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
pages: str | None = None,
|
||||||
|
) -> DocumentLineSource | None:
|
||||||
|
"""Open a document as an incremental stream of extracted lines.
|
||||||
|
|
||||||
|
Unlike :func:`extract_text`, this interface does not apply the attachment
|
||||||
|
text preview limit. Parser/file safety limits still apply. Lines that are
|
||||||
|
useful only for the rendered document view (for example sheet headers and
|
||||||
|
blank separators) have ``searchable=False`` so range reads can retain them
|
||||||
|
without making grep match synthetic text.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
if ext not in {".pdf", ".docx", ".xlsx", ".pptx"}:
|
||||||
|
return None
|
||||||
|
if error := _extraction_path_error(path):
|
||||||
|
raise DocumentExtractionError(_clean_extraction_error(error))
|
||||||
|
if ext == ".pdf":
|
||||||
|
return _open_pdf_line_source(path, pages)
|
||||||
|
if ext == ".docx":
|
||||||
|
return _open_docx_line_source(path)
|
||||||
|
if ext == ".xlsx":
|
||||||
|
return _open_xlsx_line_source(path)
|
||||||
|
return _open_pptx_line_source(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_extraction_error(error: str) -> str:
|
||||||
|
if error.startswith("[error:") and error.endswith("]"):
|
||||||
|
return error[len("[error:") : -1].strip()
|
||||||
|
return error
|
||||||
|
|
||||||
|
|
||||||
|
def _check_office_archive(path: Path) -> None:
|
||||||
|
if error := _office_archive_error(path):
|
||||||
|
raise DocumentExtractionError(_clean_extraction_error(error))
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pdf_line_source(path: Path, pages: str | None) -> DocumentLineSource:
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
reader = PdfReader(path, strict=False)
|
||||||
|
total_pages = len(reader.pages)
|
||||||
|
if total_pages == 0:
|
||||||
|
return DocumentLineSource(iter(()))
|
||||||
|
start, requested_end = _parse_pdf_page_range(pages, total_pages)
|
||||||
|
except PdfPageRangeError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DocumentExtractionError(f"failed to open PDF: {e!s}") from e
|
||||||
|
|
||||||
|
end = min(requested_end, start + _MAX_PDF_ATTACHMENT_PAGES - 1)
|
||||||
|
continuation = None
|
||||||
|
if end < total_pages - 1:
|
||||||
|
next_start = end + 2
|
||||||
|
next_end = min(end + 1 + _MAX_PDF_ATTACHMENT_PAGES, total_pages)
|
||||||
|
continuation = f"pages='{next_start}-{next_end}'"
|
||||||
|
|
||||||
|
def iter_lines() -> Iterator[LocatedDocumentLine]:
|
||||||
|
extracted_line = 0
|
||||||
|
wrote_page = False
|
||||||
|
for index in range(start, end + 1):
|
||||||
|
page = reader.pages[index]
|
||||||
|
contents = page.get_contents()
|
||||||
|
if contents is not None:
|
||||||
|
stream_size = len(contents.get_data())
|
||||||
|
if stream_size > _MAX_PDF_CONTENT_STREAM_SIZE:
|
||||||
|
raise PdfSafetyError(
|
||||||
|
f"page {index + 1} content stream exceeds "
|
||||||
|
f"{_MAX_PDF_CONTENT_STREAM_SIZE // (1024 * 1024)} MB limit"
|
||||||
|
)
|
||||||
|
text = (page.extract_text() or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if wrote_page:
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine("", extracted_line, "", searchable=False)
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
f"--- Page {index + 1} ---",
|
||||||
|
extracted_line,
|
||||||
|
"",
|
||||||
|
searchable=False,
|
||||||
|
)
|
||||||
|
page_line = 0
|
||||||
|
for text_line in text.splitlines():
|
||||||
|
extracted_line += 1
|
||||||
|
if not text_line:
|
||||||
|
yield LocatedDocumentLine("", extracted_line, "", searchable=False)
|
||||||
|
continue
|
||||||
|
page_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
text_line,
|
||||||
|
extracted_line,
|
||||||
|
f"page={index + 1},line={page_line}",
|
||||||
|
)
|
||||||
|
wrote_page = True
|
||||||
|
|
||||||
|
return DocumentLineSource(iter_lines(), continuation=continuation)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_xlsx_line_source(path: Path) -> DocumentLineSource:
|
||||||
|
_check_office_archive(path)
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
except ImportError as e:
|
||||||
|
raise DocumentExtractionError("openpyxl not installed") from e
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(path, read_only=True, data_only=True)
|
||||||
|
except Exception as e:
|
||||||
|
raise DocumentExtractionError(f"failed to open XLSX: {e!s}") from e
|
||||||
|
|
||||||
|
def iter_lines() -> Iterator[LocatedDocumentLine]:
|
||||||
|
extracted_line = 0
|
||||||
|
wrote_document_content = False
|
||||||
|
try:
|
||||||
|
for sheet_name in workbook.sheetnames:
|
||||||
|
worksheet = workbook[sheet_name]
|
||||||
|
wrote_header = False
|
||||||
|
for row_index, row in enumerate(worksheet.iter_rows(values_only=True), 1):
|
||||||
|
row_text = "\t".join(
|
||||||
|
str(cell) if cell is not None else "" for cell in row
|
||||||
|
)
|
||||||
|
if not row_text.strip():
|
||||||
|
continue
|
||||||
|
if not wrote_header:
|
||||||
|
if wrote_document_content:
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
"", extracted_line, "", searchable=False
|
||||||
|
)
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
f"--- Sheet: {sheet_name} ---",
|
||||||
|
extracted_line,
|
||||||
|
"",
|
||||||
|
searchable=False,
|
||||||
|
)
|
||||||
|
wrote_header = True
|
||||||
|
wrote_document_content = True
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
row_text,
|
||||||
|
extracted_line,
|
||||||
|
f"sheet={sheet_name!r},row={row_index}",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
return DocumentLineSource(iter_lines())
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pptx_line_source(path: Path) -> DocumentLineSource:
|
||||||
|
_check_office_archive(path)
|
||||||
|
try:
|
||||||
|
from pptx import Presentation as PptxPresentation
|
||||||
|
except ImportError as e:
|
||||||
|
raise DocumentExtractionError("python-pptx not installed") from e
|
||||||
|
try:
|
||||||
|
presentation = PptxPresentation(str(path))
|
||||||
|
except Exception as e:
|
||||||
|
raise DocumentExtractionError(f"failed to open PPTX: {e!s}") from e
|
||||||
|
|
||||||
|
def iter_lines() -> Iterator[LocatedDocumentLine]:
|
||||||
|
extracted_line = 0
|
||||||
|
wrote_slide = False
|
||||||
|
for slide_number, slide in enumerate(presentation.slides, 1):
|
||||||
|
slide_text: list[str] = []
|
||||||
|
for shape in slide.shapes:
|
||||||
|
_collect_pptx_shape_text(shape, slide_text)
|
||||||
|
rendered_lines = [line for text in slide_text for line in text.splitlines()]
|
||||||
|
if not rendered_lines:
|
||||||
|
continue
|
||||||
|
if wrote_slide:
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine("", extracted_line, "", searchable=False)
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
f"--- Slide {slide_number} ---",
|
||||||
|
extracted_line,
|
||||||
|
"",
|
||||||
|
searchable=False,
|
||||||
|
)
|
||||||
|
slide_line = 0
|
||||||
|
for text_line in rendered_lines:
|
||||||
|
extracted_line += 1
|
||||||
|
if not text_line:
|
||||||
|
yield LocatedDocumentLine("", extracted_line, "", searchable=False)
|
||||||
|
continue
|
||||||
|
slide_line += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
text_line,
|
||||||
|
extracted_line,
|
||||||
|
f"slide={slide_number},line={slide_line}",
|
||||||
|
)
|
||||||
|
wrote_slide = True
|
||||||
|
|
||||||
|
return DocumentLineSource(iter_lines())
|
||||||
|
|
||||||
|
|
||||||
|
def _open_docx_line_source(path: Path) -> DocumentLineSource:
|
||||||
|
_check_office_archive(path)
|
||||||
|
try:
|
||||||
|
from docx import Document as DocxDocument
|
||||||
|
from docx.table import Table, _Cell # pyright: ignore[reportPrivateUsage]
|
||||||
|
from docx.text.paragraph import Paragraph
|
||||||
|
except ImportError as e:
|
||||||
|
raise DocumentExtractionError("python-docx not installed") from e
|
||||||
|
try:
|
||||||
|
document = DocxDocument(str(path))
|
||||||
|
except Exception as e:
|
||||||
|
raise DocumentExtractionError(f"failed to open DOCX: {e!s}") from e
|
||||||
|
|
||||||
|
def iter_lines() -> Iterator[LocatedDocumentLine]:
|
||||||
|
table_cell_count = 0
|
||||||
|
|
||||||
|
def cell_text(cell: _Cell, depth: int) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in cell.iter_inner_content():
|
||||||
|
if isinstance(block, Paragraph):
|
||||||
|
text = " ".join(block.text.split())
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
elif isinstance(block, Table): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||||
|
parts.extend(
|
||||||
|
row.replace("\t", " | ") for row in table_rows(block, depth + 1)
|
||||||
|
)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
def table_rows(table: Table, depth: int) -> Iterator[str]:
|
||||||
|
nonlocal table_cell_count
|
||||||
|
if depth > _MAX_DOCX_TABLE_DEPTH:
|
||||||
|
raise DocxSafetyError(
|
||||||
|
f"table nesting exceeds {_MAX_DOCX_TABLE_DEPTH} levels"
|
||||||
|
)
|
||||||
|
for row in table.rows:
|
||||||
|
cells: list[str] = []
|
||||||
|
for tc in row._tr.tc_lst: # pyright: ignore[reportPrivateUsage]
|
||||||
|
table_cell_count += 1
|
||||||
|
if table_cell_count > _MAX_DOCX_TABLE_CELLS:
|
||||||
|
raise DocxSafetyError(
|
||||||
|
f"document contains more than {_MAX_DOCX_TABLE_CELLS} table cells"
|
||||||
|
)
|
||||||
|
cells.append(cell_text(_Cell(tc, table), depth))
|
||||||
|
if any(cells):
|
||||||
|
yield "\t".join(cells)
|
||||||
|
|
||||||
|
def blocks() -> Iterator[tuple[str, bool]]:
|
||||||
|
for block in document.iter_inner_content():
|
||||||
|
if isinstance(block, Paragraph):
|
||||||
|
text = block.text.strip()
|
||||||
|
if text:
|
||||||
|
yield text, True
|
||||||
|
continue
|
||||||
|
if not isinstance(block, Table): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||||
|
continue
|
||||||
|
first_row = True
|
||||||
|
for row_text in table_rows(block, 1):
|
||||||
|
yield row_text, first_row
|
||||||
|
first_row = False
|
||||||
|
|
||||||
|
extracted_line = 0
|
||||||
|
paragraph = 0
|
||||||
|
wrote_content = False
|
||||||
|
for text, separate in blocks():
|
||||||
|
if wrote_content and separate:
|
||||||
|
extracted_line += 1
|
||||||
|
yield LocatedDocumentLine("", extracted_line, "", searchable=False)
|
||||||
|
for text_line in text.splitlines():
|
||||||
|
extracted_line += 1
|
||||||
|
if not text_line:
|
||||||
|
yield LocatedDocumentLine("", extracted_line, "", searchable=False)
|
||||||
|
continue
|
||||||
|
paragraph += 1
|
||||||
|
yield LocatedDocumentLine(
|
||||||
|
text_line,
|
||||||
|
extracted_line,
|
||||||
|
f"paragraph={paragraph}",
|
||||||
|
)
|
||||||
|
wrote_content = True
|
||||||
|
|
||||||
|
return DocumentLineSource(iter_lines())
|
||||||
|
|
||||||
|
|
||||||
|
def _extraction_path_error(path: Path) -> str | None:
|
||||||
|
if not path.exists():
|
||||||
|
return f"[error: file not found: {path}]"
|
||||||
|
try:
|
||||||
|
if path.stat().st_size > _MAX_EXTRACT_FILE_SIZE:
|
||||||
|
return f"[error: file exceeds {_MAX_EXTRACT_FILE_SIZE // (1024 * 1024)} MB limit]"
|
||||||
|
except OSError as e:
|
||||||
|
return f"[error: failed to inspect file: {e!s}]"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_pdf(path: Path) -> str:
|
def _extract_pdf(path: Path) -> str:
|
||||||
"""Extract text from PDF using pypdf."""
|
"""Extract text from PDF using pypdf."""
|
||||||
try:
|
try:
|
||||||
@@ -170,144 +484,73 @@ def extract_pdf_pages(
|
|||||||
def _parse_pdf_page_range(pages: str | None, total_pages: int) -> tuple[int, int]:
|
def _parse_pdf_page_range(pages: str | None, total_pages: int) -> tuple[int, int]:
|
||||||
if not pages:
|
if not pages:
|
||||||
return 0, total_pages - 1
|
return 0, total_pages - 1
|
||||||
|
page_word = "page" if total_pages == 1 else "pages"
|
||||||
|
guidance = (
|
||||||
|
f"document has {total_pages} {page_word}; "
|
||||||
|
f"use a page number or range within 1-{total_pages}"
|
||||||
|
)
|
||||||
values = pages.strip().split("-")
|
values = pages.strip().split("-")
|
||||||
if len(values) not in {1, 2}:
|
if len(values) not in {1, 2}:
|
||||||
raise PdfPageRangeError(f"invalid page range: {pages}")
|
raise PdfPageRangeError(guidance)
|
||||||
try:
|
try:
|
||||||
start = int(values[0])
|
start = int(values[0])
|
||||||
end = int(values[-1])
|
end = int(values[-1])
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise PdfPageRangeError(f"invalid page range: {pages}") from e
|
raise PdfPageRangeError(guidance) from e
|
||||||
if start < 1 or end < start or start > total_pages:
|
if start < 1 or end < start or start > total_pages:
|
||||||
raise PdfPageRangeError(f"invalid page range: {pages}")
|
raise PdfPageRangeError(guidance)
|
||||||
return start - 1, min(end, total_pages) - 1
|
return start - 1, min(end, total_pages) - 1
|
||||||
|
|
||||||
|
|
||||||
def _extract_docx(path: Path) -> str:
|
def _render_document_preview(source: DocumentLineSource) -> str:
|
||||||
"""Extract text from DOCX using python-docx."""
|
"""Render a bounded attachment preview from the canonical line stream."""
|
||||||
|
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
||||||
|
iterator = source.lines
|
||||||
|
first_line = True
|
||||||
try:
|
try:
|
||||||
from docx import Document as DocxDocument
|
for line in iterator:
|
||||||
from docx.table import Table, _Cell # pyright: ignore[reportPrivateUsage]
|
if not first_line and not collector.add("\n"):
|
||||||
from docx.text.paragraph import Paragraph
|
break
|
||||||
except ImportError:
|
first_line = False
|
||||||
return "[error: python-docx not installed]"
|
if line.text and not collector.add(line.text):
|
||||||
try:
|
break
|
||||||
if error := _office_archive_error(path):
|
|
||||||
return error
|
|
||||||
doc = DocxDocument(str(path))
|
|
||||||
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
|
||||||
table_cell_count = 0
|
|
||||||
|
|
||||||
def cell_text(cell: _Cell, depth: int) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
for block in cell.iter_inner_content():
|
|
||||||
if isinstance(block, Paragraph):
|
|
||||||
text = " ".join(block.text.split())
|
|
||||||
if text:
|
|
||||||
parts.append(text)
|
|
||||||
elif isinstance(block, Table): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
||||||
parts.extend(row.replace("\t", " | ") for row in table_rows(block, depth + 1))
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
def table_rows(table: Table, depth: int) -> Iterator[str]:
|
|
||||||
nonlocal table_cell_count
|
|
||||||
if depth > _MAX_DOCX_TABLE_DEPTH:
|
|
||||||
raise DocxSafetyError(
|
|
||||||
f"table nesting exceeds {_MAX_DOCX_TABLE_DEPTH} levels"
|
|
||||||
)
|
|
||||||
for row in table.rows:
|
|
||||||
cells: list[str] = []
|
|
||||||
# row.cells expands w:gridSpan before callers can apply a bound.
|
|
||||||
# Physical w:tc elements keep malformed documents proportional to XML size.
|
|
||||||
for tc in row._tr.tc_lst: # pyright: ignore[reportPrivateUsage]
|
|
||||||
table_cell_count += 1
|
|
||||||
if table_cell_count > _MAX_DOCX_TABLE_CELLS:
|
|
||||||
raise DocxSafetyError(
|
|
||||||
f"document contains more than {_MAX_DOCX_TABLE_CELLS} table cells"
|
|
||||||
)
|
|
||||||
cells.append(cell_text(_Cell(tc, table), depth))
|
|
||||||
if any(cells):
|
|
||||||
yield "\t".join(cells)
|
|
||||||
|
|
||||||
for block in doc.iter_inner_content():
|
|
||||||
if isinstance(block, Paragraph):
|
|
||||||
text = block.text.strip()
|
|
||||||
if text and not collector.add(text, separator="\n\n"):
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
if not isinstance(block, Table): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
||||||
continue
|
|
||||||
first_row = True
|
|
||||||
for row_text in table_rows(block, 1):
|
|
||||||
separator = "\n\n" if first_row else "\n"
|
|
||||||
first_row = False
|
|
||||||
if not collector.add(row_text, separator=separator):
|
|
||||||
return collector.render()
|
|
||||||
return collector.render()
|
return collector.render()
|
||||||
|
finally:
|
||||||
|
close = getattr(iterator, "close", None)
|
||||||
|
if close is not None:
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx(path: Path) -> str:
|
||||||
|
"""Extract a bounded DOCX attachment preview."""
|
||||||
|
try:
|
||||||
|
return _render_document_preview(_open_docx_line_source(path))
|
||||||
except DocxSafetyError as e:
|
except DocxSafetyError as e:
|
||||||
return f"[error: unsafe DOCX: {e!s}]"
|
return f"[error: unsafe DOCX: {e!s}]"
|
||||||
|
except DocumentExtractionError as e:
|
||||||
|
return f"[error: {e!s}]"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to extract DOCX {}", path)
|
logger.exception("Failed to extract DOCX {}", path)
|
||||||
return f"[error: failed to extract DOCX: {e!s}]"
|
return f"[error: failed to extract DOCX: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
def _extract_xlsx(path: Path) -> str:
|
def _extract_xlsx(path: Path) -> str:
|
||||||
"""Extract text from XLSX using openpyxl."""
|
"""Extract a bounded XLSX attachment preview."""
|
||||||
try:
|
try:
|
||||||
from openpyxl import load_workbook
|
return _render_document_preview(_open_xlsx_line_source(path))
|
||||||
except ImportError:
|
except DocumentExtractionError as e:
|
||||||
return "[error: openpyxl not installed]"
|
return f"[error: {e!s}]"
|
||||||
try:
|
|
||||||
if error := _office_archive_error(path):
|
|
||||||
return error
|
|
||||||
wb = load_workbook(path, read_only=True, data_only=True)
|
|
||||||
try:
|
|
||||||
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
|
||||||
for sheet_name in wb.sheetnames:
|
|
||||||
ws = wb[sheet_name]
|
|
||||||
wrote_header = False
|
|
||||||
for row in ws.iter_rows(values_only=True):
|
|
||||||
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
|
||||||
if row_text.strip():
|
|
||||||
if not wrote_header:
|
|
||||||
if not collector.add(
|
|
||||||
f"--- Sheet: {sheet_name} ---",
|
|
||||||
separator="\n\n",
|
|
||||||
):
|
|
||||||
return collector.render()
|
|
||||||
wrote_header = True
|
|
||||||
if not collector.add(row_text, separator="\n"):
|
|
||||||
return collector.render()
|
|
||||||
return collector.render()
|
|
||||||
finally:
|
|
||||||
wb.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to extract XLSX {}", path)
|
logger.exception("Failed to extract XLSX {}", path)
|
||||||
return f"[error: failed to extract XLSX: {e!s}]"
|
return f"[error: failed to extract XLSX: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
def _extract_pptx(path: Path) -> str:
|
def _extract_pptx(path: Path) -> str:
|
||||||
"""Extract text from PPTX using python-pptx."""
|
"""Extract a bounded PPTX attachment preview."""
|
||||||
try:
|
try:
|
||||||
from pptx import Presentation as PptxPresentation
|
return _render_document_preview(_open_pptx_line_source(path))
|
||||||
except ImportError:
|
except DocumentExtractionError as e:
|
||||||
return "[error: python-pptx not installed]"
|
return f"[error: {e!s}]"
|
||||||
try:
|
|
||||||
if error := _office_archive_error(path):
|
|
||||||
return error
|
|
||||||
prs = PptxPresentation(str(path))
|
|
||||||
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
|
||||||
for i, slide in enumerate(prs.slides, 1):
|
|
||||||
slide_text: list[str] = []
|
|
||||||
for shape in slide.shapes:
|
|
||||||
_collect_pptx_shape_text(shape, slide_text)
|
|
||||||
if slide_text:
|
|
||||||
if not collector.add(
|
|
||||||
f"--- Slide {i} ---\n" + "\n".join(slide_text),
|
|
||||||
separator="\n\n",
|
|
||||||
):
|
|
||||||
break
|
|
||||||
return collector.render()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to extract PPTX {}", path)
|
logger.exception("Failed to extract PPTX {}", path)
|
||||||
return f"[error: failed to extract PPTX: {e!s}]"
|
return f"[error: failed to extract PPTX: {e!s}]"
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ class TestBundledToolContract:
|
|||||||
assert "Use the narrowest structured tool" in content
|
assert "Use the narrowest structured tool" in content
|
||||||
assert "Do not use `exec` as a universal workaround" in content
|
assert "Do not use `exec` as a universal workaround" in content
|
||||||
assert "## File and Coding Workflows" in content
|
assert "## File and Coding Workflows" in content
|
||||||
|
assert "`grep` returns matches with five context lines by default" in content
|
||||||
|
assert 'defaults to `output_mode="files_with_matches"`' not in content
|
||||||
assert "apply_patch" in content
|
assert "apply_patch" in content
|
||||||
assert "acceptance criteria into concrete checks" in content
|
assert "acceptance criteria into concrete checks" in content
|
||||||
assert "visual evidence reaches the model" in content
|
assert "visual evidence reaches the model" in content
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from nanobot.utils.document import (
|
|||||||
_is_text_extension,
|
_is_text_extension,
|
||||||
extract_pdf_pages,
|
extract_pdf_pages,
|
||||||
extract_text,
|
extract_text,
|
||||||
|
open_document_line_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,6 +88,39 @@ class TestExtractText:
|
|||||||
result = extract_text(json_file)
|
result = extract_text(json_file)
|
||||||
assert result == content
|
assert result == content
|
||||||
|
|
||||||
|
def test_pdf_search_lines_expose_page_continuation(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
pdf_file = tmp_path / "large.pdf"
|
||||||
|
pdf_file.write_bytes(b"%PDF")
|
||||||
|
|
||||||
|
class _Page:
|
||||||
|
@staticmethod
|
||||||
|
def get_contents():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_text():
|
||||||
|
return "needle"
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
def __init__(self, *_args, **_kwargs):
|
||||||
|
self.pages = [_Page() for _ in range(250)]
|
||||||
|
|
||||||
|
monkeypatch.setattr("pypdf.PdfReader", _Reader)
|
||||||
|
|
||||||
|
source = open_document_line_source(pdf_file, pages="101-200")
|
||||||
|
|
||||||
|
assert source is not None
|
||||||
|
iterator = source.lines
|
||||||
|
next(iterator)
|
||||||
|
line = next(iterator)
|
||||||
|
iterator.close()
|
||||||
|
assert line.locator == "page=101,line=1"
|
||||||
|
assert source.continuation == "pages='201-250'"
|
||||||
|
|
||||||
def test_extract_text_xlsx(self, tmp_path: Path):
|
def test_extract_text_xlsx(self, tmp_path: Path):
|
||||||
"""Test extracting text from an .xlsx file."""
|
"""Test extracting text from an .xlsx file."""
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.tools import file_state
|
from nanobot.agent.tools import file_state
|
||||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||||
|
from nanobot.utils.document import (
|
||||||
|
DocumentExtractionError,
|
||||||
|
DocumentLineSource,
|
||||||
|
LocatedDocumentLine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_source(text: str) -> DocumentLineSource:
|
||||||
|
lines = (
|
||||||
|
LocatedDocumentLine(line, line_no, "")
|
||||||
|
for line_no, line in enumerate(text.splitlines(), 1)
|
||||||
|
)
|
||||||
|
return DocumentLineSource(lines)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -220,6 +233,12 @@ class TestReadPdf:
|
|||||||
|
|
||||||
assert "Invalid page range" in result
|
assert "Invalid page range" in result
|
||||||
|
|
||||||
|
out_of_bounds = await tool.execute(path=str(pdf_path), pages="99")
|
||||||
|
assert out_of_bounds == (
|
||||||
|
"Error: Invalid page range '99': document has 1 page; "
|
||||||
|
"use a page number or range within 1-1."
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pdf_file_not_found_error(self, tool, tmp_path):
|
async def test_pdf_file_not_found_error(self, tool, tmp_path):
|
||||||
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
|
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
|
||||||
@@ -345,7 +364,10 @@ class TestReadOfficeDocuments:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_docx_returns_extracted_text(self, tool, tmp_path):
|
async def test_docx_returns_extracted_text(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="Title\n\nParagraph 1"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source("Title\n\nParagraph 1"),
|
||||||
|
):
|
||||||
f = tmp_path / "test.docx"
|
f = tmp_path / "test.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -355,16 +377,69 @@ class TestReadOfficeDocuments:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_xlsx_returns_extracted_text(self, tool, tmp_path):
|
async def test_xlsx_returns_extracted_text(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="--- Sheet: Sheet1 ---\nName\tAge\nAlice\t30"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source("--- Sheet: Sheet1 ---\nName\tAge\nAlice\t30"),
|
||||||
|
):
|
||||||
f = tmp_path / "test.xlsx"
|
f = tmp_path / "test.xlsx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
assert "Sheet1" in result
|
assert "Sheet1" in result
|
||||||
assert "Alice" in result
|
assert "Alice" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_office_documents_support_extracted_line_ranges(self, tool, tmp_path):
|
||||||
|
extracted = "--- Sheet: Sheet1 ---\nName\tAge\nAlice\t30\nBob\t25"
|
||||||
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source(extracted),
|
||||||
|
):
|
||||||
|
f = tmp_path / "test.xlsx"
|
||||||
|
f.write_bytes(b"PK")
|
||||||
|
result = await tool.execute(path=str(f), offset=3, limit=1)
|
||||||
|
|
||||||
|
assert "3| Alice\t30" in result
|
||||||
|
assert "Name\tAge" not in result
|
||||||
|
assert "Use offset=4 to continue" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_office_range_reaches_beyond_attachment_preview_limit(
|
||||||
|
self,
|
||||||
|
tool,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from nanobot.utils import document as document_utils
|
||||||
|
|
||||||
|
workbook_path = tmp_path / "long.xlsx"
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
for row in range(1, 20):
|
||||||
|
sheet.append([f"ordinary-row-{row}"])
|
||||||
|
sheet.append(["late-content"])
|
||||||
|
workbook.save(workbook_path)
|
||||||
|
workbook.close()
|
||||||
|
monkeypatch.setattr(document_utils, "_MAX_TEXT_LENGTH", 50)
|
||||||
|
|
||||||
|
preview = document_utils.extract_text(workbook_path)
|
||||||
|
assert preview is not None
|
||||||
|
assert "late-content" not in preview
|
||||||
|
|
||||||
|
result = await tool.execute(path=str(workbook_path), offset=21, limit=1)
|
||||||
|
|
||||||
|
assert "21| late-content" in result
|
||||||
|
assert "beyond end" not in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pptx_returns_extracted_text(self, tool, tmp_path):
|
async def test_pptx_returns_extracted_text(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="--- Slide 1 ---\nWelcome\n--- Slide 2 ---\nContent"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source(
|
||||||
|
"--- Slide 1 ---\nWelcome\n--- Slide 2 ---\nContent"
|
||||||
|
),
|
||||||
|
):
|
||||||
f = tmp_path / "test.pptx"
|
f = tmp_path / "test.pptx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -373,7 +448,10 @@ class TestReadOfficeDocuments:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_docx_missing_library(self, tool, tmp_path):
|
async def test_docx_missing_library(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="[error: python-docx not installed]"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
side_effect=DocumentExtractionError("python-docx not installed"),
|
||||||
|
):
|
||||||
f = tmp_path / "test.docx"
|
f = tmp_path / "test.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -382,7 +460,10 @@ class TestReadOfficeDocuments:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_docx_corrupt_file(self, tool, tmp_path):
|
async def test_docx_corrupt_file(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="[error: failed to extract DOCX: bad zip]"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
side_effect=DocumentExtractionError("failed to extract DOCX: bad zip"),
|
||||||
|
):
|
||||||
f = tmp_path / "test.docx"
|
f = tmp_path / "test.docx"
|
||||||
f.write_bytes(b"not-a-zip")
|
f.write_bytes(b"not-a-zip")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -391,7 +472,7 @@ class TestReadOfficeDocuments:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unsupported_extension(self, tool, tmp_path):
|
async def test_unsupported_extension(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value=None):
|
with patch("nanobot.utils.document.open_document_line_source", return_value=None):
|
||||||
f = tmp_path / "test.docx"
|
f = tmp_path / "test.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -400,7 +481,10 @@ class TestReadOfficeDocuments:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_document_returns_descriptive_message(self, tool, tmp_path):
|
async def test_empty_document_returns_descriptive_message(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value=""):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source(""),
|
||||||
|
):
|
||||||
f = tmp_path / "empty.docx"
|
f = tmp_path / "empty.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -415,7 +499,10 @@ class TestOfficeDocTruncation:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_large_document_truncated(self, tool, tmp_path):
|
async def test_large_document_truncated(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="x" * 200_000):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source("x" * 200_000),
|
||||||
|
):
|
||||||
f = tmp_path / "large.docx"
|
f = tmp_path / "large.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -424,7 +511,10 @@ class TestOfficeDocTruncation:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_small_document_not_truncated(self, tool, tmp_path):
|
async def test_small_document_not_truncated(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="Hello world"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
return_value=_document_source("Hello world"),
|
||||||
|
):
|
||||||
f = tmp_path / "small.docx"
|
f = tmp_path / "small.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
@@ -433,7 +523,12 @@ class TestOfficeDocTruncation:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_error_response_not_truncated(self, tool, tmp_path):
|
async def test_error_response_not_truncated(self, tool, tmp_path):
|
||||||
with patch("nanobot.utils.document.extract_text", return_value="[error: failed to extract DOCX: something went wrong]"):
|
with patch(
|
||||||
|
"nanobot.utils.document.open_document_line_source",
|
||||||
|
side_effect=DocumentExtractionError(
|
||||||
|
"failed to extract DOCX: something went wrong"
|
||||||
|
),
|
||||||
|
):
|
||||||
f = tmp_path / "bad.docx"
|
f = tmp_path / "bad.docx"
|
||||||
f.write_bytes(b"PK")
|
f.write_bytes(b"PK")
|
||||||
result = await tool.execute(path=str(f))
|
result = await tool.execute(path=str(f))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -124,9 +125,14 @@ async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_grep_defaults_to_files_with_matches(tmp_path: Path) -> None:
|
async def test_grep_defaults_to_match_context(tmp_path: Path) -> None:
|
||||||
(tmp_path / "src").mkdir()
|
(tmp_path / "src").mkdir()
|
||||||
(tmp_path / "src" / "main.py").write_text("match_here\n", encoding="utf-8")
|
(tmp_path / "src" / "main.py").write_text(
|
||||||
|
"\n".join(f"line {line}" for line in range(1, 6))
|
||||||
|
+ "\nmatch_here\n"
|
||||||
|
+ "\n".join(f"line {line}" for line in range(7, 13)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
result = await tool.execute(
|
result = await tool.execute(
|
||||||
@@ -134,8 +140,230 @@ async def test_grep_defaults_to_files_with_matches(tmp_path: Path) -> None:
|
|||||||
path="src",
|
path="src",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.splitlines() == ["src/main.py"]
|
assert "src/main.py:6" in result
|
||||||
assert "1|" not in result
|
assert " 1| line 1" in result
|
||||||
|
assert "> 6| match_here" in result
|
||||||
|
assert " 11| line 11" in result
|
||||||
|
assert "line 12" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_searches_xlsx_with_sheet_cell_locator(tmp_path: Path) -> None:
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
workbook_path = tmp_path / "people.xlsx"
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "People"
|
||||||
|
sheet.append(["Name", "Role"])
|
||||||
|
sheet.append(["Ada", "Engineer"])
|
||||||
|
workbook.save(workbook_path)
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern="Engineer",
|
||||||
|
path="people.xlsx",
|
||||||
|
fixed_strings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "people.xlsx:3" in result
|
||||||
|
assert "sheet='People',row=2,cell=B2" in result
|
||||||
|
assert "Ada\tEngineer" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_searches_docx_with_paragraph_locator(tmp_path: Path) -> None:
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
document_path = tmp_path / "notes.docx"
|
||||||
|
document = Document()
|
||||||
|
document.add_paragraph("Introduction")
|
||||||
|
document.add_paragraph("late needle")
|
||||||
|
document.save(document_path)
|
||||||
|
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern="late needle",
|
||||||
|
path="notes.docx",
|
||||||
|
fixed_strings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "notes.docx:3 [paragraph=2]" in result
|
||||||
|
assert "late needle" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_searches_pptx_with_slide_locator(tmp_path: Path) -> None:
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches
|
||||||
|
|
||||||
|
presentation_path = tmp_path / "deck.pptx"
|
||||||
|
presentation = Presentation()
|
||||||
|
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||||
|
textbox = slide.shapes.add_textbox(
|
||||||
|
Inches(1), Inches(1), Inches(4), Inches(1)
|
||||||
|
)
|
||||||
|
textbox.text_frame.text = "slide needle"
|
||||||
|
presentation.save(presentation_path)
|
||||||
|
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern="slide needle",
|
||||||
|
path="deck.pptx",
|
||||||
|
fixed_strings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "deck.pptx:2 [slide=1,line=1]" in result
|
||||||
|
assert "slide needle" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_searches_pdf_with_page_locator(tmp_path: Path) -> None:
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
pdf_path = tmp_path / "notes.pdf"
|
||||||
|
document = fitz.open()
|
||||||
|
page = document.new_page()
|
||||||
|
page.insert_text((72, 72), "pdf needle")
|
||||||
|
document.save(pdf_path)
|
||||||
|
document.close()
|
||||||
|
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern="pdf needle",
|
||||||
|
path="notes.pdf",
|
||||||
|
fixed_strings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "notes.pdf:2 [page=1,line=1]" in result
|
||||||
|
assert "pdf needle" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_searches_xlsx_beyond_attachment_preview_limit(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from nanobot.utils import document as document_utils
|
||||||
|
|
||||||
|
workbook_path = tmp_path / "long.xlsx"
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "Data"
|
||||||
|
for row in range(1, 20):
|
||||||
|
sheet.append([f"ordinary-row-{row}"])
|
||||||
|
sheet.append(["late-needle"])
|
||||||
|
workbook.save(workbook_path)
|
||||||
|
workbook.close()
|
||||||
|
monkeypatch.setattr(document_utils, "_MAX_TEXT_LENGTH", 50)
|
||||||
|
|
||||||
|
preview = document_utils.extract_text(workbook_path)
|
||||||
|
assert preview is not None
|
||||||
|
assert "late-needle" not in preview
|
||||||
|
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern="late-needle",
|
||||||
|
path="long.xlsx",
|
||||||
|
fixed_strings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "late-needle" in result
|
||||||
|
assert "sheet='Data',row=20,cell=A20" in result
|
||||||
|
assert "No matches found" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_keeps_an_oversized_matching_line_visible(tmp_path: Path) -> None:
|
||||||
|
long_line = "x" * 130_000 + "needle" + "y" * 10_000
|
||||||
|
(tmp_path / "huge-line.txt").write_text(long_line, encoding="utf-8")
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
pattern="needle",
|
||||||
|
path="huge-line.txt",
|
||||||
|
fixed_strings=True,
|
||||||
|
context_before=0,
|
||||||
|
context_after=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "huge-line.txt:1" in result
|
||||||
|
assert "needle" in result
|
||||||
|
assert "No matches found" not in result
|
||||||
|
assert len(result) < GrepTool._MAX_RESULT_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_size_limit_returns_a_resumable_offset(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
content = "\n".join(f"needle-{line}-" + "x" * 80 for line in range(1, 11))
|
||||||
|
(tmp_path / "many.txt").write_text(content, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(GrepTool, "_MAX_RESULT_CHARS", 350)
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
|
||||||
|
first = await tool.execute(
|
||||||
|
pattern="needle",
|
||||||
|
path="many.txt",
|
||||||
|
fixed_strings=True,
|
||||||
|
context_before=0,
|
||||||
|
context_after=0,
|
||||||
|
head_limit=10,
|
||||||
|
)
|
||||||
|
continuation = re.search(r"use offset=(\d+) to continue", first)
|
||||||
|
|
||||||
|
assert continuation is not None
|
||||||
|
next_offset = int(continuation.group(1))
|
||||||
|
assert next_offset > 0
|
||||||
|
|
||||||
|
second = await tool.execute(
|
||||||
|
pattern="needle",
|
||||||
|
path="many.txt",
|
||||||
|
fixed_strings=True,
|
||||||
|
context_before=0,
|
||||||
|
context_after=0,
|
||||||
|
head_limit=10,
|
||||||
|
offset=next_offset,
|
||||||
|
)
|
||||||
|
first_headers = {
|
||||||
|
line for line in first.splitlines() if line.startswith("many.txt:")
|
||||||
|
}
|
||||||
|
second_headers = {
|
||||||
|
line for line in second.splitlines() if line.startswith("many.txt:")
|
||||||
|
}
|
||||||
|
assert second_headers
|
||||||
|
assert first_headers.isdisjoint(second_headers)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_reports_an_invalid_pdf_page_range(tmp_path: Path) -> None:
|
||||||
|
from pypdf import PdfWriter
|
||||||
|
|
||||||
|
pdf_path = tmp_path / "one-page.pdf"
|
||||||
|
writer = PdfWriter()
|
||||||
|
writer.add_blank_page(width=100, height=100)
|
||||||
|
with pdf_path.open("wb") as output:
|
||||||
|
writer.write(output)
|
||||||
|
|
||||||
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
result = await tool.execute(pattern="needle", path="one-page.pdf", pages="bad")
|
||||||
|
|
||||||
|
assert result.startswith("Error: Invalid PDF page range 'bad'")
|
||||||
|
assert "binary/unreadable" not in result
|
||||||
|
|
||||||
|
out_of_bounds = await tool.execute(
|
||||||
|
pattern="needle",
|
||||||
|
path="one-page.pdf",
|
||||||
|
pages="99",
|
||||||
|
)
|
||||||
|
assert out_of_bounds == (
|
||||||
|
"Error: Invalid PDF page range '99': document has 1 page; "
|
||||||
|
"use a page number or range within 1-1."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -169,6 +397,7 @@ async def test_grep_type_filter_limits_files(tmp_path: Path) -> None:
|
|||||||
pattern="needle",
|
pattern="needle",
|
||||||
path="src",
|
path="src",
|
||||||
type="py",
|
type="py",
|
||||||
|
output_mode="files_with_matches",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.splitlines() == ["src/a.py"]
|
assert result.splitlines() == ["src/a.py"]
|
||||||
@@ -224,6 +453,7 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
|
|||||||
result = await tool.execute(
|
result = await tool.execute(
|
||||||
pattern="needle",
|
pattern="needle",
|
||||||
path="src",
|
path="src",
|
||||||
|
output_mode="files_with_matches",
|
||||||
head_limit=1,
|
head_limit=1,
|
||||||
offset=1,
|
offset=1,
|
||||||
)
|
)
|
||||||
@@ -319,12 +549,16 @@ async def test_grep_uses_a_larger_bounded_limit_for_an_explicit_file(
|
|||||||
assert "skipped 1 large files" in capped_result
|
assert "skipped 1 large files" in capped_result
|
||||||
|
|
||||||
|
|
||||||
def test_grep_description_keeps_size_thresholds_implementation_specific(tmp_path: Path) -> None:
|
def test_grep_schema_is_concise_and_keeps_legacy_aliases_hidden(tmp_path: Path) -> None:
|
||||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
properties = tool.parameters["properties"]
|
||||||
|
|
||||||
assert "limits are enforced by the tool" in tool.description
|
assert len(tool.description) < 150
|
||||||
assert "2 MB" not in tool.description
|
assert "2 MB" not in tool.description
|
||||||
assert "100 MB" not in tool.description
|
assert "100 MB" not in tool.description
|
||||||
|
assert "head_limit" in properties
|
||||||
|
assert "max_matches" not in properties
|
||||||
|
assert "max_results" not in properties
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -30,12 +30,24 @@ def test_coding_tool_descriptions_steer_discovery() -> None:
|
|||||||
find_files = FindFilesTool().description.lower()
|
find_files = FindFilesTool().description.lower()
|
||||||
grep = GrepTool().description.lower()
|
grep = GrepTool().description.lower()
|
||||||
|
|
||||||
assert "find_files/list_dir first" in read_file
|
assert "text, images, pdfs, and office documents" in read_file
|
||||||
assert "before editing" in read_file
|
assert "line-numbered" in read_file
|
||||||
assert "uploaded non-image attachments are referenced by path" in read_file
|
assert "targeted ranges" in read_file
|
||||||
assert "only when their contents are needed" in read_file
|
assert len(read_file) < 160
|
||||||
assert "prefer it over shell find/ls" in find_files
|
|
||||||
assert "prefer this over shell grep" in grep
|
assert "workspace paths" in find_files
|
||||||
|
assert "relative paths" in find_files
|
||||||
|
assert len(find_files) < 140
|
||||||
|
|
||||||
|
assert "pdf, docx, xlsx, and pptx" in grep
|
||||||
|
assert "five context lines" in grep
|
||||||
|
assert "source locators" in grep
|
||||||
|
assert len(grep) < 150
|
||||||
|
|
||||||
|
read_pages = ReadFileTool().parameters["properties"]["pages"]["description"].lower()
|
||||||
|
grep_pages = GrepTool().parameters["properties"]["pages"]["description"].lower()
|
||||||
|
assert "page number or range" in read_pages
|
||||||
|
assert "page number or range" in grep_pages
|
||||||
|
|
||||||
|
|
||||||
def test_exec_tool_descriptions_are_concise() -> None:
|
def test_exec_tool_descriptions_are_concise() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user