mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
* feat(sandbox): show source line numbers for short read_file ranges * fix(sandbox): address numbered read review feedback --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
218 lines
8.7 KiB
Python
218 lines
8.7 KiB
Python
"""read_file tool line-range handling for one-sided (single-bound) ranges.
|
|
|
|
Previously ``read_file`` only sliced when BOTH ``start_line`` and ``end_line``
|
|
were supplied; a lone ``start_line`` (or lone ``end_line``) was silently ignored
|
|
and the whole file was returned. These tests pin the one-sided range contract:
|
|
tail-from-start, head-to-end, rejection of non-positive line numbers, and clean
|
|
error strings for an inverted range or a start beyond EOF (instead of an
|
|
empty/garbage slice).
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
|
from deerflow.sandbox.tools import read_file_tool
|
|
|
|
_FIVE_LINES = "line1\nline2\nline3\nline4\nline5"
|
|
|
|
|
|
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 _read(tmp_path, monkeypatch, **kwargs) -> str:
|
|
runtime = _local_runtime(tmp_path)
|
|
(tmp_path / "uploads" / "five.txt").write_text(_FIVE_LINES, encoding="utf-8", newline="\n")
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
|
return read_file_tool.func(
|
|
runtime=runtime,
|
|
description="read a line range",
|
|
path="/mnt/user-data/uploads/five.txt",
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def test_only_start_line_returns_tail_from_that_line(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, start_line=3)
|
|
assert result == "line3\nline4\nline5"
|
|
|
|
|
|
def test_only_end_line_returns_head_up_to_that_line(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, end_line=2)
|
|
assert result == "line1\nline2"
|
|
|
|
|
|
def test_start_line_zero_returns_clean_error(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, start_line=0)
|
|
assert "start_line must be >= 1" in result
|
|
assert "line1" not in result
|
|
|
|
|
|
def test_start_line_negative_returns_clean_error(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, start_line=-1)
|
|
assert "start_line must be >= 1" in result
|
|
assert "line5" not in result
|
|
|
|
|
|
def test_start_line_greater_than_end_line_returns_clean_error(tmp_path, monkeypatch) -> None:
|
|
from deerflow.sandbox.read_file_contract import READ_FILE_EMPTY_RANGE
|
|
|
|
result = _read(tmp_path, monkeypatch, start_line=4, end_line=2)
|
|
assert result == READ_FILE_EMPTY_RANGE
|
|
# No garbage slice content leaked into the error.
|
|
assert "line4" not in result
|
|
|
|
|
|
def test_start_line_beyond_eof_returns_clean_error(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, start_line=99)
|
|
assert "start_line exceeds file length" in result
|
|
|
|
|
|
def test_both_bounds_still_slice_inclusive_range(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, start_line=2, end_line=4)
|
|
assert result == "line2\nline3\nline4"
|
|
|
|
|
|
def test_only_end_line_zero_returns_clean_error(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, end_line=0)
|
|
assert "end_line must be >= 1" in result
|
|
# No leaked line content in the error.
|
|
assert "line1" not in result
|
|
|
|
|
|
def test_only_end_line_negative_returns_clean_error(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, end_line=-1)
|
|
assert "end_line must be >= 1" in result
|
|
assert "line4" not in result
|
|
|
|
|
|
def test_end_line_past_eof_clamps_to_last_line(tmp_path, monkeypatch) -> None:
|
|
result = _read(tmp_path, monkeypatch, end_line=99)
|
|
assert result == _FIVE_LINES
|
|
|
|
|
|
def test_contract_constants_parity_with_localsandbox() -> None:
|
|
"""count_file_lines must agree with LocalSandbox line numbering and all no-content
|
|
strings must remain in READ_FILE_NO_CONTENT_RESULTS."""
|
|
from deerflow.sandbox.read_file_contract import (
|
|
READ_FILE_INVALID_RANGE,
|
|
READ_FILE_NO_CONTENT_RESULTS,
|
|
count_file_lines,
|
|
)
|
|
|
|
assert READ_FILE_INVALID_RANGE in READ_FILE_NO_CONTENT_RESULTS
|
|
|
|
# Parity with LocalSandbox.read_file (Python text-mode, universal \n only):
|
|
assert count_file_lines("") == 0
|
|
assert count_file_lines("line1\n") == 1
|
|
assert count_file_lines("line1\nline2\n") == 2
|
|
assert count_file_lines("line1\nline2") == 2 # no trailing newline
|
|
assert count_file_lines("page\fend\n") == 1 # \f not a line separator
|
|
assert count_file_lines("x\u2028y\n") == 1 # \u2028 not a line separator
|
|
|
|
|
|
def _model_read(tmp_path, monkeypatch, content, *, directory="uploads", **kwargs):
|
|
runtime = _local_runtime(tmp_path)
|
|
runtime.tool_call_id = "read-1"
|
|
(tmp_path / directory / "five.txt").write_bytes(content)
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
|
return read_file_tool.func(runtime=runtime, path=f"/mnt/user-data/{directory}/five.txt", **kwargs)
|
|
|
|
|
|
@pytest.mark.parametrize("directory", ["uploads", "workspace"])
|
|
def test_short_model_range_labels_original_source_lines(tmp_path, monkeypatch, directory) -> None:
|
|
result = _model_read(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"first\r\nsource\u2028note\r\n\r\n12 | literal payload\r\nlast\r\n".encode(),
|
|
directory=directory,
|
|
start_line=2,
|
|
end_line=4,
|
|
)
|
|
assert result.content == ("Source lines (for exact edits, copy the text after the first ' | '):\n2 | source\u2028note\n3 | \n4 | 12 | literal payload")
|
|
assert result.status == "success"
|
|
assert result.additional_kwargs["deerflow_tool_meta"]["status"] == "success"
|
|
|
|
|
|
@pytest.mark.parametrize("start_line", [1, 2])
|
|
def test_model_start_only_range_labels_trailing_blank_line(tmp_path, monkeypatch, start_line) -> None:
|
|
result = _model_read(tmp_path, monkeypatch, b"a\nb\n\n", start_line=start_line)
|
|
|
|
assert result.content.split("\n")[1:] == ["1 | a", "2 | b", "3 | "][start_line - 1 :]
|
|
|
|
|
|
def test_model_full_read_keeps_original_display(tmp_path, monkeypatch) -> None:
|
|
assert _model_read(tmp_path, monkeypatch, _FIVE_LINES.encode()).content == _FIVE_LINES
|
|
|
|
|
|
def test_model_range_with_over_forty_actual_lines_keeps_original_display(tmp_path, monkeypatch) -> None:
|
|
content = "\n".join(f"line{number}" for number in range(1, 43))
|
|
assert _model_read(tmp_path, monkeypatch, content.encode(), start_line=2).content == "\n".join(content.split("\n")[1:])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source_end", "bounds", "first", "last"),
|
|
[
|
|
(111, {"start_line": 97}, 97, 111),
|
|
(111, {"end_line": 25}, 1, 25),
|
|
(5, {"start_line": 1, "end_line": 41}, 1, 5),
|
|
(69, {"start_line": 30, "end_line": 150}, 30, 69),
|
|
],
|
|
ids=["short-tail", "short-head", "eof-shortens-window", "forty-actual-lines"],
|
|
)
|
|
def test_short_model_range_uses_effective_start_and_actual_line_count(
|
|
tmp_path,
|
|
monkeypatch,
|
|
source_end,
|
|
bounds,
|
|
first,
|
|
last,
|
|
) -> None:
|
|
content = "\n".join(f"line{number}" for number in range(1, source_end + 1))
|
|
result = _model_read(tmp_path, monkeypatch, content.encode(), **bounds)
|
|
rows = result.content.split("\n")[1:]
|
|
assert rows == [f"{number} | line{number}" for number in range(first, last + 1)]
|
|
|
|
|
|
@pytest.mark.parametrize("limit", [85, 240])
|
|
def test_numbered_display_does_not_change_output_when_it_exceeds_budget(tmp_path, monkeypatch, limit) -> None:
|
|
content = ("a" * 48 + "\n" + "b" * 48 + "\n" + "c" * 106).encode()
|
|
monkeypatch.setattr(
|
|
"deerflow.config.app_config.get_app_config",
|
|
lambda: SimpleNamespace(sandbox=SimpleNamespace(read_file_output_max_chars=limit)),
|
|
)
|
|
model = _model_read(tmp_path, monkeypatch, content, start_line=1, end_line=3)
|
|
raw = read_file_tool.func(
|
|
runtime=_local_runtime(tmp_path),
|
|
path="/mnt/user-data/uploads/five.txt",
|
|
start_line=1,
|
|
end_line=3,
|
|
)
|
|
assert model.content == raw
|
|
assert len(model.content) <= limit
|
|
|
|
|
|
@pytest.mark.parametrize("content", ["Error: remote read unavailable", " (start_line exceeds file length) "])
|
|
def test_model_range_preserves_in_band_read_failure_text(tmp_path, monkeypatch, content) -> None:
|
|
runtime = _local_runtime(tmp_path)
|
|
runtime.tool_call_id = "read-1"
|
|
monkeypatch.setattr("deerflow.sandbox.tools._read_file_from_sandbox", lambda *args, **kwargs: content)
|
|
|
|
result = read_file_tool.func(runtime=runtime, path="/mnt/user-data/uploads/five.txt", start_line=2, end_line=4)
|
|
|
|
assert result.content == content
|