mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat: add demand-driven document retrieval (#5525)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -124,9 +125,14 @@ async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
@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" / "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)
|
||||
result = await tool.execute(
|
||||
@@ -134,8 +140,230 @@ async def test_grep_defaults_to_files_with_matches(tmp_path: Path) -> None:
|
||||
path="src",
|
||||
)
|
||||
|
||||
assert result.splitlines() == ["src/main.py"]
|
||||
assert "1|" not in result
|
||||
assert "src/main.py:6" 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
|
||||
@@ -169,6 +397,7 @@ async def test_grep_type_filter_limits_files(tmp_path: Path) -> None:
|
||||
pattern="needle",
|
||||
path="src",
|
||||
type="py",
|
||||
output_mode="files_with_matches",
|
||||
)
|
||||
|
||||
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(
|
||||
pattern="needle",
|
||||
path="src",
|
||||
output_mode="files_with_matches",
|
||||
head_limit=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
|
||||
|
||||
|
||||
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)
|
||||
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 "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
|
||||
|
||||
Reference in New Issue
Block a user