mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
* fix read_file range validation and passthrough * chore: format test_aio_sandbox.py * test: cover e2b sandbox read_file line-range behavior * fix(boxlite): support ranged file reads * fix(sandbox): reject non-positive start lines --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
146 lines
5.6 KiB
Python
146 lines
5.6 KiB
Python
"""read_file tool behaviour on binary files.
|
|
|
|
``read_file`` decodes with UTF-8. Binary uploads (``.xlsx``, images, ...) raise
|
|
``UnicodeDecodeError`` deep in the sandbox layer, which previously surfaced to
|
|
the model as a vague ``Unexpected error reading file`` message. The model could
|
|
not tell that the file was binary, so it retried ``read_file`` instead of
|
|
switching to ``bash`` + pandas/openpyxl — burning LLM round-trips. These tests
|
|
pin the actionable error contract and guard the normal text path.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
|
from deerflow.sandbox.tools import read_file_tool
|
|
|
|
|
|
def _local_runtime(tmp_path: Path) -> SimpleNamespace:
|
|
for sub in ("workspace", "uploads", "outputs"):
|
|
(tmp_path / sub).mkdir(parents=True, exist_ok=True)
|
|
thread_data = {
|
|
"workspace_path": str(tmp_path / "workspace"),
|
|
"uploads_path": str(tmp_path / "uploads"),
|
|
"outputs_path": str(tmp_path / "outputs"),
|
|
}
|
|
return SimpleNamespace(
|
|
state={"sandbox": {"sandbox_id": "local:t1"}, "thread_data": thread_data},
|
|
context={"thread_id": "t1"},
|
|
)
|
|
|
|
|
|
def test_read_file_tool_binary_file_returns_actionable_hint(tmp_path, monkeypatch) -> None:
|
|
runtime = _local_runtime(tmp_path)
|
|
# .xlsx is a zip container: header bytes PK\x03\x04 plus a non-UTF-8 byte 0x82
|
|
# that makes strict UTF-8 decoding fail (the exact byte seen in the field logs).
|
|
(tmp_path / "uploads" / "data.xlsx").write_bytes(b"PK\x03\x04\x14\x00\x00\x00\x08\x00\x82\x6a\xb1\x55")
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
|
|
|
result = read_file_tool.func(
|
|
runtime=runtime,
|
|
description="read uploaded excel",
|
|
path="/mnt/user-data/uploads/data.xlsx",
|
|
)
|
|
|
|
assert "Unexpected error" not in result, result
|
|
assert "binary" in result.lower(), result
|
|
# The model must be steered to bash + pandas/openpyxl, not another read_file.
|
|
assert "bash" in result.lower(), result
|
|
|
|
|
|
def test_read_file_tool_text_file_unaffected(tmp_path, monkeypatch) -> None:
|
|
runtime = _local_runtime(tmp_path)
|
|
(tmp_path / "uploads" / "notes.txt").write_text("hello 你好\nsecond line", encoding="utf-8")
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
|
|
|
result = read_file_tool.func(
|
|
runtime=runtime,
|
|
description="read notes",
|
|
path="/mnt/user-data/uploads/notes.txt",
|
|
)
|
|
|
|
assert "hello 你好" in result, result
|
|
assert "binary" not in result.lower(), result
|
|
|
|
|
|
def test_read_file_tool_passes_line_range_into_sandbox(monkeypatch) -> None:
|
|
captured: dict[str, int | str | None] = {}
|
|
|
|
class RangeAwareSandbox:
|
|
def read_file(
|
|
self,
|
|
path: str,
|
|
start_line: int | None = None,
|
|
end_line: int | None = None,
|
|
) -> str:
|
|
captured["path"] = path
|
|
captured["start_line"] = start_line
|
|
captured["end_line"] = end_line
|
|
return "line 1\nline 2"
|
|
|
|
runtime = SimpleNamespace(state={"sandbox": {"sandbox_id": "aio:test"}}, context={"thread_id": "t1"})
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: RangeAwareSandbox())
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
|
|
|
result = read_file_tool.func(
|
|
runtime=runtime,
|
|
description="read top of file",
|
|
path="/mnt/user-data/workspace/huge.log",
|
|
start_line=1,
|
|
end_line=10,
|
|
)
|
|
|
|
assert result == "line 1\nline 2"
|
|
assert captured == {
|
|
"path": "/mnt/user-data/workspace/huge.log",
|
|
"start_line": 1,
|
|
"end_line": 10,
|
|
}
|
|
|
|
|
|
def test_read_file_tool_passes_open_ended_ranges_into_sandbox(monkeypatch) -> None:
|
|
calls: list[tuple[int | None, int | None]] = []
|
|
|
|
class RangeAwareSandbox:
|
|
def read_file(self, path: str, start_line: int | None = None, end_line: int | None = None) -> str:
|
|
calls.append((start_line, end_line))
|
|
return "line 1\nline 2"
|
|
|
|
runtime = SimpleNamespace(state={"sandbox": {"sandbox_id": "aio:test"}}, context={"thread_id": "t1"})
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: RangeAwareSandbox())
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
|
|
|
start_only = read_file_tool.func(
|
|
runtime=runtime,
|
|
description="read tail",
|
|
path="/mnt/user-data/workspace/huge.log",
|
|
start_line=1,
|
|
)
|
|
end_only = read_file_tool.func(
|
|
runtime=runtime,
|
|
description="read head",
|
|
path="/mnt/user-data/workspace/huge.log",
|
|
end_line=10,
|
|
)
|
|
|
|
assert start_only == "line 1\nline 2"
|
|
assert end_only == "line 1\nline 2"
|
|
assert calls == [(1, None), (None, 10)]
|
|
|
|
|
|
def test_read_file_tool_validates_range_order(monkeypatch) -> None:
|
|
runtime = SimpleNamespace(state={"sandbox": {"sandbox_id": "aio:test"}}, context={"thread_id": "t1"})
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: object())
|
|
|
|
result = read_file_tool.func(
|
|
runtime=runtime,
|
|
description="bad order",
|
|
path="/mnt/user-data/workspace/huge.log",
|
|
start_line=20,
|
|
end_line=10,
|
|
)
|
|
|
|
assert "start_line > end_line" in result
|